Skip to content

Multiselect for territory filtering #1934

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 69 additions & 62 deletions api/resolvers/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,8 @@ const subClause = (sub, num, table = 'Item', me, showNsfw) => {
// Intentionally show nsfw posts (i.e. no nsfw clause) when viewing a specific nsfw sub
if (sub) {
const tables = [...new Set(['Item', table])].map(t => `"${t}".`)
return `(${tables.map(t => `${t}"subName" = $${num}::CITEXT`).join(' OR ')})`
// support multiple sub names
return `(${tables.map(t => `${t}"subName" = ANY($${num}::CITEXT[])`).join(' OR ')})`
}

if (!me) { return HIDE_NSFW_CLAUSE }
Expand Down Expand Up @@ -506,18 +507,25 @@ export default {
orderBy: orderByClause('random', me, models, type)
}, decodedCursor.offset, limit, ...subArr)
break
default:
default: {
// sub so we know the default ranking
let anyAuctionRanking = false

if (sub) {
subFull = await models.sub.findUnique({ where: { name: sub } })
if (Array.isArray(sub)) {
subFull = await models.sub.findMany({ where: { name: { in: sub } } })
anyAuctionRanking = subFull.some(s => s.rankingType === 'AUCTION')
} else {
subFull = await models.sub.findUnique({ where: { name: sub } })
anyAuctionRanking = subFull.rankingType === 'AUCTION'
}
}

switch (subFull?.rankingType) {
case 'AUCTION':
items = await itemQueryWithMeta({
me,
models,
query: `
if (anyAuctionRanking) {
items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT},
(boost IS NOT NULL AND boost > 0)::INT AS group_rank,
CASE WHEN boost IS NOT NULL AND boost > 0
Expand All @@ -535,16 +543,15 @@ export default {
ORDER BY group_rank DESC, rank
OFFSET $2
LIMIT $3`,
orderBy: 'ORDER BY group_rank DESC, rank'
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
break
default:
if (decodedCursor.offset === 0) {
// get pins for the page and return those separately
pins = await itemQueryWithMeta({
me,
models,
query: `
orderBy: 'ORDER BY group_rank DESC, rank'
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
} else {
if (decodedCursor.offset === 0) {
// get pins for the page and return those separately
pins = await itemQueryWithMeta({
me,
models,
query: `
SELECT rank_filter.*
FROM (
${SELECT}, position,
Expand All @@ -557,73 +564,73 @@ export default {
${whereClause(
'"pinId" IS NOT NULL',
'"parentId" IS NULL',
sub ? '"subName" = $1' : '"subName" IS NULL',
sub ? '"subName" = ANY ($1::CITEXT[])' : '"subName" IS NULL',
muteClause(me))}
) rank_filter WHERE RANK = 1
ORDER BY position ASC`,
orderBy: 'ORDER BY position ASC'
}, ...subArr)
orderBy: 'ORDER BY position ASC'
}, ...subArr)

ad = await getAd(parent, { sub, subArr, showNsfw }, { me, models })
}
ad = await getAd(parent, { sub, subArr, showNsfw }, { me, models })
}

items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}, ${me ? 'GREATEST(g.tf_hot_score, l.tf_hot_score)' : 'g.tf_hot_score'} AS rank
FROM "Item"
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
${joinZapRankPersonalView(me, models)}
${whereClause(
// in home (sub undefined), filter out global pinned items since we inject them later
sub ? '"Item"."pinId" IS NULL' : 'NOT ("Item"."pinId" IS NOT NULL AND "Item"."subName" IS NULL)',
'"Item"."deletedAt" IS NULL',
'"Item"."parentId" IS NULL',
'"Item".outlawed = false',
'"Item".bio = false',
ad ? `"Item".id <> ${ad.id}` : '',
activeOrMine(me),
await filterClause(me, models, type),
subClause(sub, 3, 'Item', me, showNsfw),
muteClause(me))}
ORDER BY rank DESC
OFFSET $1
LIMIT $2`,
orderBy: 'ORDER BY rank DESC'
}, decodedCursor.offset, limit, ...subArr)

// XXX this is mostly for subs that are really empty
if (items.length < limit) {
items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}, ${me ? 'GREATEST(g.tf_hot_score, l.tf_hot_score)' : 'g.tf_hot_score'} AS rank
${SELECT}
FROM "Item"
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
${joinZapRankPersonalView(me, models)}
${whereClause(
subClause(sub, 3, 'Item', me, showNsfw),
muteClause(me),
// in home (sub undefined), filter out global pinned items since we inject them later
sub ? '"Item"."pinId" IS NULL' : 'NOT ("Item"."pinId" IS NOT NULL AND "Item"."subName" IS NULL)',
'"Item"."deletedAt" IS NULL',
'"Item"."parentId" IS NULL',
'"Item".outlawed = false',
'"Item".bio = false',
ad ? `"Item".id <> ${ad.id}` : '',
activeOrMine(me),
await filterClause(me, models, type),
subClause(sub, 3, 'Item', me, showNsfw),
muteClause(me))}
ORDER BY rank DESC
await filterClause(me, models, type))}
ORDER BY ${orderByNumerator({ models, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST,
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC
OFFSET $1
LIMIT $2`,
orderBy: 'ORDER BY rank DESC'
orderBy: `ORDER BY ${orderByNumerator({ models, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST,
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
}, decodedCursor.offset, limit, ...subArr)

// XXX this is mostly for subs that are really empty
if (items.length < limit) {
items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
${whereClause(
subClause(sub, 3, 'Item', me, showNsfw),
muteClause(me),
// in home (sub undefined), filter out global pinned items since we inject them later
sub ? '"Item"."pinId" IS NULL' : 'NOT ("Item"."pinId" IS NOT NULL AND "Item"."subName" IS NULL)',
'"Item"."deletedAt" IS NULL',
'"Item"."parentId" IS NULL',
'"Item".bio = false',
ad ? `"Item".id <> ${ad.id}` : '',
activeOrMine(me),
await filterClause(me, models, type))}
ORDER BY ${orderByNumerator({ models, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST,
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC
OFFSET $1
LIMIT $2`,
orderBy: `ORDER BY ${orderByNumerator({ models, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST,
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
}, decodedCursor.offset, limit, ...subArr)
}
break
}
}
break
}
}
return {
cursor: items.length === limit ? nextCursorEncoded(decodedCursor) : null,
Expand Down
2 changes: 1 addition & 1 deletion api/resolvers/sub.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export async function getSub (parent, { name }, { models, me }) {

return await models.sub.findUnique({
where: {
name
name: name[0]
},
...(me
? {
Expand Down
2 changes: 1 addition & 1 deletion api/typeDefs/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { gql } from 'graphql-tag'

export default gql`
extend type Query {
items(sub: String, sort: String, type: String, cursor: String, name: String, when: String, from: String, to: String, by: String, limit: Limit): Items
items(sub: [String], sort: String, type: String, cursor: String, name: String, when: String, from: String, to: String, by: String, limit: Limit): Items
item(id: ID!): Item
pageTitleAndUnshorted(url: String!): TitleUnshorted
dupes(url: String!): [Item!]
Expand Down
2 changes: 1 addition & 1 deletion api/typeDefs/sub.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { gql } from 'graphql-tag'

export default gql`
extend type Query {
sub(name: String): Sub
sub(name: [String]): Sub
subLatestPost(name: String!): String
subs: [Sub!]!
topSubs(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
Expand Down
122 changes: 121 additions & 1 deletion components/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -950,7 +950,7 @@ export function Select ({ label, items, info, groupClassName, onChange, noForm,
if (item && typeof item === 'object') {
return (
<optgroup key={item.label} label={item.label}>
{item.items.map(item => <option key={item}>{item}</option>)}
{item.items?.map(item => <option key={item}>{item}</option>)}
</optgroup>
)
} else {
Expand All @@ -971,6 +971,126 @@ export function Select ({ label, items, info, groupClassName, onChange, noForm,
)
}

// TODO: Remove clutter like handles
// TODO: Better CSS
// WIP: Handles are defined like this to have a better reading during development
export function MultiSelect ({ label, items, info, groupClassName, onChange, noForm, overrideValue, hint, defaultValue = 'select', ...props }) {
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
const formik = noForm ? null : useFormikContext()
const invalid = meta.touched && meta.error
const [selectedItems, setSelectedItems] = useState(() => field.value || [])

const shouldUpdateFromOverride = useMemo(() => {
return overrideValue && JSON.stringify(overrideValue) !== JSON.stringify(selectedItems)
}, [overrideValue, selectedItems])

useEffect(() => {
if (shouldUpdateFromOverride) {
!noForm && helpers.setValue(overrideValue)
setSelectedItems(overrideValue)
}
}, [shouldUpdateFromOverride, overrideValue, noForm, helpers])

const handleItemSelect = useCallback((item) => {
if (!selectedItems.includes(item)) {
const newSelectedItems = [...selectedItems, item]
onChange && onChange(formik, { target: { value: newSelectedItems } })
!noForm && setSelectedItems(newSelectedItems)
!noForm && helpers.setValue(newSelectedItems)
}
}, [selectedItems, noForm, helpers, onChange, formik])

const handleItemRemove = useCallback((item) => {
const newSelectedItems = selectedItems.filter((i) => i !== item)
onChange && onChange(formik, { target: { value: newSelectedItems } })
!noForm && setSelectedItems(newSelectedItems)
!noForm && helpers.setValue(newSelectedItems)
}, [selectedItems, noForm, helpers, onChange, formik])

const handleClearAll = useCallback(() => {
onChange && onChange(formik, { target: { value: [] } })
setSelectedItems([])
!noForm && helpers.setValue([])
}, [noForm, helpers, onChange, formik])

return (
<FormGroup label={label} className={groupClassName}>
<div className='my-1'>
<div className={`p-2 ${styles.multiSelectContainer} ${invalid ? 'border-danger' : ''}`}>
<div className={`${styles.multiSelectTags} flex-wrap`}>
{selectedItems && selectedItems.length > 0
? selectedItems.map((item) => (
<span key={item} className={`${styles.multiSelectItem}`}>
{item}
<button
type='button'
className={`${styles.multiSelectRemoveButton}`}
onClick={(e) => {
e.stopPropagation()
handleItemRemove(item)
}}
>
<CloseIcon className={styles.multiSelectRemoveIcon} />
</button>
</span>
))
: (
<span className='text-muted'>{defaultValue}</span>
)}
</div>
<div className={styles.multiSelectActionContainer}>
{selectedItems && selectedItems.length > 0 && (
<span
className={`d-flex align-items-center justify-content-end pointer ${styles.multiSelectRemoveAll}`}
onClick={(e) => {
e.stopPropagation()
handleClearAll()
}}
>
<CloseIcon className={styles.multiSelectRemoveAllIcon} />
</span>)}
<Dropdown className='pointer' as='div'>
<Dropdown.Toggle
as='span'
onPointerDown={e => e.preventDefault()}
>
<AddIcon className={styles.multiSelectAddIcon} />
</Dropdown.Toggle>
<Dropdown.Menu style={{ maxHeight: '15rem', overflowY: 'auto' }}>
{items.map(item => {
if (item && typeof item === 'object') {
return null
} else {
const isSelected = selectedItems && selectedItems.includes(item)
return (
<Dropdown.Item
key={item}
onClick={() => handleItemSelect(item)}
className='d-flex flex-row justify-content-between'
active={isSelected}
disabled={isSelected}
>
{item}
</Dropdown.Item>
)
}
})}
</Dropdown.Menu>
</Dropdown>
</div>
</div>
</div>
<BootstrapForm.Control.Feedback type='invalid'>
{meta.touched && meta.error}
</BootstrapForm.Control.Feedback>
{hint &&
<BootstrapForm.Text>
{hint}
</BootstrapForm.Text>}
</FormGroup>
)
}

function DatePickerSkeleton () {
return (
<div className='react-datepicker-wrapper'>
Expand Down
Loading