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:
@@ -2,3 +2,5 @@ build
|
||||
.firebase
|
||||
storybook-static
|
||||
dist
|
||||
firestore.indexes.json
|
||||
firebase.json
|
||||
@@ -1,7 +1,12 @@
|
||||
{
|
||||
"$schema": "./node_modules/firebase-tools/schema/firebase-config.json",
|
||||
"hosting": {
|
||||
"public": "build",
|
||||
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
|
||||
"ignore": [
|
||||
"firebase.json",
|
||||
"**/.*",
|
||||
"**/node_modules/**"
|
||||
],
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/api",
|
||||
@@ -86,7 +91,9 @@
|
||||
]
|
||||
},
|
||||
"functions": {
|
||||
"predeploy": ["yarn workspace functions build"],
|
||||
"predeploy": [
|
||||
"yarn workspace functions build"
|
||||
],
|
||||
"source": "functions/dist",
|
||||
"runtime": "nodejs20"
|
||||
},
|
||||
@@ -122,5 +129,8 @@
|
||||
},
|
||||
"extensions": {
|
||||
"firestore-send-email": "firebase/firestore-send-email@0.1.27"
|
||||
},
|
||||
"firestore": {
|
||||
"indexes": "./firestore.indexes.json"
|
||||
}
|
||||
}
|
||||
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",
|
||||
"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",
|
||||
|
||||
@@ -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
|
||||
|
||||
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 { 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 />}
|
||||
</>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
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_MAX_TITLE_LENGTH = 60
|
||||
export const QUESTION_MAX_DESCRIPTION_LENGTH = 1000
|
||||
export const ITEMS_PER_PAGE = 25
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
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,
|
||||
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 })
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user