From c030d87f84c4d1e06aad013b219505721077a397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rio=20Nunes?= Date: Thu, 21 Mar 2024 08:09:32 +0000 Subject: [PATCH 1/2] feat: question filters --- .prettierignore | 4 +- firebase.json | 16 +- firestore.indexes.json | 151 ++++++++++ jest.config.ts | 8 + package.json | 1 + src/pages/Howto/Category/CategoriesSelect.tsx | 11 +- src/pages/Question/QuestionFilterHeader.tsx | 120 ++++++++ src/pages/Question/QuestionListing.tsx | 268 +++++++++++------- src/pages/Question/QuestionSortOptions.ts | 8 + src/pages/Question/constants.ts | 1 + src/pages/Question/labels.ts | 12 + src/pages/Question/question.routes.test.tsx | 68 +++-- src/pages/Question/question.service.test.ts | 111 ++++++++ src/pages/Question/question.service.ts | 127 +++++++++ .../common/Category/CategoriesSelectV2.tsx | 40 +++ src/stores/Question/question.store.tsx | 3 + .../FilterSorterDecorator.ts | 4 + src/utils/firebase.ts | 4 +- src/utils/searchHelper.test.ts | 29 ++ src/utils/searchHelper.ts | 7 + src/utils/stopwords.ts | 132 +++++++++ yarn.lock | 34 +++ 22 files changed, 1031 insertions(+), 128 deletions(-) create mode 100644 firestore.indexes.json create mode 100644 jest.config.ts create mode 100644 src/pages/Question/QuestionFilterHeader.tsx create mode 100644 src/pages/Question/QuestionSortOptions.ts create mode 100644 src/pages/Question/question.service.test.ts create mode 100644 src/pages/Question/question.service.ts create mode 100644 src/pages/common/Category/CategoriesSelectV2.tsx create mode 100644 src/utils/searchHelper.test.ts create mode 100644 src/utils/searchHelper.ts create mode 100644 src/utils/stopwords.ts diff --git a/.prettierignore b/.prettierignore index a75a006f34..1c3c8ea324 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,6 @@ build .firebase storybook-static -dist \ No newline at end of file +dist +firestore.indexes.json +firebase.json \ No newline at end of file diff --git a/firebase.json b/firebase.json index 91a6bc09c0..ce2321d311 100644 --- a/firebase.json +++ b/firebase.json @@ -1,7 +1,12 @@ { + "$schema": "./node_modules/firebase-tools/schema/firebase-config.json", "hosting": { "public": "build", - "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ], "rewrites": [ { "source": "/api", @@ -86,7 +91,9 @@ ] }, "functions": { - "predeploy": ["yarn workspace functions build"], + "predeploy": [ + "yarn workspace functions build" + ], "source": "functions/dist", "runtime": "nodejs20" }, @@ -122,5 +129,8 @@ }, "extensions": { "firestore-send-email": "firebase/firestore-send-email@0.1.27" + }, + "firestore": { + "indexes": "./firestore.indexes.json" } -} +} \ No newline at end of file diff --git a/firestore.indexes.json b/firestore.indexes.json new file mode 100644 index 0000000000..e68bfd1e35 --- /dev/null +++ b/firestore.indexes.json @@ -0,0 +1,151 @@ +{ + "indexes": [ + { + "collectionGroup": "research_rev20201020", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "_createdBy", + "order": "ASCENDING" + }, + { + "fieldPath": "votedUsefulBy", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "research_rev20201020", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "collaborators", + "arrayConfig": "CONTAINS" + }, + { + "fieldPath": "votedUsefulBy", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "research_rev20201020", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "moderation", + "order": "ASCENDING" + }, + { + "fieldPath": "votedUsefulBy", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "v3_howtos", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "_createdBy", + "order": "ASCENDING" + }, + { + "fieldPath": "moderation", + "order": "ASCENDING" + }, + { + "fieldPath": "votedUsefulBy", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "v3_howtos", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "_createdBy", + "order": "ASCENDING" + }, + { + "fieldPath": "votedUsefulBy", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "v3_howtos", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "_deleted", + "order": "ASCENDING" + }, + { + "fieldPath": "_modified", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "v3_howtos", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "moderation", + "order": "ASCENDING" + }, + { + "fieldPath": "votedUsefulBy", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "questions_rev20230926", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "_created", + "order": "DESCENDING" + }, + { + "fieldPath": "_modified", + "order": "DESCENDING" + }, + { + "fieldPath": "commentCount", + "order": "ASCENDING" + }, + { + "fieldPath": "commentCount", + "order": "DESCENDING" + }, + { + "fieldPath": "latestCommentDate", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "questions_rev20230926", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "questionCategory._id", + "order": "ASCENDING" + }, + { + "fieldPath": "_created", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "DESCENDING" + } + ] + } + ], + "fieldOverrides": [] +} \ No newline at end of file diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000000..894f8b416a --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,8 @@ +import type { JestConfigWithTsJest } from 'ts-jest' + +const config: JestConfigWithTsJest = { + preset: 'ts-jest', + testEnvironment: 'node', +} + +export default config diff --git a/package.json b/package.json index eb2ca2e449..c1243a27c9 100644 --- a/package.json +++ b/package.json @@ -175,6 +175,7 @@ "start-server-and-test": "^1.11.0", "stream-browserify": "^3.0.0", "terser": "3.14.1", + "ts-jest": "^29.1.2", "ts-loader": "^7.0.5", "typescript": "^5.1.6", "wait-on": "^5.2.1", diff --git a/src/pages/Howto/Category/CategoriesSelect.tsx b/src/pages/Howto/Category/CategoriesSelect.tsx index 6d445013d8..c007c69b51 100644 --- a/src/pages/Howto/Category/CategoriesSelect.tsx +++ b/src/pages/Howto/Category/CategoriesSelect.tsx @@ -6,18 +6,19 @@ import { FieldContainer } from '../../../common/Form/FieldContainer' import type { ICategory } from 'src/models/categories.model' +/** + * @deprecated in favor of CategoriesSelectV2 + */ export const CategoriesSelect = observer( ({ value, onChange, placeholder, isForm, type }) => { + const { categoriesStore, researchCategoriesStore } = + useCommonStores().stores + let categories: ICategory[] = [] if (type === 'howto') { - const { categoriesStore } = useCommonStores().stores categories = categoriesStore.allCategories } else if (type === 'research') { - const { researchCategoriesStore } = useCommonStores().stores categories = researchCategoriesStore.allResearchCategories - } else if (type === 'question') { - const { questionCategoriesStore } = useCommonStores().stores - categories = questionCategoriesStore.allQuestionCategories } const selectOptions = categories diff --git a/src/pages/Question/QuestionFilterHeader.tsx b/src/pages/Question/QuestionFilterHeader.tsx new file mode 100644 index 0000000000..608723abeb --- /dev/null +++ b/src/pages/Question/QuestionFilterHeader.tsx @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import debounce from 'debounce' +import { Select } from 'oa-components' +import { FieldContainer } from 'src/common/Form/FieldContainer' +import { Flex, Input } from 'theme-ui' + +import { CategoriesSelectV2 } from '../common/Category/CategoriesSelectV2' +import { listing } from './labels' +import { questionService } from './question.service' +import { QuestionSortOptions } from './QuestionSortOptions' + +import type { SelectValue } from '../common/Category/CategoriesSelectV2' + +type QuestionSearchParams = 'category' | 'q' | 'sort' + +export const QuestionFilterHeader = () => { + const [categories, setCategories] = useState([]) + + const [searchParams, setSearchParams] = useSearchParams() + const categoryParam = searchParams.get('category') + const category = categories?.find((x) => x.value === categoryParam) ?? null + const q = searchParams.get('q') + const sort = searchParams.get('sort') + + const _inputStyle = { + width: ['100%', '100%', '200px'], + mr: [0, 0, 2], + mb: [3, 3, 0], + } + + useEffect(() => { + const initCategories = async () => { + const categories = (await questionService.getQuestionCategories()) || [] + setCategories( + categories.map((x) => { + return { value: x._id, label: x.label } + }), + ) + } + + initCategories() + }, []) + + const updateFilter = useCallback( + (key: QuestionSearchParams, value: string) => { + const params = new URLSearchParams(searchParams.toString()) + if (value) { + params.set(key, value) + } else { + params.delete(key) + } + setSearchParams(params) + }, + [searchParams], + ) + + const onSearchInputChange = useCallback( + debounce((value: string) => { + const params = new URLSearchParams(searchParams.toString()) + params.set('q', value) + + if (value.length > 0 && sort !== QuestionSortOptions.MostRelevant) { + params.set('sort', QuestionSortOptions.MostRelevant) + } + + if (value.length === 0 || !value) { + params.set('sort', QuestionSortOptions.Newest) + } + + setSearchParams(params) + }, 500), + [searchParams], + ) + + return ( + + + + updateFilter('category', updatedCategory) + } + placeholder={listing.filterCategory} + isForm={false} + categories={categories} + /> + + + + onSearchInputChange(e.target.value)} + /> + + + ) +} diff --git a/src/pages/Question/QuestionListing.tsx b/src/pages/Question/QuestionListing.tsx index 75630d9e77..e1ac8d17e7 100644 --- a/src/pages/Question/QuestionListing.tsx +++ b/src/pages/Question/QuestionListing.tsx @@ -1,5 +1,5 @@ -import { Link } from 'react-router-dom' -import { observer } from 'mobx-react' +import { useEffect, useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' import { Button, Category, @@ -7,17 +7,81 @@ import { Loader, ModerationStatus, } from 'oa-components' -import { useQuestionStore } from 'src/stores/Question/question.store' +import { logger } from 'src/logger' +import { questionService } from 'src/pages/Question/question.service' import { Box, Card, Flex, Grid, Heading } from 'theme-ui' -import { SortFilterHeader } from '../common/SortFilterHeader/SortFilterHeader' import { UserNameTag } from '../common/UserNameTag/UserNameTag' +import { ITEMS_PER_PAGE } from './constants' +import { headings, listing } from './labels' +import { QuestionFilterHeader } from './QuestionFilterHeader' +import { QuestionSortOptions } from './QuestionSortOptions' -import type { IQuestionDB } from 'src/models' +import type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore' +import type { IQuestion } from 'src/models' -export const QuestionListing = observer(() => { - const store = useQuestionStore() - const { filteredQuestions, isFetching } = store +export const QuestionListing = () => { + const [isFetching, setIsFetching] = useState(true) + const [questions, setQuestions] = useState([]) + const [total, setTotal] = useState(0) + const [lastVisible, setLastVisible] = useState< + QueryDocumentSnapshot | undefined + >(undefined) + + const [searchParams, setSearchParams] = useSearchParams() + const q = searchParams.get('q') || '' + const category = searchParams.get('category') || '' + const sort = searchParams.get('sort') as QuestionSortOptions + + useEffect(() => { + if (!sort) { + // ensure sort is set + const params = new URLSearchParams(searchParams.toString()) + + if (q) { + params.set('sort', QuestionSortOptions.MostRelevant) + } else { + params.set('sort', QuestionSortOptions.Newest) + } + setSearchParams(params) + } else { + // search only when sort is set (avoids duplicate requests) + fetchQuestions() + } + }, [q, category, sort]) + + const fetchQuestions = async ( + skipFrom?: QueryDocumentSnapshot, + ) => { + setIsFetching(true) + + try { + const searchWords = q ? q.toLocaleLowerCase().split(' ') : [] + + const result = await questionService.search( + searchWords, + category, + sort, + skipFrom, + ITEMS_PER_PAGE, + ) + + if (skipFrom) { + // if skipFrom is set, means we are requesting another page that should be appended + setQuestions((questions) => [...questions, ...result.items]) + } else { + setQuestions(result.items) + } + + setLastVisible(result.lastVisible) + + setTotal(result.total) + } catch (error) { + logger.error('error fetching questions', error) + } + + setIsFetching(false) + } return ( <> @@ -30,7 +94,7 @@ export const QuestionListing = observer(() => { fontSize: 5, }} > - Ask your questions and help others out + {headings.list} { flexDirection: ['column', 'column', 'row'], }} > - - - + + + - {isFetching ? ( - - ) : filteredQuestions && filteredQuestions.length ? ( - filteredQuestions - .filter( - (q: IQuestionDB) => q.moderation && q.moderation === 'accepted', - ) - .map((q: IQuestionDB, idx) => { - const url = `/questions/${encodeURIComponent(q.slug)}` - return ( - - - - - {listing.noQuestions} + )} + + {questions && + questions.length > 0 && + questions.map((question) => { + const url = `/questions/${encodeURIComponent(question.slug)}` + return ( + + + + + + - - {q.title} - - {q.category && ( - - )} - - - - - + {question.title} + + {question.questionCategory && ( + + )} - - - + + - - - - - - ) - }) - ) : ( - - No questions have been asked yet - - )} + + + + + + + + + + ) + })} + + {!isFetching && + questions && + questions.length > 0 && + questions.length < total && ( + + + + )} + + {isFetching && } ) -}) +} diff --git a/src/pages/Question/QuestionSortOptions.ts b/src/pages/Question/QuestionSortOptions.ts new file mode 100644 index 0000000000..8ebbdbe503 --- /dev/null +++ b/src/pages/Question/QuestionSortOptions.ts @@ -0,0 +1,8 @@ +export enum QuestionSortOptions { + MostRelevant = 'MostRelevant', + Newest = 'Newest', + LatestUpdated = 'LatestUpdated', + LatestComments = 'LatestComments', + Comments = 'MostComments', + LeastComments = 'LeastComments', +} diff --git a/src/pages/Question/constants.ts b/src/pages/Question/constants.ts index 6b4aec4021..60e2d50a14 100644 --- a/src/pages/Question/constants.ts +++ b/src/pages/Question/constants.ts @@ -1,3 +1,4 @@ export const QUESTION_MIN_TITLE_LENGTH = 10 export const QUESTION_MAX_TITLE_LENGTH = 60 export const QUESTION_MAX_DESCRIPTION_LENGTH = 1000 +export const ITEMS_PER_PAGE = 25 diff --git a/src/pages/Question/labels.ts b/src/pages/Question/labels.ts index 2977672068..0b2141fe6e 100644 --- a/src/pages/Question/labels.ts +++ b/src/pages/Question/labels.ts @@ -9,6 +9,7 @@ export const buttons = { export const headings = { create: 'Ask your question to the community', edit: 'Edit your question to the community', + list: 'Ask your questions and help others out', } export const fields: ILabels = { @@ -29,3 +30,14 @@ export const fields: ILabels = { placeholder: 'So what do you need to know?', }, } + +export const listing = { + create: 'Ask a question', + noQuestions: 'No questions have been asked yet', + usefulness: 'How useful is it', + totalComments: 'Total comments', + filterCategory: 'Filter by category', + search: 'Search for a question', + sort: 'Sort by', + loadMore: 'Load More', +} diff --git a/src/pages/Question/question.routes.test.tsx b/src/pages/Question/question.routes.test.tsx index 617884585d..1d284a18d4 100644 --- a/src/pages/Question/question.routes.test.tsx +++ b/src/pages/Question/question.routes.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ jest.mock('../../stores/common/module.store') import '@testing-library/jest-dom' @@ -25,6 +26,7 @@ import { FactoryUser } from 'src/test/factories/User' import { testingThemeStyles } from 'src/test/utils/themeUtils' import { questionRouteElements } from './question.routes' +import { questionService } from './question.service' import type { QuestionStore } from 'src/stores/Question/question.store' @@ -94,10 +96,30 @@ class mockQuestionStoreClass implements Partial { userCanEditQuestion = true } +jest.mock('./question.service', () => ({ + questionService: { + search: jest.fn(), + getQuestionCategories: jest.fn(), + }, +})) + +const mockQuestionService: typeof questionService = { + getQuestionCategories: jest.fn(() => { + return new Promise((resolve) => { + resolve([]) + }) + }), + search: jest.fn(() => { + return new Promise((resolve) => { + resolve({ items: [], total: 0, lastVisible: undefined }) + }) + }), +} const mockQuestionStore = new mockQuestionStoreClass() jest.mock('src/stores/Question/question.store') jest.mock('src/stores/Discussions/discussions.store') +jest.mock('src/pages/Question/question.service') describe('question.routes', () => { beforeEach(() => { @@ -116,11 +138,15 @@ describe('question.routes', () => { describe('/questions/', () => { it('renders a loading state', async () => { let wrapper - ;(useQuestionStore as any).mockReturnValue({ - ...mockQuestionStore, - isFetching: true, - activeUser: mockActiveUser, + mockQuestionService.search = jest.fn(() => { + return new Promise((resolve) => { + setTimeout( + () => resolve({ items: [], total: 0, lastVisible: undefined }), + 4000, + ) + }) }) + await act(async () => { wrapper = (await renderFn('/questions')).wrapper expect(wrapper.getByText(/loading/)).toBeInTheDocument() @@ -129,11 +155,6 @@ describe('question.routes', () => { it('renders an empty state', async () => { let wrapper - ;(useQuestionStore as any).mockReturnValue({ - ...mockQuestionStore, - fetchQuestions: jest.fn().mockResolvedValue([]), - activeUser: mockActiveUser, - }) await act(async () => { wrapper = (await renderFn('/questions')).wrapper @@ -158,19 +179,22 @@ describe('question.routes', () => { const questionTitle = faker.lorem.words(3) const questionSlug = faker.lorem.slug() - ;(useQuestionStore as any).mockReturnValue({ - ...mockQuestionStore, - filteredQuestions: [ - { - ...FactoryQuestionItem({ - title: questionTitle, - slug: questionSlug, - }), - _id: '123', - moderation: 'accepted', - }, - ], - activeUser: mockActiveUser, + questionService.search = jest.fn(() => { + return new Promise((resolve) => { + resolve({ + items: [ + { + ...FactoryQuestionItem({ + title: questionTitle, + slug: questionSlug, + }), + _id: '123', + }, + ], + total: 1, + lastVisible: undefined, + }) + }) }) await act(async () => { diff --git a/src/pages/Question/question.service.test.ts b/src/pages/Question/question.service.test.ts new file mode 100644 index 0000000000..f5acd22842 --- /dev/null +++ b/src/pages/Question/question.service.test.ts @@ -0,0 +1,111 @@ +import '@testing-library/jest-dom' + +import { exportedForTesting } from './question.service' +import { QuestionSortOptions } from './QuestionSortOptions' + +const mockWhere = jest.fn() +const mockOrderBy = jest.fn() +const mockLimit = jest.fn() +jest.mock('firebase/firestore', () => ({ + collection: jest.fn(), + query: jest.fn(), + and: jest.fn(), + where: (path, op, value) => mockWhere(path, op, value), + limit: (limit) => mockLimit(limit), + orderBy: (field, direction) => mockOrderBy(field, direction), +})) + +jest.mock('../../stores/databaseV2/endpoints', () => ({ + DB_ENDPOINTS: { + questions: 'questions', + questionCategories: 'questionCategories', + }, +})) + +jest.mock('../../config/config', () => ({ + getConfigurationOption: jest.fn(), + FIREBASE_CONFIG: { + apiKey: 'AIyChVN', + databaseURL: 'https://test.firebaseio.com', + projectId: 'test', + storageBucket: 'test.appspot.com', + }, + localStorage: jest.fn(), +})) + +describe('question.search', () => { + it('searches for text', async () => { + // prepare + const words = ['test', 'text'] + + // act + exportedForTesting.createQueries( + words, + '', + QuestionSortOptions.MostRelevant, + ) + + // assert + expect(mockWhere).toHaveBeenCalledWith( + 'keywords', + 'array-contains-any', + words, + ) + }) + + it('filters by category', async () => { + // prepare + const category = 'cat1' + + // act + exportedForTesting.createQueries( + [], + category, + QuestionSortOptions.MostRelevant, + ) + + // assert + expect(mockWhere).toHaveBeenCalledWith( + 'questionCategory._id', + '==', + category, + ) + }) + + it('should not call orderBy if sorting by most relevant', async () => { + // act + exportedForTesting.createQueries( + ['test'], + '', + QuestionSortOptions.MostRelevant, + ) + + // assert + expect(mockOrderBy).toHaveBeenCalledTimes(0) + }) + + it('should call orderBy when sorting is not MostRelevant', async () => { + // act + exportedForTesting.createQueries(['test'], '', QuestionSortOptions.Newest) + + // assert + expect(mockOrderBy).toHaveBeenLastCalledWith('_created', 'desc') + }) + + it('should limit results', async () => { + // prepare + const take = 12 + + // act + exportedForTesting.createQueries( + ['test'], + '', + QuestionSortOptions.Newest, + undefined, + take, + ) + + // assert + expect(mockLimit).toHaveBeenLastCalledWith(take) + }) +}) diff --git a/src/pages/Question/question.service.ts b/src/pages/Question/question.service.ts new file mode 100644 index 0000000000..6035c4000b --- /dev/null +++ b/src/pages/Question/question.service.ts @@ -0,0 +1,127 @@ +import { + and, + collection, + getCountFromServer, + getDocs, + limit, + orderBy, + query, + startAfter, + where, +} from 'firebase/firestore' + +import { DB_ENDPOINTS } from '../../models' +import { firestore } from '../../utils/firebase' +import { QuestionSortOptions } from './QuestionSortOptions' + +import type { + DocumentData, + QueryDocumentSnapshot, + QueryFilterConstraint, + QueryNonFilterConstraint, +} from 'firebase/firestore' +import type { IQuestion } from '../../models' +import type { ICategory } from '../../models/categories.model' + +const search = async ( + words: string[], + category: string, + sort: QuestionSortOptions, + snapshot?: QueryDocumentSnapshot, + take: number = 10, +) => { + const { itemsQuery, countQuery } = createQueries( + words, + category, + sort, + snapshot, + take, + ) + + const documentSnapshots = await getDocs(itemsQuery) + const lastVisible = documentSnapshots.docs + ? documentSnapshots.docs[documentSnapshots.docs.length - 1] + : undefined + + const items = documentSnapshots.docs + ? documentSnapshots.docs.map((x) => x.data() as IQuestion.Item) + : [] + const total = (await getCountFromServer(countQuery)).data().count + + return { items, total, lastVisible } +} + +const createQueries = ( + words: string[], + category: string, + sort: QuestionSortOptions, + snapshot?: QueryDocumentSnapshot, + take: number = 10, +) => { + const collectionRef = collection(firestore, DB_ENDPOINTS.questions) + let filters: QueryFilterConstraint[] = [] + let constraints: QueryNonFilterConstraint[] = [] + + if (words?.length > 0) { + filters = [...filters, and(where('keywords', 'array-contains-any', words))] + } + + if (category) { + filters = [...filters, where('questionCategory._id', '==', category)] + } + + if (sort) { + const sortConstraint = getSort(sort) + + if (sortConstraint) { + constraints = [...constraints, sortConstraint] + } + } + + const countQuery = query(collectionRef, and(...filters), ...constraints) + + if (snapshot) { + constraints = [...constraints, startAfter(snapshot)] + } + + const itemsQuery = query( + collectionRef, + and(...filters), + ...constraints, + limit(take), + ) + + return { countQuery, itemsQuery } +} + +const getQuestionCategories = async () => { + const collectionRef = collection(firestore, DB_ENDPOINTS.questionCategories) + + return (await getDocs(query(collectionRef))).docs.map( + (x) => x.data() as ICategory, + ) +} + +const getSort = (sort: QuestionSortOptions) => { + switch (sort) { + case QuestionSortOptions.Comments: + return orderBy('commentCount', 'desc') + case QuestionSortOptions.LeastComments: + return orderBy('commentCount', 'asc') + case QuestionSortOptions.Newest: + return orderBy('_created', 'desc') + case QuestionSortOptions.LatestComments: + return orderBy('latestCommentDate', 'desc') + case QuestionSortOptions.LatestUpdated: + return orderBy('_modified', 'desc') + } +} + +export const questionService = { + search, + getQuestionCategories, +} + +export const exportedForTesting = { + createQueries, +} diff --git a/src/pages/common/Category/CategoriesSelectV2.tsx b/src/pages/common/Category/CategoriesSelectV2.tsx new file mode 100644 index 0000000000..3fd7c0db2c --- /dev/null +++ b/src/pages/common/Category/CategoriesSelectV2.tsx @@ -0,0 +1,40 @@ +import { Select } from 'oa-components' + +import { FieldContainer } from '../../../common/Form/FieldContainer' + +export type SelectValue = { label: string; value: string } + +export type CategoriesSelectProps = { + value: SelectValue | null + placeholder: string + isForm: boolean + categories: SelectValue[] + onChange: (value: string) => void +} + +export const CategoriesSelectV2 = ({ + value, + placeholder, + isForm, + categories, + onChange, +}: CategoriesSelectProps) => { + const handleChange = (changedValue) => { + onChange(changedValue?.value ?? null) + } + + return ( + +