[questions][feat] add tags frontend

pull/510/head
Jeff Sieu 3 years ago
parent 441f45e1c7
commit 4c2c66ecdd

@ -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<QuestionsQuestionTag>;
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<TypeaheadOption | null>(null);
const locations = useMemo(() => {
if (countries === undefined) {
return undefined;
@ -185,7 +213,7 @@ export default function BaseQuestionCard({
)}
<div className="flex flex-1 flex-col items-start gap-2">
<div className="flex items-baseline justify-between self-stretch">
<div className="flex items-center gap-2 text-slate-500">
<div className="flex flex-wrap items-center gap-2 text-slate-500">
{showAggregateStatistics && (
<>
<QuestionTypeBadge type={type} />
@ -216,6 +244,11 @@ export default function BaseQuestionCard({
/>
)}
</div>
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Badge key={tag.id} label={tag.tag} variant="warning" />
))}
</div>
<p
className={clsx(
'whitespace-pre-line font-semibold',
@ -269,6 +302,34 @@ export default function BaseQuestionCard({
}}
/>
)}
{showTagForm && (
<>
<HorizontalDivider />
<div className="flex items-end gap-2">
<TagTypeahead
value={selectedTag}
onSelect={async (option) => {
setSelectedTag(option);
}}
/>
<Button
disabled={selectedTag === null}
isLoading={isAddingTag}
label="Add tag"
variant="primary"
onClick={() => {
if (selectedTag === null) {
return;
}
addTagToQuestion({
questionId,
tagId: selectedTag.id,
});
}}
/>
</div>
</>
)}
</div>
</>
);

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

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

@ -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 (
<ExpandedTypeahead
onSelect={async (option) => {
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}
/>
);
}

@ -531,6 +531,7 @@ export default function QuestionsBrowsePage() {
questionId={question.id}
receivedCount={question.receivedCount}
roles={roleCounts}
tags={question.tags}
timestamp={question.seenAt.toLocaleDateString(
undefined,
{

@ -196,6 +196,7 @@ export default function ListPage() {
questionId={question.id}
receivedCount={question.receivedCount}
roles={roleCounts}
tags={question.tags}
timestamp={question.seenAt.toLocaleDateString(
undefined,
{

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

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

@ -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({});
},
});

@ -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;
});
}
});
},
});

@ -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<QuestionTag>;
numComments: number;
numVotes: number;
receivedCount: number;
seenAt: Date;
tags: Array<QuestionsQuestionTag>;
type: QuestionsQuestionType;
updatedAt: Date;
user: string;

@ -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<QuestionTagEntry>;
encounters: AggregatableEncounters;
questionTagEntries: Array<QuestionTagEntry>;
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 ?? '',

Loading…
Cancel
Save