search filters

This commit is contained in:
keyan 2022-10-20 17:44:44 -05:00
parent 6225b9e7aa
commit e4d150413b
11 changed files with 293 additions and 173 deletions

View File

@ -8,7 +8,8 @@ import invite from './invite'
import sub from './sub' import sub from './sub'
import upload from './upload' import upload from './upload'
import growth from './growth' import growth from './growth'
import search from './search'
import { GraphQLJSONObject } from 'graphql-type-json' import { GraphQLJSONObject } from 'graphql-type-json'
export default [user, item, message, wallet, lnurl, notifications, invite, sub, export default [user, item, message, wallet, lnurl, notifications, invite, sub,
upload, growth, { JSONObject: GraphQLJSONObject }] upload, growth, search, { JSONObject: GraphQLJSONObject }]

View File

@ -435,123 +435,6 @@ export default {
comments: async (parent, { id, sort }, { me, models }) => { comments: async (parent, { id, sort }, { me, models }) => {
return comments(me, models, id, sort) return comments(me, models, id, sort)
}, },
search: async (parent, { q: query, sub, cursor }, { me, models, search }) => {
const decodedCursor = decodeCursor(cursor)
let sitems
try {
sitems = await search.search({
index: 'item',
size: LIMIT,
from: decodedCursor.offset,
body: {
query: {
bool: {
must: [
sub
? { match: { 'sub.name': sub } }
: { bool: { must_not: { exists: { field: 'sub.name' } } } },
me
? {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } },
{ match: { userId: me.id } }
]
}
}
: {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } }
]
}
},
{
bool: {
should: [
{
// all terms are matched in fields
multi_match: {
query,
type: 'most_fields',
fields: ['title^20', 'text'],
minimum_should_match: '100%',
boost: 400
}
},
{
// all terms are matched in fields
multi_match: {
query,
type: 'most_fields',
fields: ['title^20', 'text'],
fuzziness: 'AUTO',
prefix_length: 3,
minimum_should_match: '100%',
boost: 20
}
},
{
// only some terms must match
multi_match: {
query,
type: 'most_fields',
fields: ['title^20', 'text'],
fuzziness: 'AUTO',
prefix_length: 3,
minimum_should_match: '60%'
}
}
// TODO: add wildcard matches for
// user.name and url
]
}
}
],
filter: {
range: {
createdAt: {
lte: decodedCursor.time
}
}
}
}
},
highlight: {
fields: {
title: { number_of_fragments: 0, pre_tags: [':high['], post_tags: [']'] },
text: { number_of_fragments: 0, pre_tags: [':high['], post_tags: [']'] }
}
}
}
})
} catch (e) {
console.log(e)
return {
cursor: null,
items: []
}
}
// return highlights
const items = sitems.body.hits.hits.map(async e => {
// this is super inefficient but will suffice until we do something more generic
const item = await getItem(parent, { id: e._source.id }, { me, models })
item.searchTitle = (e.highlight.title && e.highlight.title[0]) || item.title
item.searchText = (e.highlight.text && e.highlight.text[0]) || item.text
return item
})
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
auctionPosition: async (parent, { id, sub, bid }, { models, me }) => { auctionPosition: async (parent, { id, sub, bid }, { models, me }) => {
const createdAt = id ? (await getItem(parent, { id }, { models, me })).createdAt : new Date() const createdAt = id ? (await getItem(parent, { id }, { models, me })).createdAt : new Date()
let where let where

176
api/resolvers/search.js Normal file
View File

@ -0,0 +1,176 @@
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { getItem } from './item'
export default {
Query: {
search: async (parent, { q: query, sub, cursor, sort, what, when }, { me, models, search }) => {
const decodedCursor = decodeCursor(cursor)
let sitems
const sortArr = []
switch (sort) {
case 'recent':
sortArr.push({ createdAt: 'desc' })
break
case 'comments':
sortArr.push({ ncomments: 'desc' })
break
case 'sats':
sortArr.push({ sats: 'desc' })
break
case 'votes':
sortArr.push({ upvotes: 'desc' })
break
default:
break
}
sortArr.push('_score')
const whatArr = []
switch (what) {
case 'posts':
whatArr.push({ bool: { must_not: { exists: { field: 'parentId' } } } })
break
case 'comments':
whatArr.push({ bool: { must: { exists: { field: 'parentId' } } } })
break
default:
break
}
let whenGte
switch (when) {
case 'day':
whenGte = 'now-1d'
break
case 'week':
whenGte = 'now-7d'
break
case 'month':
whenGte = 'now-30d'
break
case 'year':
whenGte = 'now-365d'
break
default:
break
}
try {
sitems = await search.search({
index: 'item',
size: LIMIT,
from: decodedCursor.offset,
body: {
query: {
bool: {
must: [
...whatArr,
sub
? { match: { 'sub.name': sub } }
: { bool: { must_not: { exists: { field: 'sub.name' } } } },
me
? {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } },
{ match: { userId: me.id } }
]
}
}
: {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } }
]
}
},
{
bool: {
should: [
{
// all terms are matched in fields
multi_match: {
query,
type: 'most_fields',
fields: ['title^20', 'text'],
minimum_should_match: '100%',
boost: 400
}
},
{
// all terms are matched in fields
multi_match: {
query,
type: 'most_fields',
fields: ['title^20', 'text'],
fuzziness: 'AUTO',
prefix_length: 3,
minimum_should_match: '100%',
boost: 20
}
},
{
// only some terms must match unless we're sorting
multi_match: {
query,
type: 'most_fields',
fields: ['title^20', 'text'],
fuzziness: 'AUTO',
prefix_length: 3,
minimum_should_match: sortArr.length > 1 ? '100%' : '60%'
}
}
// TODO: add wildcard matches for
// user.name and url
]
}
}
],
filter: {
range: {
createdAt: {
lte: decodedCursor.time,
gte: whenGte
}
}
}
}
},
sort: sortArr,
highlight: {
fields: {
title: { number_of_fragments: 0, pre_tags: [':high['], post_tags: [']'] },
text: { number_of_fragments: 0, pre_tags: [':high['], post_tags: [']'] }
}
}
}
})
} catch (e) {
console.log(e)
return {
cursor: null,
items: []
}
}
// return highlights
const items = sitems.body.hits.hits.map(async e => {
// this is super inefficient but will suffice until we do something more generic
const item = await getItem(parent, { id: e._source.id }, { me, models })
item.searchTitle = (e.highlight.title && e.highlight.title[0]) || item.title
item.searchText = (e.highlight.text && e.highlight.text[0]) || item.text
return item
})
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
}
}
}

View File

@ -9,7 +9,7 @@ export default gql`
pageTitle(url: String!): String pageTitle(url: String!): String
dupes(url: String!): [Item!] dupes(url: String!): [Item!]
allItems(cursor: String): Items allItems(cursor: String): Items
search(q: String, sub: String, cursor: String): Items search(q: String, sub: String, cursor: String, what: String, sort: String, when: String): Items
auctionPosition(sub: String, id: ID, bid: Int!): Int! auctionPosition(sub: String, id: ID, bid: Int!): Int!
itemRepetition(parentId: ID): Int! itemRepetition(parentId: ID): Int!
outlawedItems(cursor: String): Items outlawedItems(cursor: String): Items

View File

@ -193,11 +193,14 @@ function InputInner ({
{(clear && field.value) && {(clear && field.value) &&
<Button <Button
variant={null} variant={null}
onClick={() => { onClick={(e) => {
helpers.setValue('') helpers.setValue('')
if (storageKey) { if (storageKey) {
localStorage.removeItem(storageKey) localStorage.removeItem(storageKey)
} }
if (onChange) {
onChange(formik, { target: { value: '' } })
}
}} }}
className={`${styles.clearButton} ${invalid ? styles.isInvalid : ''}`} className={`${styles.clearButton} ${invalid ? styles.isInvalid : ''}`}
><CloseIcon className='fill-grey' height={20} width={20} /> ><CloseIcon className='fill-grey' height={20} width={20} />
@ -430,12 +433,24 @@ export function SyncForm ({
) )
} }
export function Select ({ label, items, groupClassName, ...props }) { export function Select ({ label, items, groupClassName, onChange, noForm, ...props }) {
const [field] = useField(props) const [field] = useField(props)
const formik = noForm ? null : useFormikContext()
return ( return (
<FormGroup label={label} className={groupClassName}> <FormGroup label={label} className={groupClassName}>
<BootstrapForm.Control as='select' custom {...field} {...props}> <BootstrapForm.Control
as='select'
{...field} {...props}
onChange={(e) => {
field.onChange(e)
if (onChange) {
onChange(formik, e)
}
}}
custom
>
{items.map(item => <option key={item}>{item}</option>)} {items.map(item => <option key={item}>{item}</option>)}
</BootstrapForm.Control> </BootstrapForm.Control>
</FormGroup> </FormGroup>

View File

@ -3,13 +3,13 @@ import styles from './search.module.css'
import SearchIcon from '../svgs/search-line.svg' import SearchIcon from '../svgs/search-line.svg'
import CloseIcon from '../svgs/close-line.svg' import CloseIcon from '../svgs/close-line.svg'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Form, Input, SubmitButton } from './form' import { Form, Input, Select, SubmitButton } from './form'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
export default function Search ({ sub }) { export default function Search ({ sub }) {
const router = useRouter() const router = useRouter()
const [searching, setSearching] = useState(router.query.q) const [searching, setSearching] = useState(router.query.q)
const [q, setQ] = useState(router.query.q) const [q, setQ] = useState(router.query.q || '')
const [atBottom, setAtBottom] = useState() const [atBottom, setAtBottom] = useState()
useEffect(() => { useEffect(() => {
@ -23,65 +23,105 @@ export default function Search ({ sub }) {
} }
}, []) }, [])
const search = async values => {
let prefix = ''
if (sub) {
prefix = `/~${sub}`
}
if (values.q?.trim() !== '') {
if (values.what === '') delete values.what
if (values.sort === '') delete values.sort
if (values.when === '') delete values.when
await router.push({
pathname: prefix + '/search',
query: values
})
}
}
const showSearch = atBottom || searching || router.query.q const showSearch = atBottom || searching || router.query.q
const filter = router.query.q && !sub
return ( return (
<> <>
<div className={`${styles.searchSection} ${showSearch ? styles.solid : styles.hidden}`}> <div className={`${styles.searchSection} ${showSearch ? styles.solid : styles.hidden}`}>
<Container className={`px-sm-0 ${styles.searchContainer}`}> <Container className={`px-sm-0 ${styles.searchContainer} ${filter ? styles.leaveRoom : ''}`}>
{showSearch {showSearch
? ( ? (
<Form <Form
className={styles.formActive}
initial={{ initial={{
q: router.query.q || '' q: router.query.q || '',
}} what: router.query.what || '',
className={`w-auto ${styles.active}`} sort: router.query.sort || '',
onSubmit={async ({ q }) => { when: router.query.when || ''
if (q.trim() !== '') {
let prefix = ''
if (sub) {
prefix = `/~${sub}`
}
router.push(prefix + `/search?q=${encodeURIComponent(q)}`)
}
}} }}
onSubmit={search}
> >
<Input {filter &&
name='q' <div className='text-muted font-weight-bold my-3 d-flex align-items-center'>
required <Select
autoFocus={showSearch && !atBottom} groupClassName='mr-2 mb-0'
groupClassName='mr-3 mb-0 flex-grow-1' onChange={(formik, e) => search({ ...formik?.values, what: e.target.value })}
className='flex-grow-1' name='what'
clear size='sm'
onChange={async (formik, e) => { items={['all', 'posts', 'comments']}
setSearching(true) />
setQ(e.target.value?.trim()) by
}} <Select
/> groupClassName='mx-2 mb-0'
{q || atBottom || router.query.q onChange={(formik, e) => search({ ...formik?.values, sort: e.target.value })}
? ( name='sort'
<SubmitButton variant='primary' className={styles.search}> size='sm'
<SearchIcon width={22} height={22} /> items={['match', 'recent', 'comments', 'sats', 'votes']}
</SubmitButton> />
) for
: ( <Select
<Button groupClassName='mb-0 ml-2'
className={styles.search} onClick={() => { onChange={(formik, e) => search({ ...formik?.values, when: e.target.value })}
setSearching(false) name='when'
}} size='sm'
> items={['forever', 'day', 'week', 'month', 'year']}
<CloseIcon width={26} height={26} /> />
</Button>)} </div>}
<div className={`${styles.active}`}>
<Input
name='q'
required
autoFocus={showSearch && !atBottom}
groupClassName='mr-3 mb-0 flex-grow-1'
className='flex-grow-1'
clear
onChange={async (formik, e) => {
setSearching(true)
setQ(e.target.value?.trim())
}}
/>
{q || atBottom || router.query.q
? (
<SubmitButton variant='primary' className={styles.search}>
<SearchIcon width={22} height={22} />
</SubmitButton>
)
: (
<Button
className={styles.search} onClick={() => {
setSearching(false)
}}
>
<CloseIcon width={26} height={26} />
</Button>)}
</div>
</Form> </Form>
) )
: ( : (
<Button className={`${styles.search} ${styles.active}`} onClick={() => setSearching(true)}> <Button className={`${styles.search} ${styles.formActive}`} onClick={() => setSearching(true)}>
<SearchIcon width={22} height={22} /> <SearchIcon width={22} height={22} />
</Button> </Button>
)} )}
</Container> </Container>
</div> </div>
<div className={styles.searchPadding} /> <div className={`${styles.searchPadding} ${filter ? styles.leaveRoom : ''}`} />
</> </>
) )
} }

View File

@ -3,7 +3,6 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
height: 88px;
} }
.searchContainer { .searchContainer {
@ -11,6 +10,10 @@
position: relative; position: relative;
} }
.leaveRoom {
height: 130px !important;
}
.searchSection.solid { .searchSection.solid {
pointer-events: auto; pointer-events: auto;
background: var(--theme-body); background: var(--theme-body);
@ -30,18 +33,20 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
display: flex; display: flex;
left: auto !important;
} }
.active { .formActive {
pointer-events: auto; pointer-events: auto;
bottom: 18px; bottom: 18px;
right: 18px; right: 18px;
left: 18px;
position: absolute; position: absolute;
} }
form.active { form>.active {
left: 18px;
display: flex; display: flex;
pointer-events: auto;
flex-flow: row wrap; flex-flow: row wrap;
align-items: center; align-items: center;
} }

View File

@ -175,8 +175,8 @@ export const ITEM_WITH_COMMENTS = gql`
export const ITEM_SEARCH = gql` export const ITEM_SEARCH = gql`
${ITEM_FIELDS} ${ITEM_FIELDS}
query Search($q: String, $cursor: String) { query Search($q: String, $cursor: String, $sort: String, $what: String, $when: String) {
search(q: $q, cursor: $cursor) { search(q: $q, cursor: $cursor, sort: $sort, what: $what, when: $when) {
cursor cursor
items { items {
...ItemFields ...ItemFields

View File

@ -92,7 +92,7 @@ export default function getApolloClient () {
} }
}, },
search: { search: {
keyArgs: ['q'], keyArgs: ['q', 'sub', 'sort', 'what', 'when'],
merge (existing, incoming) { merge (existing, incoming) {
if (isFirstPage(incoming.cursor, existing?.items)) { if (isFirstPage(incoming.cursor, existing?.items)) {
return incoming return incoming

View File

@ -15,7 +15,8 @@ export default function Index ({ data: { search: { items, cursor } } }) {
<SeoSearch /> <SeoSearch />
{router.query?.q && {router.query?.q &&
<SearchItems <SearchItems
items={items} cursor={cursor} variables={{ q: router.query?.q }} items={items} cursor={cursor}
variables={{ q: router.query?.q, sort: router.query?.sort, what: router.query?.what, when: router.query?.when }}
/>} />}
</Layout> </Layout>
) )

View File

@ -282,7 +282,6 @@ footer {
@media screen and (max-width: 767px) { @media screen and (max-width: 767px) {
input, input,
select,
textarea, textarea,
.form-control, .form-control,
.form-control:focus, .form-control:focus,