diff --git a/packages/cypress/src/integration/questions/search.spec.ts b/packages/cypress/src/integration/questions/search.spec.ts new file mode 100644 index 0000000000..f46b679f3b --- /dev/null +++ b/packages/cypress/src/integration/questions/search.spec.ts @@ -0,0 +1,60 @@ +describe('[How To]', () => { + beforeEach(() => { + cy.visit('/questions') + }) + + describe('[By Everyone]', () => { + it('should clear filters after navigation', () => { + cy.get('[data-cy=questions-search-box]').clear().type(`raincoat`) + cy.url().should('include', 'q=raincoat') + cy.url().should('include', 'sort=MostRelevant') + + cy.get('[data-cy=category-select]').click() + cy.get('[id^="react-select-"]').contains('screening').click() + cy.url().should('include', 'category=categoryoix4r6grC1mMA0Xz3K') + + cy.get('[data-cy=page-link]').contains('Questions').click() + + cy.wait(2000) + cy.get('[data-cy=questions-search-box]') + .invoke('val') + .then((searchText) => expect(searchText).to.equal('')) + cy.get('[data-cy=category-select]').should('have.value', '') + }) + + it('should remove category filter after back navigation', () => { + cy.get('[data-cy=category-select]').click() + cy.get('[id^="react-select-"]').contains('screening').click() + cy.url().should('include', 'category=categoryoix4r6grC1mMA0Xz3K') + cy.go('back') + cy.get('[data-cy=category-select]').should('have.value', '') + cy.url().should('not.include', 'category=categoryoix4r6grC1mMA0Xz3K') + }) + + it('should remove search filter after back navigation', () => { + cy.get('[data-cy=questions-search-box]').clear().type(`raincoat`) + cy.url().should('include', 'q=raincoat') + + cy.go('back') + cy.wait(2000) + + cy.get('[data-cy=questions-search-box]') + .invoke('val') + .then((searchText) => expect(searchText).to.equal('')) + cy.url().should('not.include', 'q=raincoat') + }) + + it('should show question list items after visit a question', () => { + cy.get('[data-cy=question-list-item]:eq(0)').click() + cy.get('[data-cy=question-title]').should('be.visible') + cy.go('back') + cy.get('[data-cy=question-list-item]').should('be.visible') + }) + + it('should load more questions', () => { + cy.get('[data-cy=question-list-item]:eq(21)').should('not.exist') + cy.get('[data-cy=load-more]').click() + cy.get('[data-cy=question-list-item]:eq(21)').should('exist') + }) + }) +}) diff --git a/src/pages/Question/QuestionFilterHeader.tsx b/src/pages/Question/QuestionFilterHeader.tsx index 6d797ac710..22372b74bf 100644 --- a/src/pages/Question/QuestionFilterHeader.tsx +++ b/src/pages/Question/QuestionFilterHeader.tsx @@ -45,6 +45,10 @@ export const QuestionFilterHeader = () => { initCategories() }, []) + useEffect(() => { + setSearchString(q || '') + }, [q]) + const updateFilter = useCallback( (key: QuestionSearchParams, value: string) => { const params = new URLSearchParams(searchParams.toString()) diff --git a/src/pages/Question/QuestionListing.tsx b/src/pages/Question/QuestionListing.tsx index 86514fceb5..c7e12cadc1 100644 --- a/src/pages/Question/QuestionListing.tsx +++ b/src/pages/Question/QuestionListing.tsx @@ -7,12 +7,10 @@ import { questionService } from 'src/pages/Question/question.service' import { commentService } from 'src/services/commentService' import { Flex, Heading } from 'theme-ui' -import { ITEMS_PER_PAGE } from './constants' import { headings, listing } from './labels' import { QuestionFilterHeader } from './QuestionFilterHeader' import { QuestionListItem } from './QuestionListItem' -import type { DocumentData, QueryDocumentSnapshot } from 'firebase/firestore' import type { IQuestion } from 'oa-shared' import type { QuestionSortOption } from './QuestionSortOptions' @@ -20,9 +18,9 @@ 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 [lastVisibleId, setLastVisibleId] = useState( + undefined, + ) const { userStore } = useCommonStores().stores const [searchParams, setSearchParams] = useSearchParams() @@ -47,9 +45,7 @@ export const QuestionListing = () => { } }, [q, category, sort]) - const fetchQuestions = async ( - skipFrom?: QueryDocumentSnapshot, - ) => { + const fetchQuestions = async (skipFrom?: string | undefined) => { setIsFetching(true) try { @@ -60,7 +56,6 @@ export const QuestionListing = () => { category, sort, skipFrom, - ITEMS_PER_PAGE, ) if (result) { @@ -71,7 +66,7 @@ export const QuestionListing = () => { setQuestions(result.items) } - setLastVisible(result.lastVisible) + setLastVisibleId(result.lastVisibleId) setTotal(result.total) @@ -168,7 +163,11 @@ export const QuestionListing = () => { justifyContent: 'center', }} > - diff --git a/src/pages/Question/question.service.test.ts b/src/pages/Question/question.service.test.ts deleted file mode 100644 index 0b0b8e9b8a..0000000000 --- a/src/pages/Question/question.service.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import '@testing-library/jest-dom/vitest' - -import { describe, expect, it, vi } from 'vitest' - -import { exportedForTesting } from './question.service' - -const mockWhere = vi.fn() -const mockOrderBy = vi.fn() -const mockLimit = vi.fn() -vi.mock('firebase/firestore', () => ({ - collection: vi.fn(), - query: vi.fn(), - and: vi.fn(), - where: (path, op, value) => mockWhere(path, op, value), - limit: (limit) => mockLimit(limit), - orderBy: (field, direction) => mockOrderBy(field, direction), -})) - -vi.mock('../../stores/databaseV2/endpoints', () => ({ - DB_ENDPOINTS: { - questions: 'questions', - questionCategories: 'questionCategories', - }, -})) - -vi.mock('../../config/config', () => ({ - getConfigurationOption: vi.fn(), - FIREBASE_CONFIG: { - apiKey: 'AIyChVN', - databaseURL: 'https://test.firebaseio.com', - projectId: 'test', - storageBucket: 'test.appspot.com', - }, - localStorage: vi.fn(), - SITE: 'unit-tests', -})) - -describe('question.search', () => { - it('searches for text', () => { - // prepare - const words = ['test', 'text'] - - // act - exportedForTesting.createQueries(words, '', 'MostRelevant') - - // assert - expect(mockWhere).toHaveBeenCalledWith( - 'keywords', - 'array-contains-any', - words, - ) - }) - - it('filters by category', () => { - // prepare - const category = 'cat1' - - // act - exportedForTesting.createQueries([], category, 'MostRelevant') - - // assert - expect(mockWhere).toHaveBeenCalledWith( - 'questionCategory._id', - '==', - category, - ) - }) - - it('should not call orderBy if sorting by most relevant', () => { - // act - exportedForTesting.createQueries(['test'], '', 'MostRelevant') - - // assert - expect(mockOrderBy).toHaveBeenCalledTimes(0) - }) - - it('should call orderBy when sorting is not MostRelevant', () => { - // act - exportedForTesting.createQueries(['test'], '', 'Newest') - - // assert - expect(mockOrderBy).toHaveBeenLastCalledWith('_created', 'desc') - }) - - it('should limit results', () => { - // prepare - const take = 12 - - // act - exportedForTesting.createQueries(['test'], '', 'Newest', undefined, take) - - // assert - expect(mockLimit).toHaveBeenLastCalledWith(take) - }) -}) diff --git a/src/pages/Question/question.service.ts b/src/pages/Question/question.service.ts index 7a133c066d..3164e95a20 100644 --- a/src/pages/Question/question.service.ts +++ b/src/pages/Question/question.service.ts @@ -1,26 +1,13 @@ +import { collection, getDocs, query, where } from 'firebase/firestore' import { - and, - collection, - getCountFromServer, - getDocs, - limit, - orderBy, - query, - startAfter, - where, -} from 'firebase/firestore' -import { IModerationStatus } from 'oa-shared' -import { DB_ENDPOINTS } from 'src/models/dbEndpoints' + DB_ENDPOINTS, + type ICategory, + type IQuestion, + type IQuestionDB, +} from 'oa-shared' +import { logger } from 'src/logger' +import { firestore } from 'src/utils/firebase' -import { firestore } from '../../utils/firebase' - -import type { - DocumentData, - QueryDocumentSnapshot, - QueryFilterConstraint, - QueryNonFilterConstraint, -} from 'firebase/firestore' -import type { ICategory, IQuestion, IQuestionDB } from 'oa-shared' import type { QuestionSortOption } from './QuestionSortOptions' export enum QuestionSearchParams { @@ -33,141 +20,43 @@ const search = async ( words: string[], category: string, sort: QuestionSortOption, - 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) => { - const item = x.data() as IQuestion.Item - return { - ...item, - commentCount: 0, - } - }) - : [] - const total = (await getCountFromServer(countQuery)).data().count - - return { items, total, lastVisible } -} - -const createQueries = ( - words: string[], - category: string, - sort: QuestionSortOption, - snapshot?: QueryDocumentSnapshot, - take: number = 10, + lastDocId?: string | undefined, ) => { - const collectionRef = collection(firestore, DB_ENDPOINTS.questions) - let filters: QueryFilterConstraint[] = [ - and( - where('_deleted', '!=', true), - where('moderation', '==', IModerationStatus.ACCEPTED), - ), - ] - 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] + try { + const url = new URL('/api/questions', window.location.origin) + url.searchParams.set('words', words.join(',')) + url.searchParams.set('category', category) + url.searchParams.set('sort', sort) + url.searchParams.set('lastDocId', lastDocId ?? '') + const response = await fetch(url) + + const { items, total } = (await response.json()) as { + items: IQuestion.Item[] + total: number } + const lastVisibleId = items ? items[items.length - 1]._id : undefined + return { items, total, lastVisibleId } + } catch (error) { + logger.error('Failed to fetch questions', { error }) + return { items: [], total: 0 } } - - 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 createDraftQuery = (userId: string) => { - const collectionRef = collection(firestore, DB_ENDPOINTS.questions) - const filters = and( - where('_createdBy', '==', userId), - where('moderation', 'in', [ - IModerationStatus.AWAITING_MODERATION, - IModerationStatus.DRAFT, - IModerationStatus.IMPROVEMENTS_NEEDED, - IModerationStatus.REJECTED, - ]), - where('_deleted', '!=', true), - ) - - const countQuery = query(collectionRef, filters) - const itemsQuery = query(collectionRef, filters, orderBy('_modified', 'desc')) - - return { countQuery, itemsQuery } -} - -const getDraftCount = async (userId: string) => { - const { countQuery } = createDraftQuery(userId) - - return (await getCountFromServer(countQuery)).data().count -} - -const getDrafts = async (userId: string) => { - const { itemsQuery } = createDraftQuery(userId) - const docs = await getDocs(itemsQuery) - - return docs.docs ? docs.docs.map((x) => x.data() as IQuestion.Item) : [] -} + try { + const response = await fetch(`/api/questions/categories`) + const responseJson = (await response.json()) as { + categories: ICategory[] + } -const getSort = (sort: QuestionSortOption) => { - switch (sort) { - case 'Comments': - return orderBy('commentCount', 'desc') - case 'LeastComments': - return orderBy('commentCount', 'asc') - case 'Newest': - return orderBy('_created', 'desc') - case 'LatestComments': - return orderBy('latestCommentDate', 'desc') - case 'LatestUpdated': - return orderBy('_modified', 'desc') + return responseJson.categories + } catch (error) { + logger.error('Failed to fetch questions', { error }) + return [] } } const getBySlug = async (slug: string) => { - // Get all that match the slug, to avoid creating an index (blocker for cypress tests) let snapshot = await getDocs( query( collection(firestore, DB_ENDPOINTS.questions), @@ -195,11 +84,5 @@ const getBySlug = async (slug: string) => { export const questionService = { search, getQuestionCategories, - getDraftCount, - getDrafts, getBySlug, } - -export const exportedForTesting = { - createQueries, -} diff --git a/src/routes/api.questions.categories.ts b/src/routes/api.questions.categories.ts new file mode 100644 index 0000000000..a1053aabdc --- /dev/null +++ b/src/routes/api.questions.categories.ts @@ -0,0 +1,24 @@ +import { json } from '@remix-run/node' +import { collection, getDocs, query } from 'firebase/firestore' +import Keyv from 'keyv' +import { DB_ENDPOINTS } from 'src/models/dbEndpoints' +import { firestore } from 'src/utils/firebase' + +import type { ICategory } from 'oa-shared' + +const cache = new Keyv({ ttl: 3600000 }) // ttl: 60 minutes + +export const loader = async () => { + const cachedCategories = await cache.get('questionCategories') + + // check if cached categories are available, if not - load from db and cache them + if (cachedCategories) return json({ categories: cachedCategories }) + + const collectionRef = collection(firestore, DB_ENDPOINTS.questionCategories) + const categories = (await getDocs(query(collectionRef))).docs.map( + (x) => x.data() as ICategory, + ) + + cache.set('questionCategories', categories) + return json({ categories }) +} diff --git a/src/routes/api.questions.ts b/src/routes/api.questions.ts new file mode 100644 index 0000000000..6936af3e7d --- /dev/null +++ b/src/routes/api.questions.ts @@ -0,0 +1,131 @@ +import { + and, + collection, + doc, + getCountFromServer, + getDoc, + getDocs, + limit, + orderBy, + query, + startAfter, + where, +} from 'firebase/firestore' +import { IModerationStatus } from 'oa-shared' +import { DB_ENDPOINTS } from 'src/models/dbEndpoints' +import { ITEMS_PER_PAGE } from 'src/pages/Question/constants' +import { firestore } from 'src/utils/firebase' + +import type { + QueryFilterConstraint, + QueryNonFilterConstraint, +} from 'firebase/firestore' +import type { IQuestion } from 'oa-shared' +import type { QuestionSortOption } from 'src/pages/Question/QuestionSortOptions' + +export const loader = async ({ request }) => { + const url = new URL(request.url) + const params = new URLSearchParams(url.search) + const words: string[] = + params.get('words') != '' ? params.get('words')?.split(',') ?? [] : [] + const category = params.get('category') || '' + const sort = params.get('sort') as QuestionSortOption + const lastDocId = params.get('lastDocId') || '' + const { itemsQuery, countQuery } = await createQueries( + words, + category, + sort, + lastDocId, + ITEMS_PER_PAGE, + ) + + const documentSnapshots = await getDocs(itemsQuery) + + const items = documentSnapshots.docs + ? documentSnapshots.docs.map((x) => { + const item = x.data() as IQuestion.Item + return { + ...item, + commentCount: 0, + } + }) + : [] + const total = (await getCountFromServer(countQuery)).data().count + + return { items, total } +} + +const createQueries = async ( + words: string[], + category: string, + sort: QuestionSortOption, + lastDocId?: string | undefined, + take: number = 10, +) => { + const collectionRef = collection(firestore, DB_ENDPOINTS.questions) + let filters: QueryFilterConstraint[] = [ + and( + where('_deleted', '!=', true), + where('moderation', '==', IModerationStatus.ACCEPTED), + ), + ] + 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 (lastDocId) { + const lastDocSnapshot = await getDoc( + doc(collection(firestore, DB_ENDPOINTS.questions), lastDocId), + ) + + if (!lastDocSnapshot.exists) { + throw new Error('Document with the provided ID does not exist.') + } + startAfter(lastDocSnapshot) + constraints.push(startAfter(lastDocSnapshot)) + } + + const itemsQuery = query( + collectionRef, + and(...filters), + ...constraints, + limit(take), + ) + + return { countQuery, itemsQuery } +} + +const getSort = (sort: QuestionSortOption) => { + switch (sort) { + case 'Comments': + return orderBy('commentCount', 'desc') + case 'LeastComments': + return orderBy('commentCount', 'asc') + case 'Newest': + return orderBy('_created', 'desc') + case 'LatestComments': + return orderBy('latestCommentDate', 'desc') + case 'LatestUpdated': + return orderBy('_modified', 'desc') + } +} + +export const exportedForTesting = { + createQueries, +}