[questions][feat] view, delete questions in list

pull/468/head
Jeff Sieu 3 years ago
parent cf94cbce10
commit e0462f3651

@ -1,17 +1,54 @@
import { forwardRef, Fragment, useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
import type { PropsWithChildren } from 'react';
import { useMemo } from 'react';
import { Fragment, useRef, useState } from 'react';
import { Menu, Transition } from '@headlessui/react';
import { CheckIcon, HeartIcon } from '@heroicons/react/20/solid';
import { lists } from '~/pages/questions/lists';
import { trpc } from '~/utils/trpc';
function classNames(...classes: Array<string>) {
return classes.filter(Boolean).join(' ');
}
export type AddToListDropdownProps = {
questionId: string;
};
export default function AddToListDropdown() {
export default function AddToListDropdown({
questionId,
}: AddToListDropdownProps) {
const [menuOpened, setMenuOpened] = useState(false);
const ref = useRef() as React.MutableRefObject<HTMLDivElement>;
const utils = trpc.useContext();
const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
const listsWithQuestionData = useMemo(() => {
return lists?.map((list) => ({
...list,
hasQuestion: list.questionEntries.some(
(entry) => entry.question.id === questionId,
),
}));
}, [lists, questionId]);
const { mutateAsync: addQuestionToList } = trpc.useMutation(
'questions.lists.createQuestionEntry',
{
// TODO: Add optimistic update
onSuccess: () => {
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
const { mutateAsync: removeQuestionFromList } = trpc.useMutation(
'questions.lists.deleteQuestionEntry',
{
// TODO: Add optimistic update
onSuccess: () => {
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
const addClickOutsideListener = () => {
document.addEventListener('click', handleClickOutside, true);
};
@ -23,7 +60,32 @@ export default function AddToListDropdown() {
}
};
const CustomMenuButton = ({ children }: any) => (
const handleAddToList = async (listId: string) => {
await addQuestionToList({
listId,
questionId,
});
};
const handleDeleteFromList = async (listId: string) => {
const list = listsWithQuestionData?.find(
(listWithQuestion) => listWithQuestion.id === listId,
);
if (!list) {
return;
}
const entry = list.questionEntries.find(
(questionEntry) => questionEntry.question.id === questionId,
);
if (!entry) {
return;
}
await removeQuestionFromList({
id: entry.id,
});
};
const CustomMenuButton = ({ children }: PropsWithChildren<unknown>) => (
<button
className="focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-100"
type="button"
@ -58,23 +120,32 @@ export default function AddToListDropdown() {
static={true}>
{menuOpened && (
<>
{lists.map((list) => (
{(listsWithQuestionData ?? []).map((list) => (
<div key={list.id} className="py-1">
<Menu.Item>
{({ active }) => (
<button
className={classNames(
className={clsx(
active
? 'bg-slate-100 text-slate-900'
: 'text-slate-700',
'group flex w-full items-center px-4 py-2 text-sm',
)}
type="button">
type="button"
onClick={() => {
if (list.hasQuestion) {
handleDeleteFromList(list.id);
} else {
handleAddToList(list.id);
}
}}>
{list.hasQuestion && (
<CheckIcon
aria-hidden="true"
className="mr-3 h-5 w-5 text-slate-400 group-hover:text-slate-500"
/>
list.name
)}
{list.name}
</button>
)}
</Menu.Item>

@ -154,7 +154,7 @@ export default function BaseQuestionCard({
<p className="text-xs">{timestamp}</p>
{showAddToList && (
<div className="pl-4">
<AddToListDropdown></AddToListDropdown>
<AddToListDropdown questionId={questionId} />
</div>
)}
</div>

@ -14,28 +14,9 @@ import DeleteListDialog from '~/components/questions/DeleteListDialog';
import { Button } from '~/../../../packages/ui/dist';
import { APP_TITLE } from '~/utils/questions/constants';
import { SAMPLE_QUESTION } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import { trpc } from '~/utils/trpc';
export const questions = [
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
];
export default function ListPage() {
const utils = trpc.useContext();
const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
@ -57,10 +38,17 @@ export default function ListPage() {
},
},
);
const [selectedList, setSelectedList] = useState(
lists?.length ? lists[0].id : '',
const { mutateAsync: deleteQuestionEntry } = trpc.useMutation(
'questions.lists.deleteQuestionEntry',
{
onSuccess: () => {
// TODO: Add optimistic update
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
const [selectedListIndex, setSelectedListIndex] = useState(0);
const [showDeleteListDialog, setShowDeleteListDialog] = useState(false);
const [showCreateListDialog, setShowCreateListDialog] = useState(false);
@ -91,19 +79,17 @@ export default function ListPage() {
const listOptions = (
<>
<ul className="flex flex-1 flex-col divide-y divide-solid divide-slate-200">
{(lists ?? []).map((list) => (
{(lists ?? []).map((list, index) => (
<li
key={list.id}
className={`flex items-center hover:bg-slate-50 ${
selectedList === list.id ? 'bg-primary-100' : ''
selectedListIndex === index ? 'bg-primary-100' : ''
}`}>
<button
className="flex w-full flex-1 justify-between "
type="button"
onClick={() => {
setSelectedList(list.id);
// eslint-disable-next-line no-console
console.log(selectedList);
setSelectedListIndex(index);
}}>
<p className="text-primary-700 text-md p-3 pl-6 font-medium">
{list.name}
@ -183,20 +169,27 @@ export default function ListPage() {
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto">
<div className="flex min-h-0 max-w-3xl flex-1 p-4">
<div className="flex flex-1 flex-col items-stretch justify-start gap-4">
{selectedList && (
{lists?.[selectedListIndex] && (
<div className="flex flex-col gap-4 pb-4">
{(questions ?? []).map((question) => (
{lists[selectedListIndex].questionEntries.map(
({ question, id: entryId }) => (
<QuestionListCard
key={question.id}
companies={question.companies}
companies={
question.aggregatedQuestionEncounters.companyCounts
}
content={question.content}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
locations={question.locations}
locations={
question.aggregatedQuestionEncounters.locationCounts
}
questionId={question.id}
receivedCount={0}
roles={question.roles}
receivedCount={question.receivedCount}
roles={
question.aggregatedQuestionEncounters.roleCounts
}
timestamp={question.seenAt.toLocaleDateString(
undefined,
{
@ -206,15 +199,17 @@ export default function ListPage() {
)}
type={question.type}
onDelete={() => {
// eslint-disable-next-line no-console
console.log('delete');
deleteQuestionEntry({ id: entryId });
}}
/>
))}
{questions?.length === 0 && (
),
)}
{lists[selectedListIndex].questionEntries?.length === 0 && (
<div className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-slate-200 p-4 text-slate-600">
<NoSymbolIcon className="h-6 w-6" />
<p>You have no added any questions to your list yet.</p>
<p>
You have not added any questions to your list yet.
</p>
</div>
)}
</div>

@ -1,6 +1,8 @@
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import createQuestionWithAggregateData from '~/utils/questions/server/createQuestionWithAggregateData';
import { createProtectedRouter } from './context';
export const questionListRouter = createProtectedRouter()
@ -8,11 +10,34 @@ export const questionListRouter = createProtectedRouter()
async resolve({ ctx }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsList.findMany({
const questionsLists = await ctx.prisma.questionsList.findMany({
include: {
questionEntries: {
include: {
question: true,
question: {
include: {
_count: {
select: {
answers: true,
comments: true,
},
},
encounters: {
select: {
company: true,
location: true,
role: true,
seenAt: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
},
},
},
},
@ -23,20 +48,54 @@ export const questionListRouter = createProtectedRouter()
userId,
},
});
const lists = questionsLists.map((list) => ({
...list,
questionEntries: list.questionEntries.map((entry) => ({
...entry,
question: createQuestionWithAggregateData(entry.question),
})),
}));
return lists;
},
})
.query('getListById', {
input: z.object({
listId: z.string(),
}),
async resolve({ ctx }) {
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { listId } = input;
return await ctx.prisma.questionsList.findMany({
const questionList = await ctx.prisma.questionsList.findFirst({
include: {
questionEntries: {
include: {
question: true,
question: {
include: {
_count: {
select: {
answers: true,
comments: true,
},
},
encounters: {
select: {
company: true,
location: true,
role: true,
seenAt: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
},
},
},
},
@ -44,9 +103,25 @@ export const questionListRouter = createProtectedRouter()
createdAt: 'asc',
},
where: {
id: userId,
id: listId,
userId,
},
});
if (!questionList) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Question list not found',
});
}
return {
...questionList,
questionEntries: questionList.questionEntries.map((questionEntry) => ({
...questionEntry,
question: createQuestionWithAggregateData(questionEntry.question),
})),
};
},
})
.mutation('create', {
@ -139,7 +214,7 @@ export const questionListRouter = createProtectedRouter()
},
});
if (listToAugment?.id !== userId) {
if (listToAugment?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -170,10 +245,10 @@ export const questionListRouter = createProtectedRouter()
},
});
if (entryToDelete?.id !== userId) {
if (entryToDelete === null) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
code: 'NOT_FOUND',
message: 'Entry not found.',
});
}
@ -183,7 +258,7 @@ export const questionListRouter = createProtectedRouter()
},
});
if (listToAugment?.id !== userId) {
if (listToAugment?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',

@ -2,9 +2,10 @@ import { z } from 'zod';
import { QuestionsQuestionType, Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import createQuestionWithAggregateData from '~/utils/questions/server/createQuestionWithAggregateData';
import { createProtectedRouter } from './context';
import type { Question } from '~/types/questions';
import { SortOrder, SortType } from '~/types/questions.d';
export const questionsQuestionRouter = createProtectedRouter()
@ -122,73 +123,10 @@ export const questionsQuestionRouter = createProtectedRouter()
},
});
const processedQuestionsData = questionsData.map((data) => {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
const processedQuestionsData = questionsData.map(
createQuestionWithAggregateData,
);
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
let latestSeenAt = data.encounters[0].seenAt;
for (let i = 0; i < data.encounters.length; i++) {
const encounter = data.encounters[i];
latestSeenAt =
latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 0;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 0;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 0;
}
roleCounts[encounter.role] += 1;
}
const question: Question = {
aggregatedQuestionEncounters: {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
},
content: data.content,
id: data.id,
numAnswers: data._count.answers,
numComments: data._count.comments,
numVotes: votes,
receivedCount: data.encounters.length,
seenAt: data.encounters[0].seenAt,
type: data.questionType,
updatedAt: data.updatedAt,
user: data.user?.name ?? '',
};
return question;
});
let nextCursor: typeof cursor | undefined = undefined;
if (questionsData.length > input.limit) {
@ -252,68 +190,8 @@ export const questionsQuestionRouter = createProtectedRouter()
message: 'Question not found',
});
}
const votes: number = questionData.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
let latestSeenAt = questionData.encounters[0].seenAt;
for (const encounter of questionData.encounters) {
latestSeenAt =
latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 1;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 1;
}
roleCounts[encounter.role] += 1;
}
const question: Question = {
aggregatedQuestionEncounters: {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
},
content: questionData.content,
id: questionData.id,
numAnswers: questionData._count.answers,
numComments: questionData._count.comments,
numVotes: votes,
receivedCount: questionData.encounters.length,
seenAt: questionData.encounters[0].seenAt,
type: questionData.questionType,
updatedAt: questionData.updatedAt,
user: questionData.user?.name ?? '',
};
return question;
return createQuestionWithAggregateData(questionData);
},
})
.query('getRelatedQuestionsByContent', {
@ -323,12 +201,11 @@ export const questionsQuestionRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const escapeChars = /[()|&:*!]/g;
const query =
input.content
.replace(escapeChars, " ")
const query = input.content
.replace(escapeChars, ' ')
.trim()
.split(/\s+/)
.join(" | ");
.join(' | ');
const relatedQuestions = await ctx.prisma.$queryRaw`
SELECT * FROM "QuestionsQuestion"
@ -338,8 +215,7 @@ export const questionsQuestionRouter = createProtectedRouter()
`;
return relatedQuestions;
}
},
})
.mutation('create', {
input: z.object({

@ -0,0 +1,92 @@
import type {
Company,
QuestionsQuestion,
QuestionsQuestionVote,
} from '@prisma/client';
import { Vote } from '@prisma/client';
import type { Question } from '~/types/questions';
type QuestionWithAggregatableData = QuestionsQuestion & {
_count: {
answers: number;
comments: number;
};
encounters: Array<{
company: Company | null;
location: string;
role: string;
seenAt: Date;
}>;
user: {
name: string | null;
} | null;
votes: Array<QuestionsQuestionVote>;
};
export default function createQuestionWithAggregateData(
data: QuestionWithAggregatableData,
): Question {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
let latestSeenAt = data.encounters[0].seenAt;
for (const encounter of data.encounters) {
latestSeenAt =
latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 0;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 0;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 0;
}
roleCounts[encounter.role] += 1;
}
const question: Question = {
aggregatedQuestionEncounters: {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
},
content: data.content,
id: data.id,
numAnswers: data._count.answers,
numComments: data._count.comments,
numVotes: votes,
receivedCount: data.encounters.length,
seenAt: data.encounters[0].seenAt,
type: data.questionType,
updatedAt: data.updatedAt,
user: data.user?.name ?? '',
};
return question;
}
Loading…
Cancel
Save