Skip to content

Commit

Permalink
feat(app/filters): allow filter on variant tags
Browse files Browse the repository at this point in the history
This patch adds some nested variant filters. In addition to filtering by
a variant's colors, users can now filter on a variant's tags. This
probably requires some polish to automatically support new nested fields
as they come, or, just make it less hacky and more explicit from the
get-go (e.g. specifying which fields can be filtered on upfront).
  • Loading branch information
nicholaschiang committed Feb 25, 2024
1 parent 4e17b55 commit 0ef6db8
Show file tree
Hide file tree
Showing 4 changed files with 237 additions and 47 deletions.
154 changes: 110 additions & 44 deletions app/components/filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,26 @@ import {
import { useHotkeys } from 'react-hotkeys-hook'
import invariant from 'tiny-invariant'

import type { loader as colors } from 'routes/_layout.colors'
import type { loader as seasons } from 'routes/_layout.seasons'
import type { loader as variants } from 'routes/_layout.variants'
import type { loader as tags } from 'routes/_layout.tags'

import { Dialog } from 'components/dialog'
import { LoadingLine } from 'components/loading-line'
import * as Menu from 'components/menu'
import { Tooltip } from 'components/tooltip'

import { uniq, useData, useLoadFetcher } from 'utils/general'
import { getColorFilter, getColorName } from 'utils/variant'
import {
type VariantColorFilter,
type VariantTagFilter,
getColorFilter,
getColorName,
isVariantColorFilter,
getTagFilter,
getTagName,
isVariantTagFilter,
} from 'utils/variant'

import type { Filter, FilterName, FilterValue } from 'filters'
import { filterToStrings } from 'filters'
Expand All @@ -52,14 +62,17 @@ const MODEL_TO_ROUTE: Record<string, string> = {
Country: '/countries',
Style: '/styles',
Size: '/sizes',
Color: '/colors',
Variant: '/variants',
Price: '/prices',
Collection: '/collections',
Season: '/seasons',
Show: '/shows',
User: '/designers',
Video: '/videos',

// Filters for nested variant relations (each variant has a color and tags are
// associated with variants instead of products).
Color: '/colors',
Tag: '/tags',
}

//////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -230,51 +243,42 @@ function SeasonItem({ filter }: ItemProps) {
)
}

function isColorsArray(
array: unknown[],
): array is { colors: { some: { name: string } } }[] {
return array.every(
(object) =>
typeof object === 'object' &&
object !== null &&
'colors' in object &&
typeof object.colors === 'object' &&
object.colors !== null &&
'some' in object.colors &&
typeof object.colors.some === 'object' &&
object.colors.some !== null &&
'name' in object.colors.some &&
typeof object.colors.some.name === 'string',
)
}

function VariantItem({ filter }: ItemProps) {
const { removeFilter } = useContext(FiltersContext)
const { name, condition } = filterToStrings(filter)
if (
typeof filter.value === 'object' &&
filter.value !== null &&
'AND' in filter.value &&
typeof filter.value.AND === 'object' &&
filter.value.AND !== null &&
filter.value.AND instanceof Array &&
isColorsArray(filter.value.AND)
)
return (
<GenericItem
name={name}
condition={condition}
value={filter.value.AND.map((c) => c.colors.some.name).join(' / ')}
onClick={() => removeFilter(filter)}
/>
)
if (isVariantColorFilter(filter)) return <VariantColorItem filter={filter} />
if (isVariantTagFilter(filter)) return <VariantTagItem filter={filter} />
throw new Error(
`<VariantItem> expected a variant filter value but got: ${JSON.stringify(
filter.value,
)}.`,
)
}

function VariantColorItem({ filter }: { filter: VariantColorFilter }) {
const { removeFilter } = useContext(FiltersContext)
const { condition } = filterToStrings(filter)
return (
<GenericItem
name='colors'
condition={condition}
value={filter.value.AND.map((c) => c.colors.some.name).join(' / ')}
onClick={() => removeFilter(filter)}
/>
)
}

function VariantTagItem({ filter }: { filter: VariantTagFilter }) {
const { removeFilter } = useContext(FiltersContext)
const { condition } = filterToStrings(filter)
return (
<GenericItem
name='tags'
condition={condition}
value={filter.value.tags.some.name}
onClick={() => removeFilter(filter)}
/>
)
}

type ItemButtonProps = {
className?: string
children: ReactNode
Expand Down Expand Up @@ -513,28 +517,90 @@ function SeasonItems({ nested }: Pick<Props, 'nested'>) {
return <>{items}</>
}

enum VariantAttribute {
COLORS = 'colors',
TAGS = 'tags',
}

function VariantItems({ nested }: Pick<Props, 'nested'>) {
const fetcher = useSearchFetcher<typeof variants>(MODEL_TO_ROUTE.Variant)
const [attribute, setAttribute] = useState<VariantAttribute>()
if (nested)
return (
<>
<VariantColorItems nested={nested} />
<VariantTagItems nested={nested} />
</>
)
return attribute === VariantAttribute.COLORS ? (
<VariantColorItems nested={nested} />
) : attribute === VariantAttribute.TAGS ? (
<VariantTagItems nested={nested} />
) : (
<>
<Menu.Item
value={VariantAttribute.COLORS}
onSelect={() => setAttribute(VariantAttribute.COLORS)}
>
<Menu.ItemLabel group='variants'>
{VariantAttribute.COLORS}
</Menu.ItemLabel>
</Menu.Item>
<Menu.Item
value={VariantAttribute.TAGS}
onSelect={() => setAttribute(VariantAttribute.TAGS)}
>
<Menu.ItemLabel group='variants'>
{VariantAttribute.TAGS}
</Menu.ItemLabel>
</Menu.Item>
</>
)
}

function VariantColorItems({ nested }: Pick<Props, 'nested'>) {
const fetcher = useSearchFetcher<typeof colors>(MODEL_TO_ROUTE.Color)
const { addOrUpdateFilter } = useContext(FiltersContext)
const setOpen = useContext(MenuContext)
if (useCommandState((state) => state.search).length < 2 && nested) return null
const items = uniq(fetcher.data ?? [], getColorName).map((variant) => (
<Menu.Item
key={variant.id}
value={`variant-${getColorName(variant)}`}
value={`color-${getColorName(variant)}`}
onSelect={() => {
addOrUpdateFilter(getColorFilter(variant))
setOpen(false)
}}
>
<Menu.ItemLabel group={nested ? 'variants' : undefined}>
<Menu.ItemLabel group={nested ? 'colors' : undefined}>
{getColorName(variant)}
</Menu.ItemLabel>
</Menu.Item>
))
return <>{items}</>
}

function VariantTagItems({ nested }: Pick<Props, 'nested'>) {
const fetcher = useSearchFetcher<typeof tags>(MODEL_TO_ROUTE.Tag)
const { addOrUpdateFilter } = useContext(FiltersContext)
const setOpen = useContext(MenuContext)
if (useCommandState((state) => state.search).length < 2 && nested) return null
const items = uniq(fetcher.data ?? [], getTagName).map((tag) => (
<Menu.Item
key={tag.id}
value={`tag-${getTagName(tag)}`}
onSelect={() => {
addOrUpdateFilter(getTagFilter(tag))
setOpen(false)
}}
>
<Menu.ItemLabel group={nested ? 'tags' : undefined}>
{getTagName(tag)}
</Menu.ItemLabel>
</Menu.Item>
))
return <>{items}</>
}

// if the field is scalar, we show an input letting the user type in what value
// they want (e.g. "price is greater than ___")
// Ex: <IntInput />, <DecimalInput />, <StringInput />
Expand Down
File renamed without changes.
43 changes: 43 additions & 0 deletions app/routes/_layout.tags.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Link, useLoaderData } from '@remix-run/react'
import { type LoaderFunctionArgs } from '@vercel/remix'

import { ListLayout } from 'components/list-layout'

import { getTagFilter, getTagName } from 'utils/variant'

import { prisma } from 'db.server'
import { FILTER_PARAM, filterToSearchParam, getSearch } from 'filters'
import { log } from 'log.server'

export async function loader({ request }: LoaderFunctionArgs) {
log.debug('getting tags...')
const search = getSearch(request)
const tags = await prisma.tag.findMany({
take: 100,
where: search ? { name: { search } } : undefined,
})
log.debug('got %d tags', tags.length)
return tags
}

export default function TagsPage() {
const tags = useLoaderData<typeof loader>()
return (
<ListLayout title='variants'>
{tags.map((tag) => {
const param = filterToSearchParam<'variants', 'some'>(getTagFilter(tag))
return (
<li key={tag.id}>
<Link
prefetch='intent'
className='link underline'
to={`/products?${FILTER_PARAM}=${encodeURIComponent(param)}`}
>
{getTagName(tag)}
</Link>
</li>
)
})}
</ListLayout>
)
}
87 changes: 84 additions & 3 deletions app/utils/variant.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,58 @@
import { type Color } from '@prisma/client'
import { type Color, type Tag } from '@prisma/client'
import { nanoid } from 'nanoid/non-secure'

import { type Serialize } from 'utils/general'

import { type Filter } from 'filters'

export type VariantFilter = Filter<'variants', 'some'>

export type VariantTagFilter = Filter<
'variants',
'some',
{ tags: { some: { id: number; name: string } } }
>

export function isVariantTagFilter(filter: Filter): filter is VariantTagFilter {
return (
isVariantFilter(filter) &&
typeof filter.value === 'object' &&
filter.value !== null &&
'tags' in filter.value &&
typeof filter.value.tags === 'object' &&
filter.value.tags !== null &&
'some' in filter.value.tags &&
typeof filter.value.tags.some === 'object' &&
filter.value.tags.some !== null &&
'id' in filter.value.tags.some &&
'name' in filter.value.tags.some
)
}

export function getTagFilter(tag: Serialize<Tag>): VariantFilter {
const filter: VariantFilter = {
id: nanoid(5),
name: 'variants',
condition: 'some',
value: { tags: { some: { id: tag.id, name: tag.name } } },
}
return filter
}

export function getTagName(tag: Serialize<Tag>): string {
return tag.name
}

export type VariantColorFilter = Filter<
'variants',
'some',
{ AND: { colors: { some: { id: number; name: string } } }[] }
>

export function getColorFilter(
variant: Serialize<{ colors: Color[] }>,
): Filter<'variants', 'some'> {
const filter: Filter<'variants', 'some'> = {
): VariantColorFilter {
const filter: VariantColorFilter = {
id: nanoid(5),
name: 'variants',
condition: 'some',
Expand All @@ -24,3 +68,40 @@ export function getColorFilter(
export function getColorName(variant: Serialize<{ colors: Color[] }>) {
return variant.colors.map((c) => c.name).join(' / ')
}

export function isVariantColorFilter(
filter: Filter,
): filter is VariantColorFilter {
return (
isVariantFilter(filter) &&
typeof filter.value === 'object' &&
filter.value !== null &&
'AND' in filter.value &&
typeof filter.value.AND === 'object' &&
filter.value.AND !== null &&
filter.value.AND instanceof Array &&
isColorsArray(filter.value.AND)
)
}

function isColorsArray(
array: unknown[],
): array is { colors: { some: { name: string } } }[] {
return array.every(
(object) =>
typeof object === 'object' &&
object !== null &&
'colors' in object &&
typeof object.colors === 'object' &&
object.colors !== null &&
'some' in object.colors &&
typeof object.colors.some === 'object' &&
object.colors.some !== null &&
'name' in object.colors.some &&
typeof object.colors.some.name === 'string',
)
}

function isVariantFilter(filter: Filter): filter is VariantFilter {
return filter.name === 'variants' && filter.condition === 'some'
}

0 comments on commit 0ef6db8

Please sign in to comment.