Skip to content
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

Multiselect for territory filtering #1934

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
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
@@ -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 }
@@ -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
@@ -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,
@@ -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,
2 changes: 1 addition & 1 deletion api/resolvers/sub.js
Original file line number Diff line number Diff line change
@@ -11,7 +11,7 @@ export async function getSub (parent, { name }, { models, me }) {

return await models.sub.findUnique({
where: {
name
name: name[0]
},
...(me
? {
2 changes: 1 addition & 1 deletion api/typeDefs/item.js
Original file line number Diff line number Diff line change
@@ -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!]
2 changes: 1 addition & 1 deletion api/typeDefs/sub.js
Original file line number Diff line number Diff line change
@@ -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
122 changes: 121 additions & 1 deletion components/form.js
Original file line number Diff line number Diff line change
@@ -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 {
@@ -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'>
Loading