[questions][feat] add content search (#478)

Co-authored-by: Jeff Sieu <jeffsy00@gmail.com>
This commit is contained in:
hpkoh
2022-10-31 19:24:07 +08:00
committed by GitHub
parent e7431867c2
commit 397ea3f4aa
5 changed files with 203 additions and 6 deletions

View File

@ -9,10 +9,14 @@ import SortOptionsSelect from './SortOptionsSelect';
export type QuestionSearchBarProps = SortOptionsSelectProps & { export type QuestionSearchBarProps = SortOptionsSelectProps & {
onFilterOptionsToggle: () => void; onFilterOptionsToggle: () => void;
onQueryChange: (query: string) => void;
query: string;
}; };
export default function QuestionSearchBar({ export default function QuestionSearchBar({
onFilterOptionsToggle, onFilterOptionsToggle,
onQueryChange,
query,
...sortOptionsSelectProps ...sortOptionsSelectProps
}: QuestionSearchBarProps) { }: QuestionSearchBarProps) {
return ( return (
@ -24,6 +28,10 @@ export default function QuestionSearchBar({
placeholder="Search by content" placeholder="Search by content"
startAddOn={MagnifyingGlassIcon} startAddOn={MagnifyingGlassIcon}
startAddOnType="icon" startAddOnType="icon"
value={query}
onChange={(value) => {
onQueryChange(value);
}}
/> />
</div> </div>
<div className="flex items-end justify-end gap-4"> <div className="flex items-end justify-end gap-4">

View File

@ -218,7 +218,7 @@ export default function BaseQuestionCard({
</div> </div>
<p <p
className={clsx( className={clsx(
'whitespace-pre-line', 'whitespace-pre-line font-semibold',
truncateContent && 'line-clamp-2 text-ellipsis', truncateContent && 'line-clamp-2 text-ellipsis',
)}> )}>
{content} {content}

View File

@ -42,7 +42,9 @@ export default function CreateQuestionEncounterForm({
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-md text-md text-slate-600">I saw this question at</p> <p className="font-md text-md text-slate-600">
I saw this question {step <= 1 ? 'at' : step === 2 ? 'for' : 'on'}
</p>
{step === 0 && ( {step === 0 && (
<div> <div>
<CompanyTypeahead <CompanyTypeahead

View File

@ -37,6 +37,8 @@ import { SortOrder } from '~/types/questions.d';
export default function QuestionsBrowsePage() { export default function QuestionsBrowsePage() {
const router = useRouter(); const router = useRouter();
const [query, setQuery] = useState('');
const [ const [
selectedCompanySlugs, selectedCompanySlugs,
setSelectedCompanySlugs, setSelectedCompanySlugs,
@ -160,13 +162,14 @@ export default function QuestionsBrowsePage() {
const questionsInfiniteQuery = trpc.useInfiniteQuery( const questionsInfiniteQuery = trpc.useInfiniteQuery(
[ [
'questions.questions.getQuestionsByFilter', 'questions.questions.getQuestionsByFilterAndContent',
{ {
// TODO: Enable filtering by countryIds and stateIds // TODO: Enable filtering by countryIds and stateIds
cityIds: selectedLocations cityIds: selectedLocations
.map(({ cityId }) => cityId) .map(({ cityId }) => cityId)
.filter((id) => id !== undefined) as Array<string>, .filter((id) => id !== undefined) as Array<string>,
companyIds: selectedCompanySlugs.map((slug) => slug.split('_')[0]), companyIds: selectedCompanySlugs.map((slug) => slug.split('_')[0]),
content: query,
countryIds: [], countryIds: [],
endDate: today, endDate: today,
limit: 10, limit: 10,
@ -475,8 +478,8 @@ export default function QuestionsBrowsePage() {
</Head> </Head>
<main className="flex flex-1 flex-col items-stretch"> <main className="flex flex-1 flex-col items-stretch">
<div className="flex h-full flex-1"> <div className="flex h-full flex-1">
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto"> <section className="min-h-0 flex-1 overflow-auto">
<div className="m-4 flex max-w-3xl flex-1 flex-col items-stretch justify-start gap-6"> <div className="my-4 mx-auto flex max-w-3xl flex-col items-stretch justify-start gap-6">
<ContributeQuestionCard <ContributeQuestionCard
onSubmit={(data) => { onSubmit={(data) => {
const { cityId, countryId, stateId } = data.location; const { cityId, countryId, stateId } = data.location;
@ -495,11 +498,15 @@ export default function QuestionsBrowsePage() {
<div className="flex flex-col items-stretch gap-4"> <div className="flex flex-col items-stretch gap-4">
<div className="sticky top-0 border-b border-slate-300 bg-slate-50 py-4"> <div className="sticky top-0 border-b border-slate-300 bg-slate-50 py-4">
<QuestionSearchBar <QuestionSearchBar
query={query}
sortOrderValue={sortOrder} sortOrderValue={sortOrder}
sortTypeValue={sortType} sortTypeValue={sortType}
onFilterOptionsToggle={() => { onFilterOptionsToggle={() => {
setFilterDrawerOpen(!filterDrawerOpen); setFilterDrawerOpen(!filterDrawerOpen);
}} }}
onQueryChange={(newQuery) => {
setQuery(newQuery);
}}
onSortOrderChange={setSortOrder} onSortOrderChange={setSortOrder}
onSortTypeChange={setSortType} onSortTypeChange={setSortType}
/> />

View File

@ -236,7 +236,8 @@ export const questionsQuestionRouter = createRouter()
SELECT id FROM "QuestionsQuestion" SELECT id FROM "QuestionsQuestion"
WHERE WHERE
to_tsvector("content") @@ to_tsquery('english', ${query}) to_tsvector("content") @@ to_tsquery('english', ${query})
ORDER BY ts_rank_cd(to_tsvector("content"), to_tsquery('english', ${query}), 4) DESC; ORDER BY ts_rank_cd(to_tsvector("content"), to_tsquery('english', ${query}), 4) DESC
LIMIT 3;
`; `;
const relatedQuestionsIdArray = relatedQuestionsId.map( const relatedQuestionsIdArray = relatedQuestionsId.map(
@ -281,4 +282,183 @@ export const questionsQuestionRouter = createRouter()
return processedQuestionsData; return processedQuestionsData;
}, },
})
.query('getQuestionsByFilterAndContent', {
input: z.object({
cityIds: z.string().array(),
companyIds: z.string().array(),
content: z.string(),
countryIds: z.string().array(),
cursor: z.string().nullish(),
endDate: z.date().default(new Date()),
limit: z.number().min(1).default(50),
questionTypes: z.nativeEnum(QuestionsQuestionType).array(),
roles: z.string().array(),
sortOrder: z.nativeEnum(SortOrder),
sortType: z.nativeEnum(SortType),
startDate: z.date().optional(),
stateIds: z.string().array(),
}),
async resolve({ ctx, input }) {
const escapeChars = /[()|&:*!]/g;
const query = input.content
.replace(escapeChars, ' ')
.trim()
.split(/\s+/)
.join(' | ');
let relatedQuestionsId: Array<{ id: string }> = [];
if (input.content !== "") {
relatedQuestionsId = await ctx.prisma
.$queryRaw`
SELECT id FROM "QuestionsQuestion"
WHERE
to_tsvector("content") @@ to_tsquery('english', ${query})
ORDER BY ts_rank_cd(to_tsvector("content"), to_tsquery('english', ${query}), 4) DESC
LIMIT 3;
`;
}
const relatedQuestionsIdArray = relatedQuestionsId.map(
(current) => current.id,
);
const { cursor } = input;
const sortCondition =
input.sortType === SortType.TOP
? [
{
upvotes: input.sortOrder,
},
{
id: input.sortOrder,
},
]
: [
{
lastSeenAt: input.sortOrder,
},
{
id: input.sortOrder,
},
];
const questionsData = await ctx.prisma.questionsQuestion.findMany({
cursor: cursor ? { id: cursor } : undefined,
include: {
_count: {
select: {
answers: true,
comments: true,
},
},
encounters: {
select: {
city: true,
company: true,
country: true,
role: true,
seenAt: true,
state: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
orderBy: sortCondition,
take: input.limit + 1,
where: {
id: input.content !== "" ? {
in: relatedQuestionsIdArray,
} : undefined,
...(input.questionTypes.length > 0
? {
questionType: {
in: input.questionTypes,
},
}
: {}),
encounters: {
some: {
seenAt: {
gte: input.startDate,
lte: input.endDate,
},
...(input.companyIds.length > 0
? {
company: {
id: {
in: input.companyIds,
},
},
}
: {}),
...(input.cityIds.length > 0
? {
city: {
id: {
in: input.cityIds,
},
},
}
: {}),
...(input.countryIds.length > 0
? {
country: {
id: {
in: input.countryIds,
},
},
}
: {}),
...(input.stateIds.length > 0
? {
state: {
id: {
in: input.stateIds,
},
},
}
: {}),
...(input.roles.length > 0
? {
role: {
in: input.roles,
},
}
: {}),
},
},
},
});
const processedQuestionsData = questionsData.map(
createQuestionWithAggregateData,
);
let nextCursor: typeof cursor | undefined = undefined;
if (questionsData.length > input.limit) {
const nextItem = questionsData.pop()!;
processedQuestionsData.pop();
const nextIdCursor: string | undefined = nextItem.id;
nextCursor = nextIdCursor;
}
return {
data: processedQuestionsData,
nextCursor,
};
},
}); });