feat: question filters

This commit is contained in:
Mário Nunes
2024-03-21 08:09:32 +00:00
committed by Ben Furber
parent 9472531c9d
commit c030d87f84
22 changed files with 1031 additions and 128 deletions

View File

@@ -1,4 +1,6 @@
build
.firebase
storybook-static
dist
dist
firestore.indexes.json
firebase.json

View File

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

151
firestore.indexes.json Normal file
View File

@@ -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": []
}

8
jest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { JestConfigWithTsJest } from 'ts-jest'
const config: JestConfigWithTsJest = {
preset: 'ts-jest',
testEnvironment: 'node',
}
export default config

View File

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

View File

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

View File

@@ -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<SelectValue[]>([])
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 (
<Flex
sx={{
flexWrap: 'nowrap',
justifyContent: 'space-between',
flexDirection: ['column', 'column', 'row'],
mb: 3,
}}
>
<Flex sx={_inputStyle}>
<CategoriesSelectV2
value={category}
onChange={(updatedCategory) =>
updateFilter('category', updatedCategory)
}
placeholder={listing.filterCategory}
isForm={false}
categories={categories}
/>
</Flex>
<Flex sx={_inputStyle}>
<FieldContainer>
<Select
options={Object.values(QuestionSortOptions).map((x) => ({
label: x.toString(),
value: x.toString(),
}))}
placeholder={listing.sort}
value={{ label: sort, value: sort }}
onChange={(sortBy) => updateFilter('sort', sortBy.label)}
/>
</FieldContainer>
</Flex>
<Flex sx={_inputStyle}>
<Input
variant="inputOutline"
data-cy="questions-search-box"
defaultValue={q || ''}
placeholder={listing.search}
onChange={(e) => onSearchInputChange(e.target.value)}
/>
</Flex>
</Flex>
)
}

View File

@@ -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<boolean>(true)
const [questions, setQuestions] = useState<IQuestion.Item[]>([])
const [total, setTotal] = useState<number>(0)
const [lastVisible, setLastVisible] = useState<
QueryDocumentSnapshot<DocumentData, DocumentData> | 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<DocumentData, DocumentData>,
) => {
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}
</Heading>
</Flex>
<Flex
@@ -41,100 +105,112 @@ export const QuestionListing = observer(() => {
flexDirection: ['column', 'column', 'row'],
}}
>
<SortFilterHeader store={store} type="question" />
<Link to={'/questions/create'}>
<Button variant={'primary'}>Ask a question</Button>
<QuestionFilterHeader />
<Link to="/questions/create">
<Button variant="primary">{listing.create}</Button>
</Link>
</Flex>
{isFetching ? (
<Loader />
) : filteredQuestions && filteredQuestions.length ? (
filteredQuestions
.filter(
(q: IQuestionDB) => q.moderation && q.moderation === 'accepted',
)
.map((q: IQuestionDB, idx) => {
const url = `/questions/${encodeURIComponent(q.slug)}`
return (
<Card
key={idx}
mb={3}
px={3}
py={3}
sx={{ position: 'relative' }}
>
<Grid columns={[1, '3fr 1fr']} gap="40px">
<Box sx={{ flexDirection: 'column' }}>
<Link to={url} key={q._id}>
<Flex
sx={{
width: '100%',
flexDirection: ['column', 'row'],
gap: [0, 3],
mb: [1, 0],
{questions?.length === 0 && !isFetching && (
<Heading sx={{ marginTop: 4 }}>{listing.noQuestions}</Heading>
)}
{questions &&
questions.length > 0 &&
questions.map((question) => {
const url = `/questions/${encodeURIComponent(question.slug)}`
return (
<Card
key={question._id}
mb={3}
px={3}
py={3}
sx={{ position: 'relative' }}
>
<Grid columns={[1, '3fr 1fr']} gap="40px">
<Box sx={{ flexDirection: 'column' }}>
<Link to={url} key={question._id}>
<Flex
sx={{
width: '100%',
flexDirection: ['column', 'row'],
gap: [0, 3],
mb: [1, 0],
}}
>
<Heading
as="span"
mb={1}
sx={{
color: 'black',
fontSize: [3, 3, 4],
}}
>
<Heading
as="span"
mb={1}
sx={{
color: 'black',
fontSize: [3, 3, 4],
}}
>
{q.title}
</Heading>
{q.category && (
<Category
category={q.category}
sx={{ fontSize: 2 }}
/>
)}
</Flex>
</Link>
<Flex>
<ModerationStatus
status={q.moderation}
contentType="question"
sx={{ top: 0, position: 'absolute', right: 0 }}
/>
<UserNameTag
userName={q._createdBy}
countryCode={q.creatorCountry}
created={q._created}
action="Asked"
/>
{question.title}
</Heading>
{question.questionCategory && (
<Category
category={question.questionCategory}
sx={{ fontSize: 2 }}
/>
)}
</Flex>
</Box>
<Box
sx={{
display: ['none', 'flex', 'flex'],
alignItems: 'center',
justifyContent: 'space-around',
}}
>
<IconCountWithTooltip
count={(q.votedUsefulBy || []).length}
icon="star-active"
text="How useful is it"
</Link>
<Flex>
<ModerationStatus
status={question.moderation}
contentType="question"
sx={{ top: 0, position: 'absolute', right: 0 }}
/>
<UserNameTag
userName={question._createdBy}
countryCode={question.creatorCountry}
created={question._created}
action="Asked"
/>
</Flex>
</Box>
<Box
sx={{
display: ['none', 'flex', 'flex'],
alignItems: 'center',
justifyContent: 'space-around',
}}
>
<IconCountWithTooltip
count={(question.votedUsefulBy || []).length}
icon="star-active"
text={listing.usefulness}
/>
<IconCountWithTooltip
count={(q as any).commentCount || 0}
icon="comment"
text="Total comments"
/>
</Box>
</Grid>
</Card>
)
})
) : (
<Heading sx={{ marginTop: 4 }}>
No questions have been asked yet
</Heading>
)}
<IconCountWithTooltip
count={(question as any).commentCount || 0}
icon="comment"
text={listing.totalComments}
/>
</Box>
</Grid>
</Card>
)
})}
{!isFetching &&
questions &&
questions.length > 0 &&
questions.length < total && (
<Flex
sx={{
justifyContent: 'center',
}}
>
<Button onClick={() => fetchQuestions(lastVisible)}>
{listing.loadMore}
</Button>
</Flex>
)}
{isFetching && <Loader />}
</>
)
})
}

View File

@@ -0,0 +1,8 @@
export enum QuestionSortOptions {
MostRelevant = 'MostRelevant',
Newest = 'Newest',
LatestUpdated = 'LatestUpdated',
LatestComments = 'LatestComments',
Comments = 'MostComments',
LeastComments = 'LeastComments',
}

View File

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

View File

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

View File

@@ -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<QuestionStore> {
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 () => {

View File

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

View File

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

View File

@@ -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 (
<FieldContainer
data-cy={categories ? 'category-select' : 'category-select-empty'}
>
<Select
variant={isForm ? 'form' : undefined}
options={categories}
placeholder={placeholder}
value={value}
onChange={handleChange}
isClearable={true}
/>
</FieldContainer>
)
}

View File

@@ -8,6 +8,7 @@ import {
isAllowedToEditContent,
randomID,
} from 'src/utils/helpers'
import { getKeywords } from 'src/utils/searchHelper'
import {
FilterSorterDecorator,
@@ -175,12 +176,14 @@ export class QuestionStore extends ModuleStore {
const user = this.activeUser as IUser
const creatorCountry = this.getCreatorCountry(user, values)
const keywords = getKeywords(values.title + ' ' + values.description)
await dbRef.set({
...(values as any),
creatorCountry,
_createdBy: values._createdBy ?? this.activeUser?.userName,
slug,
keywords: keywords,
})
logger.debug(`upsertQuestion.set`, { dbRef })

View File

@@ -39,6 +39,10 @@ export enum ItemSortingOption {
LatestComments = 'LatestComments',
Updates = 'MostUpdates',
TotalDownloads = 'TotalDownloads',
MostRelevant = 'MostRelevant',
/**
* @deprecated This won't be supported with direct firebase queries
*/
Random = 'Random',
SearchResults = 'SearchResults',
}

View File

@@ -2,6 +2,7 @@ import 'firebase/compat/auth'
import 'firebase/compat/storage'
import 'firebase/compat/functions'
import 'firebase/compat/database'
import 'firebase/compat/firestore'
import { initializeApp } from 'firebase/app'
import { connectAuthEmulator, getAuth } from 'firebase/auth'
@@ -19,6 +20,7 @@ const rtdb = firebase.database()
const storage = firebase.storage()
const auth = getAuth(firebaseApp)
const functions = firebase.functions()
const firestore = firebase.firestore()
// use emulators when running on localhost:4000
if (SITE === 'emulated_site') {
@@ -32,7 +34,7 @@ if (SITE === 'emulated_site') {
functions.useEmulator('localhost', 4002)
}
export { rtdb, storage, auth, functions }
export { rtdb, storage, auth, functions, firestore }
export const EmailAuthProvider = firebase.auth.EmailAuthProvider

View File

@@ -0,0 +1,29 @@
import '@testing-library/jest-dom'
import { getKeywords } from './searchHelper'
describe('searchHelper', () => {
it('should return all words of a string', () => {
// act
const words = getKeywords('test1 test2 test3')
// assert
expect(words).toEqual(['test1', 'test2', 'test3'])
})
it('should not return duplicate words', () => {
// act
const words = getKeywords('test1 test1')
// assert
expect(words).toEqual(['test1'])
})
it('should filter stopwords', () => {
// act
const words = getKeywords('i am test1 and themselves')
// assert
expect(words).toEqual(['test1'])
})
})

View File

@@ -0,0 +1,7 @@
import { stopwords } from './stopwords'
export const getKeywords = (text: string) => {
const words = text.toLowerCase().split(' ') // lowercase so comparisons are accurate
const filteredWords = words.filter((word) => !stopwords.has(word)) // filter stopwords
return Array.from(new Set(filteredWords)) // avoid duplicates
}

132
src/utils/stopwords.ts Normal file
View File

@@ -0,0 +1,132 @@
// NLTK's list of english stopwords
// https://gist.github.com/sebleier/554280
export const stopwords = new Set([
'i',
'me',
'my',
'myself',
'we',
'our',
'ours',
'ourselves',
'you',
'your',
'yours',
'yourself',
'yourselves',
'he',
'him',
'his',
'himself',
'she',
'her',
'hers',
'herself',
'it',
'its',
'itself',
'they',
'them',
'their',
'theirs',
'themselves',
'what',
'which',
'who',
'whom',
'this',
'that',
'these',
'those',
'am',
'is',
'are',
'was',
'were',
'be',
'been',
'being',
'have',
'has',
'had',
'having',
'do',
'does',
'did',
'doing',
'a',
'an',
'the',
'and',
'but',
'if',
'or',
'because',
'as',
'until',
'while',
'of',
'at',
'by',
'for',
'with',
'about',
'against',
'between',
'into',
'through',
'during',
'before',
'after',
'above',
'below',
'to',
'from',
'up',
'down',
'in',
'out',
'on',
'off',
'over',
'under',
'again',
'further',
'then',
'once',
'here',
'there',
'when',
'where',
'why',
'how',
'all',
'any',
'both',
'each',
'few',
'more',
'most',
'other',
'some',
'such',
'no',
'nor',
'not',
'only',
'own',
'same',
'so',
'than',
'too',
'very',
's',
't',
'can',
'will',
'just',
'don',
'should',
'now',
])

View File

@@ -27668,6 +27668,7 @@ __metadata:
stream-browserify: ^3.0.0
terser: 3.14.1
theme-ui: ^0.15.7
ts-jest: ^29.1.2
ts-loader: ^7.0.5
ts-node: ^10.4.0
tslog: ^4.9.2
@@ -34262,6 +34263,39 @@ __metadata:
languageName: node
linkType: hard
"ts-jest@npm:^29.1.2":
version: 29.1.2
resolution: "ts-jest@npm:29.1.2"
dependencies:
bs-logger: 0.x
fast-json-stable-stringify: 2.x
jest-util: ^29.0.0
json5: ^2.2.3
lodash.memoize: 4.x
make-error: 1.x
semver: ^7.5.3
yargs-parser: ^21.0.1
peerDependencies:
"@babel/core": ">=7.0.0-beta.0 <8"
"@jest/types": ^29.0.0
babel-jest: ^29.0.0
jest: ^29.0.0
typescript: ">=4.3 <6"
peerDependenciesMeta:
"@babel/core":
optional: true
"@jest/types":
optional: true
babel-jest:
optional: true
esbuild:
optional: true
bin:
ts-jest: cli.js
checksum: a0ce0affc1b716c78c9ab55837829c42cb04b753d174a5c796bb1ddf9f0379fc20647b76fbe30edb30d9b23181908138d6b4c51ef2ae5e187b66635c295cefd5
languageName: node
linkType: hard
"ts-loader@npm:^7.0.5":
version: 7.0.5
resolution: "ts-loader@npm:7.0.5"