diff --git a/apps/portal/src/components/questions/card/question/BaseQuestionCard.tsx b/apps/portal/src/components/questions/card/question/BaseQuestionCard.tsx index 7b50c1a9..a5d3baef 100644 --- a/apps/portal/src/components/questions/card/question/BaseQuestionCard.tsx +++ b/apps/portal/src/components/questions/card/question/BaseQuestionCard.tsx @@ -6,17 +6,25 @@ import { EyeIcon, TrashIcon, } from '@heroicons/react/24/outline'; -import type { QuestionsQuestionType } from '@prisma/client'; +import type { + QuestionsQuestionTag, + QuestionsQuestionType, +} from '@prisma/client'; +import type { TypeaheadOption } from '@tih/ui'; +import { HorizontalDivider } from '@tih/ui'; +import { Badge } from '@tih/ui'; import { Button } from '@tih/ui'; import { useProtectedCallback } from '~/utils/questions/useProtectedCallback'; import { useQuestionVote } from '~/utils/questions/useVote'; +import { trpc } from '~/utils/trpc'; import AddToListDropdown from '../../AddToListDropdown'; import type { CreateQuestionEncounterData } from '../../forms/CreateQuestionEncounterForm'; import CreateQuestionEncounterForm from '../../forms/CreateQuestionEncounterForm'; import QuestionAggregateBadge from '../../QuestionAggregateBadge'; import QuestionTypeBadge from '../../QuestionTypeBadge'; +import TagTypeahead from '../../typeahead/TagTypeahead'; import VotingButtons from '../../VotingButtons'; import type { CountryInfo } from '~/types/questions'; @@ -118,6 +126,8 @@ export type BaseQuestionCardProps = ActionButtonProps & content: string; questionId: string; showHover?: boolean; + showTagForm?: boolean; + tags: Array; timestamp: string | null; truncateContent?: boolean; type: QuestionsQuestionType; @@ -142,18 +152,36 @@ export default function BaseQuestionCard({ upvoteCount, timestamp, roles, + tags, countries, showHover, onReceivedSubmit, showDeleteButton, showAddToList, onDelete, + showTagForm, truncateContent = true, }: BaseQuestionCardProps) { const [showReceivedForm, setShowReceivedForm] = useState(false); const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId); + + const utils = trpc.useContext(); + const { mutateAsync: addTagToQuestion, isLoading: isAddingTag } = + trpc.useMutation('questions.tags.user.addTagToQuestion', { + onSuccess: () => { + utils.invalidateQueries([ + 'questions.questions.getQuestionById', + { + id: questionId, + }, + ]); + }, + }); + const hoverClass = showHover ? 'hover:bg-slate-50' : ''; + const [selectedTag, setSelectedTag] = useState(null); + const locations = useMemo(() => { if (countries === undefined) { return undefined; @@ -185,7 +213,7 @@ export default function BaseQuestionCard({ )}
-
+
{showAggregateStatistics && ( <> @@ -216,6 +244,11 @@ export default function BaseQuestionCard({ /> )}
+
+ {tags.map((tag) => ( + + ))} +

)} + {showTagForm && ( + <> + +

+ { + setSelectedTag(option); + }} + /> +
+ + )}
); diff --git a/apps/portal/src/components/questions/card/question/FullQuestionCard.tsx b/apps/portal/src/components/questions/card/question/FullQuestionCard.tsx index 0ea147fe..f724ef82 100644 --- a/apps/portal/src/components/questions/card/question/FullQuestionCard.tsx +++ b/apps/portal/src/components/questions/card/question/FullQuestionCard.tsx @@ -10,6 +10,7 @@ export type QuestionOverviewCardProps = Omit< showCreateEncounterButton: true; showDeleteButton: false; showReceivedStatistics: false; + showTagForm: true; showVoteButtons: true; }, | 'actionButtonLabel' @@ -21,6 +22,7 @@ export type QuestionOverviewCardProps = Omit< | 'showCreateEncounterButton' | 'showDeleteButton' | 'showReceivedStatistics' + | 'showTagForm' | 'showVoteButtons' >; @@ -34,6 +36,7 @@ export default function FullQuestionCard(props: QuestionOverviewCardProps) { showAnswerStatistics={false} showCreateEncounterButton={true} showReceivedStatistics={false} + showTagForm={true} showVoteButtons={true} truncateContent={false} /> diff --git a/apps/portal/src/components/questions/forms/ContributeQuestionForm.tsx b/apps/portal/src/components/questions/forms/ContributeQuestionForm.tsx index 10aa33ef..47ce6a62 100644 --- a/apps/portal/src/components/questions/forms/ContributeQuestionForm.tsx +++ b/apps/portal/src/components/questions/forms/ContributeQuestionForm.tsx @@ -220,6 +220,7 @@ export default function ContributeQuestionForm({ createEncounterButtonText="Yes, this is my question" questionId={question.id} roles={roleCounts} + tags={question.tags} timestamp={ question.seenAt.toLocaleDateString(undefined, { month: 'short', diff --git a/apps/portal/src/components/questions/typeahead/TagTypeahead.tsx b/apps/portal/src/components/questions/typeahead/TagTypeahead.tsx new file mode 100644 index 00000000..a80b972f --- /dev/null +++ b/apps/portal/src/components/questions/typeahead/TagTypeahead.tsx @@ -0,0 +1,110 @@ +import { useMemo, useState } from 'react'; + +import { trpc } from '~/utils/trpc'; + +import type { ExpandedTypeaheadProps } from './ExpandedTypeahead'; +import ExpandedTypeahead from './ExpandedTypeahead'; + +export type TagTypeaheadProps = Omit< + ExpandedTypeaheadProps, + 'clearOnSelect' | 'filterOption' | 'label' | 'onQueryChange' | 'options' +>; + +export const CREATE_ID = 'create'; + +export default function TagTypeahead({ + onSelect, + ...restProps +}: TagTypeaheadProps) { + const [query, setQuery] = useState(''); + + const utils = trpc.useContext(); + const { data: tags } = trpc.useQuery( + [ + 'questions.tags.getTags', + { + name: query, + }, + ], + { + keepPreviousData: true, + }, + ); + + const { mutateAsync: createTagAsync } = trpc.useMutation( + 'questions.tags.user.create', + { + onSuccess: () => { + utils.invalidateQueries(['questions.tags.getTags']); + }, + }, + ); + + const tagOptions = useMemo(() => { + return ( + tags?.map(({ id, tag }) => ({ + id, + label: tag, + value: id, + })) ?? [] + ); + }, [tags]); + + const filteredOptions = useMemo(() => { + const options = tagOptions.filter( + ({ id, label }) => + id === CREATE_ID || label.toLowerCase().includes(query.toLowerCase()), + ); + + if (query === '' || tags?.find(({ tag }) => tag === query)) { + return options; + } + + return [ + ...options, + { + id: CREATE_ID, + label: `Create "${query}"`, + value: query, + }, + ]; + }, [query, tagOptions, tags]); + + return ( + { + if (option.id === CREATE_ID) { + const { value } = option; + + setQuery(''); + onSelect({ + id: 'dummy', + label: value, + value: 'dummy', + }); + await createTagAsync( + { tag: value }, + { + onSuccess: async (tag) => { + onSelect({ + id: tag.id, + label: tag.tag, + value: tag.id, + }); + }, + }, + ); + } else { + onSelect(option); + } + }} + {...(restProps as Omit< + ExpandedTypeaheadProps & { clearOnSelect: boolean }, + 'clearOnSelect' | 'onSelect' + >)} + label="Tag" + options={filteredOptions} + onQueryChange={setQuery} + /> + ); +} diff --git a/apps/portal/src/pages/questions/browse.tsx b/apps/portal/src/pages/questions/browse.tsx index 874dfe51..a049caa5 100644 --- a/apps/portal/src/pages/questions/browse.tsx +++ b/apps/portal/src/pages/questions/browse.tsx @@ -531,6 +531,7 @@ export default function QuestionsBrowsePage() { questionId={question.id} receivedCount={question.receivedCount} roles={roleCounts} + tags={question.tags} timestamp={question.seenAt.toLocaleDateString( undefined, { diff --git a/apps/portal/src/pages/questions/lists.tsx b/apps/portal/src/pages/questions/lists.tsx index 9323499c..4f11172b 100644 --- a/apps/portal/src/pages/questions/lists.tsx +++ b/apps/portal/src/pages/questions/lists.tsx @@ -196,6 +196,7 @@ export default function ListPage() { questionId={question.id} receivedCount={question.receivedCount} roles={roleCounts} + tags={question.tags} timestamp={question.seenAt.toLocaleDateString( undefined, { diff --git a/apps/portal/src/server/router/index.ts b/apps/portal/src/server/router/index.ts index 383bcf47..c87ed0b6 100644 --- a/apps/portal/src/server/router/index.ts +++ b/apps/portal/src/server/router/index.ts @@ -20,6 +20,8 @@ import { questionsQuestionEncounterRouter } from './questions/questions-question import { questionsQuestionEncounterUserRouter } from './questions/questions-question-encounter-user-router'; import { questionsQuestionRouter } from './questions/questions-question-router'; import { questionsQuestionUserRouter } from './questions/questions-question-user-router'; +import { questionsQuestionTagRouter } from './questions/questions-tag-router'; +import { questionsQuestionTagUserRouter } from './questions/questions-tag-user-router'; import { resumeCommentsRouter } from './resumes/resumes-comments-router'; import { resumesCommentsUserRouter } from './resumes/resumes-comments-user-router'; import { resumesCommentsVotesRouter } from './resumes/resumes-comments-votes-router'; @@ -64,6 +66,8 @@ export const appRouter = createRouter() ) .merge('questions.questions.', questionsQuestionRouter) .merge('questions.questions.user.', questionsQuestionUserRouter) + .merge('questions.tags.', questionsQuestionTagRouter) + .merge('questions.tags.user.', questionsQuestionTagUserRouter) .merge('offers.', offersRouter) .merge('offers.profile.', offersProfileRouter) .merge('offers.analysis.', offersAnalysisRouter) diff --git a/apps/portal/src/server/router/questions/questions-list-router.ts b/apps/portal/src/server/router/questions/questions-list-router.ts index fd5437d3..a67b5d6c 100644 --- a/apps/portal/src/server/router/questions/questions-list-router.ts +++ b/apps/portal/src/server/router/questions/questions-list-router.ts @@ -33,6 +33,11 @@ export const questionsListRouter = createProtectedRouter() state: true, }, }, + questionTagEntries: { + select: { + tag: true, + }, + }, user: { select: { name: true, @@ -93,6 +98,11 @@ export const questionsListRouter = createProtectedRouter() state: true, }, }, + questionTagEntries: { + select: { + tag: true, + }, + }, user: { select: { name: true, diff --git a/apps/portal/src/server/router/questions/questions-tag-router.ts b/apps/portal/src/server/router/questions/questions-tag-router.ts new file mode 100644 index 00000000..39222620 --- /dev/null +++ b/apps/portal/src/server/router/questions/questions-tag-router.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +import { createRouter } from '../context'; + +export const questionsQuestionTagRouter = createRouter().query('getTags', { + input: z.object({}), + async resolve({ ctx }) { + return await ctx.prisma.questionsQuestionTag.findMany({}); + }, +}); diff --git a/apps/portal/src/server/router/questions/questions-tag-user-router.ts b/apps/portal/src/server/router/questions/questions-tag-user-router.ts index 27cb55a2..d9255ebe 100644 --- a/apps/portal/src/server/router/questions/questions-tag-user-router.ts +++ b/apps/portal/src/server/router/questions/questions-tag-user-router.ts @@ -2,72 +2,73 @@ import { z } from 'zod'; import { createProtectedRouter } from '../context'; -export const questionsTagUserRouter = createProtectedRouter() +export const questionsQuestionTagUserRouter = createProtectedRouter() .mutation('create', { input: z.object({ tag: z.string(), }), async resolve({ ctx, input }) { return await ctx.prisma.questionsQuestionTag.upsert({ - where: { - tag : input.tag, + create: { + tag: input.tag, }, update: {}, - create : { - tag : input.tag, - } + where: { + tag: input.tag, + }, }); }, }) .mutation('addTagToQuestion', { input: z.object({ - questionId: z.string(), - tagId: z.string(), + questionId: z.string(), + tagId: z.string(), }), async resolve({ ctx, input }) { - return await ctx.prisma.questionsQuestionTagEntry.create({ - data: { - question:{ - connect: { - id: input.questionId, - }, - }, - tag:{ - connect: { - id: input.tagId, - }, - }, + return await ctx.prisma.questionsQuestionTagEntry.create({ + data: { + question: { + connect: { + id: input.questionId, }, - }); + }, + tag: { + connect: { + id: input.tagId, + }, + }, + }, + }); }, }) .mutation('removeTagFromQuestion', { input: z.object({ - id: z.string(), + id: z.string(), }), async resolve({ ctx, input }) { - return await ctx.prisma.questionsQuestionTagEntry.delete({ - where: { - id: input.id, - }, - }); - } - }) - .mutation('combineTags', { + return await ctx.prisma.questionsQuestionTagEntry.delete({ + where: { + id: input.id, + }, + }); + }, + }) + .mutation('combineTags', { input: z.object({ - tagToCombineId: z.string(), - tagToCombineToId: z.string(), + tagToCombineId: z.string(), + tagToCombineToId: z.string(), }), async resolve({ ctx, input }) { return await ctx.prisma.$transaction(async (tx) => { - const questionTagsUpdated = await tx.questionsQuestionTagEntry.updateMany({ - where: { - tagId: input.tagToCombineId, - }, - data: { - tagId: input.tagToCombineId, - }, - }); + const questionTagsUpdated = + await tx.questionsQuestionTagEntry.updateMany({ + data: { + tagId: input.tagToCombineId, + }, + where: { + tagId: input.tagToCombineId, + }, + }); tx.questionsQuestionTag.delete({ where: { @@ -77,5 +78,5 @@ export const questionsTagUserRouter = createProtectedRouter() return questionTagsUpdated; }); - } - }); + }, + }); diff --git a/apps/portal/src/types/questions.d.ts b/apps/portal/src/types/questions.d.ts index 08af458b..25179cb0 100644 --- a/apps/portal/src/types/questions.d.ts +++ b/apps/portal/src/types/questions.d.ts @@ -1,15 +1,18 @@ -import type { QuestionsQuestionType } from '@prisma/client'; +import type { + QuestionsQuestionTag, + QuestionsQuestionType, +} from '@prisma/client'; export type Question = { aggregatedQuestionEncounters: AggregatedQuestionEncounter; content: string; id: string; numAnswers: number; - tags: Array; numComments: number; numVotes: number; receivedCount: number; seenAt: Date; + tags: Array; type: QuestionsQuestionType; updatedAt: Date; user: string; diff --git a/apps/portal/src/utils/questions/server/aggregate-encounters.ts b/apps/portal/src/utils/questions/server/aggregate-encounters.ts index e999a0a2..432d4444 100644 --- a/apps/portal/src/utils/questions/server/aggregate-encounters.ts +++ b/apps/portal/src/utils/questions/server/aggregate-encounters.ts @@ -3,8 +3,8 @@ import type { Company, Country, QuestionsQuestion, + QuestionsQuestionTag, QuestionsQuestionVote, - QuestionTag, State, } from '@prisma/client'; import { Vote } from '@prisma/client'; @@ -15,7 +15,7 @@ import type { Question, } from '~/types/questions'; -type QuestionTagEntry = { tag: QuestionTag; } +type QuestionTagEntry = { tag: QuestionsQuestionTag }; type AggregatableEncounters = Array<{ city: City | null; @@ -31,8 +31,8 @@ type QuestionWithAggregatableData = QuestionsQuestion & { answers: number; comments: number; }; - questionTagEntries: Array; encounters: AggregatableEncounters; + questionTagEntries: Array; user: { name: string | null; } | null; @@ -70,7 +70,7 @@ export function createQuestionWithAggregateData( numVotes: votes, receivedCount: data.encounters.length, seenAt: data.encounters[0].seenAt, - tags: data.tags, + tags: data.questionTagEntries.map(({ tag }) => tag), type: data.questionType, updatedAt: data.updatedAt, user: data.user?.name ?? '',