@drhomet/mithaq 0.0.0-stage → 0.1.1
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/LICENSE +22 -0
- package/LICENSES/payload.txt +22 -0
- package/LICENSES/ponytail.txt +21 -0
- package/dist/asas/actor.d.ts +96 -0
- package/dist/asas/actor.js +114 -0
- package/dist/asas/build.d.ts +231 -0
- package/dist/asas/build.js +787 -0
- package/dist/asas/cli/mithaq.d.ts +2 -0
- package/dist/asas/cli/mithaq.js +145 -0
- package/dist/asas/cli/upgrade.d.ts +20 -0
- package/dist/asas/cli/upgrade.js +147 -0
- package/dist/asas/http/fetch.d.ts +23 -0
- package/dist/asas/http/fetch.js +115 -0
- package/dist/asas/http/orpc.d.ts +43 -0
- package/dist/asas/http/orpc.js +135 -0
- package/dist/asas/runner/bulk.d.ts +20 -0
- package/dist/asas/runner/bulk.js +191 -0
- package/dist/asas/runner/errors.d.ts +60 -0
- package/dist/asas/runner/errors.js +59 -0
- package/dist/asas/runner/read.d.ts +68 -0
- package/dist/asas/runner/read.js +579 -0
- package/dist/asas/runner/runtime.d.ts +21 -0
- package/dist/asas/runner/runtime.js +8 -0
- package/dist/asas/runner/steps.d.ts +400 -0
- package/dist/asas/runner/steps.js +271 -0
- package/dist/asas/runner/tenant.d.ts +54 -0
- package/dist/asas/runner/tenant.js +213 -0
- package/dist/asas/runner/write.d.ts +444 -0
- package/dist/asas/runner/write.js +791 -0
- package/dist/auth/better-auth/access.d.ts +15 -0
- package/dist/auth/better-auth/access.js +71 -0
- package/dist/auth/better-auth/generate.d.ts +29 -0
- package/dist/auth/better-auth/generate.js +310 -0
- package/dist/auth/better-auth/index.d.ts +92 -0
- package/dist/auth/better-auth/index.js +81 -0
- package/dist/auth/better-auth/tenant.d.ts +18 -0
- package/dist/auth/better-auth/tenant.js +26 -0
- package/dist/auth/local/index.d.ts +285 -0
- package/dist/auth/local/index.js +736 -0
- package/dist/base/decimal.d.ts +9 -0
- package/dist/base/decimal.js +16 -0
- package/dist/base/fields.d.ts +123 -0
- package/dist/base/fields.js +51 -0
- package/dist/base/index.d.ts +46 -0
- package/dist/base/index.js +5 -0
- package/dist/base/kinds.d.ts +54 -0
- package/dist/base/kinds.js +78 -0
- package/dist/base/rules.d.ts +73 -0
- package/dist/base/rules.js +80 -0
- package/dist/drizzle/index.d.ts +438 -0
- package/dist/drizzle/index.js +832 -0
- package/dist/drizzle/kinds.d.ts +27 -0
- package/dist/drizzle/kinds.js +41 -0
- package/dist/drizzle/migrate.d.ts +133 -0
- package/dist/drizzle/migrate.js +1285 -0
- package/dist/drizzle/proxy/index.d.ts +1 -0
- package/dist/drizzle/proxy/index.js +1 -0
- package/dist/drizzle/proxy/node-postgres.d.ts +1 -0
- package/dist/drizzle/proxy/node-postgres.js +1 -0
- package/dist/drizzle/proxy/pg-core.d.ts +1 -0
- package/dist/drizzle/proxy/pg-core.js +1 -0
- package/dist/drizzle/rules.d.ts +336 -0
- package/dist/drizzle/rules.js +44 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +36 -0
- package/dist/mithaq/define.d.ts +288 -0
- package/dist/mithaq/define.js +43 -0
- package/dist/mithaq/generate-messages.d.ts +38 -0
- package/dist/mithaq/generate-messages.js +234 -0
- package/dist/mithaq/language.d.ts +335 -0
- package/dist/mithaq/language.js +76 -0
- package/dist/mithaq/lock.d.ts +71 -0
- package/dist/mithaq/lock.js +329 -0
- package/dist/mithaq/messages.d.ts +55 -0
- package/dist/mithaq/messages.js +106 -0
- package/dist/mithaq/refusal.d.ts +6 -0
- package/dist/mithaq/refusal.js +6 -0
- package/dist/mithaq/resolve.d.ts +10 -0
- package/dist/mithaq/resolve.js +954 -0
- package/dist/mithaq/rows.d.ts +87 -0
- package/dist/mithaq/rows.js +174 -0
- package/dist/mithaq/rukn.d.ts +72 -0
- package/dist/mithaq/rukn.js +15 -0
- package/dist/mithaq/standard-schema.d.ts +38 -0
- package/dist/mithaq/standard-schema.js +2 -0
- package/dist/mithaq/types.d.ts +358 -0
- package/dist/mithaq/types.js +15 -0
- package/dist/mithaq/user.d.ts +19 -0
- package/dist/mithaq/user.js +16 -0
- package/dist/mithaq/where.d.ts +32 -0
- package/dist/mithaq/where.js +87 -0
- package/dist/rbac/index.d.ts +105 -0
- package/dist/rbac/index.js +182 -0
- package/dist/rbac/steps.d.ts +22 -0
- package/dist/rbac/steps.js +161 -0
- package/dist/rich-text/index.d.ts +27 -0
- package/dist/rich-text/index.js +58 -0
- package/dist/rich-text/rich-text.d.ts +159 -0
- package/dist/rich-text/rich-text.js +372 -0
- package/dist/screen/client.d.ts +153 -0
- package/dist/screen/client.js +68 -0
- package/dist/screen/generate.d.ts +14 -0
- package/dist/screen/generate.js +226 -0
- package/dist/screen/index.d.ts +479 -0
- package/dist/screen/index.js +645 -0
- package/dist/screen/react.d.ts +95 -0
- package/dist/screen/react.js +58 -0
- package/dist/screen/studio.d.ts +24 -0
- package/dist/screen/studio.js +68 -0
- package/dist/service/index.d.ts +63 -0
- package/dist/service/index.js +15 -0
- package/dist/storage/allowed-type.d.ts +5 -0
- package/dist/storage/allowed-type.js +7 -0
- package/dist/storage/index.d.ts +43 -0
- package/dist/storage/index.js +44 -0
- package/dist/storage/storage.d.ts +384 -0
- package/dist/storage/storage.js +405 -0
- package/dist/versions/index.d.ts +37 -0
- package/dist/versions/index.js +8 -0
- package/dist/versions/versions.d.ts +104 -0
- package/dist/versions/versions.js +160 -0
- package/dist/zod/index.d.ts +184 -0
- package/dist/zod/index.js +121 -0
- package/dist/zod/kinds.d.ts +34 -0
- package/dist/zod/kinds.js +49 -0
- package/dist/zod/rules.d.ts +117 -0
- package/dist/zod/rules.js +67 -0
- package/package.json +63 -4
- package/r/mithaq-cells.json +20 -0
- package/r/mithaq-controls.json +37 -0
- package/r/mithaq-directional-icon.json +15 -0
- package/r/mithaq-expected-updated-at.json +15 -0
- package/r/mithaq-field-shell.json +18 -0
- package/r/mithaq-filter-operators.json +15 -0
- package/r/mithaq-form.json +33 -0
- package/r/mithaq-list-columns.json +23 -0
- package/r/mithaq-list-filters.json +24 -0
- package/r/mithaq-list-pagination.json +24 -0
- package/r/mithaq-list-query.json +18 -0
- package/r/mithaq-list-selection.json +19 -0
- package/r/mithaq-list.json +33 -0
- package/r/mithaq-nav.json +24 -0
- package/r/mithaq-nodes.json +34 -0
- package/r/mithaq-relationship-cell.json +20 -0
- package/r/mithaq-relationship-control.json +20 -0
- package/r/mithaq-rich-text.json +40 -0
- package/r/mithaq-screens-host.json +96 -0
- package/r/mithaq-storage.json +44 -0
- package/r/mithaq-upload.json +29 -0
- package/r/registry.json +550 -0
- package/README.md +0 -3
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { compareDecimals, decimalDigits } from '../base/decimal.js';
|
|
3
|
+
import { errorOf } from './kinds.js';
|
|
4
|
+
// One copy key per field per rule, with no number in it: the browser already
|
|
5
|
+
// holds the field's rules for any number it shows.
|
|
6
|
+
const keyOf = (field, rule) => field.kayan ? `${field.kayan}.${field.name}.${rule}` : undefined;
|
|
7
|
+
// `required`, `defaultValue` and `defaultNow` come last: an optional shape is a
|
|
8
|
+
// `ZodOptional` with none of the narrowing methods left on it.
|
|
9
|
+
export const rules = {
|
|
10
|
+
/** The database enforces it, and only the database can. */
|
|
11
|
+
unique: ({ shape }) => shape,
|
|
12
|
+
/** Nothing about reading rows faster changes what a write must look like. */
|
|
13
|
+
index: ({ shape }) => shape,
|
|
14
|
+
/** A rule about deleting the other row, and a write is not a delete. */
|
|
15
|
+
onDelete: ({ shape }) => shape,
|
|
16
|
+
// Read where the objects are assembled: a field the client does not supply is
|
|
17
|
+
// absent from that shape, so the strict object refuses a caller that sends it.
|
|
18
|
+
providedBy: ({ shape }) => shape,
|
|
19
|
+
minLength: ({ shape, value: length, field }) => shape.min(length, { error: keyOf(field, 'minLength') }),
|
|
20
|
+
maxLength: ({ shape, value: length, field }) => shape.max(length, { error: keyOf(field, 'maxLength') }),
|
|
21
|
+
pattern: ({ shape, value: expression, field }) => shape.regex(expression, { error: keyOf(field, 'pattern') }),
|
|
22
|
+
// Counted as `numeric(precision, scale)` counts, so Postgres never rounds a value or refuses it as a 500.
|
|
23
|
+
precision: ({ shape, value: precision, field }) => shape.refine((held) => {
|
|
24
|
+
const { whole, fraction } = decimalDigits(String(held));
|
|
25
|
+
return whole + fraction <= precision && whole <= precision - Number(field.rules?.scale);
|
|
26
|
+
}, { error: keyOf(field, 'precision') }),
|
|
27
|
+
scale: ({ shape, value: scale, field }) => shape.refine((held) => decimalDigits(String(held)).fraction <= scale, { error: keyOf(field, 'scale') }),
|
|
28
|
+
// `min` on a string shape is a length, so an ISO day gets a check of its own.
|
|
29
|
+
min: ({ shape, value, field }) => typeof value === 'number'
|
|
30
|
+
? shape.min(value, { error: keyOf(field, 'min') })
|
|
31
|
+
: shape.refine((held) => (field.kind === 'decimal' ? compareDecimals(String(held), value) >= 0 : String(held) >= value), {
|
|
32
|
+
error: keyOf(field, 'min') ?? `Too small: expected value to be >=${value}`,
|
|
33
|
+
}),
|
|
34
|
+
max: ({ shape, value, field }) => typeof value === 'number'
|
|
35
|
+
? shape.max(value, { error: keyOf(field, 'max') })
|
|
36
|
+
: shape.refine((held) => (field.kind === 'decimal' ? compareDecimals(String(held), value) <= 0 : String(held) <= value), {
|
|
37
|
+
error: keyOf(field, 'max') ?? `Too big: expected value to be <=${value}`,
|
|
38
|
+
}),
|
|
39
|
+
// An empty list passes the floor unless the field is `required`.
|
|
40
|
+
minRows: ({ shape, value: rows, field }) => shape.refine((held) => held.length >= rows || (!field.rules?.required && !held.length), {
|
|
41
|
+
error: keyOf(field, 'minRows') ?? `Too small: expected array to have >=${rows} items`,
|
|
42
|
+
}),
|
|
43
|
+
maxRows: ({ shape, value: rows, field }) => shape.max(rows, { error: keyOf(field, 'maxRows') }),
|
|
44
|
+
// Piped in front of what the kind and earlier rules built, so `maxLength` and
|
|
45
|
+
// `z.email()` judge the trimmed value; before `localized`, so each locale is trimmed.
|
|
46
|
+
trim: ({ shape, value: on, field }) => (on ? z.string({ error: errorOf(field) }).trim().pipe(shape) : shape),
|
|
47
|
+
uppercase: ({ shape, value: on, field }) => (on ? z.string({ error: errorOf(field) }).toUpperCase().pipe(shape) : shape),
|
|
48
|
+
lowercase: ({ shape, value: on, field }) => (on ? z.string({ error: errorOf(field) }).toLowerCase().pipe(shape) : shape),
|
|
49
|
+
// After every narrowing rule and before `required`: rules apply to each locale's
|
|
50
|
+
// value, and a field nobody wrote is one null rather than an object of them.
|
|
51
|
+
localized: ({ shape, value: on, field }) => on ? z.strictObject(Object.fromEntries(field.locales.map((locale) => [locale, shape]))) : shape,
|
|
52
|
+
// A required text refuses the empty string too. A required `textArray` may be
|
|
53
|
+
// empty; `minRows: 1` refuses that.
|
|
54
|
+
required: ({ shape, value: on, field }) => field.name === 'id'
|
|
55
|
+
? shape
|
|
56
|
+
: !on
|
|
57
|
+
? shape.nullable().optional()
|
|
58
|
+
: field.kind === 'text'
|
|
59
|
+
? shape.refine((held) => !(field.rules?.localized ? Object.values(held) : [held]).includes(''), { error: keyOf(field, 'required') })
|
|
60
|
+
: shape,
|
|
61
|
+
// Optional, not nullable: the runner fills every default on every write that makes or changes a row.
|
|
62
|
+
defaultValue: ({ shape }) => shape.optional(),
|
|
63
|
+
/** The moment the defaults step writes, which leaves the key optional as a literal does. */
|
|
64
|
+
defaultNow: ({ shape, value: on }) => (on ? shape.optional() : shape),
|
|
65
|
+
/** Filled on update by the defaults step, and never client-supplied there, so nothing to shape. */
|
|
66
|
+
nowOnUpdate: ({ shape }) => shape,
|
|
67
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drhomet/mithaq",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./dist/index.js",
|
|
8
|
+
"./generate-auth": "./dist/auth/better-auth/generate.js",
|
|
9
|
+
"./generate-messages": "./dist/mithaq/generate-messages.js",
|
|
10
|
+
"./generate-screens": "./dist/screen/generate.js",
|
|
11
|
+
"./upgrade": "./dist/asas/cli/upgrade.js",
|
|
12
|
+
"./admin": "./dist/screen/client.js",
|
|
13
|
+
"./admin/react": "./dist/screen/react.js",
|
|
14
|
+
"./orpc": "./dist/asas/http/orpc.js",
|
|
15
|
+
"./rich-text": "./dist/rich-text/rich-text.js",
|
|
16
|
+
"./drizzle": "./dist/drizzle/proxy/index.js",
|
|
17
|
+
"./drizzle/pg-core": "./dist/drizzle/proxy/pg-core.js",
|
|
18
|
+
"./drizzle/node-postgres": "./dist/drizzle/proxy/node-postgres.js"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"mithaq": "dist/asas/cli/mithaq.js"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@babel/helper-validator-identifier": "^8.0.4",
|
|
25
|
+
"@lexical/extension": "0.52.0",
|
|
26
|
+
"@lexical/link": "0.52.0",
|
|
27
|
+
"@lexical/list": "0.52.0",
|
|
28
|
+
"@lexical/rich-text": "0.52.0",
|
|
29
|
+
"drizzle-kit": "1.0.0-rc.4",
|
|
30
|
+
"drizzle-orm": "1.0.0-rc.4",
|
|
31
|
+
"jose": "6.2.12",
|
|
32
|
+
"lexical": "0.52.0",
|
|
33
|
+
"pg": "^8.16.3",
|
|
34
|
+
"zod": "^4.6.5"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@orpc/openapi": "^1.15.1",
|
|
38
|
+
"@orpc/server": "^1.15.1",
|
|
39
|
+
"better-auth": "1.7.4",
|
|
40
|
+
"react": "^19.3.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependenciesMeta": {
|
|
43
|
+
"@orpc/openapi": {
|
|
44
|
+
"optional": true
|
|
45
|
+
},
|
|
46
|
+
"@orpc/server": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"react": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "git+https://github.com/drhomet/mithaq.git",
|
|
56
|
+
"directory": "packages/mithaq"
|
|
57
|
+
},
|
|
58
|
+
"homepage": "https://github.com/drhomet/mithaq#readme",
|
|
59
|
+
"bugs": {
|
|
60
|
+
"url": "https://github.com/drhomet/mithaq/issues"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=24"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "mithaq-cells",
|
|
4
|
+
"title": "Mithaq cells",
|
|
5
|
+
"description": "One table cell per shipped kind, resolved through the same chain a control is.",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"@mithaq/mithaq-controls",
|
|
8
|
+
"@mithaq/mithaq-relationship-cell",
|
|
9
|
+
"@mithaq/mithaq-upload"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "registry/cells.tsx",
|
|
14
|
+
"content": "import { useSyncExternalStore, type ReactNode } from 'react'\nimport type { ClientField, ClientKayan } from 'mithaq/admin'\nimport { formatDate, listed, noValue, type Translate } from './controls.tsx'\nimport { RelationshipCell, type RelationshipTitles } from './relationship-cell.tsx'\nimport { UploadCell } from './upload.tsx'\n\n// The server does not know the viewer's zone, so it and the hydrating render draw the day in UTC\n// and the mounted client swaps in the moment; drawing both in a zone would be a hydration mismatch.\nfunction Moment({ value, locale }: { value: unknown; locale: string }): ReactNode {\n const mounted = useSyncExternalStore(() => () => {}, () => true, () => false)\n const text = formatDate({ value, locale, day: !mounted })\n return text === undefined ? plain(value) : <time dateTime={new Date(value as string).toISOString()} suppressHydrationWarning>{text}</time>\n}\n\n/** What every cell is handed. The locale is the browser's; `t` is here because a cell that renders a word rather than the value is rendering the project's copy. */\nexport type CellProps = {\n kayan: ClientKayan\n field: ClientField\n value: unknown\n t: Translate\n locale: string\n /**\n * The titles of the rows the relationship columns point at, read once per page. Absent on a\n * server render, where every relationship cell reads as loading; no cell shows the id it holds.\n */\n titles?: RelationshipTitles\n}\n\nconst plain = (value: unknown) => (value === null || value === undefined ? '' : String(value))\n\nconst worded = (field: ClientField, value: unknown, locale: string) => {\n const held = field.rules.localized === true && value && typeof value === 'object' ? (value as Record<string, unknown>)[locale] : value\n return field.options?.find((one) => one.value === held)?.label[locale] ?? plain(held)\n}\n\n/** The text a reader sees in a rich text value, a block to a line. Lives here, not beside the editor, so lists read it without loading Lexical. */\nexport const richTextPlainText = (node: unknown, depth = 64): string => {\n // A stored value nested past the bound ends in an ellipsis rather than overflowing the stack.\n if (depth <= 0) return '…'\n if (!node || typeof node !== 'object') return ''\n const held = node as { root?: unknown; type?: unknown; text?: unknown; children?: unknown }\n if (held.root) return richTextPlainText(held.root, depth - 1)\n if (held.type === 'linebreak') return '\\n'\n const children: unknown[] = Array.isArray(held.children) ? held.children : []\n const inner = children.map((child) => richTextPlainText(child, depth - 1)).join(children.some((child) => typeof (child as { text?: unknown } | null)?.text === 'string') ? '' : '\\n')\n return (typeof held.text === 'string' ? held.text : '') + inner\n}\n\n/** The blocks of a rich text value: its root's children, or the value itself where it holds none. */\nexport const blocksOf = (value: unknown): unknown[] => {\n const root = (value as { root?: unknown } | null)?.root ?? value\n return Array.isArray((root as { children?: unknown } | null)?.children) ? (root as { children: unknown[] }).children : [root]\n}\n\nconst lineLength = 120\n\n/** The words of a rich text value's first non-empty block on one line, cut at 120 characters with an ellipsis. */\nexport const richTextLine = (value: unknown): string => {\n const first = blocksOf(value).map((block) => richTextPlainText(block).replace(/\\s+/g, ' ').trim()).find(Boolean) ?? ''\n const characters = Array.from(first)\n return characters.length > lineLength ? `${characters.slice(0, lineLength).join('').trimEnd()}…` : characters.join('')\n}\n\n/** One cell per shipped kind, keyed by the kind's own name. */\nexport const cells: Record<string, (props: CellProps) => ReactNode> = {\n text: ({ field, value, locale }) => worded(field, value, locale),\n email: ({ value }) =>\n value ? (\n <a href={`mailto:${String(value)}`} className=\"underline underline-offset-2\">\n {String(value)}\n </a>\n ) : (\n ''\n ),\n number: ({ value, locale }) => (typeof value === 'number' ? value.toLocaleString(locale) : plain(value)),\n integer: (props) => cells.number!(props),\n // Exact text: a JS double would lose digits.\n decimal: ({ field, value, locale }) => worded(field, value, locale),\n // The glyph is aria-hidden, so the sr-only word is what a screen reader gets. The words are keyed per field because `true` means different things on `archived` and `published`.\n checkbox: ({ kayan, field, value, t }) => (\n <>\n <span aria-hidden className=\"tabular-nums\">\n {value === true ? '✓' : value === false ? '✗' : '—'}\n </span>\n <span className=\"sr-only\">\n {value === true\n ? (t(`${kayan.name}.${field.name}.true`) ?? t('admin.yes', 'Yes') ?? 'Yes')\n : (t(`${kayan.name}.${field.name}.false`) ?? t('admin.no', 'No') ?? 'No')}\n </span>\n </>\n ),\n datetime: ({ value, locale }) => <Moment value={value} locale={locale} />,\n date: ({ value, locale }) => formatDate({ value, locale, day: true }) ?? plain(value),\n textArray: ({ field, value, locale }) => (Array.isArray(value) ? listed(value.map((one) => worded(field, one, locale)), locale) : plain(value)),\n uuid: ({ value }) => <span className=\"font-mono text-xs\">{plain(value)}</span>,\n relationship: RelationshipCell,\n upload: UploadCell,\n // Nothing where the read carried no join: an absent key means the read did not ask, an empty array means no children, and a count of zero would say both.\n join: ({ value, locale }) => (Array.isArray(value) ? value.length.toLocaleString(locale) : ''),\n // min-w-48 keeps a phone-width table from squeezing the words to one per line.\n richText: ({ value }) => {\n const line = richTextLine(value)\n return line ? <span dir=\"auto\" className=\"line-clamp-2 min-w-48 whitespace-normal\">{line}</span> : noValue\n },\n}\n// No prototype, so a kind named `constructor` never resolves to an inherited property.\nObject.setPrototypeOf(cells, null)\n\n/** The cell for a field, first match wins: the list column's named cell, the field's own cell, the kind's cell, then plain text. */\nexport const cellFor = (field: ClientField, column?: string): ((props: CellProps) => ReactNode) =>\n cells[column ?? ''] ?? cells[field.cell] ?? cells[field.kind] ?? cells.text!\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 95340e7fd65595abe95ca51f11ac4b6f98a69775637a46591d6aa053f6aa5f27\n",
|
|
15
|
+
"type": "registry:component",
|
|
16
|
+
"target": "@components/mithaq/cells.tsx"
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"type": "registry:component"
|
|
20
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "mithaq-controls",
|
|
4
|
+
"title": "Mithaq controls",
|
|
5
|
+
"description": "One control per shipped kind, the copy chain a label is resolved through, the contract every control is written to, and the reader that turns a form's held values into an operation's input.",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"button",
|
|
8
|
+
"button-group",
|
|
9
|
+
"calendar",
|
|
10
|
+
"direction",
|
|
11
|
+
"popover",
|
|
12
|
+
"combobox",
|
|
13
|
+
"input-otp",
|
|
14
|
+
"radio-group",
|
|
15
|
+
"select",
|
|
16
|
+
"slider",
|
|
17
|
+
"switch",
|
|
18
|
+
"toggle-group",
|
|
19
|
+
"checkbox",
|
|
20
|
+
"input",
|
|
21
|
+
"input-group",
|
|
22
|
+
"textarea",
|
|
23
|
+
"toggle",
|
|
24
|
+
"@mithaq/mithaq-relationship-control",
|
|
25
|
+
"@mithaq/mithaq-rich-text",
|
|
26
|
+
"@mithaq/mithaq-upload"
|
|
27
|
+
],
|
|
28
|
+
"files": [
|
|
29
|
+
{
|
|
30
|
+
"path": "registry/controls.tsx",
|
|
31
|
+
"content": "import { useState, useSyncExternalStore, type ChangeEvent, type ReactElement } from 'react'\nimport { Calendar as CalendarIcon } from 'lucide-react'\nimport type { AccessReport, ClientField, ClientKayan } from 'mithaq/admin'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { Switch } from '@/components/ui/switch'\nimport { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport { Slider } from '@/components/ui/slider'\nimport { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'\nimport { REGEXP_ONLY_DIGITS } from 'input-otp'\nimport { Combobox, ComboboxContent, ComboboxInput, ComboboxItem, ComboboxList } from '@/components/ui/combobox'\nimport { Input } from '@/components/ui/input'\nimport { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '@/components/ui/input-group'\nimport { Textarea } from '@/components/ui/textarea'\nimport { Toggle } from '@/components/ui/toggle'\nimport { Button } from '@/components/ui/button'\nimport { ButtonGroup } from '@/components/ui/button-group'\nimport { Calendar } from '@/components/ui/calendar'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'\nimport { useDirection } from '@/components/ui/direction'\nimport { ar, enUS } from 'react-day-picker/locale'\nimport { RelationshipControl } from './relationship-control.tsx'\nimport { RichTextControl } from './rich-text.tsx'\nimport { UploadControl } from './upload.tsx'\n\n/**\n * How a string of copy is resolved. A project passes one function over its own committed copy files, whose keys are the flat `<scope>.<key>` space `mithaq generate messages` writes. A missing key degrades to `fallback`.\n */\nexport type Translate = (key: string, fallback?: string) => string | undefined\n\n/** What every screen is handed to resolve copy with. */\nexport type Copy = {\n t: Translate\n locale: string\n /** Every locale the host reads in, which a localized field draws one box for. */\n locales?: readonly string[]\n}\n\n/**\n * A field's label: the record's string for the locale, else the project's copy under `<kayan>.<field>`, else the field name. A label written as a function crosses the projection as nothing, so the key is rebuilt from the two names.\n */\nexport const labelFor = ({ kayan, field, copy: { t, locale } }: { kayan: ClientKayan; field: ClientField; copy: Copy }): string =>\n (typeof field.label === 'object' ? field.label[locale] : undefined)\n ?? t(`${kayan.name}.${field.name}`, field.name)\n ?? field.name\n\n/** The same chain for a field's description, which is optional everywhere. */\nexport const descriptionFor = ({ kayan, field, copy: { t, locale } }: { kayan: ClientKayan; field: ClientField; copy: Copy }): string | undefined =>\n (typeof field.description === 'object' ? field.description[locale] : undefined)\n ?? t(`${kayan.name}.${field.name}.description`)\n\n/**\n * The same chain for a field's placeholder, the hint a text-like control shows while empty. A kind whose control has nowhere to draw a hint ignores it.\n */\nexport const placeholderFor = ({ kayan, field, copy: { t, locale } }: { kayan: ClientKayan; field: ClientField; copy: Copy }): string | undefined =>\n (typeof field.placeholder === 'object' ? field.placeholder[locale] : undefined)\n ?? t(`${kayan.name}.${field.name}.placeholder`)\n\n/**\n * What a kayan is for, in one sentence, resolved like a field's description. `undefined` when the kayan declares none, so a heading is never repeated as its own subtitle.\n */\nexport const descriptionOf = (kayan: ClientKayan, { t, locale }: Copy): string | undefined =>\n (typeof kayan.description === 'object' ? kayan.description[locale] : undefined)\n ?? t(`${kayan.name}.description`)\n\n/**\n * The words on a page's button for a kayan's operation: the page's label for the locale, else the project's copy under `<kayan>.operations.<name>`, else the operation's name.\n */\nexport const operationLabelFor = ({\n kayan,\n operation,\n copy: { t, locale },\n}: {\n kayan: ClientKayan\n operation: { name: string; label?: Readonly<Record<string, string>> }\n copy: Copy\n}): string =>\n (typeof operation.label === 'object' ? operation.label[locale] : undefined)\n ?? t(`${kayan.name}.operations.${operation.name}`, operation.name)\n ?? operation.name\n\n/**\n * The words on one box of an operation's dialog, under `<kayan>.operations.<name>.<field>`, so an operation's field and a kayan's field can share a name and never a key.\n */\nexport const operationFieldLabelFor = ({\n kayan,\n operation,\n field,\n copy: { t, locale },\n}: {\n kayan: ClientKayan\n operation: { name: string }\n field: ClientField\n copy: Copy\n}): string =>\n (typeof field.label === 'object' ? field.label[locale] : undefined)\n ?? t(`${kayan.name}.operations.${operation.name}.${field.name}`, field.name)\n ?? field.name\n\n/**\n * What a kayan is called: the `singular` or `plural` key of its copy scope, else its name. Takes the name alone, so a shell that hands its browser only a kayan's name can still name it.\n */\nexport const titleOf = ({ kayan, copy: { t }, many = false }: { kayan: Pick<ClientKayan, 'name'>; copy: Copy; many?: boolean }): string =>\n t(`${kayan.name}.${many ? 'plural' : 'singular'}`, kayan.name) ?? kayan.name\n\n/**\n * What a create page is titled: the kayan's `new` key, else \"Create {kind}\" with its singular.\n */\nexport const newTitleOf = ({ kayan, copy }: { kayan: Pick<ClientKayan, 'name'>; copy: Copy }): string =>\n copy.t(`${kayan.name}.new`) ?? (copy.t('admin.createTitle', 'Create {kind}') ?? 'Create {kind}').replace('{kind}', () => titleOf({ kayan, copy }))\n\n/**\n * Several values as one phrase in the viewer's own language, by `Intl.ListFormat`. The type is `unit`, so English joins with commas alone and Arabic with its own conjunction.\n */\nexport const listed = (values: readonly string[], locale: string) => new Intl.ListFormat(locale, { type: 'unit' }).format(values)\n\n/**\n * What a cell draws for a value nobody set: a dash hidden from screen readers, since a cell read as nothing says the same.\n */\nexport const noValue = <span aria-hidden=\"true\">—</span>\n\n/**\n * A sentence that counts, in the plural form the locale takes for that count: `key` plus `_one`, `_two`, `_few`, `_many` or `_other` as `Intl.PluralRules` names it, falling back to `_other`. `{count}` is the number in the locale's digits.\n */\nexport const counted = ({ t, locale, key, count, fallback }: { t: Translate; locale: string; key: string; count: number; fallback: string }): string =>\n (t(`${key}_${new Intl.PluralRules(locale || undefined).select(count)}`) ?? t(`${key}_other`, fallback) ?? fallback).replace('{count}', () => count.toLocaleString(locale || undefined))\n\n/**\n * A moment, or a calendar day where `day` is set, in the one format every cell draws it in for this locale; `undefined` for anything that is not a date. A day is drawn in UTC, so a `yyyy-mm-dd` string never reads as the day before west of Greenwich. The time of day is on the 24-hour clock.\n */\nexport const formatDate = ({ value, locale, day = false }: { value: unknown; locale: string; day?: boolean }): string | undefined => {\n const date = value instanceof Date ? value : typeof value === 'string' ? new Date(value) : undefined\n if (!date || Number.isNaN(date.getTime())) return undefined\n return new Intl.DateTimeFormat(locale || undefined, day ? { dateStyle: 'long', timeZone: 'UTC' } : { dateStyle: 'long', timeStyle: 'short', hourCycle: 'h23' }).format(date)\n}\n\n/**\n * The key a refusal carries, as `data: { key, params }`, which a client resolves against its own copy. Anything that is not a refusal carries no key.\n */\nexport const refusalKeyOf = (error: unknown): string | undefined => {\n const key = (error as { data?: { key?: unknown } } | null | undefined)?.data?.key\n return typeof key === 'string' ? key : undefined\n}\n\n/**\n * Which locales of each localized field a refusal names, off the issue paths: `title.ar` names the Arabic box of `title`. A path of one segment names no locale.\n */\nexport const localesOf = (error: unknown): Readonly<Record<string, readonly string[]>> => {\n const issues = (error as { data?: { params?: { issues?: unknown } } } | null | undefined)?.data?.params?.issues\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 }[]) {\n const [name, locale] = typeof issue?.path === 'string' ? issue.path.split('.') : []\n if (name && locale) (byField[name] ??= []).push(locale)\n }\n return byField\n}\n\n/**\n * One page, as much of the managed reader's answer as a control reads. Every list `Page` is assignable to it.\n */\nexport type FoundPage = {\n docs: readonly Record<string, unknown>[]\n hasNextPage: boolean\n nextPage: number | null\n}\n\n/**\n * The typed client, as much of it as a control reads: one kayan's `find`, looked up by the kayan's name. An app hands an adapter over its own client, because a control builds its argument as a record and no typing survives that boundary.\n */\nexport type FindApi = Readonly<\n Record<\n string,\n | {\n find: (input: Record<string, unknown>) => Promise<FoundPage>\n }\n | undefined\n >\n>\n\n/**\n * What every control is handed. A control is controlled over one value and holds no form state; the shell draws its label, description and refusal. It gets no `path`, `validate` or `locale`: the server validates, a field's name is its path, and `t` closes over the locale.\n */\nexport type ControlProps = {\n value: unknown\n /** Current values of the form that owns this control. */\n values: Readonly<Record<string, unknown>>\n /** Write another field through the same form, subject to its write access. */\n setValue: (name: string, value: unknown) => void\n /**\n * What the control reports a change through. A value cleared back to nothing is `null`, not left out, which lets a form empty an optional field.\n */\n onChange: (value: unknown) => void\n field: ClientField\n kayan: ClientKayan\n /** Every kayan of the build, so a relationship control can find its target by `field.relationTo`. */\n client?: readonly ClientKayan[]\n readOnly: boolean\n /** A refusal for this field, already resolved through the project's copy. */\n error?: string\n /**\n * The hint an empty control shows, already resolved through the project's copy. A control drawn for something other than a row of this kayan, such as the filter panel's value box, gets none.\n */\n placeholder?: string\n t: Translate\n /** The `id` the label points at, unique within the form. */\n id: string\n /**\n * The id of the `<form>` this control saves through, named as a native box's `form` attribute: a leaf outside the form element saves on Enter only through it. A control drawn for no row gets none.\n */\n form?: string\n /** The typed client, for a control that reads a second kayan. */\n api?: FindApi\n /**\n * The ids of the field's description and refusal, for the control's own `aria-describedby`, which belongs on the focusable element the control renders.\n */\n describedBy?: string\n /**\n * The viewer's locale, for the words a control reads off the projection rather than through `t`: an option's label and a relationship's title.\n */\n locale?: string\n /**\n * Every locale the host reads in, for a control that asks a localized field of its own, one box per locale.\n */\n locales?: readonly string[]\n}\n\n\n\n/**\n * What a control reports a change through: a read-only control reports none, which still holds where a control is not a native input.\n */\nexport const changeOf = ({ onChange, readOnly }: ControlProps) => (value: unknown) => {\n if (!readOnly) onChange(value)\n}\n\nconst sliderKeys = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'])\n\n/**\n * A value as an input's text; a missing value is empty. A `Date` is its ISO string, because the fetch plug does not read `String(date)` back as a date. An invalid `Date`, which has no ISO string, is drawn as `String` does.\n */\nexport const asText = (value: unknown) =>\n value === null || value === undefined ? '' : value instanceof Date && !Number.isNaN(value.getTime()) ? value.toISOString() : String(value)\n\nconst cleared = (text: string) => (text === '' ? null : text)\n\n/**\n * A moment as the `yyyy-mm-ddThh:mm` of the reader's own wall clock. It is shifted by the reader's offset before it is sliced, because `toISOString` alone would draw a London evening as the next morning in Riyadh.\n */\nexport const asMoment = (value: unknown) => {\n const date = value instanceof Date ? value : typeof value === 'string' || typeof value === 'number' ? new Date(value) : undefined\n if (!date || Number.isNaN(date.getTime())) return ''\n return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16)\n}\n\nconst typed = (\n type: string,\n format: (value: unknown) => string = asText,\n parse: (text: string) => unknown = cleared,\n) =>\n function TypedControl(props: ControlProps): ReactElement {\n const { describedBy, error, field, form, id, locale, placeholder, readOnly, value } = props\n const change = changeOf(props)\n const input = {\n id,\n form,\n name: field.name,\n type,\n className: 'max-sm:h-11',\n dir: type === 'text' && field.kind !== 'decimal' ? 'auto' : 'ltr',\n step: field.kind === 'integer' ? '1' : undefined,\n inputMode: field.kind === 'decimal' ? ('decimal' as const) : undefined,\n value: format(value),\n onChange: (event: ChangeEvent<HTMLInputElement>) => change(parse(event.target.value)),\n disabled: readOnly,\n readOnly,\n placeholder,\n 'aria-describedby': describedBy,\n 'aria-invalid': error ? true : undefined,\n 'aria-required': ariaRequired(field),\n }\n const prefix = locale ? field.prefix?.[locale] : undefined\n const suffix = locale ? field.suffix?.[locale] : undefined\n if (prefix === undefined && suffix === undefined) return <Input {...input} />\n return (\n <InputGroup className=\"max-sm:h-11\">\n {prefix !== undefined ? <InputGroupAddon data-affix=\"prefix\" className={`${affix} rounded-s-[inherit] border-e`}><InputGroupText className=\"text-foreground\">{prefix}</InputGroupText></InputGroupAddon> : null}\n <InputGroupInput {...input} />\n {suffix !== undefined ? <InputGroupAddon data-affix=\"suffix\" align=\"inline-end\" className={`${affix} rounded-e-[inherit] border-s`}><InputGroupText className=\"text-foreground\">{suffix}</InputGroupText></InputGroupAddon> : null}\n </InputGroup>\n )\n }\n\n/**\n * The input group classes that make a combobox's box and its addon buttons 44 pixels on a phone, because the shadcn primitive sizes them at 36 and 24.\n */\nexport const comboboxPhone = 'max-sm:h-11 max-sm:[&_[data-slot=input-group-addon]]:py-0 max-sm:[&_[data-slot=input-group-button]]:size-11 max-sm:[&_[data-slot=combobox-clear]]:size-11 max-sm:[&_[data-slot=input-group-control]]:h-11'\n\n/**\n * The one size every checkbox of a screen is drawn at, so a box in a list, a form and the column picker match.\n */\nexport const checkboxSize = 'size-4'\n\nconst affix = 'bg-muted border-input self-stretch px-3'\n\n/**\n * A `uuid` is always the store's to make, so this reports no change whatever `readOnly` says.\n */\nfunction ReadOnlyControl({ describedBy, field, id, value }: ControlProps): ReactElement {\n return (\n <Input\n id={id}\n name={field.name}\n type=\"text\"\n value={asText(value)}\n readOnly\n disabled\n aria-describedby={describedBy}\n />\n )\n}\n\nconst dayPickerLocales: Record<string, typeof enUS> = { ar, en: enUS }\n\n/**\n * `aria-required` where the field's rules hold `required`, for whatever element of a control takes focus. The browser's `required` is not used, because its bubble is replaced by the form's own check.\n */\nexport const ariaRequired = (field: Pick<ControlProps['field'], 'rules'>): true | undefined => (field.rules.required === true ? true : undefined)\n\nfunction SelectControl(props: ControlProps): ReactElement {\n const options = props.field.options ?? []\n return <Select value={typeof props.value === 'string' ? props.value : null} onValueChange={changeOf(props)} disabled={props.readOnly}>\n <SelectTrigger id={props.id} className=\"w-full max-sm:data-[size=default]:h-11\" aria-describedby={props.describedBy} aria-required={ariaRequired(props.field)}><SelectValue>{options.find((one) => one.value === props.value)?.label[props.locale ?? ''] ?? props.placeholder ?? ''}</SelectValue></SelectTrigger>\n <SelectContent>{options.map((one) => <SelectItem key={one.value} value={one.value}>{one.label[props.locale ?? ''] ?? one.value}</SelectItem>)}</SelectContent>\n </Select>\n}\n\nconst dayOf = (value: unknown): Date | undefined => {\n const [year, month, day] = typeof value === 'string' ? value.split('-').map(Number) : []\n return year && month && day ? new Date(year, month - 1, day) : undefined\n}\nconst isoDay = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`\n\nfunction DatePicker(props: ControlProps): ReactElement {\n const { describedBy, error, field, form, id, locale = '', placeholder, readOnly, t, value } = props\n const change = changeOf(props)\n const dir = useDirection()\n const [open, setOpen] = useState(false)\n const moment = field.kind === 'datetime'\n const clock = asMoment(value)\n const time = clock.slice(11)\n // A moment is held only when both its day and its time are set: a day picked before any time is typed stays here and writes nothing.\n const [picked, setPicked] = useState<Date | undefined>()\n // False only while hydrating, so the server and the first client render agree on a moment's day (UTC); the viewer's own day follows once mounted.\n const mounted = useSyncExternalStore(() => () => {}, () => true, () => false)\n const held = moment ? (clock ? new Date(clock) : picked) : dayOf(value)\n const at = (day: Date, text: string) => {\n const [hours = 0, minutes = 0] = text.split(':').map(Number)\n return new Date(day.getFullYear(), day.getMonth(), day.getDate(), hours, minutes)\n }\n const label = labelFor({ kayan: props.kayan, field, copy: { t, locale } })\n const shown = held\n ? (moment && clock && !mounted ? formatDate({ value, locale, day: true }) : undefined) ?? new Intl.DateTimeFormat(locale || undefined, { dateStyle: 'long' }).format(held)\n : placeholder ?? t('admin.pickDate', 'Pick a date')\n return (\n // Wraps, so a phone puts the time under the day.\n <div className=\"flex flex-wrap gap-2\">\n <span id={`${id}-name`} className=\"sr-only\">{label}</span>\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger\n id={id}\n disabled={readOnly}\n // The label's `htmlFor` leaves out the day the button shows, so the name is built from both texts on the page.\n aria-labelledby={`${id}-name ${id}-day`}\n aria-describedby={describedBy}\n aria-invalid={error ? true : undefined}\n aria-required={ariaRequired(field)}\n render={<Button type=\"button\" variant=\"outline\" className=\"min-w-32 flex-1 justify-start font-normal max-sm:h-11\" />}\n >\n <CalendarIcon aria-hidden=\"true\" />\n <span id={`${id}-day`} className={held ? 'truncate' : 'text-muted-foreground truncate'}>{shown}</span>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className=\"w-auto p-0\">\n <Calendar\n mode=\"single\"\n autoFocus\n dir={dir}\n locale={dayPickerLocales[locale]}\n selected={held}\n defaultMonth={held}\n onSelect={(day: Date | undefined) => {\n setPicked(time ? undefined : day)\n if (!moment) change(day ? isoDay(day) : null)\n else if (!day) clock && change(null)\n else if (time) change(at(day, time))\n setOpen(false)\n }}\n />\n </PopoverContent>\n </Popover>\n {moment ? (\n <Input\n type=\"time\"\n form={form}\n dir=\"ltr\"\n className=\"w-28 shrink-0 max-sm:h-11\"\n value={time}\n disabled={readOnly || !held}\n readOnly={readOnly}\n aria-label={`${label} ${t('admin.time', 'Time')}`}\n aria-describedby={describedBy}\n aria-invalid={error ? true : undefined}\n aria-required={ariaRequired(field)}\n onChange={(event) => {\n const text = event.target.value\n // A time no clock reads is reported as nothing to write, not as a value that would clear the column.\n if (text !== '' && !/^\\d{2}:\\d{2}/.test(text)) return change(undefined)\n // Clearing the time clears the value and never writes midnight; the day stays on the button.\n if (text === '' || !held) return (setPicked(held), change(null))\n setPicked(undefined)\n change(at(held, text))\n }}\n />\n ) : null}\n </div>\n )\n}\n\n/** One control per shipped kind, keyed by the kind's own name. */\nexport const controls: Record<string, (props: ControlProps) => ReactElement> = {\n combobox: function ComboboxControl(props: ControlProps): ReactElement {\n const options = props.field.options ?? []\n const label = (value: string) => options.find((one) => one.value === value)?.label[props.locale ?? ''] ?? value\n return <Combobox items={options.map((one) => one.value)} value={typeof props.value === 'string' ? props.value : null} onValueChange={changeOf(props)} itemToStringLabel={label} disabled={props.readOnly}>\n <ComboboxInput id={props.id} className={comboboxPhone} placeholder={props.placeholder} clearLabel={props.t('admin.clear', 'Clear')} triggerLabel={props.t('admin.open', 'Open')} aria-describedby={props.describedBy} aria-required={ariaRequired(props.field)} />\n <ComboboxContent><ComboboxList>{options.map((one) => <ComboboxItem key={one.value} value={one.value}>{label(one.value)}</ComboboxItem>)}</ComboboxList></ComboboxContent>\n </Combobox>\n },\n otp: function OtpControl(props: ControlProps): ReactElement {\n const length = typeof props.field.rules.maxLength === 'number' ? props.field.rules.maxLength : 6\n return <InputOTP id={props.id} value={asText(props.value)} onChange={changeOf(props)} maxLength={length} pattern={REGEXP_ONLY_DIGITS} disabled={props.readOnly} aria-describedby={props.describedBy}>\n <InputOTPGroup>{Array.from({ length }, (_, index) => <InputOTPSlot key={index} index={index} />)}</InputOTPGroup>\n </InputOTP>\n },\n slider: function SliderControl(props: ControlProps): ReactElement {\n const min = typeof props.field.rules.min === 'number' ? props.field.rules.min : 0\n const max = typeof props.field.rules.max === 'number' ? props.field.rules.max : 100\n // Unset is drawn with no thumb or fill, not as `min`. The slider reports no change for a value it already holds,\n // so the first press or key reports `min` itself, before the slider moves on.\n const held = typeof props.value === 'number'\n const change = changeOf(props)\n const first = held ? undefined : () => change(min)\n return <Slider\n value={held ? props.value as number : min}\n data-empty={held ? undefined : ''}\n className={held ? undefined : '**:data-[slot=slider-range]:opacity-0 **:data-[slot=slider-thumb]:not-has-focus-visible:opacity-0'}\n getAriaValueText={held ? undefined : () => props.t('admin.unset', 'unset') ?? 'unset'}\n onPointerDownCapture={first}\n onKeyDownCapture={first && ((event) => { if (sliderKeys.has(event.key)) first() })}\n min={min}\n max={max}\n onValueChange={change}\n disabled={props.readOnly}\n aria-label={labelFor({ kayan: props.kayan, field: props.field, copy: { t: props.t, locale: props.locale ?? '' } })}\n aria-describedby={props.describedBy}\n />\n },\n // `nativeSelect` draws the same shadcn select, so two boxes over one kind of choice never differ in their border.\n nativeSelect: SelectControl,\n select: SelectControl,\n toggleGroup: function ToggleGroupControl(props: ControlProps): ReactElement {\n return <ToggleGroup value={typeof props.value === 'string' ? [props.value] : []} onValueChange={(next) => changeOf(props)(next[0] ?? null)} disabled={props.readOnly} aria-describedby={props.describedBy} className=\"flex gap-1\">\n {props.field.options?.map((option) => <ToggleGroupItem key={option.value} value={option.value}>{option.label[props.locale ?? ''] ?? option.value}</ToggleGroupItem>)}\n </ToggleGroup>\n },\n radio: function RadioControl(props: ControlProps): ReactElement {\n return <RadioGroup name={props.field.name} value={typeof props.value === 'string' ? props.value : ''} onValueChange={changeOf(props)} disabled={props.readOnly} aria-describedby={props.describedBy} aria-required={ariaRequired(props.field)} className=\"grid gap-2\">\n {props.field.options?.map((option) => <label key={option.value} className=\"flex items-center gap-2 text-sm\">\n <RadioGroupItem value={option.value} />{option.label[props.locale ?? ''] ?? option.value}\n </label>)}\n </RadioGroup>\n },\n switch: function SwitchControl(props: ControlProps): ReactElement {\n return <Switch id={props.id} name={props.field.name} checked={props.value === true} onCheckedChange={changeOf(props)} disabled={props.readOnly} aria-describedby={props.describedBy} aria-required={ariaRequired(props.field)} />\n },\n text: typed('text'),\n email: typed('email'),\n // The write shape is `z.number()` with no coercion, so a reported string is a 400. `NaN` is reported as `undefined`,\n // which `valuesOf` leaves out of the write, rather than as a value that would clear the column.\n number: typed('number', asText, (text) => {\n if (text === '') return null\n const parsed = Number(text)\n return Number.isNaN(parsed) ? undefined : parsed\n }),\n integer: (props) => controls.number!(props),\n // A text box keeps every digit; a number box reads through a JS double and cannot edit exact money.\n decimal: typed('text'),\n datetime: DatePicker,\n // A calendar day reports the `yyyy-mm-dd` the column holds and never goes through a moment, which would move the day.\n date: DatePicker,\n checkbox: function CheckboxControl(props: ControlProps): ReactElement {\n const { describedBy, field, id, readOnly, value } = props\n const change = changeOf(props)\n // `=== true` because the checked state is `boolean | 'indeterminate'` and only a boolean is a value this column takes.\n return (\n <Checkbox\n id={id}\n name={field.name}\n checked={value === true}\n onCheckedChange={(next) => change(next === true)}\n className={checkboxSize}\n disabled={readOnly}\n aria-describedby={describedBy}\n aria-required={ariaRequired(field)}\n />\n )\n },\n textArray: function TextArrayControl(props: ControlProps): ReactElement {\n const { describedBy, error, field, id, placeholder, readOnly, value } = props\n const change = changeOf(props)\n return (\n <Textarea\n id={id}\n name={field.name}\n rows={3}\n placeholder={placeholder}\n dir=\"auto\"\n value={Array.isArray(value) ? value.map(asText).join('\\n') : asText(value)}\n onChange={(event) => {\n const lines = event.target.value.split('\\n').map((line) => line.trim()).filter(Boolean)\n change(lines.length ? lines : null)\n }}\n disabled={readOnly}\n readOnly={readOnly}\n aria-describedby={describedBy}\n aria-invalid={error ? true : undefined}\n aria-required={ariaRequired(field)}\n />\n )\n },\n uuid: ReadOnlyControl,\n relationship: RelationshipControl,\n upload: UploadControl,\n richText: RichTextControl,\n textarea: function TextareaControl(props: ControlProps): ReactElement {\n const { describedBy, error, field, id, placeholder, readOnly, value } = props\n const change = changeOf(props)\n return <Textarea id={id} name={field.name} rows={4} dir=\"auto\" value={asText(value)} onChange={(event) => change(cleared(event.target.value))} disabled={readOnly} readOnly={readOnly} placeholder={placeholder} aria-describedby={describedBy} aria-invalid={error ? true : undefined} aria-required={ariaRequired(field)} />\n },\n toggle: function ToggleControl(props: ControlProps): ReactElement {\n return <Toggle id={props.id} variant=\"outline\" pressed={props.value === true} onPressedChange={(pressed) => changeOf(props)(pressed)} disabled={props.readOnly} aria-describedby={props.describedBy}>\n {labelFor({ kayan: props.kayan, field: props.field, copy: { t: props.t, locale: props.locale ?? '' } })}\n </Toggle>\n },\n buttonGroup: function ButtonGroupControl(props: ControlProps): ReactElement {\n return <ButtonGroup aria-describedby={props.describedBy}>\n {props.field.options?.map((option) => <Button key={option.value} type=\"button\" variant={props.value === option.value ? 'default' : 'outline'} aria-pressed={props.value === option.value} disabled={props.readOnly} onClick={() => changeOf(props)(option.value)}>\n {option.label[props.locale ?? ''] ?? option.value}\n </Button>)}\n </ButtonGroup>\n },\n checkboxGroup: function CheckboxGroupControl(props: ControlProps): ReactElement {\n const options = props.field.options ?? []\n const held = new Set(Array.isArray(props.value) ? props.value.map(String) : [])\n const change = changeOf(props)\n return <div role=\"group\" id={props.id} aria-describedby={props.describedBy} className=\"grid gap-2\">\n {options.map((option) => <label key={option.value} className=\"flex items-center gap-2 text-sm\">\n <Checkbox className={checkboxSize} checked={held.has(option.value)} disabled={props.readOnly} onCheckedChange={(on) => {\n const next = options.map((one) => one.value).filter((value) => (value === option.value ? on === true : held.has(value)))\n change(next.length ? next : null)\n }} />\n {option.label[props.locale ?? ''] ?? option.value}\n </label>)}\n </div>\n },\n calendar: function CalendarControl(props: ControlProps): ReactElement {\n const held = dayOf(props.value)\n return <Calendar mode=\"single\" locale={dayPickerLocales[props.locale ?? '']} selected={held} defaultMonth={held} disabled={props.readOnly} onSelect={(date: Date | undefined) => changeOf(props)(date ? isoDay(date) : null)} aria-describedby={props.describedBy} />\n },\n}\n// No prototype: a field's `component` is a string off the projection, and a plain object would answer `constructor` or\n// `toString` with a function that is not a control.\nObject.setPrototypeOf(controls, null)\n\n/**\n * Whether a control's label sits beside it rather than above: a checkbox or a switch reads as attached to its words.\n */\nexport const besideLabel = (Control: unknown) => Control === controls.checkbox || Control === controls.switch\n\n/**\n * The control for a field: the one its screen decided, else the kind's own, else a read-only fallback. A name the registry does not carry falls through to the next step.\n */\nexport const controlFor = (field: ClientField): ((props: ControlProps) => ReactElement) =>\n controls[field.component] ?? controls[field.kind] ?? ReadOnlyControl\n\n/**\n * Whether a field is the server's to fill on this operation: a `uuid`, or a field that names a provider other than the client for it. Restates the server's `providerOf` rather than importing it, because this file is copied into a browser bundle that imports nothing from `mithaq` at runtime.\n */\nexport const readOnlyField = (field: ClientField, on: 'create' | 'update'): boolean =>\n field.kind === 'uuid'\n || ((field.rules?.providedBy as { create?: string; update?: string } | undefined)?.[on] ?? 'client') !== 'client'\n\n/**\n * Whether the report lets this actor create a row of a kayan with something to write: the create, and at least one client-provided field they may set. A role that may set only server-filled fields meets no New.\n */\nexport const createsIn = ({ kayan, report }: { kayan: ClientKayan; report: NonNullable<AccessReport['collections']>[string] }): boolean =>\n report?.create === true && kayan.fields.some((field) => !readOnlyField(field, 'create') && report.fields?.[field.name]?.create === true)\n\n/**\n * Whether a read was refused by the viewer's access (a 403) rather than failed, so a cell says the viewer cannot see a row for the first and never for a 500 or a network failure.\n */\nexport const isForbidden = (thrown: unknown): boolean =>\n (thrown as { status?: unknown } | null | undefined)?.status === 403 || refusalKeyOf(thrown)?.startsWith('error.forbidden') === true\n\n/**\n * What a form starts holding: the row's values, one key per field, so a control is controlled from its first render. A field the row does not carry starts `undefined` and stays out of the write until something sets it.\n */\nexport const seedOf = (fields: readonly ClientField[], value?: Record<string, unknown> | null): Record<string, unknown> =>\n Object.fromEntries(\n fields.map((field) => [\n field.name,\n // On a create a checkbox starts `false` and a required text list `[]`, as drawn; an update leaves a missing key\n // unseeded, since it is a column this actor may not read and seeding it would overwrite the row.\n field.kind === 'datetime' && typeof value?.[field.name] === 'string' && !Number.isNaN(new Date(value[field.name] as string).getTime())\n ? new Date(value[field.name] as string)\n : value?.[field.name] ?? (value ? undefined : field.kind === 'checkbox' ? false : field.kind === 'textArray' && field.rules.required === true ? [] : undefined),\n ]),\n )\n\n/**\n * Every field of `fields` the caller may write, as the object an operation takes. `owned` is the form's own answer for who provides a field, handed in so the fields drawn disabled and the fields left out of the write cannot drift apart.\n */\nexport const valuesOf = ({\n fields,\n values,\n owned,\n}: {\n fields: readonly ClientField[]\n values: Record<string, unknown>\n owned: (field: ClientField) => boolean\n}): Record<string, unknown> => {\n const written: Record<string, unknown> = {}\n for (const field of fields) {\n if (owned(field)) continue\n if (values[field.name] !== undefined) written[field.name] = values[field.name]\n }\n return written\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 c8c8412c13f92b549fa31642c5ca78a301fd0809919eb37f35a7a2bd7c0bf474\n",
|
|
32
|
+
"type": "registry:component",
|
|
33
|
+
"target": "@components/mithaq/controls.tsx"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"type": "registry:component"
|
|
37
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "mithaq-directional-icon",
|
|
4
|
+
"title": "Mithaq directional icon",
|
|
5
|
+
"description": "A wrapper that mirrors the icon inside it in a right-to-left page, so the pager's chevrons point the way the page reads. No shadcn registry serves it under a bare name, so this registry ships it.",
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"path": "registry/ui/directional-icon.tsx",
|
|
9
|
+
"content": "import * as React from 'react'\n\nimport { cn } from '@/lib/utils'\n\ntype DirectionalIconProps = {\n children: React.ReactNode\n className?: string\n}\n\nexport function DirectionalIcon({\n children,\n className,\n}: DirectionalIconProps) {\n return (\n <span className={cn('inline-flex shrink-0 rtl:-scale-x-100', className)}>\n {children}\n </span>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 4fedc7b1d6b2b8d33522dc8ea4091ec01ec38321b660e29561820722e59a2086\n",
|
|
10
|
+
"type": "registry:ui",
|
|
11
|
+
"target": "@components/ui/directional-icon.tsx"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"type": "registry:ui"
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "mithaq-expected-updated-at",
|
|
4
|
+
"title": "Mithaq expectedUpdatedAt step",
|
|
5
|
+
"description": "A write step that refuses an update with a 409 when the row's updatedAt is not the instant the caller read, compared against the row the runner locked inside the update's transaction. A kayan adds it by widening its update input with an expectedUpdatedAt and listing the step before the store step.",
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"path": "registry/steps/expected-updated-at.ts",
|
|
9
|
+
"content": "// Refuses an update when the row changed after the caller read it. A kayan opts in by\n// widening its update input and listing the step before the store step:\n//\n// update: {\n// input: ({ update }) => update.extend({ expectedUpdatedAt: z.date() }),\n// steps: ({ steps }) => [expectedUpdatedAt, steps.update],\n// },\n//\n// Use `z.date()`: a string or `null` would not be a moment and would answer a misleading 409.\n// Listing the step without widening the input protects nothing, since the strict update\n// shape refuses the key by name.\n//\n// The compare is against `ctx.dataBefore`, loaded with `lock: 'update'` inside the update's\n// transaction, so on a store that honours the lock a second writer waits and no write lands\n// between the compare and the store step. A store that ignores the lock lets both through.\n//\n// updateMany runs this once per row with no instant per row: a kayan whose input requires the\n// key refuses every updateMany, and one that leaves it optional runs the bulk unguarded.\n//\n// A kayan with no `updatedAt` field has nothing to compare, so listing the step on it throws.\n//\n// A project that copies this step adds the `error.conflict.stale` copy key in every locale.\nimport { conflict, type Ctx } from 'mithaq'\n\nexport const expectedUpdatedAt = (ctx: Ctx<{ expectedUpdatedAt?: Date | string }>): Ctx => {\n if (!('updatedAt' in ctx.kayan.fields)) {\n throw new Error(`Kayan \"${ctx.kayan.name}\" lists expectedUpdatedAt but has no updatedAt field, so the step cannot guard it. Add defaultTimestampFields or remove the step.`)\n }\n const { expectedUpdatedAt: expected } = ctx.data\n const held = ctx.dataBefore?.updatedAt\n if (expected === undefined || held === undefined) return ctx\n if (new Date(expected).getTime() !== new Date(held as Date).getTime()) {\n throw conflict({\n message: `Kayan \"${ctx.kayan.name}\" row changed after it was read, so it is not updated. Read it again and send the change on the row as it is.`,\n key: 'error.conflict.stale',\n params: { kayan: ctx.kayan.name },\n })\n }\n return ctx\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 dad3f2c08ca33c2d63db0e3e11df5a2ba9d8acbba71ba77599ab76d625f2189f\n",
|
|
10
|
+
"type": "registry:lib",
|
|
11
|
+
"target": "~/src/steps/expected-updated-at.ts"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"type": "registry:lib"
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "mithaq-field-shell",
|
|
4
|
+
"title": "Mithaq field shell",
|
|
5
|
+
"description": "The label, description and refusal around one control, with htmlFor and aria-describedby wired, and the checkbox's label beside its box.",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"field"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "registry/field-shell.tsx",
|
|
12
|
+
"content": "import type { ReactElement, ReactNode } from 'react'\nimport { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'\n\n/** The ids a control points `aria-describedby` at: its description, its error, both, or none. The shell derives the same ids from the same `id`. */\nexport const describedBy = ({ id, description, error }: { id: string; description?: string; error?: string }): string | undefined =>\n [description ? `${id}-description` : '', error ? `${id}-error` : ''].filter(Boolean).join(' ') || undefined\n\nexport type FieldShellProps = {\n /** The control's own id, which the label points at and the two ids below are derived from. */\n id: string\n label: string\n description?: string\n /** A refusal for this field, already resolved through the project's copy. */\n error?: string\n /** Whether the label goes beside the control rather than above it, as for a checkbox. */\n beside?: boolean\n /** The control, which owns `id` and `aria-describedby`. */\n children: ReactNode\n /** What the declaration asked this field's column to be, as a CSS width. */\n width?: string\n /** The control this field is drawn with, as the page decided it, written on the shell as `data-control`. */\n control?: string\n /** Called when the person leaves this field. React's `onBlur` bubbles, so one handler on the region serves any control. */\n onBlur?: () => void\n}\n\nexport function FieldShell({ beside, children, control, description, error, id, label, onBlur, width }: FieldShellProps): ReactElement {\n const words = (\n <>\n {description ? <FieldDescription id={`${id}-description`}>{description}</FieldDescription> : null}\n {/* `FieldError` is a `role=\"alert\"`, so a refusal that appears after a save is announced. */}\n {error ? <FieldError id={`${id}-error`}>{error}</FieldError> : null}\n </>\n )\n return (\n <Field data-control={control} onBlur={onBlur} orientation={beside ? 'horizontal' : 'vertical'} style={width ? { width } : undefined}>\n {beside ? (\n <>\n {children}\n {/* 44 pixels tall on a phone: the label is the tap target for a switch or box smaller than that. */}\n <FieldLabel htmlFor={id} id={`${id}-label`} className=\"max-sm:min-h-11 max-sm:items-center\">{label}</FieldLabel>\n </>\n ) : (\n <>\n <FieldLabel htmlFor={id} id={`${id}-label`}>{label}</FieldLabel>\n {children}\n </>\n )}\n {words}\n </Field>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 3f7afa168ed2627b97bbad5385f114694b5411293252636db56de5d1443ebb66\n",
|
|
13
|
+
"type": "registry:component",
|
|
14
|
+
"target": "@components/mithaq/field-shell.tsx"
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
"type": "registry:component"
|
|
18
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "mithaq-filter-operators",
|
|
4
|
+
"title": "Mithaq filter operators",
|
|
5
|
+
"description": "Which operators a kind offers, what shape each operator's value takes, and which paths of a kayan the filter panel may offer.",
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"path": "registry/filter-operators.ts",
|
|
9
|
+
"content": "import type { ClientField, ClientKayan } from 'mithaq/admin'\n\n/** The operators a `Where` may name, which is the server's own list. */\nexport type OperatorName =\n | 'contains'\n | 'equals'\n | 'exists'\n | 'greater_than'\n | 'greater_than_equal'\n | 'in'\n | 'less_than'\n | 'less_than_equal'\n | 'like'\n | 'not_equals'\n | 'not_in'\n\nconst equality = ['equals', 'not_equals'] as const\nconst membership = ['in', 'not_in'] as const\nconst comparisons = ['greater_than', 'greater_than_equal', 'less_than', 'less_than_equal'] as const\n\n/**\n * One row per shipped kind, each the subset the server's `assertWhere` admits for it. A kind with\n * no row offers no operator, so a project kind gets a row here to reach the panel.\n */\nexport const operatorsByKind: Readonly<Record<string, readonly OperatorName[]>> = {\n text: [...equality, 'contains', 'like', ...membership, 'exists'],\n email: [...equality, 'contains', 'like', ...membership, 'exists'],\n number: [...equality, ...comparisons, ...membership, 'exists'],\n integer: [...equality, ...comparisons, ...membership, 'exists'],\n decimal: [...equality, ...comparisons, ...membership, 'exists'],\n datetime: [...equality, ...comparisons, ...membership, 'exists'],\n date: [...equality, ...comparisons, ...membership, 'exists'],\n checkbox: [...equality, 'exists'],\n uuid: [...equality, ...membership, 'exists'],\n relationship: [...equality, ...membership, 'exists'],\n textArray: ['exists'],\n}\n\n/** What an operator's value is: a boolean for `exists`, a list for `in` and `not_in`, one value of the field's kind otherwise. */\nexport type ValueShape = 'boolean' | 'list' | 'scalar'\n\nexport const valueShapeOf = (operator: string): ValueShape =>\n operator === 'exists' ? 'boolean' : operator === 'in' || operator === 'not_in' ? 'list' : 'scalar'\n\n/** One path the panel may offer: what to write on the wire, and what kind it is. */\nexport type FilterPath = {\n /** The key a `Where` carries, at most two segments. */\n path: string\n /** The kind whose operators and whose input control this path takes. */\n kind: string\n /** The kayan the field belongs to, for the label's copy key. */\n kayan: ClientKayan\n /** The field itself, for its label. */\n field: ClientField\n /** A locale of a localized field, or the target's own name for a hop. */\n through?: string\n}\n\n/** What `filterPathsOf` needs beyond the kayan: the locales, and what may be read. */\nexport type FilterContext = {\n /** Which locales exist, from the app; the projection carries no list of them. */\n locales?: readonly string[]\n /** Every kayan of the build, so a relationship's target can be looked up. */\n client?: readonly ClientKayan[]\n /** Whether this actor may read one field. Chrome only: the read refuses a filter on a denied field whatever this says. */\n reads?: (kayan: string, field: string) => boolean\n /** The fields a page's list node offers the filter panel; wins over each field's own `filter`. */\n filters?: readonly string[]\n}\n\n/**\n * Every path one kayan offers the filter panel for the fields that opt in. A localized field offers its\n * locale paths only (the bare one is refused); a relationship adds one hop, since a path has two segments at most.\n */\nexport const filterPathsOf = (kayan: ClientKayan, context: FilterContext = {}): readonly FilterPath[] => {\n const { client, filters, locales, reads } = context\n const allowed = (owner: ClientKayan, field: ClientField) => !reads || reads(owner.name, field.name)\n // A hop's field answers for itself, since the page's list names only this kayan's fields.\n const filterable = (field: ClientField, own = true) => (own && filters ? filters.includes(field.name) : field.filter === true)\n const paths: FilterPath[] = []\n for (const field of kayan.fields) {\n if (!filterable(field) || !allowed(kayan, field)) continue\n if (field.rules.localized === true) {\n for (const locale of locales ?? []) paths.push({ path: `${field.name}.${locale}`, kind: field.kind, kayan, field, through: locale })\n continue\n }\n paths.push({ path: field.name, kind: field.kind, kayan, field })\n if (field.kind !== 'relationship' || !field.relationTo) continue\n const target = client?.find((one) => one.name === field.relationTo)\n if (!target) continue\n for (const hop of target.fields) {\n if (hop.kind === 'relationship' || hop.rules.localized === true) continue\n if (!filterable(hop, false) || !allowed(target, hop)) continue\n paths.push({ path: `${field.name}.${hop.name}`, kind: hop.kind, kayan: target, field: hop, through: field.name })\n }\n }\n return paths\n}\n\n/**\n * Whether a path and an operator are ones this kayan offers. Everything parsed off the URL is\n * re-derived through it, so a hostile link naming a field the viewer may not read renders no row.\n */\nexport const offers = ({ paths, path, operator }: { paths: readonly FilterPath[]; path: string; operator: string }): FilterPath | undefined => {\n const found = paths.find((one) => one.path === path)\n return found && (operatorsByKind[found.kind] ?? []).includes(operator as OperatorName) ? found : undefined\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 bf6e8ee48efdc1f1e25230933b6df1f25760a60576debb251b2374dec06effbd\n",
|
|
10
|
+
"type": "registry:lib",
|
|
11
|
+
"target": "@components/mithaq/filter-operators.ts"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"type": "registry:lib"
|
|
15
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
}
|