From 0ef6db8d23a8c78935b762ad6e68fcbe745afbcb Mon Sep 17 00:00:00 2001 From: Nicholas Chiang Date: Sun, 25 Feb 2024 14:08:49 -0700 Subject: [PATCH] feat(app/filters): allow filter on variant tags 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). --- app/components/filters.tsx | 154 +++++++++++++----- ...layout.variants.tsx => _layout.colors.tsx} | 0 app/routes/_layout.tags.tsx | 43 +++++ app/utils/variant.ts | 87 +++++++++- 4 files changed, 237 insertions(+), 47 deletions(-) rename app/routes/{_layout.variants.tsx => _layout.colors.tsx} (100%) create mode 100644 app/routes/_layout.tags.tsx diff --git a/app/components/filters.tsx b/app/components/filters.tsx index 73d37182..90ee5792 100644 --- a/app/components/filters.tsx +++ b/app/components/filters.tsx @@ -25,8 +25,9 @@ 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' @@ -34,7 +35,16 @@ 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' @@ -52,14 +62,17 @@ const MODEL_TO_ROUTE: Record = { 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', } ////////////////////////////////////////////////////////////////// @@ -230,44 +243,9 @@ 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 ( - c.colors.some.name).join(' / ')} - onClick={() => removeFilter(filter)} - /> - ) + if (isVariantColorFilter(filter)) return + if (isVariantTagFilter(filter)) return throw new Error( ` expected a variant filter value but got: ${JSON.stringify( filter.value, @@ -275,6 +253,32 @@ function VariantItem({ filter }: ItemProps) { ) } +function VariantColorItem({ filter }: { filter: VariantColorFilter }) { + const { removeFilter } = useContext(FiltersContext) + const { condition } = filterToStrings(filter) + return ( + c.colors.some.name).join(' / ')} + onClick={() => removeFilter(filter)} + /> + ) +} + +function VariantTagItem({ filter }: { filter: VariantTagFilter }) { + const { removeFilter } = useContext(FiltersContext) + const { condition } = filterToStrings(filter) + return ( + removeFilter(filter)} + /> + ) +} + type ItemButtonProps = { className?: string children: ReactNode @@ -513,21 +517,61 @@ function SeasonItems({ nested }: Pick) { return <>{items} } +enum VariantAttribute { + COLORS = 'colors', + TAGS = 'tags', +} + function VariantItems({ nested }: Pick) { - const fetcher = useSearchFetcher(MODEL_TO_ROUTE.Variant) + const [attribute, setAttribute] = useState() + if (nested) + return ( + <> + + + + ) + return attribute === VariantAttribute.COLORS ? ( + + ) : attribute === VariantAttribute.TAGS ? ( + + ) : ( + <> + setAttribute(VariantAttribute.COLORS)} + > + + {VariantAttribute.COLORS} + + + setAttribute(VariantAttribute.TAGS)} + > + + {VariantAttribute.TAGS} + + + + ) +} + +function VariantColorItems({ nested }: Pick) { + const fetcher = useSearchFetcher(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) => ( { addOrUpdateFilter(getColorFilter(variant)) setOpen(false) }} > - + {getColorName(variant)} @@ -535,6 +579,28 @@ function VariantItems({ nested }: Pick) { return <>{items} } +function VariantTagItems({ nested }: Pick) { + const fetcher = useSearchFetcher(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) => ( + { + addOrUpdateFilter(getTagFilter(tag)) + setOpen(false) + }} + > + + {getTagName(tag)} + + + )) + 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: , , diff --git a/app/routes/_layout.variants.tsx b/app/routes/_layout.colors.tsx similarity index 100% rename from app/routes/_layout.variants.tsx rename to app/routes/_layout.colors.tsx diff --git a/app/routes/_layout.tags.tsx b/app/routes/_layout.tags.tsx new file mode 100644 index 00000000..06d9748a --- /dev/null +++ b/app/routes/_layout.tags.tsx @@ -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() + return ( + + {tags.map((tag) => { + const param = filterToSearchParam<'variants', 'some'>(getTagFilter(tag)) + return ( +
  • + + {getTagName(tag)} + +
  • + ) + })} +
    + ) +} diff --git a/app/utils/variant.ts b/app/utils/variant.ts index 1039e7c9..765447e2 100644 --- a/app/utils/variant.ts +++ b/app/utils/variant.ts @@ -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): 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): 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', @@ -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' +}