[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 { Menu, Transition } from '@headlessui/react';
import { CheckIcon, HeartIcon } from '@heroicons/react/20/solid'; import { CheckIcon, HeartIcon } from '@heroicons/react/20/solid';
import { lists } from '~/pages/questions/lists'; import { trpc } from '~/utils/trpc';
function classNames(...classes: Array<string>) { export type AddToListDropdownProps = {
return classes.filter(Boolean).join(' '); questionId: string;
} };
export default function AddToListDropdown() { export default function AddToListDropdown({
questionId,
}: AddToListDropdownProps) {
const [menuOpened, setMenuOpened] = useState(false); const [menuOpened, setMenuOpened] = useState(false);
const ref = useRef() as React.MutableRefObject<HTMLDivElement>; 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 = () => { const addClickOutsideListener = () => {
document.addEventListener('click', handleClickOutside, true); 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 <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" 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" type="button"
@ -58,23 +120,32 @@ export default function AddToListDropdown() {
static={true}> static={true}>
{menuOpened && ( {menuOpened && (
<> <>
{lists.map((list) => ( {(listsWithQuestionData ?? []).map((list) => (
<div key={list.id} className="py-1"> <div key={list.id} className="py-1">
<Menu.Item> <Menu.Item>
{({ active }) => ( {({ active }) => (
<button <button
className={classNames( className={clsx(
active active
? 'bg-slate-100 text-slate-900' ? 'bg-slate-100 text-slate-900'
: 'text-slate-700', : 'text-slate-700',
'group flex w-full items-center px-4 py-2 text-sm', 'group flex w-full items-center px-4 py-2 text-sm',
)} )}
type="button"> type="button"
<CheckIcon onClick={() => {
aria-hidden="true" if (list.hasQuestion) {
className="mr-3 h-5 w-5 text-slate-400 group-hover:text-slate-500" handleDeleteFromList(list.id);
/> } else {
list.name 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}
</button> </button>
)} )}
</Menu.Item> </Menu.Item>

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

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

@ -1,6 +1,8 @@
import { z } from 'zod'; import { z } from 'zod';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import createQuestionWithAggregateData from '~/utils/questions/server/createQuestionWithAggregateData';
import { createProtectedRouter } from './context'; import { createProtectedRouter } from './context';
export const questionListRouter = createProtectedRouter() export const questionListRouter = createProtectedRouter()
@ -8,11 +10,34 @@ export const questionListRouter = createProtectedRouter()
async resolve({ ctx }) { async resolve({ ctx }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsList.findMany({ const questionsLists = await ctx.prisma.questionsList.findMany({
include: { include: {
questionEntries: { questionEntries: {
include: { 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, userId,
}, },
}); });
const lists = questionsLists.map((list) => ({
...list,
questionEntries: list.questionEntries.map((entry) => ({
...entry,
question: createQuestionWithAggregateData(entry.question),
})),
}));
return lists;
}, },
}) })
.query('getListById', { .query('getListById', {
input: z.object({ input: z.object({
listId: z.string(), listId: z.string(),
}), }),
async resolve({ ctx }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const { listId } = input;
return await ctx.prisma.questionsList.findMany({ const questionList = await ctx.prisma.questionsList.findFirst({
include: { include: {
questionEntries: { questionEntries: {
include: { 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', createdAt: 'asc',
}, },
where: { 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', { .mutation('create', {
@ -139,7 +214,7 @@ export const questionListRouter = createProtectedRouter()
}, },
}); });
if (listToAugment?.id !== userId) { if (listToAugment?.userId !== userId) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', 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({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'NOT_FOUND',
message: 'User have no authorization to record.', message: 'Entry not found.',
}); });
} }
@ -183,7 +258,7 @@ export const questionListRouter = createProtectedRouter()
}, },
}); });
if (listToAugment?.id !== userId) { if (listToAugment?.userId !== userId) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', message: 'User have no authorization to record.',

@ -2,9 +2,10 @@ import { z } from 'zod';
import { QuestionsQuestionType, Vote } from '@prisma/client'; import { QuestionsQuestionType, Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import createQuestionWithAggregateData from '~/utils/questions/server/createQuestionWithAggregateData';
import { createProtectedRouter } from './context'; import { createProtectedRouter } from './context';
import type { Question } from '~/types/questions';
import { SortOrder, SortType } from '~/types/questions.d'; import { SortOrder, SortType } from '~/types/questions.d';
export const questionsQuestionRouter = createProtectedRouter() export const questionsQuestionRouter = createProtectedRouter()
@ -122,72 +123,9 @@ export const questionsQuestionRouter = createProtectedRouter()
}, },
}); });
const processedQuestionsData = questionsData.map((data) => { const processedQuestionsData = questionsData.map(
const votes: number = data.votes.reduce( createQuestionWithAggregateData,
(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 (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; let nextCursor: typeof cursor | undefined = undefined;
@ -252,68 +190,8 @@ export const questionsQuestionRouter = createProtectedRouter()
message: 'Question not found', 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 = { return createQuestionWithAggregateData(questionData);
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;
}, },
}) })
.query('getRelatedQuestionsByContent', { .query('getRelatedQuestionsByContent', {
@ -323,12 +201,11 @@ export const questionsQuestionRouter = createProtectedRouter()
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const escapeChars = /[()|&:*!]/g; const escapeChars = /[()|&:*!]/g;
const query = const query = input.content
input.content .replace(escapeChars, ' ')
.replace(escapeChars, " ") .trim()
.trim() .split(/\s+/)
.split(/\s+/) .join(' | ');
.join(" | ");
const relatedQuestions = await ctx.prisma.$queryRaw` const relatedQuestions = await ctx.prisma.$queryRaw`
SELECT * FROM "QuestionsQuestion" SELECT * FROM "QuestionsQuestion"
@ -338,8 +215,7 @@ export const questionsQuestionRouter = createProtectedRouter()
`; `;
return relatedQuestions; return relatedQuestions;
} },
}) })
.mutation('create', { .mutation('create', {
input: z.object({ input: z.object({
@ -562,7 +438,7 @@ export const questionsQuestionRouter = createProtectedRouter()
const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1; const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
const [ questionVote ] = await ctx.prisma.$transaction([ const [questionVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionVote.delete({ ctx.prisma.questionsQuestionVote.delete({
where: { where: {
id: input.id, id: input.id,

@ -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