@drhomet/mithaq 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSES/openstatus-time-picker.txt +21 -0
- package/README.md +161 -0
- package/dist/asas/build.d.ts +10 -2
- package/dist/asas/build.js +69 -22
- package/dist/asas/cli/mithaq.js +35 -3
- package/dist/asas/cli/upgrade.d.ts +22 -9
- package/dist/asas/cli/upgrade.js +187 -78
- package/dist/asas/http/fetch.d.ts +21 -3
- package/dist/asas/http/fetch.js +123 -60
- package/dist/asas/http/orpc.d.ts +5 -3
- package/dist/asas/http/orpc.js +126 -108
- package/dist/asas/runner/bulk.d.ts +1 -1
- package/dist/asas/runner/bulk.js +16 -11
- package/dist/asas/runner/errors.d.ts +5 -2
- package/dist/asas/runner/errors.js +18 -2
- package/dist/asas/runner/read.d.ts +6 -2
- package/dist/asas/runner/read.js +28 -6
- package/dist/asas/runner/steps.d.ts +22 -4
- package/dist/asas/runner/steps.js +15 -9
- package/dist/asas/runner/store.d.ts +4 -0
- package/dist/asas/runner/store.js +21 -0
- package/dist/asas/runner/tenant.d.ts +2 -2
- package/dist/asas/runner/tenant.js +78 -16
- package/dist/asas/runner/write.d.ts +173 -11
- package/dist/asas/runner/write.js +274 -46
- package/dist/auth/better-auth/access.d.ts +14 -0
- package/dist/auth/better-auth/access.js +32 -0
- package/dist/auth/better-auth/generate.d.ts +3 -1
- package/dist/auth/better-auth/generate.js +26 -8
- package/dist/auth/better-auth/index.d.ts +2 -0
- package/dist/auth/better-auth/tenant.d.ts +11 -3
- package/dist/auth/better-auth/tenant.js +10 -2
- package/dist/auth/local/index.js +5 -3
- package/dist/base/fields.d.ts +3 -2
- package/dist/base/fields.js +3 -2
- package/dist/base/index.d.ts +2 -1
- package/dist/base/json-schema.d.ts +7 -0
- package/dist/base/json-schema.js +35 -0
- package/dist/base/kinds.d.ts +21 -0
- package/dist/base/kinds.js +46 -1
- package/dist/base/rules.d.ts +4 -4
- package/dist/base/rules.js +4 -3
- package/dist/drizzle/index.d.ts +9 -139
- package/dist/drizzle/index.js +143 -24
- package/dist/drizzle/kinds.d.ts +1 -0
- package/dist/drizzle/kinds.js +3 -1
- package/dist/drizzle/migrate.d.ts +26 -0
- package/dist/drizzle/migrate.js +69 -26
- package/dist/drizzle/rules.d.ts +1 -1
- package/dist/index.d.ts +11 -21
- package/dist/index.js +8 -18
- package/dist/mithaq/define.d.ts +62 -15
- package/dist/mithaq/define.js +23 -9
- package/dist/mithaq/generate-messages.js +5 -4
- package/dist/mithaq/language.d.ts +43 -4
- package/dist/mithaq/message-file.d.ts +13 -0
- package/dist/mithaq/message-file.js +29 -0
- package/dist/mithaq/messages.js +5 -0
- package/dist/mithaq/resolve.js +70 -10
- package/dist/mithaq/types.d.ts +29 -1
- package/dist/rbac/index.d.ts +2 -2
- package/dist/rbac/index.js +1 -1
- package/dist/rich-text/index.d.ts +1 -0
- package/dist/rich-text/index.js +1 -0
- package/dist/rich-text/rich-text.d.ts +2 -55
- package/dist/rich-text/rich-text.js +2 -125
- package/dist/rich-text/value.d.ts +55 -0
- package/dist/rich-text/value.js +126 -0
- package/dist/screen/client.d.ts +5 -2
- package/dist/screen/client.js +5 -2
- package/dist/screen/generate.js +48 -6
- package/dist/screen/index.d.ts +18 -1
- package/dist/screen/index.js +24 -6
- package/dist/screen/react.d.ts +2 -0
- package/dist/storage/index.d.ts +2 -3
- package/dist/storage/index.js +1 -2
- package/dist/storage/storage.d.ts +2 -0
- package/dist/storage/storage.js +9 -2
- package/dist/targets.d.ts +14 -0
- package/dist/targets.js +16 -0
- package/dist/vite.d.ts +7 -0
- package/dist/vite.js +81 -0
- package/dist/zod/index.d.ts +12 -3
- package/dist/zod/index.js +6 -5
- package/dist/zod/kinds.d.ts +3 -1
- package/dist/zod/kinds.js +11 -2
- package/dist/zod/rules.d.ts +1 -1
- package/docs/packages/mithaq/README.md +962 -0
- package/docs/packages/mithaq/src/asas/cli/README.md +252 -0
- package/docs/packages/mithaq/src/asas/runner/README.md +472 -0
- package/docs/packages/mithaq/src/drizzle/README.md +39 -0
- package/docs/packages/mithaq-admin/README.md +312 -0
- package/docs/templates/asas/README.md +1056 -0
- package/package.json +37 -8
- package/skills/add-field/SKILL.md +27 -0
- package/skills/add-kayan/SKILL.md +26 -0
- package/skills/eject-admin-piece/SKILL.md +28 -0
- package/skills/upgrade-mithaq/SKILL.md +35 -0
- package/r/mithaq-cells.json +0 -20
- package/r/mithaq-controls.json +0 -37
- package/r/mithaq-directional-icon.json +0 -15
- package/r/mithaq-expected-updated-at.json +0 -15
- package/r/mithaq-field-shell.json +0 -18
- package/r/mithaq-filter-operators.json +0 -15
- package/r/mithaq-form.json +0 -33
- package/r/mithaq-list-columns.json +0 -23
- package/r/mithaq-list-filters.json +0 -24
- package/r/mithaq-list-pagination.json +0 -24
- package/r/mithaq-list-query.json +0 -18
- package/r/mithaq-list-selection.json +0 -19
- package/r/mithaq-list.json +0 -33
- package/r/mithaq-nav.json +0 -24
- package/r/mithaq-nodes.json +0 -34
- package/r/mithaq-relationship-cell.json +0 -20
- package/r/mithaq-relationship-control.json +0 -20
- package/r/mithaq-rich-text.json +0 -40
- package/r/mithaq-screens-host.json +0 -96
- package/r/mithaq-storage.json +0 -44
- package/r/mithaq-upload.json +0 -29
- package/r/registry.json +0 -550
package/r/mithaq-form.json
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "mithaq-form",
|
|
4
|
-
"title": "Mithaq form",
|
|
5
|
-
"description": "The form screen over a kayan's create, update and delete, holding its values in TanStack Form: it walks a screen's page, checks the rules the projection carries before any request, sends only the fields that changed on an update, and renders a refusal by the key the server sent.",
|
|
6
|
-
"dependencies": [
|
|
7
|
-
"@tanstack/react-form"
|
|
8
|
-
],
|
|
9
|
-
"registryDependencies": [
|
|
10
|
-
"alert-dialog",
|
|
11
|
-
"badge",
|
|
12
|
-
"button",
|
|
13
|
-
"card",
|
|
14
|
-
"spinner",
|
|
15
|
-
"toggle-group",
|
|
16
|
-
"@mithaq/mithaq-cells",
|
|
17
|
-
"@mithaq/mithaq-controls",
|
|
18
|
-
"@mithaq/mithaq-field-shell",
|
|
19
|
-
"@mithaq/mithaq-nodes",
|
|
20
|
-
"@mithaq/mithaq-relationship-cell",
|
|
21
|
-
"@mithaq/mithaq-rich-text",
|
|
22
|
-
"@mithaq/mithaq-upload"
|
|
23
|
-
],
|
|
24
|
-
"files": [
|
|
25
|
-
{
|
|
26
|
-
"path": "registry/form.tsx",
|
|
27
|
-
"content": "import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState, type FormEvent, type ReactElement, type ReactNode } from 'react'\nimport { useField, useForm, useStore, type AnyFormApi } from '@tanstack/react-form'\nimport { Check } from 'lucide-react'\nimport { compareDecimals, decimalDigits, isDecimal, type ClientField, type ClientKayan, type LeafClient, type Row } from 'mithaq/admin'\nimport { FormContext, ScreenContext } from 'mithaq/admin/react'\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n AlertDialogTrigger,\n} from '@/components/ui/alert-dialog'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Spinner } from '@/components/ui/spinner'\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'\nimport { cellFor } from './cells.tsx'\nimport { RichTextValueView } from './rich-text.tsx'\nimport { UploadValue } from './upload.tsx'\nimport { useRelationshipTitles } from './relationship-cell.tsx'\nimport {\n besideLabel,\n controlFor,\n counted,\n descriptionFor,\n descriptionOf,\n labelFor,\n listed,\n localesOf,\n operationFieldLabelFor,\n operationLabelFor,\n placeholderFor,\n readOnlyField,\n refusalKeyOf,\n seedOf,\n titleOf,\n valuesOf,\n type Copy,\n type FindApi,\n type Translate,\n} from './controls.tsx'\nimport { FieldShell, describedBy } from './field-shell.tsx'\nimport { BulkChoice, LeafControl, NavLinks, NodeButton, OwnButton, initialFocusIn, rowWarning, drawNodes, drawsKind, frameOf, leavesOf, nodesOf, pageButtonOf, pageOf, refOf, scopeOf, type Draw, type Node, type PageButton, type ScreenPage } from './nodes.tsx'\n\n/**\n * The three operations this screen drives, typed structurally so a typed oRPC client, an in-process\n * router client or a plain object fits.\n */\nexport type FormClient = {\n create: (input: Record<string, unknown>, options?: DraftOptions) => Promise<unknown>\n update: (input: Record<string, unknown>, options?: DraftOptions) => Promise<unknown>\n delete: (input: Record<string, unknown>) => Promise<unknown>\n /** A kayan's own operation, by name, for a page button that asks for one. A host that hands none draws no such button. */\n run?: (operation: string, input: Record<string, unknown>) => Promise<unknown>\n /** The plain read of one row; on a kayan that keeps drafts, the published copy. A host that hands none draws no draft switch. */\n findByID?: (input: Record<string, unknown>) => Promise<unknown>\n}\n\n/** What a write asks beside its input on a kayan that keeps drafts: a draft save, and an autosave beside it. */\nexport type DraftOptions = { readonly draft?: boolean; readonly autosave?: boolean }\n\n/** Where a row of a kayan that keeps drafts stands: never published, published, or published with a newer draft pending. */\nexport type DraftStatus = 'draft' | 'published' | 'changed'\n\nconst afterDraft = (status?: DraftStatus): DraftStatus => (status === 'published' || status === 'changed' ? 'changed' : 'draft')\n\nconst pills: Record<DraftStatus, string> = {\n draft: 'bg-muted text-muted-foreground',\n published: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',\n changed: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',\n}\n\nconst publishColour = 'bg-emerald-700 text-white hover:bg-emerald-700/90 focus-visible:border-emerald-600 focus-visible:ring-emerald-600/40 dark:bg-emerald-500 dark:text-emerald-950 dark:hover:bg-emerald-500/90 dark:focus-visible:ring-emerald-500/40'\nconst draftColour = 'border-transparent bg-amber-500 text-amber-950 hover:bg-amber-500/90 hover:text-amber-950 focus-visible:border-amber-600 focus-visible:ring-amber-500/50 dark:bg-amber-400 dark:hover:bg-amber-400/90'\n\nexport type FormProps = {\n kayan: ClientKayan\n client: FormClient\n /** The row being edited, or nothing for a row being written. */\n value?: Record<string, unknown> | null\n t: Translate\n locale: string\n /** Which locales exist, from the host: a localized field draws one box for each. */\n locales?: readonly string[]\n /**\n * What the host does once a write lands. The second argument is the effect that ran (`create`,\n * `update` or `delete`), which decides where the host lands.\n */\n onDone: (row: unknown, effect?: string) => void\n /** Where Cancel goes. A host that hands none gets no Cancel button. */\n onCancel?: () => void\n /** A refusal the host already caught, for a host that writes on its own path too. */\n error?: unknown\n /** Every kayan of the build, so a relationship control can find its target. */\n kayans?: readonly ClientKayan[]\n /** The typed client's reads, for a control that pages a second kayan. */\n api?: FindApi\n /**\n * Whether this actor may write one field on the operation being rendered. Chrome only: the server\n * authorizes the write whatever this says. Undeclared is allowed.\n */\n writes?: (field: string, on: 'create' | 'update') => boolean\n /**\n * This actor's answer per operation name. A button for a kayan's own operation reads its own name\n * here, never its effect's. Chrome only; an absent key is denied.\n */\n operations?: Readonly<Record<string, boolean>>\n /**\n * Whether this form holds a change nobody saved, reported each time the answer changes, so the\n * host's router can block a navigation away.\n */\n onDirty?: (dirty: boolean) => void\n /**\n * A navigation the host's router held back while this form reported a change. The form asks the\n * discard question itself so the autosave is held while it asks: `proceed` lets the navigation go,\n * `reset` keeps the person here.\n */\n blocked?: { proceed: () => void; reset: () => void }\n /** A screen's page, where this form draws one: its regions go to the page's layout and nothing the page does not name is drawn. */\n screen?: ScreenPage\n /** What the host draws a list node on this page with. A host that hands none draws no list. */\n related?: Draw['list']\n /** The nodes this form draws in place of a page, as one form node and no shell. They win over `screen`'s page. */\n nodes?: readonly Node[]\n /**\n * The kayan keeps drafts and this actor holds its `findVersions`: the form offers Save draft and\n * Publish changes in place of Save, and autosaves a draft after `autosave` ms of stillness.\n * A host that hands none keeps the plain Save.\n */\n drafts?: { status?: DraftStatus; autosave?: number }\n /**\n * The kayan's update takes `expectedUpdatedAt`: an update sends the `updatedAt` this form last read\n * or wrote, so a save over a newer one from another tab is refused 409. On a kayan that keeps\n * drafts it is the latest version's, and an autosave sends it too.\n */\n expectedUpdatedAt?: boolean\n /**\n * The kayan's other operations that take `expectedUpdatedAt`, of `delete` and `restoreVersion`, so\n * a newer draft another person saved is kept.\n */\n expectedUpdatedAtOn?: readonly string[]\n}\n\n/**\n * Which fields a schema refusal names, and what it says about each, keyed by the first segment of\n * the issue's path (the field the form drew a control for).\n */\nexport const issuesOf = (error: unknown): Readonly<Record<string, string>> => {\n const issues = (error as { data?: { params?: { issues?: unknown } } } | null | undefined)?.data?.params?.issues\n // No prototype, so a field named `toString` has no issue it was never given.\n const byField: Record<string, string> = Object.create(null)\n if (!Array.isArray(issues)) return byField\n for (const issue of issues as readonly { path?: unknown; message?: unknown }[]) {\n const path = typeof issue?.path === 'string' ? issue.path.split('.')[0] : ''\n if (!path || typeof issue.message !== 'string' || byField[path] !== undefined) continue\n byField[path] = issue.message\n }\n return byField\n}\n\n/** An email address, by the pattern the server's `email` kind runs, copied so the browser loads no zod. */\nconst emailShape = /^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$/\n\n/** Whether a value is nothing a person wrote. An empty list is a value, so a required list may be saved empty; `minRows` asks for rows. */\nconst empty = (value: unknown) => value === undefined || value === null || value === ''\n\n/**\n * The first rule of the projection this value breaks, as the last segment of the server's copy key,\n * in the server's order, or nothing. An empty box passes every rule but `required`; a value outside\n * `options` is `invalid`.\n */\nconst brokenRule = (field: ClientField, held: unknown): string | undefined => {\n const { rules } = field\n if (rules.localized === true && held && typeof held === 'object') {\n return Object.values(held).map((one) => brokenRule({ ...field, rules: { ...rules, localized: false } }, one)).find(Boolean)\n }\n // The server trims and cases before it judges, so judge the rewritten value.\n const text = typeof held === 'string' && rules.trim === true ? held.trim() : held\n const value = typeof text !== 'string' ? text : rules.uppercase === true ? text.toUpperCase() : rules.lowercase === true ? text.toLowerCase() : text\n if (Array.isArray(value) && (value.length || rules.required === true)) {\n if (typeof rules.minRows === 'number' && value.length < rules.minRows) return 'minRows'\n if (typeof rules.maxRows === 'number' && value.length > rules.maxRows) return 'maxRows'\n }\n if (empty(value)) return rules.required === true ? 'required' : undefined\n if (field.kind === 'integer' && !(Number.isInteger(value) && (value as number) >= -2147483648 && (value as number) <= 2147483647)) return 'invalid'\n if (field.kind === 'decimal') {\n if (!isDecimal(value)) return 'invalid'\n const { whole, fraction } = decimalDigits(value)\n if (typeof rules.precision === 'number' && (whole + fraction > rules.precision || whole > rules.precision - Number(rules.scale))) return 'precision'\n if (typeof rules.scale === 'number' && fraction > rules.scale) return 'scale'\n if (typeof rules.min === 'string' && compareDecimals(value, rules.min) < 0) return 'min'\n if (typeof rules.max === 'string' && compareDecimals(value, rules.max) > 0) return 'max'\n return undefined\n }\n if (field.kind === 'email' && typeof value === 'string' && !emailShape.test(value)) return 'invalid'\n const size = typeof value === 'string' || Array.isArray(value) ? value.length : undefined\n if (size !== undefined && typeof rules.minLength === 'number' && size < rules.minLength) return 'minLength'\n if (size !== undefined && typeof rules.maxLength === 'number' && size > rules.maxLength) return 'maxLength'\n if (typeof value === 'string' && rules.pattern instanceof RegExp && !new RegExp(rules.pattern).test(value)) return 'pattern'\n const { min, max } = rules\n if (typeof min === 'number' ? typeof value === 'number' && value < min : typeof min === 'string' && String(value) < min) return 'min'\n if (typeof max === 'number' ? typeof value === 'number' && value > max : typeof max === 'string' && String(value) > max) return 'max'\n if (field.options && [value].flat().some((one) => !field.options!.some((option) => option.value === one))) return 'invalid'\n return undefined\n}\n\n/**\n * The refusal the server would send for these values, in its wire shape, or nothing when every rule\n * holds. The rules are checked here because the derived zod shape does not cross `structuredClone`\n * into the browser; the server stays the authority.\n */\nexport const refusalOf = ({\n kayan,\n fields,\n values,\n}: {\n kayan: string\n fields: readonly ClientField[]\n values: Readonly<Record<string, unknown>>\n}) => {\n const issues = fields.flatMap((field) => {\n const rule = brokenRule(field, values[field.name])\n return rule ? [{ path: field.name, message: `${kayan}.${field.name}.${rule}` }] : []\n })\n return issues.length ? { data: { key: 'error.invalid.value', params: { issues } } } : undefined\n}\n\n/** The sentence over a refused save: a schema refusal that names fields lists them by label; any other is its own key's sentence, or `fallback`. */\nexport const refusalWords = ({ shown, labels, fallback, copy: { t, locale } }: { shown: string; labels: readonly string[]; fallback?: string; copy: Copy }): string =>\n shown === 'error.invalid.value' && labels.length\n ? counted({ t, locale, key: 'error.invalid.fields', count: labels.length, fallback: 'The following fields are invalid: {fields}' }).replace('{fields}', () => listed(labels, locale))\n : (t(shown, fallback ?? shown) ?? shown)\n\nconst KayanForm = createContext<\n | {\n form: AnyFormApi\n issues: Readonly<Record<string, string>>\n locales: Readonly<Record<string, readonly string[]>>\n writes?: (field: string, on: 'create' | 'update') => boolean\n on: 'create' | 'update'\n check: (name: string, value: unknown) => string | undefined\n forget: (name: string) => void\n }\n | undefined\n>(undefined)\n\nexport const useValues = <Values extends object = Record<string, unknown>>(): Values => {\n const held = useContext(KayanForm)\n if (!held) throw new Error('useValues is called inside the form this registry draws, and nowhere else')\n return useStore(held.form.store, (state) => state.values as Values)\n}\n\ntype KayanField = {\n value: unknown\n onChange: (value: unknown) => void\n onBlur: () => void\n /** Whether this field is the client's to write on this operation, off the access report. */\n writable: boolean\n /** Whether a person has left this box, or the server has refused it. */\n touched: boolean\n /** This field's refusal, in the words a person reads, or nothing. */\n error?: string\n /** The locales of a localized field the server's refusal named, whose boxes alone are marked. */\n locales?: readonly string[]\n}\n/**\n * One field's value, change, blur, write access and error, read from the form this field is inside.\n * The error shows once the box is left or the server has refused it, and falls back to the form's\n * refusal record because a remount or a server render has no error map.\n */\nexport function useKayanField<Values extends object, Name extends keyof Values & string>(name: Name): Omit<KayanField, 'value'> & { value: Values[Name] }\nexport function useKayanField(name: string): KayanField\nexport function useKayanField(name: string): KayanField {\n const held = useContext(KayanForm)\n if (!held) throw new Error('useKayanField is called inside the form this registry draws, and nowhere else')\n const { check, forget, form, issues, locales, on, writes } = held\n const field = useField({\n form,\n name,\n // Validated on blur, then on every change; a change also drops the refusal a save left on this box.\n validators: { onBlur: ({ value }) => check(name, value) },\n listeners: {\n onChange: ({ fieldApi }) => {\n forget(name)\n if (fieldApi.state.meta.isBlurred) fieldApi.validate('blur')\n },\n },\n })\n const meta = field.state.meta\n const refused = (meta.errorMap.onServer as string | undefined) ?? issues[name]\n const touched = meta.isTouched || refused !== undefined\n const invalid = !meta.isValid || refused !== undefined\n const first = meta.errors.find((one) => one !== undefined && one !== null)\n return {\n value: field.state.value,\n onChange: (next: unknown) => field.handleChange(next),\n onBlur: field.handleBlur,\n writable: writes ? writes(name, on) : true,\n touched,\n error: touched && invalid ? (refused ?? (typeof first === 'string' ? first : undefined)) : undefined,\n locales: locales[name],\n }\n}\n\n/**\n * The delete, in two clicks. The dialog stays open until the delete lands, so a refusal is said inside it.\n * With `open` handed in, the dialog is controlled and draws no trigger, so a menu can open it.\n */\nexport function DeleteButton({\n busy,\n copy,\n kayan,\n label,\n onDelete,\n onOpenChange,\n open: given,\n row,\n}: {\n busy: boolean\n copy: Copy\n kayan: ClientKayan\n label: string\n /** Runs the delete, handing a refusal to `refuse`, and answers whether it landed, which is what the dialog closes on. */\n onDelete: (refuse: (thrown: unknown) => void) => Promise<boolean>\n open?: boolean\n onOpenChange?: (open: boolean) => void\n /** The row it deletes, which the dialog names. */\n row?: Row\n}): ReactElement {\n const { t } = copy\n const [held, setHeld] = useState(false)\n const [refused, setRefused] = useState<unknown>(undefined)\n const setOpen = (next: boolean) => {\n if (!next) setRefused(undefined)\n ;(onOpenChange ?? setHeld)(next)\n }\n return (\n <AlertDialog open={given ?? held} onOpenChange={setOpen}>\n {given === undefined ? (\n <AlertDialogTrigger\n render={\n <Button type=\"button\" variant=\"destructive\" className=\"max-sm:h-11\" data-button=\"delete\" disabled={busy}>\n {label}\n </Button>\n }\n />\n ) : null}\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>{label}</AlertDialogTitle>\n <AlertDialogDescription>\n {[row ? rowWarning({ kayan, row, operation: 'delete', copy }) : undefined, t('admin.deleteWarning', 'This cannot be undone.') ?? 'This cannot be undone.'].filter(Boolean).join(' ')}\n </AlertDialogDescription>\n </AlertDialogHeader>\n {refused === undefined ? null : (\n <p role=\"alert\" className=\"rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive\">\n {deleteRefusal({ refused, t })}\n </p>\n )}\n <AlertDialogFooter className=\"max-sm:[&_button]:h-11\">\n <AlertDialogCancel>{t('admin.cancel', 'Cancel') ?? 'Cancel'}</AlertDialogCancel>\n <AlertDialogAction\n variant=\"destructive\"\n disabled={busy}\n onClick={async () => {\n setRefused(undefined)\n if (await onDelete(setRefused)) setOpen(false)\n }}\n >\n {t(`${kayan.name}.delete.confirm`) ?? t('admin.deleteConfirm', 'Delete for good') ?? 'Delete for good'}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n )\n}\n\nconst deleteRefusal = ({ refused, t }: { refused: unknown; t: Translate }): string => {\n const key = refusalKeyOf(refused)\n if (!key) return t('admin.deleteFailed', 'Something went wrong, so nothing was deleted. Try again.') ?? 'Something went wrong, so nothing was deleted. Try again.'\n const by = (refused as { data?: { params?: { by?: unknown } } }).data?.params?.by\n if (key === 'error.conflict.referenced' && typeof by === 'string') {\n const kind = t(`${by}.plural`, by) ?? by\n return (t('error.conflict.referencedBy', '{kind} still use this, so it is not deleted.') ?? '{kind} still use this, so it is not deleted.').replace('{kind}', () => kind)\n }\n return t(key, key) ?? key\n}\n\n/**\n * The question asked before an unsaved edit is thrown away, used by the form's Cancel and by a\n * navigation the host's router blocked. It stays mounted with the form, so an autosave landing while\n * it asks cannot unmount it.\n */\nexport function DiscardDialog({\n busy,\n note,\n onDiscard,\n onOpenChange,\n open,\n t,\n}: {\n open: boolean\n onOpenChange?: (open: boolean) => void\n /** What discarding means, which is leaving: the Cancel's navigation, or the one the router blocked. */\n onDiscard: () => void\n /** A sentence after the question, such as that a draft already saved is kept. */\n note?: string\n busy?: boolean\n t: Translate\n}): ReactElement {\n return (\n <AlertDialog open={open} onOpenChange={onOpenChange}>\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>{t('admin.discard', 'Discard changes') ?? 'Discard changes'}</AlertDialogTitle>\n <AlertDialogDescription>\n {[t('admin.discardWarning', 'You have unsaved changes. Leave without saving?') ?? 'You have unsaved changes. Leave without saving?', note].filter(Boolean).join(' ')}\n </AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter className=\"max-sm:[&_button]:h-11\">\n <AlertDialogCancel>{t('admin.cancel', 'Cancel') ?? 'Cancel'}</AlertDialogCancel>\n <AlertDialogAction variant=\"destructive\" disabled={busy} onClick={onDiscard}>\n {t('admin.discard', 'Discard changes') ?? 'Discard changes'}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n )\n}\n\n/**\n * One of a kayan's own operations, as a button and the dialog behind it. The dialog is controlled so a\n * refusal keeps it open over the boxes it names; a list's own buttons open this same dialog.\n */\nexport function OperationButton({\n api,\n busy,\n copy,\n description,\n inputOf,\n kayan,\n kayans,\n operation,\n run,\n ...controlled\n}: {\n api?: FindApi\n busy: boolean\n copy: Copy\n /** Handed in by a caller that opens the dialog from elsewhere, such as a list row's menu, in which case no trigger is drawn. */\n open?: boolean\n onOpenChange?: (open: boolean) => void\n /** What the dialog says it runs on, the row being edited unless the caller says otherwise. */\n description?: string\n /** The call's input from what the boxes hold: the row's id beside them on a form or list row, the ticked rows' `where` around them on a bulk. */\n inputOf: (values: Record<string, unknown>) => Record<string, unknown>\n kayan: ClientKayan\n kayans?: readonly ClientKayan[]\n operation: PageButton\n /** The form's own writer, which lands the call and hands a refusal back here. */\n run: (input: Record<string, unknown>, refuse: (thrown: unknown) => void) => Promise<boolean>\n}): ReactElement {\n const { t } = copy\n const [held, setHeld] = useState(false)\n const open = controlled.open ?? held\n const setOpen = (next: boolean) => (controlled.onOpenChange ?? setHeld)(next)\n const [values, setValues] = useState<Record<string, unknown>>({})\n const [refused, setRefused] = useState<unknown>(undefined)\n const fields = operation.fields ?? []\n const setValue = (name: string, next: unknown) => {\n if (!busy && fields.some((field) => field.name === name)) setValues((already) => ({ ...already, [name]: next }))\n }\n // Closing drops the refusal and the typed values, so the next opening starts empty.\n const close = () => {\n setOpen(false)\n setRefused(undefined)\n setValues({})\n }\n const label = operationLabelFor({ kayan, operation, copy })\n const shown = refusalKeyOf(refused)\n const issues = issuesOf(refused)\n // A bulk update whose boxes are row data: a box left empty leaves each row's value as it is, and\n // with every box empty there is nothing to write, which the server refuses too.\n const writesData = operation.updatesMany === true && fields.length > 0\n const popup = useRef<HTMLDivElement>(null)\n\n const submit = async (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault()\n // React events from a portaled dialog still reach the screen's form through the React tree;\n // without this one click would run the operation and save the row.\n event.stopPropagation()\n if (busy) return\n // An untouched box is left out rather than sent as `undefined`, so a schema default for it still applies.\n const typed: Record<string, unknown> = {}\n for (const field of fields) {\n if (values[field.name] !== undefined) typed[field.name] = values[field.name]\n }\n if (writesData && !Object.keys(typed).length) return setRefused({ data: { key: 'admin.bulkNothingFilled' }, fallback: 'Fill in at least one field.' })\n if (await run(inputOf(typed), setRefused)) close()\n }\n\n return (\n <AlertDialog\n open={open}\n onOpenChange={(next: boolean) => (next ? setOpen(true) : close())}\n >\n {controlled.open === undefined ? (\n <AlertDialogTrigger\n render={\n <Button type=\"button\" variant=\"outline\" className=\"max-sm:h-11\" disabled={busy}>\n {label}\n </Button>\n }\n />\n ) : null}\n {/* On a phone the dialog takes focus, not its first box, so the keyboard stays down. */}\n <AlertDialogContent ref={popup} initialFocus={initialFocusIn(popup)}>\n <AlertDialogHeader>\n <AlertDialogTitle>{operation.confirm?.title[copy.locale] ?? label}</AlertDialogTitle>\n <AlertDialogDescription>\n {[description, operation.confirm?.body[copy.locale], writesData && t('admin.bulkFilledOnly', 'Only the fields you fill in change.')].filter(Boolean).join(' ')\n || (t('admin.operationWarning', 'This runs on the row you are editing.') ?? 'This runs on the row you are editing.')}\n </AlertDialogDescription>\n </AlertDialogHeader>\n {shown ? (\n <p role=\"alert\" className=\"rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive\">\n {refusalWords({ shown, fallback: (refused as { fallback?: string }).fallback, labels: fields.filter((field) => issues[field.name] !== undefined).map((field) => operationFieldLabelFor({ kayan, operation, field, copy })), copy })}\n </p>\n ) : null}\n {/* A form and not a div, so Enter in a one-box dialog submits. */}\n <form className=\"grid gap-4\" noValidate onSubmit={(event) => void submit(event)}>\n {fields.map((field) => {\n const Control = controlFor(field)\n const inputId = `${kayan.name}-${operation.name}-${field.name}`\n const issue = issues[field.name]\n const failed = issue === undefined ? undefined : (t(issue, issue) ?? issue)\n const choice = writesData && besideLabel(Control)\n return (\n <FieldShell\n key={field.name}\n id={inputId}\n label={operationFieldLabelFor({ kayan, operation, field, copy })}\n error={failed}\n beside={!choice && besideLabel(Control)}\n width={field.width}\n >\n {choice ? (\n <BulkChoice\n copy={copy}\n describedBy={describedBy({ id: inputId, description: undefined, error: failed })}\n error={failed}\n field={field}\n id={inputId}\n kayan={kayan}\n onChange={(next) => setValue(field.name, next)}\n readOnly={busy}\n value={values[field.name]}\n />\n ) : <Control\n field={field}\n kayan={kayan}\n client={kayans}\n api={api}\n t={t}\n id={inputId}\n value={values[field.name]}\n values={values}\n setValue={setValue}\n onChange={(next) => setValue(field.name, next)}\n readOnly={busy}\n error={failed}\n describedBy={describedBy({ id: inputId, description: undefined, error: failed })}\n />}\n </FieldShell>\n )\n })}\n <AlertDialogFooter className=\"max-sm:[&_button]:h-11\">\n <AlertDialogCancel>{t('admin.cancel', 'Cancel') ?? 'Cancel'}</AlertDialogCancel>\n <AlertDialogAction type=\"submit\" disabled={busy}>\n {label}\n </AlertDialogAction>\n </AlertDialogFooter>\n </form>\n </AlertDialogContent>\n </AlertDialog>\n )\n}\n\n\nfunction BoundField({\n api,\n copy,\n field,\n form,\n kayan,\n kayans,\n leaf,\n readOnly,\n screen,\n values,\n setValue,\n}: {\n api?: FindApi\n copy: Copy\n field: ClientField\n /** The id of the form this field is saved by, wherever the page drew it. */\n form: string\n kayan: ClientKayan\n kayans?: readonly ClientKayan[]\n /** What this page alone draws the field with, on a screen's page. */\n leaf?: LeafClient\n /** Whether the control takes no edit, which is the form's own answer and never this field's to decide. */\n readOnly: boolean\n screen?: ScreenPage\n values: Readonly<Record<string, unknown>>\n setValue: (name: string, value: unknown) => void\n}): ReactElement {\n const bound = useKayanField(field.name)\n return (\n <LeafControl\n field={field}\n form={form}\n kayan={kayan}\n kayans={kayans}\n leaf={leaf}\n screen={screen}\n copy={copy}\n api={api}\n value={bound.value}\n values={values}\n setValue={setValue}\n onChange={bound.onChange}\n onBlur={bound.onBlur}\n readOnly={readOnly}\n error={bound.error}\n invalidLocales={bound.locales}\n />\n )\n}\n\n/**\n * One field of a row that is read and never edited: its label over the cell a list column draws it\n * with. A field the report hides is not in the projection, so nothing is drawn for it.\n */\nexport function FieldValue({\n api,\n copy,\n field,\n kayan,\n kayans,\n leaf,\n row,\n screen,\n}: {\n api?: FindApi\n copy: Copy\n field: ClientField\n kayan: ClientKayan\n kayans?: readonly ClientKayan[]\n leaf?: LeafClient\n row: Readonly<Record<string, unknown>> | null\n screen?: ScreenPage\n}): ReactElement {\n const titles = useRelationshipTitles({ fields: [field], docs: row ? [row] : [], context: { api, client: kayans, locale: copy.locale } })\n const label = useId()\n const value = row?.[field.name]\n const Ref = refOf({ screen, place: 'cell', path: leaf?.cell ?? field.cell })\n const Cell = cellFor(field, leaf?.cell)\n const description = leaf?.description?.[copy.locale] ?? descriptionFor({ kayan, field, copy })\n const width = leaf?.width ?? field.width\n return (\n <div role=\"group\" aria-labelledby={label} data-field-value={field.name} className=\"grid gap-1.5\" style={width ? { width } : undefined}>\n <span id={label} className=\"text-sm font-medium\">{labelFor({ kayan, field, copy })}</span>\n <span className=\"text-sm\">\n {Ref && screen\n ? <Ref field={field} value={value} row={row ?? {}} />\n : (leaf?.cell ?? field.cell) === 'richText'\n ? <RichTextValueView value={value} t={copy.t} />\n : (leaf?.cell ?? field.cell) === 'upload'\n ? <UploadValue kayan={kayan} field={field} value={value} t={copy.t} locale={copy.locale} titles={titles} />\n : <Cell kayan={kayan} field={field} value={value} t={copy.t} locale={copy.locale} titles={titles} />}\n </span>\n {description ? <span className=\"text-muted-foreground text-sm\">{description}</span> : null}\n </div>\n )\n}\n\nconst units = [['year', 31_536_000], ['month', 2_592_000], ['week', 604_800], ['day', 86_400], ['hour', 3_600], ['minute', 60], ['second', 1]] as const\n\n/** How long ago a moment was, in the viewer's language via `Intl.RelativeTimeFormat`: \"2 hours ago\", and \"now\" for the moment just passed or a moment ahead of this clock. */\nexport const timeAgo = ({ at, now, locale }: { at: Date; now: number; locale: string }) => {\n const seconds = Math.min(0, (at.getTime() - now) / 1000)\n const [unit, size] = units.find(([, size]) => -seconds >= size) ?? units.at(-1)!\n return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(Math.round(seconds / size), unit)\n}\n\nfunction UpdatedAgo({ at, t, locale }: { at: Date; t: Translate; locale: string }): ReactElement {\n const [now, setNow] = useState(Date.now)\n useEffect(() => {\n const interval = setInterval(() => setNow(Date.now()), 60_000)\n return () => clearInterval(interval)\n }, [])\n return <>{(t('version.updatedAgo', 'Updated {time}') ?? 'Updated {time}').replace('{time}', timeAgo({ at, now, locale }))}</>\n}\n\n/**\n * The form screen over one kayan's `create`, `update` and `delete`, holding its values in TanStack Form.\n * It is mounted under a key of the kayan and the row's id, so a host that hands another row gets a form\n * seeded from that row, and a refusal in hand goes with it.\n */\nexport function Form(props: FormProps): ReactElement {\n const id = String(props.value?.id ?? '')\n // Exception: the row this form's own autosave made on a create. The host's move to its edit page\n // hands that row and the form stays mounted, so nothing typed since is lost.\n const [held, setHeld] = useState<{ id: string; mount: number; made?: string }>({ id, mount: 0 })\n // The made row's id is kept across that move alone, which is how the page knows the row is its own autosave's.\n if (held.id !== id) setHeld(held.id === '' && held.made === id ? { ...held, id } : { id, mount: held.mount + 1 })\n return <FormScreen key={`${props.kayan.name}:${held.mount}`} {...props} made={held.made} onMade={(made) => setHeld((before) => ({ ...before, made }))} />\n}\n\nfunction FormScreen({\n api,\n blocked,\n client,\n drafts,\n error,\n expectedUpdatedAt,\n expectedUpdatedAtOn,\n kayan,\n kayans,\n locale,\n locales,\n made,\n nodes,\n onCancel,\n onDirty,\n onDone,\n onMade,\n operations,\n related,\n t,\n screen,\n value,\n writes,\n}: FormProps & { made?: string; onMade: (id: string) => void }): ReactElement {\n const copy = { t, locale, locales }\n const [refused, setRefused] = useState<unknown>(undefined)\n const [busy, setBusy] = useState(false)\n // Bumped to remount every control and panel, so a panel reads what it shows afresh.\n const [revision, setRevision] = useState(0)\n const reload = () => setRevision((already) => already + 1)\n // Saves landed since mount (plain, draft, publish, autosave), for panels that keep their own history\n // of the row; a draft save never moves `record.updatedAt`.\n const [saves, setSaves] = useState(0)\n // When the last save landed, shown as the row's last update ahead of its own `updatedAt`.\n const [savedAt, setSavedAt] = useState<Date>()\n const noteSave = () => {\n setSaves((already) => already + 1)\n setSavedAt(new Date())\n }\n // The seed is held in state, not read off `value`: a save makes what it wrote the seed before the host\n // reads the row again. `saved` is what the server holds, kept apart because an autosave leaves the seed alone.\n const seeded = (values: Record<string, unknown>, status = drafts?.status, updatedAt = value?.updatedAt) => ({ row: value, values, saved: values, status, updatedAt })\n const [seed, setSeed] = useState(() => seeded(seedOf(kayan.fields, value)))\n // The focused box and its caret when the host handed another row, put back once the new page has\n // drawn it: an autosaved create moves to an edit page whose tree is new.\n const focused = useRef<{ id: string; start: number | null; end: number | null }>(undefined)\n if (seed.row !== value) {\n const box = typeof document === 'undefined' ? null : (document.activeElement as HTMLInputElement | null)\n focused.current = box?.id ? { id: box.id, start: box.selectionStart ?? null, end: box.selectionEnd ?? null } : undefined\n setSeed(seeded(seedOf(kayan.fields, value)))\n }\n useLayoutEffect(() => {\n const was = focused.current\n focused.current = undefined\n if (!was || document.activeElement?.id === was.id) return\n const box = document.getElementById(was.id) as HTMLInputElement | null\n box?.focus()\n if (was.start !== null) box?.setSelectionRange(was.start, was.end)\n }, [seed])\n const form = useForm({ defaultValues: seed.values })\n const values = useStore(form.store, (state) => state.values as Record<string, unknown>)\n // The access report a node's `when` reads as `$user`; none outside a screen.\n const access = useContext(ScreenContext)?.access\n const held = refused === undefined ? error : refused\n const shown = refusalKeyOf(held)\n const issues = issuesOf(held)\n // The wire message is itself a copy key; a key nothing answers is drawn as it arrived, which is what\n // a custom shape that writes none of our keys sends.\n const sentences: Record<string, string> = Object.create(null)\n for (const [name, issue] of Object.entries(issues)) sentences[name] = t(issue, issue) ?? issue\n const byName = new Map(kayan.fields.map((field) => [field.name, field]))\n const banner = shown ? refusalWords({ shown, labels: Object.keys(issues).flatMap((name) => (byName.has(name) ? [labelFor({ kayan, field: byName.get(name)!, copy })] : [])), copy }) : undefined\n // A create that has autosaved its row edits that row from then on, before\n // the host has handed it.\n const id = String(value?.id ?? made ?? '')\n // A row with an id is being changed, one without is being made; a field may be the client's on one operation and the server's on the other.\n const on = id ? 'update' : 'create'\n\n // Put the refusal where TanStack keeps a server's answer (`onServer`), so a project's own control reading\n // `field.state.meta.errors` sees the same sentence the shell draws.\n useEffect(() => {\n // The slot is typed off an `onServer` validator, which this form declares none of; TanStack\n // writes the key anyway, so the cast only bridges the types.\n form.setErrorMap({ onServer: { form: undefined, fields: sentences } } as Parameters<AnyFormApi['setErrorMap']>[0])\n }, [form, JSON.stringify(sentences)])\n\n // TanStack's per-field `isDefaultValue` over the whole form, so a value typed back to its seed is no change.\n const dirty = useStore(form.store, (state) => !state.isDefaultValue)\n\n // Guards a half-written row against a closed tab or a typed URL. The browser shows its own sentence;\n // cleared on unmount so a form left behind does not hold the guard for the next screen.\n useEffect(() => {\n window.onbeforeunload = dirty ? () => '' : null\n return () => {\n window.onbeforeunload = null\n }\n }, [dirty])\n\n // The same fact to the host, for navigations the browser's guard cannot see; cleared on unmount so a\n // form left behind does not hold the host's blocker.\n useEffect(() => {\n onDirty?.(dirty)\n return () => onDirty?.(false)\n }, [dirty, onDirty])\n\n // Whether the server owns this field on this operation (read-only, `uuid`, a non-client provider, or\n // access denied); it decides both the disabled control and whether the field is in the write.\n const owned = (field: ClientField) => readOnlyField(field, on) || (writes ? !writes(field.name, on) : false)\n // Every field `setValue` changed, which a save counts as named.\n const set = useRef(new Set<string>())\n const setValue = (name: string, next: unknown) => {\n const field = byName.get(name)\n if (!busy && !viewing && field && !owned(field)) {\n set.current.add(name)\n form.setFieldValue(name, next)\n }\n }\n // A field the server provides is not the person's to fix, so it is not checked, as the save does not check it.\n const check = (name: string, next: unknown) => {\n const field = byName.get(name)\n const rule = field && !owned(field) ? brokenRule(field, next) : undefined\n if (!rule) return undefined\n const key = `${kayan.name}.${name}.${rule}`\n return t(key, key) ?? key\n }\n // Once the refusal names no field it is `null`, not `undefined`, so the host's own refusal does not come back.\n const forget = (name: string) => {\n if (issues[name] === undefined) return\n const { data } = held as { data: { params: { issues: { path?: unknown }[] } } }\n const rest = data.params.issues.filter((issue) => String(issue.path).split('.')[0] !== name)\n setRefused(rest.length ? { ...(held as object), data: { ...data, params: { ...data.params, issues: rest } } } : null)\n }\n const readOnly = (field: ClientField) => busy || owned(field)\n\n // A leaf in another region is outside the page's one <form>, so its box names the form by this id\n // (the platform's `form` attribute) and Enter in it still saves.\n const formId = useId()\n const control = (leaf: string | LeafClient) => {\n const field = byName.get(typeof leaf === 'string' ? leaf : leaf.field)\n // A leaf naming a field the kayan lacks is refused at `buildAsas`, so this fires only for a hand-built\n // layout. A field the page names is drawn whoever provides it.\n if (!field) return null\n // The published copy is drawn read-only; the draft's boxes are not drawn, but what was typed stays in the form's values.\n if (viewing) {\n return (\n <LeafControl\n key={`${field.name}-published`}\n field={field}\n form={formId}\n kayan={kayan}\n kayans={kayans}\n leaf={typeof leaf === 'string' ? undefined : leaf}\n screen={screen}\n copy={copy}\n api={api}\n value={showing[field.name]}\n values={showing}\n setValue={() => {}}\n onChange={() => {}}\n readOnly\n />\n )\n }\n // A leaf the page marks read-only is read as text with its cell's formatter. A field the server\n // provides keeps its control, which the side card draws.\n if (typeof leaf !== 'string' && leaf.readOnly === true) {\n return <FieldValue key={`${field.name}-${revision}`} api={api} copy={copy} field={field} kayan={kayan} kayans={kayans} leaf={leaf} row={values} screen={screen} />\n }\n return (\n <BoundField\n key={`${field.name}-${revision}`}\n api={api}\n copy={copy}\n field={field}\n form={formId}\n kayan={kayan}\n kayans={kayans}\n leaf={typeof leaf === 'string' ? undefined : leaf}\n readOnly={readOnly(field)}\n screen={screen}\n values={values}\n setValue={setValue}\n />\n )\n }\n\n const draw: Draw = {\n kayan,\n copy,\n issues,\n scope: scopeOf({ values, record: value, access, kayan }),\n control,\n // A component has no field behind it; inside a form it reads and writes the form's values through\n // `useForm()`. A reference with no registered component is said, not drawn blank.\n component: (path, index) => {\n const Ref = refOf({ screen, place: 'component', path })\n if (Ref && screen) return <Ref key={`component-${index}-${revision}`} page={screen.page} />\n return (\n <p key={`component-${index}`} data-missing-component={path} className=\"text-muted-foreground text-sm\">\n {(t('admin.missingComponent', 'No component is registered for \"{component}\".')\n ?? 'No component is registered for \"{component}\".'\n ).replace('{component}', path)}\n </p>\n )\n },\n layout: ({ path, regions, index }) => screen && frameOf({ screen, t, path, regions, key: index }),\n list: related,\n nav: () => screen && <NavLinks />,\n button: (node) => {\n const button = id && client.run && !viewing ? pageButtonOf({ kayan, node, allowed: operations }) : undefined\n return button ? buttonOf(button) : null\n },\n // The page's one <form>, so a list of another kayan beside it, with forms of its own, is never inside it;\n // a leaf elsewhere on the page is still this form's value. A page with a controls node draws the actions there.\n // `noValidate` so the browser's own bubble never stands in for the field's refusal in the page's language.\n form: (children) => (\n <form id={formId} noValidate onSubmit={submit} className=\"flex flex-col gap-6\">\n {chrome}\n <div className={headed ? 'flex flex-col gap-4 border-t pt-6' : 'flex flex-col gap-4'}>\n {children}\n {controlled ? null : (\n <Card>\n <CardContent>{actionsOf(false)}</CardContent>\n </Card>\n )}\n </div>\n </form>\n ),\n // Only while the page draws the form: a role page that draws a fixed role in place of its form offers no save.\n controls: () => (tree && drawsKind({ nodes: tree, kind: 'form', scope: draw.scope }) ? actionsOf(true) : null),\n }\n const walk = (nodes: readonly Node[]) => drawNodes({ nodes, draw })\n // A screen's page names every field it draws; a form handed no page draws every field in declared\n // order, and no join, whose rows are another kayan's.\n const tree = nodes ?? (screen && nodesOf(screen.page))\n const controlled = !!tree && drawsKind({ nodes: tree, kind: 'controls', scope: draw.scope })\n const unnamed = tree ? [] : kayan.fields.filter((field) => field.kind !== 'join')\n const provided = unnamed.filter(owned)\n const rest = unnamed.filter((field) => !owned(field))\n // Titles of the rows the side card points at, read once for this row. With no `api` each id settles as\n // a row the viewer cannot see; the cell never shows the id it holds.\n const titles = useRelationshipTitles({ fields: provided, docs: value ? [value] : [], context: { api, client: kayans, locale } })\n // A page that draws a row draws no kayan description, except a global's one row, whose page is the\n // only place it is drawn.\n const about = screen && value?.id !== undefined && !kayan.single ? undefined : descriptionOf(kayan, copy)\n const headed = !screen || !!about\n\n const runOperation =\n (operation: PageButton) =>\n async (input: Record<string, unknown>, refuse: (thrown: unknown) => void) => {\n const landed = await run(() => client.run!(operation.name, input), operation.effect, refuse)\n // A kayan's own operation can change anything the row touches, children included, so reload the panels.\n if (landed) reload()\n return landed\n }\n\n // A relationship is drawn by the list's cell, not a picker: a disabled picker over a row the viewer\n // may not choose shows the raw id, where the cell reads the target's title.\n const sideEntry = (field: ClientField) => {\n if (field.kind !== 'relationship') return control(field.name)\n const Cell = cellFor(field)\n return (\n <div key={field.name} className=\"grid gap-1.5\">\n <span className=\"text-sm font-medium\">{labelFor({ kayan, field, copy })}</span>\n <span className=\"text-muted-foreground text-sm\">\n <Cell kayan={kayan} field={field} value={values[field.name]} t={t} locale={locale} titles={titles} />\n </span>\n </div>\n )\n }\n\n /** The instant this form last read or wrote the row at, for a write whose operation takes it. */\n const readAt = (takes?: boolean) => (takes && seed.updatedAt !== undefined ? { expectedUpdatedAt: seed.updatedAt } : {})\n /** Whether a write of the person's own has landed here, which makes a row this page's autosave made theirs. */\n const kept = useRef(false)\n // `refuse` is where a refusal lands: the form's own box by default, or an operation dialog's boxes, which\n // stays open to hold them. Answers whether the write landed.\n const run = async (write: () => Promise<unknown>, effect?: string, refuse: (thrown: unknown) => void = setRefused, status?: DraftStatus): Promise<boolean> => {\n setRefused(undefined)\n setBusy(true)\n try {\n const written = await write()\n kept.current = true\n // What was just written is what the form now holds, so it holds no change: Save goes disabled and\n // the unload guard comes down.\n form.reset(values)\n // The instant the row now stands at, which the next update sends.\n setSeed(seeded(values, status ?? seed.status, (written as { updatedAt?: unknown } | null)?.updatedAt ?? seed.updatedAt))\n noteSave()\n // A write may change the published copy, which View published reads again.\n setPublished(undefined)\n setView('draft')\n onDone(written, effect)\n return true\n } catch (thrown) {\n refusedOver.current = JSON.stringify(values)\n refuse(thrown)\n return false\n } finally {\n setBusy(false)\n }\n }\n\n // Differs from the seed or from what the server holds since; an update sends only these.\n const changed = (field: ClientField) =>\n form.getFieldMeta(field.name)?.isDefaultValue === false || JSON.stringify(values[field.name]) !== JSON.stringify(seed.saved[field.name])\n\n const submit = (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault()\n save()\n }\n\n // A draft save holds each value to its kind and none of its rules, so the rules are not checked here either.\n const save = (options?: DraftOptions) => {\n // An update sends only the fields that changed, so two people saving different fields of one row each\n // write their own and neither writes the other's back as it was opened.\n const fields = writable().filter((field) => !id || changed(field))\n const refusal = options?.draft ? undefined : refusalOf({ kayan: kayan.name, fields, values })\n if (refusal) return setRefused(refusal)\n const written = valuesOf({ fields, values, owned })\n const status = drafts && (options?.draft ? afterDraft(seed.status) : 'published')\n void run(() => (id ? client.update({ ...written, ...readAt(expectedUpdatedAt), id }, options) : client.create(written, options)), on, undefined, status)\n }\n\n const writable = () => {\n // A page's form writes only what the page names and draws writable: sending a field it leaves out would\n // overwrite whatever was saved since. A field a control or panel set through `setValue` counts as named.\n const named = tree && new Set([...leavesOf(tree, (leaf) => !leaf.readOnly), ...set.current])\n return (named ? kayan.fields.filter((field) => named.has(field.name)) : kayan.fields).filter((field) => !owned(field))\n }\n\n const status = drafts && seed.status\n // Which copy a changed row shows: the draft, or the published copy, read once when first chosen and drawn read-only with no save or autosave.\n const [view, setView] = useState<'draft' | 'published'>('draft')\n const [published, setPublished] = useState<Record<string, unknown> | null>()\n const viewing = status === 'changed' && view === 'published' && !!published\n const showing = viewing ? seedOf(kayan.fields, published) : values\n const choose = async (next: string) => {\n if (next === 'published' && published === undefined) {\n try {\n setPublished((await client.findByID!({ id })) as Record<string, unknown> | null)\n } catch (thrown) {\n return setRefused(thrown)\n }\n }\n setView(next === 'published' ? 'published' : 'draft')\n }\n /** The values a save was last refused over, which the autosave below never sends again. */\n const refusedOver = useRef<string | undefined>(undefined)\n const [autosaved, setAutosaved] = useState<'saving' | 'saved'>()\n /** Whether the form is still on screen, which an autosave that lands late asks before it moves the host. */\n const alive = useRef(true)\n useEffect(() => {\n alive.current = true\n return () => {\n alive.current = false\n }\n }, [])\n const json = JSON.stringify(values)\n // Autosave a draft of what differs once the form has stayed still for the interval. Never while a save is out,\n // over values a save was refused over, or while a discard question is open: that question says the change is unsaved.\n const [asking, setAsking] = useState(false)\n /** Whether a Discard is on its way out, deleting a draft first where it has one to delete. */\n const [leaving, setLeaving] = useState(false)\n // Nor once a write was refused because the row changed since this form read it: every autosave after it\n // would be refused the same way, so it stops and the refusal stays to say why.\n const stale = shown === 'error.conflict.stale'\n const interval = viewing || asking || blocked || leaving || stale ? undefined : drafts?.autosave\n useEffect(() => {\n if (!interval || busy || autosaved === 'saving' || refusedOver.current === json) return\n const fields = writable()\n if (!fields.some((field) => JSON.stringify(values[field.name]) !== JSON.stringify(seed.saved[field.name]))) return\n const timer = setTimeout(() => {\n const sent = valuesOf({ fields: fields.filter((field) => !id || changed(field)), values, owned })\n setAutosaved('saving')\n const words = { draft: true, autosave: true }\n ;(id ? client.update({ ...sent, ...readAt(expectedUpdatedAt), id }, words) : client.create(sent, words)).then(\n (written) => {\n setAutosaved('saved')\n // A refusal a save drew earlier is over: the row just saved fine, so a banner naming no field\n // would sit there with nothing left to say.\n setRefused(undefined)\n refusedOver.current = undefined\n noteSave()\n // The instant the draft now stands at, which the next write sends.\n const updatedAt = (written as { updatedAt?: unknown } | null)?.updatedAt ?? seed.updatedAt\n if (JSON.stringify(form.state.values) === json) {\n form.reset(values)\n setSeed(seeded(values, afterDraft(seed.status), updatedAt))\n } else {\n setSeed((held) => ({ ...held, saved: { ...held.saved, ...sent }, status: afterDraft(held.status), updatedAt }))\n }\n const row = (written as { id?: unknown } | null)?.id\n if (!id && row !== undefined && alive.current) {\n onMade(String(row))\n onDone(written, 'create')\n }\n },\n (thrown: unknown) => {\n refusedOver.current = json\n setAutosaved(undefined)\n setRefused(thrown)\n },\n )\n }, interval)\n return () => clearTimeout(timer)\n }, [interval, busy, autosaved, json, seed, id])\n // Leaving unsaved deletes the untouched draft this page's autosave made, so a cancelled new row leaves none.\n // A published row is kept (a plain read finds it), and so is a draft someone saved since (the delete gets a 409).\n const ownDraft = !!made && id === made && !kept.current && status === 'draft'\n const keepsDraft = ownDraft && operations?.delete !== true\n const out = useRef(false)\n const discard = async (leave: () => void) => {\n if (out.current) return\n out.current = true\n setLeaving(true)\n if (ownDraft && !keepsDraft && client.findByID) {\n const found = await client.findByID({ id }).then(\n (row) => (row ? 'published' : 'absent'),\n (thrown: unknown) => ((thrown as { status?: unknown } | null)?.status === 404 ? 'absent' : 'unknown'),\n )\n if (found === 'absent') await client.delete({ id, ...readAt(expectedUpdatedAtOn?.includes('delete')) }).catch((thrown: unknown) => console.error('The draft this page made could not be deleted:', thrown))\n }\n // The edit is thrown away before the navigation, so a slow navigation leaves no change for the autosave to save.\n form.reset(seed.saved)\n out.current = false\n setLeaving(false)\n setAsking(false)\n leave()\n }\n useEffect(() => {\n if (autosaved !== 'saved') return\n const timer = setTimeout(() => setAutosaved(undefined), 2000)\n return () => clearTimeout(timer)\n }, [autosaved, saves])\n\n const buttonOf = (operation: PageButton) => {\n const Ref = refOf({ screen, place: 'button', path: operation.component })\n if (Ref && screen) {\n return (\n <OwnButton\n key={operation.name}\n Own={Ref}\n operation={operation}\n busy={busy}\n copy={copy}\n call={async (input) => {\n await runOperation(operation)({ ...input, id }, setRefused)\n }}\n />\n )\n }\n // A delete is the one write nothing brings back, so its button is always the two-step dialog, whatever the node carries.\n if (operation.effect === 'delete') {\n return (\n <DeleteButton\n key={operation.name}\n busy={busy}\n copy={copy}\n kayan={kayan}\n label={operationLabelFor({ kayan, operation, copy })}\n row={value ?? undefined}\n onDelete={(refuse) => run(() => client.delete({ id }), 'delete', refuse)}\n />\n )\n }\n // A button that asks for no values and carries a confirm asks it in the app's own dialog; any other opens the\n // registry's dialog, which asks the confirm in its heading. Each names the row it runs on.\n const description = value ? rowWarning({ kayan, row: value, operation: operation.name, copy }) : undefined\n if (operation.confirm && !operation.fields?.length) {\n return <NodeButton key={operation.name} node={operation} copy={copy} busy={busy} description={description} run={() => void runOperation(operation)({ id }, setRefused)} />\n }\n return (\n <OperationButton\n key={operation.name}\n api={api}\n busy={busy}\n copy={copy}\n description={description}\n inputOf={(values) => ({ id, ...values })}\n kayan={kayan}\n kayans={kayans}\n operation={operation}\n run={runOperation(operation)}\n />\n )\n }\n // Below sm the action bar is fixed, so the document keeps its height free as padding and scroll padding\n // (the document is what scrolls), and a field reached with Tab is never under the bar.\n const measureBar = useCallback((bar: HTMLElement | null) => {\n // Inside a sheet or a dialog the bar is the popup's own, so the document keeps no room for it.\n if (!bar || bar.closest('[role=dialog]')) return\n const root = document.documentElement.style\n // `--action-bar-height` too, which a list's own bar on a phone stands on rather than covering this one.\n const keep = (height: number) => {\n for (const property of ['padding-bottom', 'scroll-padding-bottom', '--action-bar-height']) {\n if (height) root.setProperty(property, `${height}px`)\n else root.removeProperty(property)\n }\n }\n // `borderBoxSize` is missing before Safari 15.4 and one box rather than a\n // list before Firefox 92, so the bar's own `offsetHeight` stands in there.\n const observer = new ResizeObserver(([entry]) => keep(entry ? (entry.borderBoxSize?.[0]?.blockSize ?? (entry.target as HTMLElement).offsetHeight) : 0))\n observer.observe(bar)\n return () => {\n observer.disconnect()\n keep(0)\n }\n }, [])\n const updated = id ? (savedAt ?? (value?.updatedAt ? new Date(value.updatedAt as string | Date) : undefined)) : undefined\n // The draft or the published copy, on a changed row only. Beside the actions from xl up; below xl a row at\n // the top of the form (`chrome`), so it is never in the phone's fixed bar.\n const viewSwitch = (className: string, phone = false) =>\n status === 'changed' && client.findByID ? (\n <ToggleGroup\n data-view-switch=\"\"\n value={[viewing ? 'published' : 'draft']}\n onValueChange={(next) => next[0] && void choose(next[0])}\n aria-label={t('version.shownCopy', 'Version shown')}\n className={`bg-muted flex shrink-0 rounded-md p-0.5 ${className}`}\n >\n {(['draft', 'published'] as const).map((one) => (\n <ToggleGroupItem key={one} value={one} className={`data-pressed:bg-background border-0 px-2 py-0.5 text-xs data-pressed:shadow-sm${phone ? ' min-h-11 flex-1' : ''}`}>\n {t(`version.${one}`, { draft: 'Draft', published: 'Published' }[one])}\n </ToggleGroupItem>\n ))}\n </ToggleGroup>\n ) : null\n // A submit button in a controls node sits outside the <form>, so every submit names it by its id. The spacing is\n // logical, so the row mirrors in Arabic.\n const actionsOf = (header: boolean) => {\n const size = header ? 'sm' : 'default'\n const tall = header ? 'max-sm:h-11' : ''\n const actions = (\n <>\n {/* A refusal naming no field, drawn here and not in the form: the form sits in the Details panel, and a\n Publish pressed from another tab would be refused where nobody is looking. */}\n {header && shown ? (\n <p role=\"alert\" className=\"text-destructive line-clamp-2 min-w-24 flex-1 text-sm max-sm:basis-full sm:-order-1\">\n {banner}\n </p>\n ) : null}\n {status && !header ? (\n <span data-status={status} className={`me-auto rounded-md px-2 py-0.5 text-sm ${pills[status]}`}>\n {t('version.status', 'Status')}: {t(`version.${status}`, { draft: 'Draft', published: 'Published', changed: 'Changed' }[status])}\n </span>\n ) : null}\n {/* Below xl it leaves the header for the top of the form (`chrome`). */}\n {viewSwitch(header ? 'max-xl:hidden' : '')}\n {autosaved ? (\n <span\n role=\"status\"\n data-autosave={autosaved}\n className={autosaved === 'saved'\n ? 'animate-out fade-out fill-mode-forwards delay-1500 animation-duration-500 flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400'\n : 'text-muted-foreground text-sm'}\n >\n {autosaved === 'saved' ? <Check aria-hidden className=\"size-4\" /> : null}\n {autosaved === 'saving' ? t('version.saving', 'Saving…') : t('version.saved', 'Saved')}\n </span>\n ) : null}\n {/* Read off the browser's clock, so it can differ from the server's render. */}\n {header && updated && !Number.isNaN(updated.getTime()) ? (\n <span data-updated=\"\" className=\"text-muted-foreground text-sm max-xl:hidden\" suppressHydrationWarning>\n <UpdatedAgo at={updated} t={t} locale={locale} />\n </span>\n ) : null}\n {/* A dirty form asks before Cancel: the router's navigation raises no `beforeunload`, so the browser's\n guard does not see it. A clean form's Cancel is a plain button. */}\n {onCancel ? (\n <Button\n type=\"button\"\n variant=\"outline\"\n size={size}\n className={tall}\n disabled={busy}\n aria-haspopup={dirty ? 'dialog' : undefined}\n onClick={dirty ? () => setAsking(true) : onCancel}\n >\n {t('admin.cancel', 'Cancel') ?? 'Cancel'}\n </Button>\n ) : null}\n {viewing ? null : drafts ? (\n <>\n {/* Save draft waits for a change on an update and is always offered beside the autosave. Publish is\n offered while anything is unpublished, with no change of its own. */}\n <Button type=\"button\" size={size} className={`${draftColour} ${tall}`} data-button=\"save-draft\" disabled={busy || autosaved === 'saving' || (!!id && !dirty)} onClick={() => save({ draft: true })}>\n {t('version.saveDraft', 'Save draft')}\n </Button>\n <Button type=\"submit\" form={formId} size={size} className={`${publishColour} ${tall}`} data-button=\"publish\" disabled={busy || autosaved === 'saving' || (!dirty && status === 'published')}>\n {busy ? <Spinner /> : null}\n {t('version.publishChanges', 'Publish changes')}\n </Button>\n </>\n ) : (\n // Waits for a change on an update alone: a create is pressed to be checked.\n <Button type=\"submit\" form={formId} size={size} className={tall} disabled={busy || (on === 'update' && !dirty)}>\n {busy ? <Spinner /> : null}\n {t('admin.save', 'Save')}\n </Button>\n )}\n </>\n )\n // `flex-wrap`: a row that cannot wrap is as wide as all its buttons together, which widens every grid\n // and flex box above it past a phone's width.\n if (!header) return <div className=\"flex flex-wrap items-center justify-end gap-2 rtl:justify-start\">{actions}</div>\n // Below sm the header keeps the trigger, title and pill, and the rest moves to a bar fixed to the bottom of the\n // screen; from sm up the bar is `contents`. Inside a sheet or dialog the row sticks to the popup's foot instead.\n return (\n <div className=\"ms-auto flex min-w-min items-center justify-end gap-2 sm:flex-wrap max-sm:in-[[role=dialog]]:bg-background max-sm:in-[[role=dialog]]:sticky max-sm:in-[[role=dialog]]:bottom-0 max-sm:in-[[role=dialog]]:z-20 max-sm:in-[[role=dialog]]:order-last max-sm:in-[[role=dialog]]:w-full max-sm:in-[[role=dialog]]:border-t\">\n {/* Coloured by status and always carrying its word, never the colour alone. The row shrinks no further\n than the narrowest it can be drawn, so the title beside it wraps rather than sitting under it. */}\n {status ? (\n <Badge data-status={status} className={pills[status]} title={t('version.status', 'Status')}>\n {t(`version.${status}`, { draft: 'Draft', published: 'Published', changed: 'Changed' }[status])}\n </Badge>\n ) : null}\n <div\n data-action-bar=\"\"\n ref={measureBar}\n className=\"max-sm:bg-background max-sm:fixed max-sm:in-[[role=dialog]]:static max-sm:in-[[role=dialog]]:flex-1 max-sm:in-[[role=dialog]]:border-t-0 max-sm:inset-x-0 max-sm:bottom-0 max-sm:z-20 max-sm:flex max-sm:flex-wrap max-sm:items-center max-sm:justify-end max-sm:gap-2 max-sm:border-t max-sm:px-4 max-sm:pt-2 max-sm:pb-[calc(0.5rem+env(safe-area-inset-bottom))] sm:contents\"\n >\n {actions}\n </div>\n </div>\n )\n }\n // On a screen's page the title is the page's own `title` node, so the form keeps only the sentence under it.\n const chrome = (\n <>\n {/* On a screen's page a kayan with no sentence draws no header and no rule under it. */}\n {headed ? (\n <header className=\"max-w-3xl\">\n {screen ? null : <h1 className=\"text-2xl font-bold tracking-tight\">{titleOf({ kayan, copy })}</h1>}\n {about ? <p className=\"text-muted-foreground mt-1 text-sm\">{about}</p> : null}\n </header>\n ) : null}\n {shown && !controlled ? (\n <p role=\"alert\" className=\"rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive\">\n {banner}\n </p>\n ) : null}\n {controlled ? viewSwitch('xl:hidden', true) : null}\n {status === 'changed' && viewing ? (\n <p data-draft-banner=\"published\" className=\"bg-muted text-muted-foreground rounded-md border px-3 py-1.5 text-sm\">\n {t('version.readOnlyPublished', 'Read only. This is the published version.')}\n </p>\n ) : status === 'changed' ? (\n <div data-draft-banner=\"draft\" className=\"flex flex-wrap items-center gap-x-3 gap-y-1 rounded-md border border-amber-500/50 bg-amber-500/10 px-3 py-1.5 text-sm text-amber-900 dark:text-amber-200\">\n <span className=\"me-auto\">{t('version.editingDraft', 'You are editing a draft. The published version is different.')}</span>\n {client.findByID ? (\n <Button type=\"button\" variant=\"link\" size=\"sm\" className=\"h-auto p-0\" onClick={() => void choose('published')}>\n {t('version.viewPublished', 'View published')}\n </Button>\n ) : null}\n {/* Restores the row's own published copy, which needs both keys a restore needs. */}\n {operations?.restoreVersion === true && client.run ? (\n <AlertDialog>\n <AlertDialogTrigger\n render={\n <Button type=\"button\" variant=\"link\" size=\"sm\" className=\"text-destructive h-auto p-0\" disabled={busy || autosaved === 'saving'}>\n {t('admin.discard', 'Discard changes')}\n </Button>\n }\n />\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>{t('version.confirmRevertToSaved', 'Confirm revert to saved')}</AlertDialogTitle>\n <AlertDialogDescription>\n {t('version.aboutToRevertToPublished', 'You are about to revert this document\\'s changes to its published state. Are you sure?')}\n </AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter className=\"max-sm:[&_button]:h-11\">\n <AlertDialogCancel>{t('admin.cancel', 'Cancel') ?? 'Cancel'}</AlertDialogCancel>\n <AlertDialogAction variant=\"destructive\" data-button=\"confirm-discard-draft\" disabled={busy || autosaved === 'saving'} onClick={() => void run(() => client.run!('restoreVersion', { id, ...readAt(expectedUpdatedAtOn?.includes('restoreVersion')) }), 'update', undefined, 'published')}>\n {t('admin.discard', 'Discard changes')}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n ) : null}\n </div>\n ) : null}\n </>\n )\n return (\n // The provider is around the whole screen, not just the column of inputs, so a panel the project\n // registered binds a field of this form through `useKayanField` wherever the layout put it.\n <KayanForm.Provider value={{ check, forget, form, issues: sentences, locales: localesOf(held), on, writes }}>\n <FormContext value={{ getData: () => showing, setValue, disabled: busy || viewing, saves, expectedUpdatedAtOn }}>\n {/* One question for both ways out, Cancel and a blocked navigation,\n kept mounted so nothing but an answer closes it. */}\n <DiscardDialog\n t={t}\n busy={busy || autosaved === 'saving' || leaving}\n open={asking || !!blocked}\n note={keepsDraft ? (t('admin.discardKeepsDraft', 'The draft saved so far is kept.') ?? 'The draft saved so far is kept.') : undefined}\n onOpenChange={(open) => {\n if (open || leaving) return\n setAsking(false)\n blocked?.reset()\n }}\n onDiscard={() => void discard(blocked ? blocked.proceed : onCancel ?? (() => {}))}\n />\n {nodes || screen ? (\n <div className=\"flex flex-1 flex-col gap-6\">{nodes ? walk([{ form: [...nodes] }]) : pageOf(screen!, draw)}</div>\n ) : (\n <form id={formId} noValidate onSubmit={submit} className=\"flex flex-1 flex-col gap-6\">\n {chrome}\n <div className=\"border-t pt-6\">\n <div className={provided.length ? 'grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(18rem,1fr)]' : 'max-w-3xl'}>\n {/* A div and not a `main`: the page's shell already holds the one main landmark. */}\n <div data-fields=\"\" className=\"grid min-w-0 gap-4\">\n <Card className=\"overflow-visible\">\n <CardContent className=\"flex flex-col gap-4\">\n {rest.length ? <div className=\"flex flex-col gap-4\">{rest.map((field) => control(field.name))}</div> : null}\n {actionsOf(false)}\n </CardContent>\n </Card>\n </div>\n {provided.length ? (\n <aside className=\"grid h-fit gap-4 lg:sticky lg:top-20\">\n <Card className=\"overflow-visible rounded-lg\">\n <CardHeader>\n <CardTitle>\n <h2 className=\"text-base font-medium\">{t('admin.record', 'Record') ?? 'Record'}</h2>\n </CardTitle>\n </CardHeader>\n <CardContent className=\"grid gap-4\">{provided.map(sideEntry)}</CardContent>\n </Card>\n </aside>\n ) : null}\n </div>\n </div>\n </form>\n )}\n </FormContext>\n </KayanForm.Provider>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 6a3950dbcb0d13b0f3d3943056ec08b120e52ccaf71877c2bf0e29a7823bbbdc\n",
|
|
28
|
-
"type": "registry:component",
|
|
29
|
-
"target": "@components/mithaq/form.tsx"
|
|
30
|
-
}
|
|
31
|
-
],
|
|
32
|
-
"type": "registry:component"
|
|
33
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "mithaq-list-columns",
|
|
4
|
-
"title": "Mithaq list columns",
|
|
5
|
-
"description": "The column popup: two sections, a switch per column and two buttons for order, with a denied or undeclared column absent rather than disabled.",
|
|
6
|
-
"registryDependencies": [
|
|
7
|
-
"button",
|
|
8
|
-
"checkbox",
|
|
9
|
-
"label",
|
|
10
|
-
"popover",
|
|
11
|
-
"@mithaq/mithaq-controls",
|
|
12
|
-
"@mithaq/mithaq-list-query"
|
|
13
|
-
],
|
|
14
|
-
"files": [
|
|
15
|
-
{
|
|
16
|
-
"path": "registry/list-columns.tsx",
|
|
17
|
-
"content": "import { useState, type ReactElement } from 'react'\nimport { ChevronDown, Columns as LayoutColumns } from 'lucide-react'\nimport type { ClientField, ClientKayan } from 'mithaq/admin'\nimport { Button } from '@/components/ui/button'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { Label } from '@/components/ui/label'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'\nimport { checkboxSize, labelFor, type Translate } from './controls.tsx'\nimport type { ColumnChoice } from './list-query.ts'\n\nexport type ColumnsProps = {\n kayan: ClientKayan\n /** Every column, in order, with whether it is shown. */\n columns: readonly ColumnChoice[]\n onChange: (columns: readonly ColumnChoice[]) => void\n t: Translate\n locale: string\n}\n\n/** Which fields the popup may offer: the ones this actor may read. Chrome only: the read masks a denied column whatever the popup shows. */\nexport const columnCandidates = (\n kayan: ClientKayan,\n reads?: (kayan: string, field: string) => boolean,\n): readonly ClientField[] =>\n kayan.fields.filter(\n (field) => !reads || reads(kayan.name, field.name),\n )\n\n/** The next column in the same section, or -1 at its end. The popup draws one list twice, so a neighbour never crosses into the other section. */\nexport const neighbour = ({ columns, at, by }: { columns: readonly ColumnChoice[]; at: number; by: -1 | 1 }): number => {\n for (let index = at + by; index >= 0 && index < columns.length; index += by) {\n if (columns[index]!.active === columns[at]!.active) return index\n }\n return -1\n}\n\n/** One column swapped with its neighbour in the same section; at a section's end the same list comes back. */\nexport const move = ({ columns, at, by }: { columns: readonly ColumnChoice[]; at: number; by: -1 | 1 }): readonly ColumnChoice[] => {\n const to = neighbour({ columns, at, by })\n if (to < 0) return columns\n const next = [...columns]\n next[at] = columns[to]!\n next[to] = columns[at]!\n return next\n}\n\nexport function Columns({ columns, kayan, locale, onChange, t }: ColumnsProps): ReactElement {\n const [open, setOpen] = useState(false)\n const byName = new Map(kayan.fields.map((field) => [field.name, field]))\n const nameOf = (name: string) => {\n const field = byName.get(name)\n return field ? labelFor({ kayan, field, copy: { t, locale } }) : name\n }\n const toggle = (name: string) =>\n onChange(columns.map((one) => (one.name === name ? { ...one, active: !one.active } : one)))\n // The box is a `role=\"checkbox\"` button, so the label points at it by id; the kayan name keeps ids unique when two lists share a screen.\n const boxId = (name: string) => `mithaq-column-${kayan.name}-${name}`\n const section = (active: boolean) => (\n <ul className=\"flex flex-col gap-1\">\n {columns.map((column, at) =>\n column.active !== active ? null : (\n <li key={column.name} className=\"flex items-center justify-between gap-2\">\n <span className=\"flex items-center gap-2\">\n <Checkbox className={checkboxSize} id={boxId(column.name)} checked={column.active} onCheckedChange={() => toggle(column.name)} />\n <Label htmlFor={boxId(column.name)} className=\"font-normal\">\n {nameOf(column.name)}\n </Label>\n </span>\n <span className=\"flex gap-1\">\n <Button type=\"button\" variant=\"outline\" size=\"icon-xs\" className=\"max-sm:size-11\" disabled={neighbour({ columns, at, by: -1 }) < 0} onClick={() => onChange(move({ columns, at, by: -1 }))} aria-label={`${t('columns.up', 'Move up') ?? 'Move up'}: ${nameOf(column.name)}`}>\n ↑\n </Button>\n <Button type=\"button\" variant=\"outline\" size=\"icon-xs\" className=\"max-sm:size-11\" disabled={neighbour({ columns, at, by: 1 }) < 0} onClick={() => onChange(move({ columns, at, by: 1 }))} aria-label={`${t('columns.down', 'Move down') ?? 'Move down'}: ${nameOf(column.name)}`}>\n ↓\n </Button>\n </span>\n </li>\n ),\n )}\n </ul>\n )\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger\n render={\n <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"max-sm:h-11\">\n <LayoutColumns />\n <span className=\"hidden lg:inline\">{t('columns.customize', 'Customize Columns') ?? 'Customize Columns'}</span>\n <span className=\"lg:hidden\">{t('columns.title', 'Columns') ?? 'Columns'}</span>\n <ChevronDown />\n </Button>\n }\n />\n <PopoverContent align=\"end\" className=\"flex max-h-[min(var(--available-height),28rem)] w-56 flex-col gap-3 overflow-y-auto p-3\">\n <div className=\"flex flex-col gap-1\">\n <p className=\"text-xs font-medium uppercase text-muted-foreground\">{t('columns.shown', 'Shown') ?? 'Shown'}</p>\n {section(true)}\n </div>\n <div className=\"flex flex-col gap-1\">\n <p className=\"text-xs font-medium uppercase text-muted-foreground\">{t('columns.hidden', 'Not shown') ?? 'Not shown'}</p>\n {section(false)}\n </div>\n </PopoverContent>\n </Popover>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 94cd106d0ec41f77e03f3e843d130f7cf848c1c56619ab392d98a5d0d95bd0f1\n",
|
|
18
|
-
"type": "registry:component",
|
|
19
|
-
"target": "@components/mithaq/list-columns.tsx"
|
|
20
|
-
}
|
|
21
|
-
],
|
|
22
|
-
"type": "registry:component"
|
|
23
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "mithaq-list-filters",
|
|
4
|
-
"title": "Mithaq list filters",
|
|
5
|
-
"description": "The where panel: rows of field, operator and value joined by and or or, offered only for a field that declares itself filterable.",
|
|
6
|
-
"registryDependencies": [
|
|
7
|
-
"button",
|
|
8
|
-
"input",
|
|
9
|
-
"select",
|
|
10
|
-
"@mithaq/mithaq-controls",
|
|
11
|
-
"@mithaq/mithaq-filter-operators",
|
|
12
|
-
"@mithaq/mithaq-list-query",
|
|
13
|
-
"@mithaq/mithaq-relationship-control"
|
|
14
|
-
],
|
|
15
|
-
"files": [
|
|
16
|
-
{
|
|
17
|
-
"path": "registry/list-filters.tsx",
|
|
18
|
-
"content": "import { useEffect, useRef, useState, type ReactElement } from 'react'\nimport type { ClientKayan } from 'mithaq/admin'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport { controls, labelFor, type FindApi, type Translate } from './controls.tsx'\nimport { RelationshipControl } from './relationship-control.tsx'\nimport { operatorsByKind, valueShapeOf, type FilterPath } from './filter-operators.ts'\nimport { instantOf, type Condition, type FilterWhere } from './list-query.ts'\n\nexport type FiltersProps = {\n /** Every path this kayan offers, already narrowed by the access report. */\n paths: readonly FilterPath[]\n value?: FilterWhere\n /** Where a committed panel goes. `undefined` is \"no filter at all\". */\n onChange: (where: FilterWhere | undefined) => void\n t: Translate\n locale: string\n /** Every kayan of the build, so a relationship row's picker can find its target. */\n client?: readonly ClientKayan[]\n /** The typed client's reads, for that picker's own paging. */\n api?: FindApi\n}\n\ntype Row = { path: string; operator: string; value: unknown }\n\nconst rowOf = (condition: Condition): Row => {\n const [path = '', operators = {}] = Object.entries(condition)[0] ?? []\n const [operator = 'equals', value] = Object.entries(operators)[0] ?? []\n return { path, operator, value }\n}\n\nconst conditionOf = (row: Row): Condition => ({ [row.path]: { [row.operator]: row.value } })\n\n/**\n * The rows the panel holds: the `or` of `and`s a `FilterWhere` carries. Held as state, not derived\n * from `value`, because a row with no value yet does not survive the round trip through the URL.\n */\nexport type Conditions = readonly { and: Condition[] }[]\n\n/** What a set of rows commits: the filled rows, and no group that keeps none. */\nexport const committedOf = (conditions: Conditions): FilterWhere | undefined => {\n const kept = conditions\n .map((group) => ({ and: group.and.filter((one) => !isEmptyConditionValue(rowOf(one).value)) }))\n .filter((group) => group.and.length > 0)\n return kept.length ? { or: kept } : undefined\n}\n\n/** One more row, in a group of its own; `and` is reached by adding and then switching the join. */\nexport const addCondition = (conditions: Conditions, condition: Condition): Conditions => [\n ...conditions,\n { and: [condition] },\n]\n\n/** One row gone, and the group it emptied gone with it. */\nexport const removeCondition = ({ conditions, orIndex, andIndex }: { conditions: Conditions; orIndex: number; andIndex: number }): Conditions =>\n conditions\n .map((group, o) => (o === orIndex ? { and: group.and.filter((_, a) => a !== andIndex) } : group))\n .filter((group) => group.and.length > 0)\n\n/** One row rewritten in place. */\nexport const updateCondition = ({\n conditions,\n orIndex,\n andIndex,\n condition,\n}: {\n conditions: Conditions\n orIndex: number\n andIndex: number\n condition: Condition\n}): Conditions =>\n conditions.map((group, o) => (o === orIndex ? { and: group.and.map((one, a) => (a === andIndex ? condition : one)) } : group))\n\n/** Moves one row between `and` and `or` by flattening the groups to one list and rebuilding them from each row's join. */\nexport const updateJoin = ({\n conditions,\n orIndex,\n andIndex,\n join,\n}: {\n conditions: Conditions\n orIndex: number\n andIndex: number\n join: 'and' | 'or'\n}): Conditions => {\n const flattened: { condition: Condition; join: 'and' | 'or' | null }[] = []\n conditions.forEach((group, o) => {\n group.and.forEach((condition, a) => {\n const isTarget = o === orIndex && a === andIndex\n const current = flattened.length === 0 ? null : a === 0 ? 'or' : 'and'\n flattened.push({ condition, join: isTarget ? join : current })\n })\n })\n const rebuilt: { and: Condition[] }[] = []\n flattened.forEach(({ condition, join: itemJoin }, index) => {\n if (index === 0 || itemJoin === 'or') rebuilt.push({ and: [condition] })\n else rebuilt[rebuilt.length - 1]!.and.push(condition)\n })\n return rebuilt\n}\n\nconst isEmptyConditionValue = (value: unknown): boolean => value === undefined || value === null || value === ''\n\n/**\n * An empty row reports `undefined` as soon as it mounts, and committing that stores\n * `{ [operator]: undefined }`, a row gone on the next parse. Only a value edit can be a no-op.\n */\nconst isNoOpConditionValueUpdate = (stored: unknown, incoming: unknown): boolean =>\n isEmptyConditionValue(stored) && isEmptyConditionValue(incoming)\n\n// A select item's value may not be '' (the primitive reserves it for nothing chosen), so an unanswered row is carried under this name and mapped back to ''.\nconst unanswered = 'unanswered'\n\n// `like` needs telling apart from `contains`: the drizzle store runs one `ilike` per whitespace-separated word. Labels go through `t` under `operator.<name>`.\nconst operatorFallbacks: Readonly<Record<string, string>> = {\n contains: 'contains',\n equals: 'is',\n exists: 'exists',\n greater_than: 'is after',\n greater_than_equal: 'is at least',\n in: 'is one of',\n less_than: 'is before',\n less_than_equal: 'is at most',\n like: 'matches every word',\n not_equals: 'is not',\n not_in: 'is none of',\n}\n\nconst asText = (value: unknown) =>\n value === null || value === undefined ? '' : Array.isArray(value) ? value.join(', ') : String(value)\n\n// A moment typed with no zone becomes the instant it names on the viewer's clock; a moment with its own zone, and text no clock reads, are sent as typed for the server to judge.\nconst valueFrom = (kind: string, operator: string, text: string): unknown => {\n const shape = valueShapeOf(operator)\n if (shape === 'boolean') return text === '' ? undefined : text === 'true'\n if (shape === 'list') {\n const tokens = text.split(',').map((one) => one.trim()).filter(Boolean)\n return tokens.length ? (kind === 'number' || kind === 'integer' ? tokens.map(Number) : kind === 'datetime' ? tokens.map(instantOf) : tokens) : undefined\n }\n if (text === '') return undefined\n if (kind === 'checkbox') return text === 'true'\n return kind === 'number' || kind === 'integer' ? Number(text) : kind === 'datetime' ? instantOf(text) : text\n}\n\nfunction ValueInput({ id, locale, operator, onCommit, path, t, value }: { id: string; locale: string; operator: string; path: FilterPath; t: Translate; value: unknown; onCommit: (value: unknown) => void }): ReactElement {\n const { kind } = path\n const [text, setText] = useState(asText(value))\n // `sent` is the last commit read back as text; it differs from the typed text where the commit converts it.\n const reported = useRef(asText(value))\n const sent = useRef(asText(value))\n useEffect(() => {\n // Re-seeding from this input's own commit coming back would drop keystrokes typed since, or swap the moment typed for its instant.\n const incoming = asText(value)\n if (incoming !== sent.current) {\n reported.current = sent.current = incoming\n setText(incoming)\n }\n }, [value])\n useEffect(() => {\n if (text === reported.current) return\n const timer = setTimeout(() => {\n const next = valueFrom(kind, operator, text)\n reported.current = text\n sent.current = asText(next)\n onCommit(next)\n }, 300)\n return () => clearTimeout(timer)\n }, [kind, onCommit, operator, text])\n\n const shape = valueShapeOf(operator)\n if (shape === 'boolean' || kind === 'checkbox') {\n return (\n <Select value={text === '' ? unanswered : text} onValueChange={(next) => setText(next === null || next === unanswered ? '' : next)}>\n {/* The word is the trigger's own child: the item list mounts only while open, so a server render would show an empty trigger. */}\n <SelectTrigger aria-label=\"value\" size=\"sm\">\n <SelectValue>{text === '' ? '—' : text}</SelectValue>\n </SelectTrigger>\n <SelectContent>\n <SelectItem value={unanswered}>—</SelectItem>\n <SelectItem value=\"true\">true</SelectItem>\n <SelectItem value=\"false\">false</SelectItem>\n </SelectContent>\n </Select>\n )\n }\n // A moment is sent as its ISO instant, so 09:30 picked at UTC+3 asks for 06:30Z and the picker draws it back on the reader's clock. `in` and `not_in` keep the token list.\n if ((kind === 'date' || kind === 'datetime') && shape !== 'list') {\n const Picker = controls[kind]!\n return (\n <Picker\n field={path.field}\n kayan={path.kayan}\n values={{}}\n setValue={() => {}}\n t={t}\n locale={locale}\n id={id}\n value={text}\n readOnly={false}\n onChange={(next) => setText(next instanceof Date ? next.toISOString() : asText(next))}\n />\n )\n }\n // A relationship reaches this arm only for `in` and `not_in`, a list of ids; its scalar operators get the picker.\n const type = shape === 'list' ? 'text' : kind === 'number' || kind === 'integer' ? 'number' : kind === 'email' ? 'email' : 'text'\n return (\n <Input\n aria-label=\"value\"\n className=\"w-auto\"\n type={type}\n step={kind === 'integer' && shape !== 'list' ? '1' : undefined}\n inputMode={kind === 'decimal' ? 'decimal' : undefined}\n dir=\"auto\"\n value={text}\n placeholder={shape === 'list' ? 'a, b, c' : undefined}\n onChange={(event) => setText(event.target.value)}\n />\n )\n}\n\nexport function Filters({ api, client, locale, onChange, paths, t, value }: FiltersProps): ReactElement | null {\n const [conditions, setConditions] = useState<Conditions>(() => value?.or ?? [])\n // What this panel commits comes back through the URL with the unfilled rows gone; re-seeding from that would drop the row being written. A `value` it did not report is an outside change and does re-seed.\n const reported = useRef(JSON.stringify(value ?? null))\n useEffect(() => {\n const incoming = JSON.stringify(value ?? null)\n if (incoming === reported.current) return\n reported.current = incoming\n setConditions(value?.or ?? [])\n }, [value])\n\n // No panel for a kayan with no filterable field; after the hooks, which must run on every render.\n if (!paths.length) return null\n const first = paths[0]!\n const flat = conditions.flatMap((group, orIndex) => group.and.map((condition, andIndex) => ({ andIndex, condition, orIndex })))\n const blank = (): Condition => ({ [first.path]: { [(operatorsByKind[first.kind] ?? ['equals'])[0]!]: undefined } })\n\n const commit = (next: Conditions) => {\n setConditions(next)\n const where = committedOf(next)\n const encoded = JSON.stringify(where ?? null)\n reported.current = encoded\n // Nothing goes up when the commit equals the filter the host holds: a host resets to page one on any `onChange`, so Add filter on page three would lose the page.\n if (encoded !== JSON.stringify(value ?? null)) onChange(where)\n }\n\n const replace = (orIndex: number, andIndex: number, row: Row) =>\n commit(updateCondition({ conditions, orIndex, andIndex, condition: conditionOf(row) }))\n\n // With nothing committed the panel shows the virtual row below, so the click makes it real and appends beside it.\n const add = () => commit(flat.length ? addCondition(conditions, blank()) : [{ and: [blank()] }, { and: [blank()] }])\n\n const labelOf = (path: FilterPath) => {\n const own = labelFor({ kayan: path.kayan, field: path.field, copy: { t, locale } })\n return path.through && path.through !== path.field.name ? `${path.through} > ${own}` : own\n }\n\n // The virtual first row commits on the first real edit rather than on mount, so an untouched panel writes no URL.\n const rows = flat.length ? flat : [{ andIndex: 0, condition: blank(), orIndex: 0 }]\n\n return (\n <div className=\"flex flex-col gap-2 rounded-lg border p-3\" data-mithaq-filters={rows.length}>\n {rows.map(({ andIndex, condition, orIndex }, index) => {\n const row = rowOf(condition)\n const path = paths.find((one) => one.path === row.path) ?? first\n const operators = operatorsByKind[path.kind] ?? []\n const operator = operators.includes(row.operator as never) ? row.operator : (operators[0] ?? 'equals')\n const virtual = flat.length === 0\n const write = (next: Row) => (virtual ? commit([{ and: [conditionOf(next)] }]) : replace(orIndex, andIndex, next))\n return (\n <div key={`${orIndex}.${andIndex}`} className=\"flex flex-wrap items-center gap-2\">\n {index === 0 ? (\n <span className=\"text-sm text-muted-foreground\">{t('filter.where', 'Where') ?? 'Where'}</span>\n ) : (\n <Select\n value={andIndex === 0 ? 'or' : 'and'}\n onValueChange={(next) => commit(updateJoin({ conditions, orIndex, andIndex, join: next === 'or' ? 'or' : 'and' }))}\n >\n <SelectTrigger aria-label={t('filter.join', 'Join') ?? 'Join'} size=\"sm\">\n <SelectValue>\n {andIndex === 0 ? (t('filter.or', 'or') ?? 'or') : (t('filter.and', 'and') ?? 'and')}\n </SelectValue>\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"and\">{t('filter.and', 'and') ?? 'and'}</SelectItem>\n <SelectItem value=\"or\">{t('filter.or', 'or') ?? 'or'}</SelectItem>\n </SelectContent>\n </Select>\n )}\n <Select\n value={path.path}\n onValueChange={(chosen) => {\n const next = paths.find((one) => one.path === chosen) ?? first\n const kept = (operatorsByKind[next.kind] ?? []).includes(operator as never) ? operator : (operatorsByKind[next.kind] ?? ['equals'])[0]!\n write({ path: next.path, operator: kept, value: undefined })\n }}\n >\n <SelectTrigger aria-label={t('filter.field', 'Field') ?? 'Field'} size=\"sm\">\n <SelectValue>{labelOf(path)}</SelectValue>\n </SelectTrigger>\n <SelectContent>\n {paths.map((one) => (\n <SelectItem key={one.path} value={one.path}>\n {labelOf(one)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n <Select value={operator} onValueChange={(chosen) => chosen && write({ path: path.path, operator: chosen, value: undefined })}>\n <SelectTrigger aria-label={t('filter.operator', 'Operator') ?? 'Operator'} size=\"sm\">\n <SelectValue>{t(`operator.${operator}`, operatorFallbacks[operator]) ?? operator}</SelectValue>\n </SelectTrigger>\n <SelectContent>\n {operators.map((one) => (\n <SelectItem key={one} value={one}>\n {t(`operator.${one}`, operatorFallbacks[one]) ?? one}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n {path.kind === 'relationship' && valueShapeOf(operator) === 'scalar' ? (\n // The form's picker rather than a box for typing a uuid; `in` and `not_in` keep the token list because the picker settles on one id.\n <RelationshipControl\n field={path.field}\n values={{}}\n setValue={() => {}}\n kayan={path.kayan}\n client={client}\n api={api}\n t={t}\n id={`filter-${orIndex}-${andIndex}`}\n value={row.value}\n readOnly={false}\n onChange={(next) => {\n if (isNoOpConditionValueUpdate(row.value, next)) return\n write({ path: path.path, operator, value: next })\n }}\n />\n ) : (\n <ValueInput\n id={`filter-${orIndex}-${andIndex}`}\n locale={locale}\n path={path}\n t={t}\n operator={operator}\n value={row.value}\n onCommit={(next) => {\n if (isNoOpConditionValueUpdate(row.value, next)) return\n write({ path: path.path, operator, value: next })\n }}\n />\n )}\n <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={() => commit(virtual ? [] : removeCondition({ conditions, orIndex, andIndex }))}>\n {t('filter.remove', 'Remove') ?? 'Remove'}\n </Button>\n </div>\n )\n })}\n <div>\n <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={add}>\n {t('filter.add', 'Add filter') ?? 'Add filter'}\n </Button>\n </div>\n </div>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 8606b5fd4339471a01d4f8ff1c783d4c79ae3c0f92bd195586d04260a77a9ea0\n",
|
|
19
|
-
"type": "registry:component",
|
|
20
|
-
"target": "@components/mithaq/list-filters.tsx"
|
|
21
|
-
}
|
|
22
|
-
],
|
|
23
|
-
"type": "registry:component"
|
|
24
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "mithaq-list-pagination",
|
|
4
|
-
"title": "Mithaq list pagination",
|
|
5
|
-
"description": "Two arrows, a page input clamped to the page count, the per-page popup and the range line, all off the paginated shape the read answered.",
|
|
6
|
-
"registryDependencies": [
|
|
7
|
-
"button",
|
|
8
|
-
"input",
|
|
9
|
-
"label",
|
|
10
|
-
"select",
|
|
11
|
-
"@mithaq/mithaq-controls",
|
|
12
|
-
"@mithaq/mithaq-directional-icon",
|
|
13
|
-
"@mithaq/mithaq-list-query"
|
|
14
|
-
],
|
|
15
|
-
"files": [
|
|
16
|
-
{
|
|
17
|
-
"path": "registry/list-pagination.tsx",
|
|
18
|
-
"content": "import { useEffect, useState, type ReactElement } from 'react'\nimport { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { DirectionalIcon } from '@/components/ui/directional-icon'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport type { Translate } from './controls.tsx'\nimport { useScreen } from 'mithaq/admin/react'\nimport { limitsUpTo } from './list-query.ts'\n\n/** As much of one page as the pager reads. */\nexport type PageInfo = {\n hasNextPage: boolean\n hasPrevPage: boolean\n limit: number\n nextPage: number | null\n page: number\n prevPage: number | null\n totalDocs: number\n totalPages: number\n}\n\nexport type PaginationProps = {\n page: PageInfo\n onPage: (page: number) => void\n /** A new page size. The host resets the page to one, as it does for a filter. */\n onLimit: (limit: number) => void\n t: Translate\n locale: string\n}\n\n// The trigger is a button, not a native select, so the label points at it by id; one pager renders per screen.\nconst perPageId = 'mithaq-per-page'\n\nexport function Pagination({ locale, onLimit, onPage, page, t }: PaginationProps): ReactElement {\n const [typed, setTyped] = useState(String(page.page))\n useEffect(() => setTyped(String(page.page)), [page.page])\n const { maxLimit } = useScreen()\n\n const clamp = () => {\n const parsed = Number.parseInt(typed, 10)\n const within = Number.isInteger(parsed) ? Math.min(Math.max(parsed, 1), Math.max(page.totalPages, 1)) : page.page\n setTyped(String(within))\n if (within !== page.page) onPage(within)\n }\n\n const from = page.totalDocs === 0 ? 0 : (page.page - 1) * page.limit + 1\n const to = Math.min(page.page * page.limit, page.totalDocs)\n\n return (\n <footer className=\"flex flex-wrap items-center justify-between gap-x-8 gap-y-3 px-2\">\n <span className=\"text-muted-foreground flex flex-1 text-sm whitespace-nowrap\" data-mithaq-range={`${from}-${to}/${page.totalDocs}`}>\n {from.toLocaleString(locale)}–{to.toLocaleString(locale)} {t('admin.of', 'of') ?? 'of'} {page.totalDocs.toLocaleString(locale)}\n </span>\n {/* `contents` on a phone, so the size box joins the range's line and the rest wraps. */}\n <div className=\"ms-auto flex items-center gap-8 max-sm:contents\">\n <div className=\"flex items-center gap-2\">\n <Label className=\"text-sm font-medium\" htmlFor={perPageId}>\n {t('admin.perPage', 'Per page') ?? 'Per page'}\n </Label>\n {/* The size is the trigger's own child: the item list mounts only while open, so a server render would show an empty trigger. */}\n <Select value={String(page.limit)} onValueChange={(next: string | null) => next && onLimit(Number(next))}>\n <SelectTrigger id={perPageId} size=\"sm\" className=\"h-8 w-[70px] max-sm:data-[size=sm]:h-11\">\n <SelectValue>{page.limit}</SelectValue>\n </SelectTrigger>\n <SelectContent side=\"top\">\n {limitsUpTo(maxLimit).map((one) => (\n <SelectItem key={one} value={String(one)}>\n {one}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n <div className=\"flex w-fit items-center justify-center gap-2 text-sm font-medium\">\n <Input\n className=\"h-8 w-16 text-center max-sm:h-11\"\n type=\"number\"\n min={1}\n max={Math.max(page.totalPages, 1)}\n value={typed}\n aria-label={t('admin.page', 'Page') ?? 'Page'}\n onChange={(event) => setTyped(event.target.value)}\n onBlur={clamp}\n onKeyDown={(event) => {\n if (event.key === 'Enter') clamp()\n if (event.key === 'Escape') setTyped(String(page.page))\n }}\n />\n <span className=\"text-muted-foreground\">\n {t('admin.of', 'of') ?? 'of'} {Math.max(page.totalPages, 1).toLocaleString(locale)}\n </span>\n </div>\n <div className=\"ms-auto flex items-center gap-2 sm:ms-0\">\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n className=\"hidden size-8 sm:flex\"\n disabled={!page.hasPrevPage}\n onClick={() => onPage(1)}\n >\n <span className=\"sr-only\">{t('admin.first', 'First') ?? 'First'}</span>\n <DirectionalIcon>\n <ChevronsLeft />\n </DirectionalIcon>\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n className=\"size-8 max-sm:size-11\"\n disabled={!page.hasPrevPage}\n onClick={() => onPage(page.prevPage ?? 1)}\n >\n <span className=\"sr-only\">{t('admin.previous', 'Previous') ?? 'Previous'}</span>\n <DirectionalIcon>\n <ChevronLeft />\n </DirectionalIcon>\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n className=\"size-8 max-sm:size-11\"\n disabled={!page.hasNextPage}\n onClick={() => onPage(page.nextPage ?? page.page)}\n >\n <span className=\"sr-only\">{t('admin.next', 'Next') ?? 'Next'}</span>\n <DirectionalIcon>\n <ChevronRight />\n </DirectionalIcon>\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n className=\"hidden size-8 sm:flex\"\n disabled={!page.hasNextPage}\n onClick={() => onPage(Math.max(page.totalPages, 1))}\n >\n <span className=\"sr-only\">{t('admin.last', 'Last') ?? 'Last'}</span>\n <DirectionalIcon>\n <ChevronsRight />\n </DirectionalIcon>\n </Button>\n </div>\n </div>\n </footer>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 78d68b28893401072ea1b499a0b3c22588ded882bd7964122ccbb0048698d373\n",
|
|
19
|
-
"type": "registry:component",
|
|
20
|
-
"target": "@components/mithaq/list-pagination.tsx"
|
|
21
|
-
}
|
|
22
|
-
],
|
|
23
|
-
"type": "registry:component"
|
|
24
|
-
}
|
package/r/mithaq-list-query.json
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "mithaq-list-query",
|
|
4
|
-
"title": "Mithaq list query",
|
|
5
|
-
"description": "The list's URL in Payload's qs form, read against the list with a hostile or unknown filter refused before any request, the find input a page asks with, the per-viewer half remembered in localStorage, and the search term folded into a where.",
|
|
6
|
-
"registryDependencies": [
|
|
7
|
-
"@mithaq/mithaq-filter-operators"
|
|
8
|
-
],
|
|
9
|
-
"files": [
|
|
10
|
-
{
|
|
11
|
-
"path": "registry/list-query.ts",
|
|
12
|
-
"content": "import { isDecimal, type ClientField, type ClientKayan } from 'mithaq/admin'\nimport { filterPathsOf, offers, valueShapeOf, type FilterContext, type FilterPath } from './filter-operators.ts'\n\n// The reader is a trust boundary: any bad key, field, operator or value refuses the whole URL (`QueryRefused`),\n// and a link naming a field the viewer may not read is refused, never read as a smaller filter.\n\n/** One row of the panel: a path, an operator and its value. */\nexport type Condition = Record<string, Record<string, unknown>>\n\n/** The panel's state, two levels deep and never deeper: a disjunction of conjunctions, the shape of a `where` the server takes. */\nexport type FilterWhere = { or: { and: Condition[] }[] }\n\n/** What the screen drives `find` with, and what the URL carries. */\nexport type ListQuery = {\n where?: FilterWhere\n search?: string\n sort?: string\n page?: number\n limit?: number\n /** The column accessor, prefixed `-` when the column is hidden. */\n cols?: readonly string[]\n}\n\n/** One column as the popup holds it, which is what the `-` prefix encodes. */\nexport type ColumnChoice = { name: string; active: boolean }\n\n/** The URL's column list read back into choices; a `-` prefix marks a hidden column. */\nexport const columnsFrom = (cols: readonly string[] | undefined): ColumnChoice[] =>\n (cols ?? []).map((one) => (one.startsWith('-') ? { name: one.slice(1), active: false } : { name: one, active: true }))\n\n/** The choices written back as the URL's column list. */\nexport const columnsTo = (columns: readonly ColumnChoice[]): string[] =>\n columns.map((one) => (one.active ? one.name : `-${one.name}`))\n\n/** The page sizes the screen offers. The first is the one a list opens with. */\nexport const limits = [10, 25, 50, 100] as const\n\n/**\n * The page sizes a build whose served read answers at most `maxLimit` rows\n * offers: the ones up to it, since a larger one is a 400, or the maximum alone\n * where none of them fits.\n */\nexport const limitsUpTo = (maxLimit: number): readonly number[] => {\n const fit = limits.filter((one) => one <= maxLimit)\n return fit.length ? fit : [maxLimit]\n}\n\nconst whole = (value: unknown) => {\n const parsed = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10)\n return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined\n}\n\n/** Why a URL was refused, which the host shows in place of the rows it never asked for. */\nexport class QueryRefused extends Error {\n override name = 'QueryRefused'\n}\nfunction refuse(sentence: string): never {\n throw new QueryRefused(`This link ${sentence}`)\n}\n\n/** Whether a field can order a page: the same questions the read answers before it takes a sort. */\nexport const canSort = (field: ClientField) =>\n field.rules.localized !== true && field.hasMany !== true && field.kind !== 'join' && field.sortable !== false\n\n/** The fields a list may be sorted by. */\nexport const sortableOf = (kayan: { fields: readonly ClientField[] }) => kayan.fields.filter(canSort).map((field) => field.name)\n\n/**\n * What a URL is read against: the paths the list filters by, the fields it\n * sorts by, and the list node's own page size, which is one the URL may name\n * whether or not the pager offers it.\n */\nexport type ListRules = { paths: readonly FilterPath[]; sortable: readonly string[]; limit?: number; maxLimit: number }\n\n/** What a list node says about its query: what it filters and searches by, and the sort and limit it opens with. */\nexport type ListDefaults = { filters?: readonly string[]; search?: readonly string[]; sort?: string; limit?: number }\n\n/** The rules one list node reads its URL by. */\nexport const rulesOf = ({ kayan, node, maxLimit, context = {} }: { kayan: ClientKayan; node: ListDefaults | undefined; maxLimit: number; context?: FilterContext }): ListRules => ({\n paths: filterPathsOf(kayan, { ...context, ...(node?.filters && { filters: node.filters }) }),\n sortable: sortableOf(kayan),\n maxLimit,\n ...(node?.limit !== undefined && { limit: node.limit }),\n})\n\n/** A `where` in `qs`'s bracket form: `where[or][0][and][0][title][like]`. */\nconst bracketed = /^where((?:\\[[^[\\]]*\\])+)$/\n/** `where[or][0][and][0][path][operator][index]`: seven segments and never more. */\nconst depth = 7\n/** A `where` is at most this many values, which is far past what the panel writes. */\nconst most = 64\n/** The three keys that reach an object's prototype, which a `where` never names. */\nconst unsafe = ['__proto__', 'constructor', 'prototype']\n\n/** The `where` pairs of a URL built into the object they spell; anything `qs` would silently repair is refused. */\nconst treeOf = (pairs: readonly [string, string][]): unknown => {\n if (pairs.length > most) refuse(`carries more than ${most} where values.`)\n const tree: Record<string, unknown> = {}\n for (const [key, value] of pairs) {\n const segments = [...(bracketed.exec(key)?.[1] ?? refuse('carries a where that is not written in brackets.')).matchAll(/\\[([^[\\]]*)\\]/g)].map((one) => one[1]!)\n if (segments.length > depth) refuse('is nested deeper than a where goes.')\n let at: Record<string, unknown> | unknown[] = tree\n segments.forEach((segment, index) => {\n if (unsafe.includes(segment)) refuse(`names \"${segment}\", which no where may name.`)\n const numbered = /^\\d{1,2}$/.test(segment)\n if (numbered !== Array.isArray(at) || segment === '') refuse(`mixes a list and an object at \"${segment}\".`)\n const slot = at as Record<string, unknown>\n if (index === segments.length - 1) {\n if (slot[segment] !== undefined) refuse(`says \"${key}\" twice.`)\n slot[segment] = value\n return\n }\n slot[segment] ??= /^\\d{1,2}$/.test(segments[index + 1]!) ? [] : {}\n if (typeof slot[segment] !== 'object') refuse(`says \"${key}\" twice.`)\n at = slot[segment] as Record<string, unknown> | unknown[]\n })\n }\n const gapless = (value: unknown): boolean =>\n !value || typeof value !== 'object' || ((!Array.isArray(value) || [...value].every((one) => one !== undefined)) && Object.values(value).every(gapless))\n if (!gapless(tree)) refuse('holds a gap in its list.')\n return tree\n}\n\n/**\n * A `where` hoisted into the panel's two levels: an `or` whose entries hold no\n * `and` gets one, a bare `and` is one group, and top-level siblings are one\n * group of their own.\n */\nconst hoisted = (where: Record<string, unknown>) => {\n const or = where.or as Record<string, unknown>[] | undefined\n const and = where.and as unknown[] | undefined\n // `where[or][title]` builds an object where a list belongs, which the read would refuse.\n if (or !== undefined && !Array.isArray(or)) refuse('holds an or that is no list.')\n if (and !== undefined && !Array.isArray(and)) refuse('holds an and that is no list.')\n if (or && and) refuse('holds or and and at the root of one where.')\n if (or) return or.map((group) => (Array.isArray(group.and) ? group.and : [group]))\n if (and) return [and]\n return [[where]]\n}\n\n/**\n * A moment with no zone, `2026-10-07T09:30`, as the ISO instant on the reader's own clock.\n * Anything else (a moment with its zone, text no clock reads) is handed back for the server to judge.\n */\nexport const instantOf = (text: string): string => {\n if (!/^\\d{4}-\\d\\d-\\d\\d[T ]\\d\\d:\\d\\d(?::\\d\\d(?:\\.\\d{1,3})?)?$/.test(text)) return text\n const local = new Date(text.replace(' ', 'T'))\n return Number.isNaN(local.getTime()) ? text : local.toISOString()\n}\n\n/** One value in the kind of its field: the URL carries strings, and the read takes typed values. */\nconst valueOf = (path: FilterPath, operator: string, value: unknown): unknown => {\n if (typeof value !== 'string') refuse(`asks field \"${path.path}\" for \"${operator}\" with a value that is no single value.`)\n if (path.kind === 'number' || path.kind === 'integer') {\n const number = Number(value)\n const integer = path.kind === 'integer'\n const held = value.trim() !== '' && (integer ? Number.isInteger(number) && number >= -2147483648 && number <= 2147483647 : Number.isFinite(number))\n return held ? number : refuse(`asks field \"${path.path}\" to equal \"${value}\", which is not ${integer ? 'a 32-bit integer' : 'a number'}.`)\n }\n // A decimal stays the text it is, which is the form its row holds.\n if (path.kind === 'decimal') return isDecimal(value) ? value : refuse(`asks field \"${path.path}\" to equal \"${value}\", which is not a decimal.`)\n if (path.kind === 'checkbox') return value === 'true' ? true : value === 'false' ? false : refuse(`asks field \"${path.path}\" to equal \"${value}\", which is not true or false.`)\n // A moment with no zone is read on the reader's own clock, not handed on for the store to read as UTC.\n if (path.kind === 'datetime') return instantOf(value)\n return value\n}\n\n/** A `where` off the URL, every condition checked the way the read checks one, so nothing is asked of the server that it would refuse. */\nconst whereOf = (paths: readonly FilterPath[], tree: unknown): FilterWhere | undefined => {\n const where = tree as Record<string, unknown>\n if (!Object.keys(where).length) return undefined\n const groups = hoisted(where).map((conditions) => ({\n and: conditions.flatMap((condition) =>\n Object.entries(condition as Record<string, unknown>).flatMap(([name, operators]) => {\n const path = paths.find((one) => one.path === name) ?? refuse(`names field \"${name}\", which this list does not filter by.`)\n if (!operators || typeof operators !== 'object' || Array.isArray(operators)) refuse(`names field \"${name}\" with no operator.`)\n return Object.entries(operators as Record<string, unknown>).map(([operator, value]): Condition => {\n if (!offers({ paths, path: name, operator })) refuse(`asks field \"${name}\" for \"${operator}\", which it does not take.`)\n const shape = valueShapeOf(operator)\n if (shape === 'list' && !Array.isArray(value)) refuse(`asks field \"${name}\" for \"${operator}\" with one value, and \"${operator}\" takes a list.`)\n const typed =\n shape === 'boolean' ? valueOf({ ...path, kind: 'checkbox' }, operator, value)\n : shape === 'list' ? (value as unknown[]).map((one) => valueOf(path, operator, one))\n : valueOf(path, operator, value)\n return { [name]: { [operator]: typed } }\n })\n }),\n ),\n }))\n return { or: groups }\n}\n\n/** What a reader is handed: the URL's own search params, or the router's record of them. */\nexport type QuerySource = URLSearchParams | Readonly<Record<string, unknown>>\n\n/**\n * The URL's list keys as the strings it carries: every `where[…]` pair,\n * `search`, `sort`, `page`, `limit` and `cols`. A route validates its search\n * into this before any kayan is in hand; `readQuery` reads it against the list.\n */\nexport type RawQuery = Readonly<Record<string, string>>\n\n/** A key with a list-of-another-kayan prefix taken off (`sessions.course.page` is `page`), so two lists on one page never read each other's. */\nconst baseOf = (key: string) => key.replace(/^[^.[\\]]+\\.[^.[\\]]+\\./, '')\nconst listKey = (key: string) => {\n const base = baseOf(key)\n return base.startsWith('where') || ['search', 'sort', 'page', 'limit', 'cols'].includes(base)\n}\n\n/** The prefix one list node's keys carry in the URL: none for a page's own list, `<of>.<where>.` for a list of another kayan. */\nexport const prefixOf = (node: { of?: string; where?: string } | undefined) => (node?.of ? `${node.of}.${node.where}.` : '')\n\nconst ownKey = (key: string, prefix: string) => key.startsWith(prefix) && baseOf(key) === key.slice(prefix.length)\n\n/** One list's own keys off the URL's, its prefix taken off, which is what `readQuery` reads. */\nexport const listRaw = (raw: RawQuery, prefix: string): RawQuery =>\n Object.fromEntries(Object.entries(raw).flatMap(([key, value]) => (ownKey(key, prefix) ? [[key.slice(prefix.length), value]] : [])))\n\n/** The URL's keys with one list's replaced by what it now asks, every other list's kept. */\nexport const withList = ({ raw, prefix, written }: { raw: RawQuery; prefix: string; written: Readonly<Record<string, string>> }): Record<string, string> => ({\n ...Object.fromEntries(Object.entries(raw).filter(([key]) => !ownKey(key, prefix))),\n ...Object.fromEntries(Object.entries(written).map(([key, value]) => [`${prefix}${key}`, value])),\n})\n\nexport const readRaw = (source: QuerySource): RawQuery =>\n Object.fromEntries(\n (source instanceof URLSearchParams ? [...source] : Object.entries(source)).filter(\n (pair): pair is [string, string] => listKey(pair[0]) && typeof pair[1] === 'string',\n ),\n )\n\n/**\n * The whole query, read against the list it is for, or `QueryRefused` for a\n * URL that asks for what the list does not offer.\n */\nexport const readQuery = (source: QuerySource, rules: ListRules): ListQuery => {\n const raw = readRaw(source)\n const where = whereOf(rules.paths, treeOf(Object.entries(raw).filter(([key]) => key.startsWith('where'))))\n const { search, sort, cols } = raw\n if (sort && !rules.sortable.includes(sort.replace(/^-/, ''))) refuse(`sorts by \"${sort.replace(/^-/, '')}\", which this list does not sort by.`)\n const page = whole(raw.page)\n const limit = whole(raw.limit)\n const list = (cols ?? '').split(',').map((one) => one.trim()).filter(Boolean)\n return {\n ...(where && { where }),\n ...(search && { search }),\n ...(sort && { sort }),\n ...(page && { page }),\n ...(limit && (limitsUpTo(rules.maxLimit).includes(limit) || limit === rules.limit) && { limit }),\n ...(list.length > 0 && { cols: list }),\n }\n}\n\n/** A value's pairs in bracket form: `where[or][0][and][0][title][like]`. */\nconst pairsOf = (prefix: string, value: unknown): [string, string][] =>\n value && typeof value === 'object'\n ? Object.entries(value).flatMap(([key, one]) => pairsOf(`${prefix}[${key}]`, one))\n : value === undefined ? [] : [[prefix, String(value)]]\n\n/**\n * The same query as a URL would carry it, one string per key and no key for a\n * value that is not there. Handed to `new URLSearchParams(...)` or to a\n * router's own `search`, which is why it is a record rather than a string.\n */\nexport const writeQuery = (query: ListQuery): Record<string, string> => ({\n ...Object.fromEntries(pairsOf('where', query.where)),\n ...(query.search && { search: query.search }),\n ...(query.sort && { sort: query.sort }),\n ...(query.page && query.page > 1 && { page: String(query.page) }),\n ...(query.limit && { limit: String(query.limit) }),\n ...(query.cols?.length && { cols: query.cols.join(',') }),\n})\n\n/**\n * The same query with one part changed and the page back at one: each of\n * filter, search, sort and limit changes which rows are on which page, and a\n * page number that outlived its result is a blank table.\n */\nexport const refined = (query: ListQuery, next: Partial<ListQuery>): ListQuery => ({ ...query, ...next, page: 1 })\n\n/**\n * The per-viewer state the URL does not carry: page size and columns, in one `localStorage` key per kayan.\n * Access is in a try/catch: `localStorage` throws in a private window and is absent in a server render.\n */\nexport type Remembered = { limit?: number; cols?: readonly string[] }\n\nconst keyFor = (kayan: string) => `mithaq.list.${kayan}`\n\nexport const remembered = (kayan: string, maxLimit: number): Remembered => {\n try {\n const held = globalThis.localStorage?.getItem(keyFor(kayan))\n const value = held ? (JSON.parse(held) as unknown) : undefined\n if (!value || typeof value !== 'object') return {}\n const { cols, limit } = value as Remembered\n return {\n ...(typeof limit === 'number' && limitsUpTo(maxLimit).includes(limit) && { limit }),\n ...(Array.isArray(cols) && cols.every((one) => typeof one === 'string') && { cols }),\n }\n } catch {\n return {}\n }\n}\n\nexport const remember = (kayan: string, value: Remembered): void => {\n try {\n globalThis.localStorage?.setItem(keyFor(kayan), JSON.stringify(value))\n } catch {\n // A viewer whose storage is full or blocked keeps the URL's answer, which\n // is the whole of what the screen needs to render.\n }\n}\n\n/** The half of a kayan a search reads: the two admin keys, the kinds, and where a relationship points. */\nexport type Searchable = {\n name?: string\n useAsTitle?: string\n /** The rows every list of the kayan on its screen is held to. */\n baseFilter?: unknown\n fields?: readonly { name: string; kind: string; searchable?: true; relationTo?: string; rules?: { localized?: unknown } }[]\n}\n\n/**\n * Whether the viewer may read what a search path names, in this kayan or across one relationship.\n * A projection holds only readable fields; a path it lacks would 403 and the search box would refuse every word.\n */\nconst reads = (kayan: Searchable, path: string, client: readonly Searchable[] = []) => {\n const [own, far] = path.split('.')\n const near = kayan.fields?.find((field) => field.name === own)\n if (!near || far === undefined) return near !== undefined\n return client.some((one) => one.name === near.relationTo && one.fields?.some((field) => field.name === far))\n}\n\n/**\n * The fields a search runs over: the node's `search`, else those declared `searchable`, else `useAsTitle`, else `id`.\n * Only the fallbacks need a text or email kind, as the read takes `like` on those alone; a uuid `id` has no search.\n */\nexport const searchableFields = ({ kayan, search, client }: { kayan: Searchable; search?: readonly string[]; client?: readonly Searchable[] }): readonly string[] => {\n const declared = search ?? (kayan.fields ?? []).filter((field) => field.searchable).map((field) => field.name)\n if (declared.length) return declared.filter((path) => reads(kayan, path, client))\n const name = kayan.useAsTitle ?? 'id'\n const kind = kayan.fields?.find((field) => field.name === name)?.kind\n return kind === 'text' || kind === 'email' ? [name] : []\n}\n\n/**\n * A search term as a `where`: one `like` per searchable field, ORed. `like` rather than `contains` because\n * the drizzle store splits it on whitespace and runs one `ilike` per word.\n */\nexport const searchWhere = ({\n kayan,\n search,\n fields = searchableFields({ kayan }),\n locales = [],\n}: {\n kayan: Searchable\n search: string | undefined\n fields?: readonly string[]\n locales?: readonly string[]\n}): FilterWhere | undefined => {\n const term = search?.trim()\n // A localized field is one column per locale and a `like` names one of them,\n // `name.ar`, so it is searched in every locale the host reads in.\n const paths = fields.flatMap((field) =>\n kayan.fields?.find((one) => one.name === field)?.rules?.localized === true ? locales.map((locale) => `${field}.${locale}`) : [field],\n )\n // A kayan with no searchable field is no filter at all, not a `where` of no\n // groups, which the server refuses as an empty disjunction.\n return term && paths.length ? { or: paths.map((field) => ({ and: [{ [field]: { like: term } }] })) } : undefined\n}\n\n/**\n * What one render hands `find`: the panel's `where`, the search's and the\n * screen's `baseFilter` for the kayan, ANDed in that order. One alone is passed\n * through rather than wrapped.\n */\nexport const findWhere = ({\n kayan,\n query,\n search,\n locales,\n client,\n}: {\n kayan: Searchable\n query: ListQuery\n search?: readonly string[]\n locales?: readonly string[]\n client?: readonly Searchable[]\n}): unknown => {\n const searched = searchWhere({ kayan, search: query.search, fields: searchableFields({ kayan, search, client }), locales })\n const parts = [query.where, searched, kayan.baseFilter].filter((one) => one !== undefined)\n return parts.length > 1 ? { and: parts } : parts[0]\n}\n\n/**\n * What one page asks `find`: the panel's and the search's `where`, and the URL's sort and limit over the node's defaults.\n * Server and browser both use it, so the first page and every later one share one query and cache key.\n */\nexport const findOf = ({\n kayan,\n query,\n node,\n locales,\n client,\n}: {\n kayan: Searchable\n query: ListQuery\n node?: ListDefaults\n locales?: readonly string[]\n client?: readonly Searchable[]\n}) => {\n const where = findWhere({ kayan, query, search: node?.search, locales, client })\n const sort = query.sort ?? node?.sort\n return {\n ...(where !== undefined && { where }),\n ...(sort && { sort }),\n ...(query.page && { page: query.page }),\n // None where neither names one, so the server's own `defaultLimit`\n // answers, which the build holds to its `maxLimit`.\n ...((query.limit ?? node?.limit) !== undefined && { limit: query.limit ?? node?.limit }),\n }\n}\n\n/**\n * A page's input held to the rows a list of another kayan draws: those whose\n * relationship holds the page's row, ANDed with whatever the URL asked. It is\n * part of the one input and the one cache key.\n */\nexport const scopedTo = (input: Readonly<Record<string, unknown>>, where: Readonly<Record<string, unknown>>): Record<string, unknown> => ({\n ...input,\n where: input.where === undefined ? where : { and: [where, input.where] },\n})\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 41bf95fa8c90940a8942f07ab193a03ad1381581c59b96c0ab92b784091895bd\n",
|
|
13
|
-
"type": "registry:lib",
|
|
14
|
-
"target": "@components/mithaq/list-query.ts"
|
|
15
|
-
}
|
|
16
|
-
],
|
|
17
|
-
"type": "registry:lib"
|
|
18
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "mithaq-list-selection",
|
|
4
|
-
"title": "Mithaq list selection",
|
|
5
|
-
"description": "The selection a list's bulk buttons run over: the head checkbox, one per row, and the fields a bulk update may write, which are the ones a person could edit on a row.",
|
|
6
|
-
"registryDependencies": [
|
|
7
|
-
"checkbox",
|
|
8
|
-
"@mithaq/mithaq-controls"
|
|
9
|
-
],
|
|
10
|
-
"files": [
|
|
11
|
-
{
|
|
12
|
-
"path": "registry/list-selection.tsx",
|
|
13
|
-
"content": "import type { ReactElement } from 'react'\nimport type { ClientField, ClientKayan } from 'mithaq/admin'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { checkboxSize, readOnlyField, type Translate } from './controls.tsx'\n\n/**\n * The list's checkbox size, with the invisible hit layer grown to 44 pixels below `sm`. Only for\n * the list: a checkbox group's options sit 28 pixels apart, so a 44 pixel layer there would tick the option above.\n */\nexport const selectBox = `${checkboxSize} after:-inset-x-3.5 max-sm:after:-inset-y-3.5`\n\n/** Which fields a bulk update may offer: the ones a person could edit on a row of this kayan. Chrome only: the runner refuses whatever the list draws. */\nexport const writableFields = (\n kayan: ClientKayan,\n writes?: (field: string) => boolean,\n): readonly ClientField[] =>\n kayan.fields.filter(\n (field) =>\n !readOnlyField(field, 'update')\n && (writes ? writes(field.name) : true),\n )\n\n/** The head checkbox of the selection column; reports `aria-checked=\"mixed\"` while some of the page is ticked. */\nexport function SelectAll({\n ids,\n selected,\n onSelect,\n t,\n}: {\n ids: readonly string[]\n selected: ReadonlySet<string>\n onSelect: (ids: ReadonlySet<string>) => void\n t: Translate\n}): ReactElement {\n const ticked = ids.filter((one) => selected.has(one)).length\n const all = ids.length > 0 && ticked === ids.length\n return (\n <Checkbox\n className={selectBox}\n checked={all}\n aria-checked={ticked > 0 && !all ? 'mixed' : all}\n aria-label={t('admin.selectAll', 'Select every row on this page') ?? 'Select every row on this page'}\n onCheckedChange={(checked: boolean | 'indeterminate') => onSelect(checked === true ? new Set(ids) : new Set())}\n />\n )\n}\n\n/** One row's checkbox. */\nexport function SelectRow({\n id,\n selected,\n onSelect,\n t,\n}: {\n id: string\n selected: ReadonlySet<string>\n onSelect: (ids: ReadonlySet<string>) => void\n t: Translate\n}): ReactElement {\n return (\n <Checkbox\n className={selectBox}\n checked={selected.has(id)}\n aria-label={t('admin.selectRow', 'Select this row') ?? 'Select this row'}\n onCheckedChange={(checked: boolean | 'indeterminate') => {\n const next = new Set(selected)\n if (checked === true) next.add(id)\n else next.delete(id)\n onSelect(next)\n }}\n />\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 576cedc3611379cc2dcbda75ac13cbf42a5f1d6fbdd43ed6e56fd9d621f3e821\n",
|
|
14
|
-
"type": "registry:component",
|
|
15
|
-
"target": "@components/mithaq/list-selection.tsx"
|
|
16
|
-
}
|
|
17
|
-
],
|
|
18
|
-
"type": "registry:component"
|
|
19
|
-
}
|