diff --git a/apps/portal/prisma/schema.prisma b/apps/portal/prisma/schema.prisma index 59c53a40..af2ecc0e 100644 --- a/apps/portal/prisma/schema.prisma +++ b/apps/portal/prisma/schema.prisma @@ -204,8 +204,8 @@ model QuestionsQuestionEncounter { userId String? // TODO: sync with models company String @db.Text - location String @db.Text - role String @db.Text + location String? @db.Text + role String? @db.Text seenAt DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/apps/portal/src/components/questions/ContributeQuestionCard.tsx b/apps/portal/src/components/questions/ContributeQuestionCard.tsx index c647e835..fa11f098 100644 --- a/apps/portal/src/components/questions/ContributeQuestionCard.tsx +++ b/apps/portal/src/components/questions/ContributeQuestionCard.tsx @@ -7,8 +7,16 @@ import { import { TextInput } from '@tih/ui'; import ContributeQuestionDialog from './ContributeQuestionDialog'; +import type { ContributeQuestionFormProps } from './ContributeQuestionForm'; -export default function ContributeQuestionCard() { +export type ContributeQuestionCardProps = Pick< + ContributeQuestionFormProps, + 'onSubmit' +>; + +export default function ContributeQuestionCard({ + onSubmit, +}: ContributeQuestionCardProps) { const [showDraftDialog, setShowDraftDialog] = useState(false); const handleDraftDialogCancel = () => { @@ -68,6 +76,7 @@ export default function ContributeQuestionCard() { ); diff --git a/apps/portal/src/components/questions/ContributeQuestionDialog.tsx b/apps/portal/src/components/questions/ContributeQuestionDialog.tsx index 7406b7c5..e2ae8c8c 100644 --- a/apps/portal/src/components/questions/ContributeQuestionDialog.tsx +++ b/apps/portal/src/components/questions/ContributeQuestionDialog.tsx @@ -3,16 +3,21 @@ import { Dialog, Transition } from '@headlessui/react'; import { HorizontalDivider } from '~/../../../packages/ui/dist'; +import type { ContributeQuestionFormProps } from './ContributeQuestionForm'; import ContributeQuestionForm from './ContributeQuestionForm'; import DiscardDraftDialog from './DiscardDraftDialog'; -export type ContributeQuestionDialogProps = { +export type ContributeQuestionDialogProps = Pick< + ContributeQuestionFormProps, + 'onSubmit' +> & { onCancel: () => void; show: boolean; }; export default function ContributeQuestionDialog({ show, + onSubmit, onCancel, }: ContributeQuestionDialogProps) { const [showDiscardDialog, setShowDiscardDialog] = useState(false); @@ -72,8 +77,7 @@ export default function ContributeQuestionDialog({ setShowDiscardDialog(true)} onSubmit={(data) => { - // eslint-disable-next-line no-console - console.log(data); + onSubmit(data); onCancel(); }} /> diff --git a/apps/portal/src/components/questions/ContributeQuestionForm.tsx b/apps/portal/src/components/questions/ContributeQuestionForm.tsx index 5a0b562b..4215bae2 100644 --- a/apps/portal/src/components/questions/ContributeQuestionForm.tsx +++ b/apps/portal/src/components/questions/ContributeQuestionForm.tsx @@ -5,6 +5,7 @@ import { CalendarDaysIcon, // UserIcon, } from '@heroicons/react/24/outline'; +import type { QuestionsQuestionType } from '@prisma/client'; import { Button, Collapsible, @@ -29,7 +30,7 @@ export type ContributeQuestionData = { location: string; position: string; questionContent: string; - questionType: string; + questionType: QuestionsQuestionType; }; export type ContributeQuestionFormProps = { @@ -87,7 +88,9 @@ export default function ContributeQuestionForm({ required={true} startAddOn={CalendarDaysIcon} startAddOnType="icon" - {...register('date')} + {...register('date', { + valueAsDate: true, + })} /> diff --git a/apps/portal/src/components/questions/filter/FilterSection.tsx b/apps/portal/src/components/questions/filter/FilterSection.tsx index 9c5f5dae..f7b4f800 100644 --- a/apps/portal/src/components/questions/filter/FilterSection.tsx +++ b/apps/portal/src/components/questions/filter/FilterSection.tsx @@ -4,13 +4,15 @@ import { Collapsible, TextInput } from '@tih/ui'; import Checkbox from '../ui-patch/Checkbox'; import RadioGroup from '../ui-patch/RadioGroup'; -export type FilterOption = { +export type FilterOption = { checked: boolean; label: string; - value: string; + value: V; }; -export type FilterChoices = Array>; +export type FilterChoices = Array< + Omit, 'checked'> +>; type FilterSectionType> = | { diff --git a/apps/portal/src/pages/questions/index.tsx b/apps/portal/src/pages/questions/index.tsx index 0db97b18..255e1d32 100644 --- a/apps/portal/src/pages/questions/index.tsx +++ b/apps/portal/src/pages/questions/index.tsx @@ -1,5 +1,6 @@ import { useRouter } from 'next/router'; import { useEffect, useMemo, useState } from 'react'; +import type { QuestionsQuestionType } from '@prisma/client'; import QuestionOverviewCard from '~/components/questions/card/QuestionOverviewCard'; import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard'; @@ -13,32 +14,54 @@ import { LOCATIONS, QUESTION_AGES, QUESTION_TYPES, - SAMPLE_QUESTION, } from '~/utils/questions/constants'; +import createSlug from '~/utils/questions/createSlug'; import { useSearchFilter, useSearchFilterSingle, } from '~/utils/questions/useSearchFilter'; +import { trpc } from '~/utils/trpc'; export default function QuestionsHomePage() { const router = useRouter(); const [selectedCompanies, setSelectedCompanies, areCompaniesInitialized] = useSearchFilter('companies'); - const [ selectedQuestionTypes, setSelectedQuestionTypes, areQuestionTypesInitialized, - ] = useSearchFilter('questionTypes'); + ] = useSearchFilter('questionTypes'); const [ selectedQuestionAge, setSelectedQuestionAge, isQuestionAgeInitialized, - ] = useSearchFilterSingle('questionAge', 'all'); + ] = useSearchFilterSingle('questionAge', 'all'); const [selectedLocations, setSelectedLocations, areLocationsInitialized] = useSearchFilter('locations'); + // TODO: Implement filtering + const { data: questions } = trpc.useQuery([ + 'questions.questions.getQuestionsByFilter', + { + // TODO: Update when query accepts multiple question types + questionType: + selectedQuestionTypes.length > 0 + ? (selectedQuestionTypes[0].toUpperCase() as QuestionsQuestionType) + : 'CODING', + }, + ]); + + const utils = trpc.useContext(); + const { mutate: createQuestion } = trpc.useMutation( + 'questions.questions.create', + { + onSuccess: () => { + utils.invalidateQueries('questions.questions.getQuestionsByFilter'); + }, + }, + ); + const [hasLanded, setHasLanded] = useState(false); const [loaded, setLoaded] = useState(false); @@ -74,7 +97,7 @@ export default function QuestionsHomePage() { const { company, location, questionType } = data; setSelectedCompanies([company]); setSelectedLocations([location]); - setSelectedQuestionTypes([questionType]); + setSelectedQuestionTypes([questionType as QuestionsQuestionType]); setHasLanded(true); }; @@ -184,7 +207,17 @@ export default function QuestionsHomePage() {
- + { + createQuestion({ + company: data.company, + content: data.questionContent, + location: data.location, + questionType: data.questionType, + seenAt: data.date, + }); + }} + /> - {Array.from({ length: 10 }).map((_, index) => ( + {(questions ?? []).map((question) => ( ))}
diff --git a/apps/portal/src/server/router/questions-question-router.ts b/apps/portal/src/server/router/questions-question-router.ts index 8918f036..b54d27b0 100644 --- a/apps/portal/src/server/router/questions-question-router.ts +++ b/apps/portal/src/server/router/questions-question-router.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import {QuestionsQuestionType, Vote } from '@prisma/client'; +import { QuestionsQuestionType, Vote } from '@prisma/client'; import { TRPCError } from '@trpc/server'; import { createProtectedRouter } from './context'; @@ -17,28 +17,28 @@ export const questionsQuestionRouter = createProtectedRouter() async resolve({ ctx, input }) { const questionsData = await ctx.prisma.questionsQuestion.findMany({ include: { - _count: { - select: { - answers: true, - comments: true, + _count: { + select: { + answers: true, + comments: true, + }, }, - }, - encounters: { - select: { - company: true, - location: true, - role: true, + encounters: { + select: { + company: true, + location: true, + role: true, + }, }, - }, - user: { - select: { - name: true, + user: { + select: { + name: true, + }, }, - }, - votes: true, + votes: true, }, orderBy: { - createdAt: 'desc', + createdAt: 'desc', }, where: { questionType: input.questionType, @@ -47,68 +47,91 @@ export const questionsQuestionRouter = createProtectedRouter() return questionsData .filter((data) => { for (let i = 0; i < data.encounters.length; i++) { - const encounter = data.encounters[i] - const matchCompany = (!input.company || (encounter.company === input.company)); - const matchLocation = (!input.location || (encounter.location === input.location)); - const matchRole = (!input.company || (encounter.role === input.role)); - if (matchCompany && matchLocation && matchRole) {return true}; + const encounter = data.encounters[i]; + const matchCompany = + !input.company || encounter.company === input.company; + const matchLocation = + !input.location || encounter.location === input.location; + const matchRole = !input.company || encounter.role === input.role; + if (matchCompany && matchLocation && matchRole) { + return true; + } } return false; }) .map((data) => { - const votes:number = data.votes.reduce( - (previousValue:number, currentValue) => { - let result:number = previousValue; + 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; + switch (currentValue.vote) { + case Vote.UPVOTE: + result += 1; + break; + case Vote.DOWNVOTE: + result -= 1; + break; } return result; }, - 0 - ); + 0, + ); - let userName = ""; + let userName = ''; if (data.user) { userName = data.user.name!; } const question: Question = { - company: "", + company: data.encounters[0].company, content: data.content, id: data.id, - location: "", + location: data.encounters[0].location ?? 'Unknown location', numAnswers: data._count.answers, numComments: data._count.comments, numVotes: votes, - role: "", + role: data.encounters[0].role ?? 'Unknown role', updatedAt: data.updatedAt, user: userName, }; return question; - }); - } + }); + }, }) .mutation('create', { input: z.object({ + company: z.string(), content: z.string(), + location: z.string(), questionType: z.nativeEnum(QuestionsQuestionType), + role: z.string().optional(), + seenAt: z.date(), }), async resolve({ ctx, input }) { const userId = ctx.session?.user?.id; - return await ctx.prisma.questionsQuestion.create({ + const question = await ctx.prisma.questionsQuestion.create({ data: { - ...input, + content: input.content, + questionType: input.questionType, + userId, + }, + }); + + // Create question encounter + await ctx.prisma.questionsQuestionEncounter.create({ + data: { + company: input.company, + location: input.location, + questionId: question.id, + role: input.role, + seenAt: input.seenAt, userId, }, }); + + return question; }, }) .mutation('update', { @@ -116,7 +139,6 @@ export const questionsQuestionRouter = createProtectedRouter() content: z.string().optional(), id: z.string(), questionType: z.nativeEnum(QuestionsQuestionType).optional(), - }), async resolve({ ctx, input }) { const userId = ctx.session?.user?.id; @@ -179,11 +201,11 @@ export const questionsQuestionRouter = createProtectedRouter() }), async resolve({ ctx, input }) { const userId = ctx.session?.user?.id; - const {questionId} = input + const { questionId } = input; return await ctx.prisma.questionsQuestionVote.findUnique({ where: { - questionId_userId : {questionId,userId } + questionId_userId: { questionId, userId }, }, }); }, @@ -211,7 +233,7 @@ export const questionsQuestionRouter = createProtectedRouter() }), async resolve({ ctx, input }) { const userId = ctx.session?.user?.id; - const {id, vote} = input + const { id, vote } = input; const voteToUpdate = await ctx.prisma.questionsQuestionVote.findUnique({ where: { @@ -246,7 +268,8 @@ export const questionsQuestionRouter = createProtectedRouter() const voteToDelete = await ctx.prisma.questionsQuestionVote.findUnique({ where: { id: input.id, - },}); + }, + }); if (voteToDelete?.id !== userId) { throw new TRPCError({ @@ -261,4 +284,4 @@ export const questionsQuestionRouter = createProtectedRouter() }, }); }, - }); \ No newline at end of file + }); diff --git a/apps/portal/src/utils/questions/constants.ts b/apps/portal/src/utils/questions/constants.ts index 966e1a1f..155359d5 100644 --- a/apps/portal/src/utils/questions/constants.ts +++ b/apps/portal/src/utils/questions/constants.ts @@ -1,3 +1,5 @@ +import type { QuestionsQuestionType } from '@prisma/client'; + import type { FilterChoices } from '~/components/questions/filter/FilterSection'; export const COMPANIES: FilterChoices = [ @@ -12,18 +14,18 @@ export const COMPANIES: FilterChoices = [ ]; // Code, design, behavioral -export const QUESTION_TYPES: FilterChoices = [ +export const QUESTION_TYPES: FilterChoices = [ { label: 'Coding', - value: 'coding', + value: 'CODING', }, { label: 'Design', - value: 'design', + value: 'SYSTEM_DESIGN', }, { label: 'Behavioral', - value: 'behavioral', + value: 'BEHAVIORAL', }, ]; diff --git a/apps/portal/src/utils/questions/createSlug.ts b/apps/portal/src/utils/questions/createSlug.ts new file mode 100644 index 00000000..9c8a81ae --- /dev/null +++ b/apps/portal/src/utils/questions/createSlug.ts @@ -0,0 +1,7 @@ +export default function createSlug(content: string) { + return content + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)+/g, '') + .substring(0, 100); +} diff --git a/apps/portal/src/utils/questions/useSearchFilter.ts b/apps/portal/src/utils/questions/useSearchFilter.ts index 711f5ead..392a69c8 100644 --- a/apps/portal/src/utils/questions/useSearchFilter.ts +++ b/apps/portal/src/utils/questions/useSearchFilter.ts @@ -1,14 +1,14 @@ import { useRouter } from 'next/router'; import { useCallback, useEffect, useState } from 'react'; -export const useSearchFilter = ( +export const useSearchFilter = ( name: string, - defaultValues?: Array, + defaultValues?: Array, ) => { const [isInitialized, setIsInitialized] = useState(false); const router = useRouter(); - const [filters, setFilters] = useState>(defaultValues || []); + const [filters, setFilters] = useState>(defaultValues || []); useEffect(() => { if (router.isReady && !isInitialized) { @@ -16,7 +16,7 @@ export const useSearchFilter = ( const query = router.query[name]; if (query) { const queryValues = Array.isArray(query) ? query : [query]; - setFilters(queryValues); + setFilters(queryValues as Array); } else { // Try to load from local storage const localStorageValue = localStorage.getItem(name); @@ -37,7 +37,7 @@ export const useSearchFilter = ( }, [isInitialized, name, router]); const setFiltersCallback = useCallback( - (newFilters: Array) => { + (newFilters: Array) => { setFilters(newFilters); localStorage.setItem(name, JSON.stringify(newFilters)); router.replace({ @@ -54,14 +54,17 @@ export const useSearchFilter = ( return [filters, setFiltersCallback, isInitialized] as const; }; -export const useSearchFilterSingle = (name: string, defaultValue: string) => { +export const useSearchFilterSingle = ( + name: string, + defaultValue: Value, +) => { const [filters, setFilters, isInitialized] = useSearchFilter(name, [ defaultValue, ]); return [ filters[0], - (value: string) => { + (value: Value) => { setFilters([value]); }, isInitialized,