[questions][feat] create question encounters

pull/411/head
Jeff Sieu 3 years ago
parent d61b5fab02
commit f4f5b30c5e

@ -7,7 +7,7 @@ import {
import { TextInput } from '@tih/ui';
import ContributeQuestionDialog from './ContributeQuestionDialog';
import type { ContributeQuestionFormProps } from './ContributeQuestionForm';
import type { ContributeQuestionFormProps } from './forms/ContributeQuestionForm';
export type ContributeQuestionCardProps = Pick<
ContributeQuestionFormProps,

@ -2,9 +2,9 @@ import { Fragment, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { HorizontalDivider } from '@tih/ui';
import type { ContributeQuestionFormProps } from './ContributeQuestionForm';
import ContributeQuestionForm from './ContributeQuestionForm';
import DiscardDraftDialog from './DiscardDraftDialog';
import type { ContributeQuestionFormProps } from './forms/ContributeQuestionForm';
import ContributeQuestionForm from './forms/ContributeQuestionForm';
export type ContributeQuestionDialogProps = Pick<
ContributeQuestionFormProps,
@ -60,7 +60,7 @@ export default function ContributeQuestionDialog({
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel className="relative max-w-5xl transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 w-full">
<Dialog.Panel className="relative w-full max-w-5xl transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8">
<div className="bg-white p-6 pt-5 sm:pb-4">
<div className="flex flex-1 items-stretch">
<div className="mt-3 w-full sm:mt-0 sm:text-left">

@ -4,13 +4,15 @@ import QuestionCard from './QuestionCard';
export type QuestionOverviewCardProps = Omit<
QuestionCardProps & {
showActionButton: false;
showUserStatistics: false;
showAnswerStatistics: false;
showReceivedStatistics: true;
showVoteButtons: true;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showUserStatistics'
| 'showAnswerStatistics'
| 'showReceivedStatistics'
| 'showVoteButtons'
>;
@ -19,7 +21,8 @@ export default function FullQuestionCard(props: QuestionOverviewCardProps) {
<QuestionCard
{...props}
showActionButton={false}
showUserStatistics={false}
showAnswerStatistics={false}
showReceivedStatistics={true}
showVoteButtons={true}
/>
);

@ -1,9 +1,15 @@
import { ChatBubbleBottomCenterTextIcon } from '@heroicons/react/24/outline';
import { useState } from 'react';
import {
ChatBubbleBottomCenterTextIcon,
EyeIcon,
} from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import { Badge, Button } from '@tih/ui';
import { useQuestionVote } from '~/utils/questions/useVote';
import type { CreateQuestionEncounterData } from '../forms/CreateQuestionEncounterForm';
import CreateQuestionEncounterForm from '../forms/CreateQuestionEncounterForm';
import QuestionTypeBadge from '../QuestionTypeBadge';
import VotingButtons from '../VotingButtons';
@ -17,14 +23,14 @@ type UpvoteProps =
upvoteCount?: never;
};
type StatisticsProps =
type AnswerStatisticsProps =
| {
answerCount: number;
showUserStatistics: true;
showAnswerStatistics: true;
}
| {
answerCount?: never;
showUserStatistics?: false;
showAnswerStatistics?: false;
};
type ActionButtonProps =
@ -39,14 +45,26 @@ type ActionButtonProps =
showActionButton?: false;
};
type ReceivedStatisticsProps =
| {
onReceivedSubmit: (data: CreateQuestionEncounterData) => void;
receivedCount: number;
showReceivedStatistics: true;
}
| {
onReceivedSubmit?: never;
receivedCount?: never;
showReceivedStatistics?: false;
};
export type QuestionCardProps = ActionButtonProps &
StatisticsProps &
AnswerStatisticsProps &
ReceivedStatisticsProps &
UpvoteProps & {
company: string;
content: string;
location: string;
questionId: string;
receivedCount: number;
role: string;
showHover?: boolean;
timestamp: string;
@ -58,10 +76,11 @@ export default function QuestionCard({
company,
answerCount,
content,
// ReceivedCount,
receivedCount,
type,
showVoteButtons,
showUserStatistics,
showAnswerStatistics,
showReceivedStatistics,
showActionButton,
actionButtonLabel,
onActionButtonClick,
@ -70,12 +89,24 @@ export default function QuestionCard({
role,
location,
showHover,
onReceivedSubmit,
}: QuestionCardProps) {
const [showReceivedForm, setShowReceivedForm] = useState(false);
const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId);
const hoverClass = showHover ? 'hover:bg-slate-50' : '';
return (
<article
className={`flex gap-4 rounded-md border border-slate-300 bg-white p-4 ${hoverClass}`}>
const cardContent = showReceivedForm ? (
<CreateQuestionEncounterForm
onCancel={() => {
setShowReceivedForm(false);
}}
onSubmit={(data) => {
onReceivedSubmit?.(data);
setShowReceivedForm(false);
}}
/>
) : (
<>
{showVoteButtons && (
<VotingButtons
upvoteCount={upvoteCount}
@ -105,25 +136,40 @@ export default function QuestionCard({
<div className="ml-2">
<p className="line-clamp-2 text-ellipsis ">{content}</p>
</div>
{showUserStatistics && (
{(showAnswerStatistics || showReceivedStatistics) && (
<div className="flex gap-2">
<Button
addonPosition="start"
icon={ChatBubbleBottomCenterTextIcon}
label={`${answerCount} answers`}
size="sm"
variant="tertiary"
/>
{/* <Button
addonPosition="start"
icon={EyeIcon}
label={`${receivedCount} received this`}
size="sm"
variant="tertiary"
/> */}
{showAnswerStatistics && (
<Button
addonPosition="start"
icon={ChatBubbleBottomCenterTextIcon}
label={`${answerCount} answers`}
size="sm"
variant="tertiary"
/>
)}
{showReceivedStatistics && (
<Button
addonPosition="start"
icon={EyeIcon}
label={`${receivedCount} received this`}
size="sm"
variant="tertiary"
onClick={(event) => {
event.preventDefault();
setShowReceivedForm(true);
}}
/>
)}
</div>
)}
</div>
</>
);
return (
<article
className={`flex gap-4 rounded-md border border-slate-300 bg-white p-4 ${hoverClass}`}>
{cardContent}
</article>
);
}

@ -6,13 +6,15 @@ import QuestionCard from './QuestionCard';
export type QuestionOverviewCardProps = Omit<
QuestionCardProps & {
showActionButton: false;
showUserStatistics: true;
showAnswerStatistics: true;
showReceivedStatistics: true;
showVoteButtons: true;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showUserStatistics'
| 'showAnswerStatistics'
| 'showReceivedStatistics'
| 'showVoteButtons'
>;
@ -21,8 +23,9 @@ function QuestionOverviewCardWithoutHref(props: QuestionOverviewCardProps) {
<QuestionCard
{...props}
showActionButton={false}
showAnswerStatistics={true}
showHover={true}
showUserStatistics={true}
showReceivedStatistics={true}
showVoteButtons={true}
/>
);

@ -17,9 +17,11 @@ import {
useSelectRegister,
} from '~/utils/questions/useFormRegister';
import CompaniesTypeahead from '../shared/CompaniesTypeahead';
import type { Month } from '../shared/MonthYearPicker';
import MonthYearPicker from '../shared/MonthYearPicker';
import LocationTypeahead from '../typeahead/LocationTypeahead';
import RoleTypeahead from '../typeahead/RoleTypeahead';
import CompaniesTypeahead from '../../shared/CompaniesTypeahead';
import type { Month } from '../../shared/MonthYearPicker';
import MonthYearPicker from '../../shared/MonthYearPicker';
export type ContributeQuestionData = {
company: string;
@ -86,9 +88,7 @@ export default function ContributeQuestionForm({
control={control}
name="location"
render={({ field }) => (
<Typeahead
label="Location"
options={LOCATIONS}
<LocationTypeahead
required={true}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
@ -142,9 +142,7 @@ export default function ContributeQuestionForm({
control={control}
name="role"
render={({ field }) => (
<Typeahead
label="Role"
options={ROLES}
<RoleTypeahead
required={true}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}

@ -0,0 +1,110 @@
import { startOfMonth } from 'date-fns';
import { useState } from 'react';
import { Button } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import type { Month } from '~/components/shared/MonthYearPicker';
import MonthYearPicker from '~/components/shared/MonthYearPicker';
import LocationTypeahead from '../typeahead/LocationTypeahead';
import RoleTypeahead from '../typeahead/RoleTypeahead';
export type CreateQuestionEncounterData = {
company: string;
location: string;
role: string;
seenAt: Date;
};
export type CreateQuestionEncounterFormProps = {
onCancel: () => void;
onSubmit: (data: CreateQuestionEncounterData) => void;
};
export default function CreateQuestionEncounterForm({
onCancel,
onSubmit,
}: CreateQuestionEncounterFormProps) {
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
const [selectedLocation, setSelectedLocation] = useState<string | null>(null);
const [selectedRole, setSelectedRole] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<Date>(
startOfMonth(new Date()),
);
return (
<div>
<Button
label="Cancel"
variant="tertiary"
onClick={(event) => {
event.preventDefault();
onCancel();
}}
/>
<div className="flex items-center gap-2">
<p className="font-md text-xl text-slate-600">I saw this question at</p>
<div>
<CompaniesTypeahead
isLabelHidden={true}
onSelect={({ value: company }) => {
setSelectedCompany(company);
}}
/>
</div>
<div>
<LocationTypeahead
isLabelHidden={true}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
onSelect={({ value: location }) => {
setSelectedLocation(location);
}}
/>
</div>
<p className="font-md text-xl text-slate-600">for</p>
<div>
<RoleTypeahead
isLabelHidden={true}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
onSelect={({ value: role }) => {
setSelectedRole(role);
}}
/>
</div>
<p className="font-md text-xl text-slate-600">for</p>
<MonthYearPicker
value={{
month: ((selectedDate?.getMonth() ?? 0) + 1) as Month,
year: selectedDate?.getFullYear() as number,
}}
onChange={(value) => {
setSelectedDate(
startOfMonth(new Date(value.year, value.month - 1)),
);
}}
/>
<Button
label="Submit"
variant="primary"
onClick={() => {
if (
selectedCompany &&
selectedLocation &&
selectedRole &&
selectedDate
) {
onSubmit({
company: selectedCompany,
location: selectedLocation,
role: selectedRole,
seenAt: selectedDate,
});
}
}}
/>
</div>
</div>
);
}

@ -0,0 +1,12 @@
import type { ComponentProps } from 'react';
import { Typeahead } from '@tih/ui';
import { LOCATIONS } from '~/utils/questions/constants';
type TypeaheadProps = ComponentProps<typeof Typeahead>;
export type LocationTypeaheadProps = Omit<TypeaheadProps, 'label' | 'options'>;
export default function LocationTypeahead(props: LocationTypeaheadProps) {
return <Typeahead label="Location" options={LOCATIONS} {...props} />;
}

@ -0,0 +1,12 @@
import type { ComponentProps } from 'react';
import { Typeahead } from '@tih/ui';
import { ROLES } from '~/utils/questions/constants';
type TypeaheadProps = ComponentProps<typeof Typeahead>;
export type RoleTypeaheadProps = Omit<TypeaheadProps, 'label' | 'options'>;
export default function RoleTypeahead(props: RoleTypeaheadProps) {
return <Typeahead label="Role" options={ROLES} {...props} />;
}

@ -45,6 +45,11 @@ export default function QuestionPage() {
{ id: questionId as string },
]);
const { data: aggregatedEncounters } = trpc.useQuery([
'questions.questions.encounters.getAggregatedEncounters',
{ questionId: questionId as string },
]);
const utils = trpc.useContext();
const { data: comments } = trpc.useQuery([
@ -74,6 +79,18 @@ export default function QuestionPage() {
},
});
const { mutate: addEncounter } = trpc.useMutation(
'questions.questions.encounters.create',
{
onSuccess: () => {
utils.invalidateQueries(
'questions.questions.encounters.getAggregatedEncounters',
);
utils.invalidateQueries('questions.questions.getQuestionById');
},
},
);
const handleBackNavigation = () => {
router.back();
};
@ -114,12 +131,21 @@ export default function QuestionPage() {
<FullQuestionCard
{...question}
questionId={question.id}
receivedCount={0}
receivedCount={question.receivedCount}
timestamp={question.seenAt.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
})}
upvoteCount={question.numVotes}
onReceivedSubmit={(data) => {
addEncounter({
companyId: data.company,
location: data.location,
questionId: questionId as string,
role: data.role,
seenAt: data.seenAt,
});
}}
/>
<div className="mx-2">
<Collapsible label={`${(comments ?? []).length} comment(s)`}>
@ -180,7 +206,7 @@ export default function QuestionPage() {
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={0}
upvoteCount={comment.numVotes}
/>
))}
</Collapsible>

@ -381,14 +381,17 @@ export default function QuestionsHomePage() {
)}`}
location={question.location}
questionId={question.id}
receivedCount={0}
receivedCount={question.receivedCount}
role={question.role}
timestamp={question.seenAt.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
})} // TODO: Implement received count
})}
type={question.type}
upvoteCount={question.numVotes}
onReceivedSubmit={(data) => {
console.log(data);
}}
/>
))}
{questions?.length === 0 && (

@ -10,6 +10,7 @@ import { protectedExampleRouter } from './protected-example-router';
import { questionsAnswerCommentRouter } from './questions-answer-comment-router';
import { questionsAnswerRouter } from './questions-answer-router';
import { questionsQuestionCommentRouter } from './questions-question-comment-router';
import { questionsQuestionEncounterRouter } from './questions-question-encounter-router';
import { questionsQuestionRouter } from './questions-question-router';
import { resumeCommentsRouter } from './resumes/resumes-comments-router';
import { resumesCommentsUserRouter } from './resumes/resumes-comments-user-router';
@ -40,6 +41,7 @@ export const appRouter = createRouter()
.merge('questions.answers.comments.', questionsAnswerCommentRouter)
.merge('questions.answers.', questionsAnswerRouter)
.merge('questions.questions.comments.', questionsQuestionCommentRouter)
.merge('questions.questions.encounters.', questionsQuestionEncounterRouter)
.merge('questions.questions.', questionsQuestionRouter)
.merge('offers.', offersRouter)
.merge('offers.profile.', offersProfileRouter)

@ -11,46 +11,46 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const questionEncountersData = await ctx.prisma.questionsQuestionEncounter.findMany({
include: {
company : true,
},
where: {
...input,
},
});
const questionEncountersData =
await ctx.prisma.questionsQuestionEncounter.findMany({
include: {
company: true,
},
where: {
...input,
},
});
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts:Record<string, number> = {};
const roleCounts: Record<string, number> = {};
for (let i = 0; i < questionEncountersData.length; i++) {
const encounter = questionEncountersData[i];
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1;
companyCounts[encounter.company!.name] = 1;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 1;
locationCounts[encounter.location] = 1;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 1;
roleCounts[encounter.role] = 1;
}
roleCounts[encounter.role] += 1;
}
const questionEncounter:AggregatedQuestionEncounter = {
const questionEncounter: AggregatedQuestionEncounter = {
companyCounts,
locationCounts,
roleCounts,
}
};
return questionEncounter;
}
},
})
.mutation('create', {
input: z.object({
@ -58,7 +58,7 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
location: z.string(),
questionId: z.string(),
role: z.string(),
seenAt: z.date()
seenAt: z.date(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
@ -83,11 +83,12 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionEncounterToUpdate = await ctx.prisma.questionsQuestionEncounter.findUnique({
where: {
id: input.id,
},
});
const questionEncounterToUpdate =
await ctx.prisma.questionsQuestionEncounter.findUnique({
where: {
id: input.id,
},
});
if (questionEncounterToUpdate?.id !== userId) {
throw new TRPCError({
@ -113,11 +114,12 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionEncounterToDelete = await ctx.prisma.questionsQuestionEncounter.findUnique({
where: {
id: input.id,
},
});
const questionEncounterToDelete =
await ctx.prisma.questionsQuestionEncounter.findUnique({
where: {
id: input.id,
},
});
if (questionEncounterToDelete?.id !== userId) {
throw new TRPCError({

@ -107,6 +107,7 @@ export const questionsQuestionRouter = createProtectedRouter()
numAnswers: data._count.answers,
numComments: data._count.comments,
numVotes: votes,
receivedCount: data.encounters.length,
role: data.encounters[0].role ?? 'Unknown role',
seenAt: data.encounters[0].seenAt,
type: data.questionType,
@ -180,6 +181,7 @@ export const questionsQuestionRouter = createProtectedRouter()
numAnswers: questionData._count.answers,
numComments: questionData._count.comments,
numVotes: votes,
receivedCount: questionData.encounters.length,
role: questionData.encounters[0].role ?? 'Unknown role',
seenAt: questionData.encounters[0].seenAt,
type: questionData.questionType,

@ -9,6 +9,7 @@ export type Question = {
numAnswers: number;
numComments: number;
numVotes: number;
receivedCount: number;
role: string;
seenAt: Date;
type: QuestionsQuestionType;
@ -20,7 +21,7 @@ export type AggregatedQuestionEncounter = {
companyCounts: Record<string, number>;
locationCounts: Record<string, number>;
roleCounts: Record<string, number>;
}
};
export type AnswerComment = {
content: string;

Loading…
Cancel
Save