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

feat(app/filters): allow filters on variant tags #141

Merged
merged 4 commits into from
Feb 25, 2024
Merged
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
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
6 changes: 4 additions & 2 deletions app/components/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export type RootProps = {

export function Root({ children }: RootProps) {
return (
<Command className='frosted fixed mt-0.5 flex min-w-min max-w-xl origin-top-left flex-col overflow-hidden rounded-lg border border-gray-200 text-xs shadow-xl will-change-transform dark:border-gray-800'>
<Command className='frosted fixed mt-0.5 flex min-w-min max-w-xl origin-top-left flex-col overflow-hidden rounded-lg border border-gray-200 text-xs shadow-xl will-change-transform dark:border-gray-800 max-h-[calc(100vh_-_84px)]'>
{children}
</Command>
)
Expand Down Expand Up @@ -56,7 +56,9 @@ export type ListProps = { children?: ReactNode }
export function List({ children }: ListProps) {
const count = useCommandState((state) => state.filtered.count)
return (
<Command.List className={cn(count > 0 && 'py-1')}>{children}</Command.List>
<Command.List className={cn('overflow-auto', count > 0 && 'py-1')}>
{children}
</Command.List>
)
}

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'
}
Loading
Loading