Skip to content
Open
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
27 changes: 27 additions & 0 deletions .changeset/find-many-where.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@example/erp": minor
"@genseki/react": minor
---

## @genseki/react: `findMany` method's `where`

Previously the server doesn't have filter functionality.
The only way to do this is via client-side, which will be sophisticated and troublesome; i.e., filtering data that are limited to the first n records would give the user an incomplete result.

Nevertheless, providing a trivial filter, such as `like` would not be sufficient for real-world use cases.
So it's better to give them all [operators that drizzle provided](https://github.com/softnetics/genseki/pull/61/files#diff-64e2e95728cdee0c91653b1fe43241682ab23fb39642970eb9f054d709202690).

Along with type-safety capability, that would help developers from foot-gunning themselves by mistyping or unknowingly using the where filter in a misconceived way.

See [simple](https://github.com/softnetics/genseki/pull/61/files?diff=unified&w=0#diff-57a06782ec15e2e9a736382771d2f201304e359479752f893038c5bab01d76dcR33-R46) and [complex filter samples](https://github.com/softnetics/genseki/pull/61/files?diff=unified&w=0#diff-0a8eec2eb4f9356b504b16d81f11d60b5b6e6566d24fed79a4c2bd92b2ec588eR305-R383) for reference.

## @example/erp: new `/admin/collections/custom-food-page` page

intended to demonstrate how to use a filter in source code.

https://github.com/user-attachments/assets/53cb2a21-ac7a-4c8f-a238-b107cda00c59

> [!TIP]
> Also added a [custom list component](https://github.com/softnetics/genseki/pull/61/files?diff=unified&w=0#diff-bb8bf64086ef44df2aa775aa4431016d1b340674be456571131bf9ea38bc16c7) to demonstrate how to display data with custom cell rendering.
>
> see [renderFoodsCells](https://github.com/softnetics/genseki/pull/61/files?diff=unified&w=0#diff-e0a7d24561c070f8468733072ef63f60f8960a2e752d7334cc0979b98d313076) for reference.
3 changes: 3 additions & 0 deletions examples/erp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@genseki/react": "workspace:^",
"@genseki/react-query": "workspace:^",
"@genseki/rest": "workspace:^",
"@hookform/resolvers": "^5.0.1",
"@tailwindcss/postcss": "^4.1.7",
"@tanstack/react-query": "^5.71.5",
"@tiptap/extension-color": "^2.14.0",
Expand All @@ -26,13 +27,15 @@
"@tiptap/extension-text-style": "^2.14.0",
"@tiptap/extension-underline": "^2.14.0",
"@tiptap/starter-kit": "^2.14.0",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.41.0",
"next": "15.2.2",
"next-themes": "^0.4.6",
"pg": "^8.14.1",
"postcss": "^8.5.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.56.3",
"tailwindcss": "^4.1.7",
"zod": "3.25.53"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use client'

import { format, parse } from 'date-fns'
import Image from 'next/image'

import type { serverConfig } from '~/drizzlify/config'
import type { FieldsRenderFn } from '~/src/components/list.client'

const formatDate: (data: string) => React.ReactNode = (date) => format(date, 'dd MMM yyyy')
const formatTime = (time: string): string =>
format(parse(time, 'HH:mm:ss', new Date()), "H 'hr(s)', m 'minutes'")

const typeDisplay = {
fruit: '🍌',
vegetable: '🥗',
meat: '🍖',
dairy: '🥛',
grain: '🌾',
other: '...',
}

export const renderFoodsCells: FieldsRenderFn<typeof serverConfig.collections.foods> = {
name: String,
cookingDate: formatDate,
cookingTime: formatTime,
isCooked: (cooked) => (cooked ? '✅' : '❌'),
cookingTypes: (type) => typeDisplay[type as unknown as keyof typeof typeDisplay],
foodAvatar: (src) => (src === 'Unknown' ? 'N/A' : <Image alt="" src={src} />),
description: () => 'N/A',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
'use client'
import { useForm } from 'react-hook-form'

import { zodResolver } from '@hookform/resolvers/zod'
// eslint-disable-next-line no-restricted-imports
import { z } from 'zod'

import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
type InferFields,
Select,
SelectList,
SelectOption,
SelectTrigger,
SubmitButton,
Switch,
TextField,
type ToClientCollection,
useNavigation,
} from '@genseki/react'

import type { serverConfig } from '~/drizzlify/config'
import { ListTable } from '~/src/components/list.client'

import { renderFoodsCells } from './cells'

import { typesEnum } from '../../../../../../db/schema'

type FoodCollection = typeof serverConfig.collections.foods

interface IDisplayProps {
data: InferFields<FoodCollection['fields']>[]
collection: ToClientCollection<FoodCollection>
}

const filterFormSchema = z.object({
name: z.string().optional(),
isCooked: z.boolean().optional(),
cookingType: z.enum(typesEnum.enumValues).optional(),
})

export const Display = ({ data, collection }: IDisplayProps) => {
const { navigate } = useNavigation()

const filterForm = useForm({
resolver: zodResolver(filterFormSchema),
mode: 'onSubmit',
})
async function search(data: z.infer<typeof filterFormSchema>) {
const query = Object.entries(data)
.map(([field, value]) => value !== undefined && `${field}=${value}`)
.filter((v) => v)
.join('&')

navigate(`?${query}`)
}
return (
<div className="p-16">
<Form {...filterForm}>
<form
className="grid grid-cols-2 w-full items-start gap-4"
onSubmit={filterForm.handleSubmit(search)}
>
<FormField
control={filterForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormControl>
<TextField
{...field}
name="name"
label="Search by name"
placeholder="search cooking by its name"
className="mb-8"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-4">
<FormField
control={filterForm.control}
name="isCooked"
defaultValue={false}
render={({ field }) => (
<FormItem>
<FormControl>
<Switch {...field} value={`${field.value}`} name="isCooked" label="Cooked?" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={filterForm.control}
name="cookingType"
render={({ field }) => (
<FormItem>
<FormControl>
<Select
placeholder="Cooking type"
{...field}
onSelectionChange={field.onChange}
>
<SelectTrigger />
<SelectList
items={typesEnum.enumValues.map((type) => ({
type,
}))}
>
{({ type }) => (
<SelectOption id={type} textValue={type}>
{type}
</SelectOption>
)}
</SelectList>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<SubmitButton>Search</SubmitButton>
</form>
</Form>
<ListTable collection={collection} data={data} renderCellFns={renderFoodsCells} />
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { headers } from 'next/headers'

import { RootLayout } from '@genseki/next'
import { CollectionAppLayout, Context, createAuth, getClientCollection } from '@genseki/react'

import { Display } from './display'

import { serverConfig } from '../../../../../../drizzlify/config'
import { serverFunction } from '../../../_helper/server'

const collection = serverConfig.collections.foods

const getHeadersObject = (headers: Headers): Record<string, string> => {
const headersRecord: Record<string, string> = {}
headers.forEach((value, key) => {
headersRecord[key] = value
})
return headersRecord
}

interface CustomFoodProps {
searchParams: Promise<Record<string, string>>
}

const CustomFoodPage = async (props: CustomFoodProps) => {
const { context: authContext } = createAuth(serverConfig.auth, serverConfig.context)

const searchParams = await props.searchParams
const nameQuery = searchParams['name']
const isCookedQuery = JSON.parse(searchParams['isCooked'] ?? 'null') as boolean | null
const cookingTypeQuery = searchParams['cookingType']

const result = await collection.admin.endpoints.findMany.handler({
context: Context.toRequestContext(authContext, getHeadersObject(await headers())),
query: {
limit: 10,
offset: 0,
orderBy: undefined,
orderType: undefined,
where: {
name: nameQuery ? { $like: `%${nameQuery}%` } : '$isNotNull',
isCooked: isCookedQuery !== null ? { $eq: isCookedQuery } : '$isNotNull',
cookingTypes: cookingTypeQuery ? { $eq: cookingTypeQuery } : '$isNotNull',
},
},
})

return (
<RootLayout serverConfig={serverConfig} serverFunction={serverFunction}>
<CollectionAppLayout serverConfig={serverConfig}>
<Display collection={getClientCollection(collection)} data={result.body.data} />
</CollectionAppLayout>
</RootLayout>
)
}

export default CustomFoodPage
82 changes: 82 additions & 0 deletions examples/erp/src/components/list.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
'use client'

import type { AnyCollection, InferFields, ToClientCollection } from '@genseki/react'
import { Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from '@genseki/react'

const tableDataExtract = <TCollection extends AnyCollection>(
collection: ToClientCollection<TCollection>,
data: InferFields<TCollection['fields']>[]
) => {
const headers = Object.values(collection.fields).map((column) => {
return { ...column, label: column.fieldName /* Fallback to key if no label */ }
})

headers.sort((a, b) => (b.label === collection.identifierColumn ? 1 : -1))

const rows = data.map((record) => ({
key: record.__id,
rows: headers.map(
(header) =>
record[header.fieldName as keyof typeof record] ??
'Unknown' /* Unknown meant that it's missing a correct heading label */
),
}))

return { headers, rows }
}

export type FieldsRenderFn<
TCollection extends AnyCollection,
TFields extends InferFields<TCollection['fields']> = InferFields<TCollection['fields']>,
> = { [key in keyof TFields]?: (data: TFields[key]) => React.ReactNode }

interface IListTableProps<TCollection extends AnyCollection> {
collection: ToClientCollection<TCollection>
data: InferFields<TCollection['fields']>[]
renderCellFns?: FieldsRenderFn<TCollection>
}

export function ListTable<TCollection extends AnyCollection>(props: IListTableProps<TCollection>) {
const { headers, rows } = tableDataExtract(props.collection, props.data)

return (
<>
<Table
bleed
aria-label="Items"
selectionMode="none"
className="overflow-clip rounded-xl"
allowResize
>
<TableHeader>
{headers.map(({ label }) => (
<TableColumn isResizable isRowHeader key={label}>
{label}
</TableColumn>
))}
</TableHeader>
<TableBody items={rows}>
{({ key, rows }) => (
<TableRow href={`./${props.collection.slug}/update/${key}`} id={key}>
{
/* @ts-expect-error union type too complex */
rows.map((cell, i) => {
const fieldName = headers[i].fieldName
let renderFn: (data: any) => React.ReactNode = JSON.stringify

if (!!props.renderCellFns && fieldName in props.renderCellFns)
renderFn =
props.renderCellFns[fieldName as keyof InferFields<TCollection['fields']>]!

return (
<TableCell key={`${i}-${JSON.stringify(cell)}`}>{renderFn(cell)}</TableCell>
)
})
}
</TableRow>
)}
</TableBody>
</Table>
</>
)
}
2 changes: 1 addition & 1 deletion packages/react/src/core/__mocks__/all-type-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const allFieldTypes = pgTable('table', {
integer: integer(),
smallint: smallint(),
bigint: bigint({ mode: 'bigint' }),
serial: serial(),
serial: serial().primaryKey(),
smallserial: smallserial(),
bigserialNumber: bigserial({ mode: 'number' }),
bigserialBigInt: bigserial({ mode: 'bigint' }),
Expand Down
Loading
Loading