mirror of
https://github.com/fergalmoran/onearmy-community-platform.git
synced 2025-12-22 09:37:54 +00:00
feat: question filters
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
build
|
build
|
||||||
.firebase
|
.firebase
|
||||||
storybook-static
|
storybook-static
|
||||||
dist
|
dist
|
||||||
|
firestore.indexes.json
|
||||||
|
firebase.json
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
{
|
{
|
||||||
|
"$schema": "./node_modules/firebase-tools/schema/firebase-config.json",
|
||||||
"hosting": {
|
"hosting": {
|
||||||
"public": "build",
|
"public": "build",
|
||||||
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
|
"ignore": [
|
||||||
|
"firebase.json",
|
||||||
|
"**/.*",
|
||||||
|
"**/node_modules/**"
|
||||||
|
],
|
||||||
"rewrites": [
|
"rewrites": [
|
||||||
{
|
{
|
||||||
"source": "/api",
|
"source": "/api",
|
||||||
@@ -86,7 +91,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"functions": {
|
"functions": {
|
||||||
"predeploy": ["yarn workspace functions build"],
|
"predeploy": [
|
||||||
|
"yarn workspace functions build"
|
||||||
|
],
|
||||||
"source": "functions/dist",
|
"source": "functions/dist",
|
||||||
"runtime": "nodejs20"
|
"runtime": "nodejs20"
|
||||||
},
|
},
|
||||||
@@ -122,5 +129,8 @@
|
|||||||
},
|
},
|
||||||
"extensions": {
|
"extensions": {
|
||||||
"firestore-send-email": "firebase/firestore-send-email@0.1.27"
|
"firestore-send-email": "firebase/firestore-send-email@0.1.27"
|
||||||
|
},
|
||||||
|
"firestore": {
|
||||||
|
"indexes": "./firestore.indexes.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
151
firestore.indexes.json
Normal file
151
firestore.indexes.json
Normal 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
8
jest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { JestConfigWithTsJest } from 'ts-jest'
|
||||||
|
|
||||||
|
const config: JestConfigWithTsJest = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
@@ -175,6 +175,7 @@
|
|||||||
"start-server-and-test": "^1.11.0",
|
"start-server-and-test": "^1.11.0",
|
||||||
"stream-browserify": "^3.0.0",
|
"stream-browserify": "^3.0.0",
|
||||||
"terser": "3.14.1",
|
"terser": "3.14.1",
|
||||||
|
"ts-jest": "^29.1.2",
|
||||||
"ts-loader": "^7.0.5",
|
"ts-loader": "^7.0.5",
|
||||||
"typescript": "^5.1.6",
|
"typescript": "^5.1.6",
|
||||||
"wait-on": "^5.2.1",
|
"wait-on": "^5.2.1",
|
||||||
|
|||||||
@@ -6,18 +6,19 @@ import { FieldContainer } from '../../../common/Form/FieldContainer'
|
|||||||
|
|
||||||
import type { ICategory } from 'src/models/categories.model'
|
import type { ICategory } from 'src/models/categories.model'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated in favor of CategoriesSelectV2
|
||||||
|
*/
|
||||||
export const CategoriesSelect = observer(
|
export const CategoriesSelect = observer(
|
||||||
({ value, onChange, placeholder, isForm, type }) => {
|
({ value, onChange, placeholder, isForm, type }) => {
|
||||||
|
const { categoriesStore, researchCategoriesStore } =
|
||||||
|
useCommonStores().stores
|
||||||
|
|
||||||
let categories: ICategory[] = []
|
let categories: ICategory[] = []
|
||||||
if (type === 'howto') {
|
if (type === 'howto') {
|
||||||
const { categoriesStore } = useCommonStores().stores
|
|
||||||
categories = categoriesStore.allCategories
|
categories = categoriesStore.allCategories
|
||||||
} else if (type === 'research') {
|
} else if (type === 'research') {
|
||||||
const { researchCategoriesStore } = useCommonStores().stores
|
|
||||||
categories = researchCategoriesStore.allResearchCategories
|
categories = researchCategoriesStore.allResearchCategories
|
||||||
} else if (type === 'question') {
|
|
||||||
const { questionCategoriesStore } = useCommonStores().stores
|
|
||||||
categories = questionCategoriesStore.allQuestionCategories
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectOptions = categories
|
const selectOptions = categories
|
||||||
|
|||||||
120
src/pages/Question/QuestionFilterHeader.tsx
Normal file
120
src/pages/Question/QuestionFilterHeader.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { useEffect, useState } from 'react'
|
||||||
import { observer } from 'mobx-react'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Category,
|
Category,
|
||||||
@@ -7,17 +7,81 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
ModerationStatus,
|
ModerationStatus,
|
||||||
} from 'oa-components'
|
} 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 { Box, Card, Flex, Grid, Heading } from 'theme-ui'
|
||||||
|
|
||||||
import { SortFilterHeader } from '../common/SortFilterHeader/SortFilterHeader'
|
|
||||||
import { UserNameTag } from '../common/UserNameTag/UserNameTag'
|
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(() => {
|
export const QuestionListing = () => {
|
||||||
const store = useQuestionStore()
|
const [isFetching, setIsFetching] = useState<boolean>(true)
|
||||||
const { filteredQuestions, isFetching } = store
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -30,7 +94,7 @@ export const QuestionListing = observer(() => {
|
|||||||
fontSize: 5,
|
fontSize: 5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Ask your questions and help others out
|
{headings.list}
|
||||||
</Heading>
|
</Heading>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex
|
<Flex
|
||||||
@@ -41,100 +105,112 @@ export const QuestionListing = observer(() => {
|
|||||||
flexDirection: ['column', 'column', 'row'],
|
flexDirection: ['column', 'column', 'row'],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SortFilterHeader store={store} type="question" />
|
<QuestionFilterHeader />
|
||||||
<Link to={'/questions/create'}>
|
<Link to="/questions/create">
|
||||||
<Button variant={'primary'}>Ask a question</Button>
|
<Button variant="primary">{listing.create}</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</Flex>
|
</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'],
|
{questions?.length === 0 && !isFetching && (
|
||||||
gap: [0, 3],
|
<Heading sx={{ marginTop: 4 }}>{listing.noQuestions}</Heading>
|
||||||
mb: [1, 0],
|
)}
|
||||||
|
|
||||||
|
{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
|
{question.title}
|
||||||
as="span"
|
</Heading>
|
||||||
mb={1}
|
{question.questionCategory && (
|
||||||
sx={{
|
<Category
|
||||||
color: 'black',
|
category={question.questionCategory}
|
||||||
fontSize: [3, 3, 4],
|
sx={{ fontSize: 2 }}
|
||||||
}}
|
/>
|
||||||
>
|
)}
|
||||||
{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"
|
|
||||||
/>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Link>
|
||||||
<Box
|
<Flex>
|
||||||
sx={{
|
<ModerationStatus
|
||||||
display: ['none', 'flex', 'flex'],
|
status={question.moderation}
|
||||||
alignItems: 'center',
|
contentType="question"
|
||||||
justifyContent: 'space-around',
|
sx={{ top: 0, position: 'absolute', right: 0 }}
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconCountWithTooltip
|
|
||||||
count={(q.votedUsefulBy || []).length}
|
|
||||||
icon="star-active"
|
|
||||||
text="How useful is it"
|
|
||||||
/>
|
/>
|
||||||
|
<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
|
<IconCountWithTooltip
|
||||||
count={(q as any).commentCount || 0}
|
count={(question as any).commentCount || 0}
|
||||||
icon="comment"
|
icon="comment"
|
||||||
text="Total comments"
|
text={listing.totalComments}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
})
|
})}
|
||||||
) : (
|
|
||||||
<Heading sx={{ marginTop: 4 }}>
|
{!isFetching &&
|
||||||
No questions have been asked yet
|
questions &&
|
||||||
</Heading>
|
questions.length > 0 &&
|
||||||
)}
|
questions.length < total && (
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button onClick={() => fetchQuestions(lastVisible)}>
|
||||||
|
{listing.loadMore}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFetching && <Loader />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|||||||
8
src/pages/Question/QuestionSortOptions.ts
Normal file
8
src/pages/Question/QuestionSortOptions.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export enum QuestionSortOptions {
|
||||||
|
MostRelevant = 'MostRelevant',
|
||||||
|
Newest = 'Newest',
|
||||||
|
LatestUpdated = 'LatestUpdated',
|
||||||
|
LatestComments = 'LatestComments',
|
||||||
|
Comments = 'MostComments',
|
||||||
|
LeastComments = 'LeastComments',
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export const QUESTION_MIN_TITLE_LENGTH = 10
|
export const QUESTION_MIN_TITLE_LENGTH = 10
|
||||||
export const QUESTION_MAX_TITLE_LENGTH = 60
|
export const QUESTION_MAX_TITLE_LENGTH = 60
|
||||||
export const QUESTION_MAX_DESCRIPTION_LENGTH = 1000
|
export const QUESTION_MAX_DESCRIPTION_LENGTH = 1000
|
||||||
|
export const ITEMS_PER_PAGE = 25
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const buttons = {
|
|||||||
export const headings = {
|
export const headings = {
|
||||||
create: 'Ask your question to the community',
|
create: 'Ask your question to the community',
|
||||||
edit: 'Edit your question to the community',
|
edit: 'Edit your question to the community',
|
||||||
|
list: 'Ask your questions and help others out',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fields: ILabels = {
|
export const fields: ILabels = {
|
||||||
@@ -29,3 +30,14 @@ export const fields: ILabels = {
|
|||||||
placeholder: 'So what do you need to know?',
|
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',
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable max-classes-per-file */
|
||||||
jest.mock('../../stores/common/module.store')
|
jest.mock('../../stores/common/module.store')
|
||||||
|
|
||||||
import '@testing-library/jest-dom'
|
import '@testing-library/jest-dom'
|
||||||
@@ -25,6 +26,7 @@ import { FactoryUser } from 'src/test/factories/User'
|
|||||||
import { testingThemeStyles } from 'src/test/utils/themeUtils'
|
import { testingThemeStyles } from 'src/test/utils/themeUtils'
|
||||||
|
|
||||||
import { questionRouteElements } from './question.routes'
|
import { questionRouteElements } from './question.routes'
|
||||||
|
import { questionService } from './question.service'
|
||||||
|
|
||||||
import type { QuestionStore } from 'src/stores/Question/question.store'
|
import type { QuestionStore } from 'src/stores/Question/question.store'
|
||||||
|
|
||||||
@@ -94,10 +96,30 @@ class mockQuestionStoreClass implements Partial<QuestionStore> {
|
|||||||
userCanEditQuestion = true
|
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()
|
const mockQuestionStore = new mockQuestionStoreClass()
|
||||||
|
|
||||||
jest.mock('src/stores/Question/question.store')
|
jest.mock('src/stores/Question/question.store')
|
||||||
jest.mock('src/stores/Discussions/discussions.store')
|
jest.mock('src/stores/Discussions/discussions.store')
|
||||||
|
jest.mock('src/pages/Question/question.service')
|
||||||
|
|
||||||
describe('question.routes', () => {
|
describe('question.routes', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -116,11 +138,15 @@ describe('question.routes', () => {
|
|||||||
describe('/questions/', () => {
|
describe('/questions/', () => {
|
||||||
it('renders a loading state', async () => {
|
it('renders a loading state', async () => {
|
||||||
let wrapper
|
let wrapper
|
||||||
;(useQuestionStore as any).mockReturnValue({
|
mockQuestionService.search = jest.fn(() => {
|
||||||
...mockQuestionStore,
|
return new Promise((resolve) => {
|
||||||
isFetching: true,
|
setTimeout(
|
||||||
activeUser: mockActiveUser,
|
() => resolve({ items: [], total: 0, lastVisible: undefined }),
|
||||||
|
4000,
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = (await renderFn('/questions')).wrapper
|
wrapper = (await renderFn('/questions')).wrapper
|
||||||
expect(wrapper.getByText(/loading/)).toBeInTheDocument()
|
expect(wrapper.getByText(/loading/)).toBeInTheDocument()
|
||||||
@@ -129,11 +155,6 @@ describe('question.routes', () => {
|
|||||||
|
|
||||||
it('renders an empty state', async () => {
|
it('renders an empty state', async () => {
|
||||||
let wrapper
|
let wrapper
|
||||||
;(useQuestionStore as any).mockReturnValue({
|
|
||||||
...mockQuestionStore,
|
|
||||||
fetchQuestions: jest.fn().mockResolvedValue([]),
|
|
||||||
activeUser: mockActiveUser,
|
|
||||||
})
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = (await renderFn('/questions')).wrapper
|
wrapper = (await renderFn('/questions')).wrapper
|
||||||
@@ -158,19 +179,22 @@ describe('question.routes', () => {
|
|||||||
const questionTitle = faker.lorem.words(3)
|
const questionTitle = faker.lorem.words(3)
|
||||||
const questionSlug = faker.lorem.slug()
|
const questionSlug = faker.lorem.slug()
|
||||||
|
|
||||||
;(useQuestionStore as any).mockReturnValue({
|
questionService.search = jest.fn(() => {
|
||||||
...mockQuestionStore,
|
return new Promise((resolve) => {
|
||||||
filteredQuestions: [
|
resolve({
|
||||||
{
|
items: [
|
||||||
...FactoryQuestionItem({
|
{
|
||||||
title: questionTitle,
|
...FactoryQuestionItem({
|
||||||
slug: questionSlug,
|
title: questionTitle,
|
||||||
}),
|
slug: questionSlug,
|
||||||
_id: '123',
|
}),
|
||||||
moderation: 'accepted',
|
_id: '123',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
activeUser: mockActiveUser,
|
total: 1,
|
||||||
|
lastVisible: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
111
src/pages/Question/question.service.test.ts
Normal file
111
src/pages/Question/question.service.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
127
src/pages/Question/question.service.ts
Normal file
127
src/pages/Question/question.service.ts
Normal 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,
|
||||||
|
}
|
||||||
40
src/pages/common/Category/CategoriesSelectV2.tsx
Normal file
40
src/pages/common/Category/CategoriesSelectV2.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
isAllowedToEditContent,
|
isAllowedToEditContent,
|
||||||
randomID,
|
randomID,
|
||||||
} from 'src/utils/helpers'
|
} from 'src/utils/helpers'
|
||||||
|
import { getKeywords } from 'src/utils/searchHelper'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FilterSorterDecorator,
|
FilterSorterDecorator,
|
||||||
@@ -175,12 +176,14 @@ export class QuestionStore extends ModuleStore {
|
|||||||
|
|
||||||
const user = this.activeUser as IUser
|
const user = this.activeUser as IUser
|
||||||
const creatorCountry = this.getCreatorCountry(user, values)
|
const creatorCountry = this.getCreatorCountry(user, values)
|
||||||
|
const keywords = getKeywords(values.title + ' ' + values.description)
|
||||||
|
|
||||||
await dbRef.set({
|
await dbRef.set({
|
||||||
...(values as any),
|
...(values as any),
|
||||||
creatorCountry,
|
creatorCountry,
|
||||||
_createdBy: values._createdBy ?? this.activeUser?.userName,
|
_createdBy: values._createdBy ?? this.activeUser?.userName,
|
||||||
slug,
|
slug,
|
||||||
|
keywords: keywords,
|
||||||
})
|
})
|
||||||
logger.debug(`upsertQuestion.set`, { dbRef })
|
logger.debug(`upsertQuestion.set`, { dbRef })
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ export enum ItemSortingOption {
|
|||||||
LatestComments = 'LatestComments',
|
LatestComments = 'LatestComments',
|
||||||
Updates = 'MostUpdates',
|
Updates = 'MostUpdates',
|
||||||
TotalDownloads = 'TotalDownloads',
|
TotalDownloads = 'TotalDownloads',
|
||||||
|
MostRelevant = 'MostRelevant',
|
||||||
|
/**
|
||||||
|
* @deprecated This won't be supported with direct firebase queries
|
||||||
|
*/
|
||||||
Random = 'Random',
|
Random = 'Random',
|
||||||
SearchResults = 'SearchResults',
|
SearchResults = 'SearchResults',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'firebase/compat/auth'
|
|||||||
import 'firebase/compat/storage'
|
import 'firebase/compat/storage'
|
||||||
import 'firebase/compat/functions'
|
import 'firebase/compat/functions'
|
||||||
import 'firebase/compat/database'
|
import 'firebase/compat/database'
|
||||||
|
import 'firebase/compat/firestore'
|
||||||
|
|
||||||
import { initializeApp } from 'firebase/app'
|
import { initializeApp } from 'firebase/app'
|
||||||
import { connectAuthEmulator, getAuth } from 'firebase/auth'
|
import { connectAuthEmulator, getAuth } from 'firebase/auth'
|
||||||
@@ -19,6 +20,7 @@ const rtdb = firebase.database()
|
|||||||
const storage = firebase.storage()
|
const storage = firebase.storage()
|
||||||
const auth = getAuth(firebaseApp)
|
const auth = getAuth(firebaseApp)
|
||||||
const functions = firebase.functions()
|
const functions = firebase.functions()
|
||||||
|
const firestore = firebase.firestore()
|
||||||
|
|
||||||
// use emulators when running on localhost:4000
|
// use emulators when running on localhost:4000
|
||||||
if (SITE === 'emulated_site') {
|
if (SITE === 'emulated_site') {
|
||||||
@@ -32,7 +34,7 @@ if (SITE === 'emulated_site') {
|
|||||||
functions.useEmulator('localhost', 4002)
|
functions.useEmulator('localhost', 4002)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { rtdb, storage, auth, functions }
|
export { rtdb, storage, auth, functions, firestore }
|
||||||
|
|
||||||
export const EmailAuthProvider = firebase.auth.EmailAuthProvider
|
export const EmailAuthProvider = firebase.auth.EmailAuthProvider
|
||||||
|
|
||||||
|
|||||||
29
src/utils/searchHelper.test.ts
Normal file
29
src/utils/searchHelper.test.ts
Normal 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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
7
src/utils/searchHelper.ts
Normal file
7
src/utils/searchHelper.ts
Normal 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
132
src/utils/stopwords.ts
Normal 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',
|
||||||
|
])
|
||||||
34
yarn.lock
34
yarn.lock
@@ -27668,6 +27668,7 @@ __metadata:
|
|||||||
stream-browserify: ^3.0.0
|
stream-browserify: ^3.0.0
|
||||||
terser: 3.14.1
|
terser: 3.14.1
|
||||||
theme-ui: ^0.15.7
|
theme-ui: ^0.15.7
|
||||||
|
ts-jest: ^29.1.2
|
||||||
ts-loader: ^7.0.5
|
ts-loader: ^7.0.5
|
||||||
ts-node: ^10.4.0
|
ts-node: ^10.4.0
|
||||||
tslog: ^4.9.2
|
tslog: ^4.9.2
|
||||||
@@ -34262,6 +34263,39 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"ts-loader@npm:^7.0.5":
|
||||||
version: 7.0.5
|
version: 7.0.5
|
||||||
resolution: "ts-loader@npm:7.0.5"
|
resolution: "ts-loader@npm:7.0.5"
|
||||||
|
|||||||
Reference in New Issue
Block a user