@drhomet/mithaq 0.1.1 → 0.2.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/LICENSES/openstatus-time-picker.txt +21 -0
- package/README.md +161 -0
- package/dist/asas/build.d.ts +10 -2
- package/dist/asas/build.js +69 -22
- package/dist/asas/cli/mithaq.js +35 -3
- package/dist/asas/cli/upgrade.d.ts +22 -9
- package/dist/asas/cli/upgrade.js +187 -78
- package/dist/asas/http/fetch.d.ts +21 -3
- package/dist/asas/http/fetch.js +123 -60
- package/dist/asas/http/orpc.d.ts +5 -3
- package/dist/asas/http/orpc.js +126 -108
- package/dist/asas/runner/bulk.d.ts +1 -1
- package/dist/asas/runner/bulk.js +16 -11
- package/dist/asas/runner/errors.d.ts +5 -2
- package/dist/asas/runner/errors.js +18 -2
- package/dist/asas/runner/read.d.ts +6 -2
- package/dist/asas/runner/read.js +28 -6
- package/dist/asas/runner/steps.d.ts +22 -4
- package/dist/asas/runner/steps.js +15 -9
- package/dist/asas/runner/store.d.ts +4 -0
- package/dist/asas/runner/store.js +21 -0
- package/dist/asas/runner/tenant.d.ts +2 -2
- package/dist/asas/runner/tenant.js +78 -16
- package/dist/asas/runner/write.d.ts +173 -11
- package/dist/asas/runner/write.js +274 -46
- package/dist/auth/better-auth/access.d.ts +14 -0
- package/dist/auth/better-auth/access.js +32 -0
- package/dist/auth/better-auth/generate.d.ts +3 -1
- package/dist/auth/better-auth/generate.js +26 -8
- package/dist/auth/better-auth/index.d.ts +2 -0
- package/dist/auth/better-auth/tenant.d.ts +11 -3
- package/dist/auth/better-auth/tenant.js +10 -2
- package/dist/auth/local/index.js +5 -3
- package/dist/base/fields.d.ts +3 -2
- package/dist/base/fields.js +3 -2
- package/dist/base/index.d.ts +2 -1
- package/dist/base/json-schema.d.ts +7 -0
- package/dist/base/json-schema.js +35 -0
- package/dist/base/kinds.d.ts +21 -0
- package/dist/base/kinds.js +46 -1
- package/dist/base/rules.d.ts +4 -4
- package/dist/base/rules.js +4 -3
- package/dist/drizzle/index.d.ts +9 -139
- package/dist/drizzle/index.js +143 -24
- package/dist/drizzle/kinds.d.ts +1 -0
- package/dist/drizzle/kinds.js +3 -1
- package/dist/drizzle/migrate.d.ts +26 -0
- package/dist/drizzle/migrate.js +69 -26
- package/dist/drizzle/rules.d.ts +1 -1
- package/dist/index.d.ts +11 -21
- package/dist/index.js +8 -18
- package/dist/mithaq/define.d.ts +62 -15
- package/dist/mithaq/define.js +23 -9
- package/dist/mithaq/generate-messages.js +5 -4
- package/dist/mithaq/language.d.ts +43 -4
- package/dist/mithaq/message-file.d.ts +13 -0
- package/dist/mithaq/message-file.js +29 -0
- package/dist/mithaq/messages.js +5 -0
- package/dist/mithaq/resolve.js +70 -10
- package/dist/mithaq/types.d.ts +29 -1
- package/dist/rbac/index.d.ts +2 -2
- package/dist/rbac/index.js +1 -1
- package/dist/rich-text/index.d.ts +1 -0
- package/dist/rich-text/index.js +1 -0
- package/dist/rich-text/rich-text.d.ts +2 -55
- package/dist/rich-text/rich-text.js +2 -125
- package/dist/rich-text/value.d.ts +55 -0
- package/dist/rich-text/value.js +126 -0
- package/dist/screen/client.d.ts +5 -2
- package/dist/screen/client.js +5 -2
- package/dist/screen/generate.js +48 -6
- package/dist/screen/index.d.ts +18 -1
- package/dist/screen/index.js +24 -6
- package/dist/screen/react.d.ts +2 -0
- package/dist/storage/index.d.ts +2 -3
- package/dist/storage/index.js +1 -2
- package/dist/storage/storage.d.ts +2 -0
- package/dist/storage/storage.js +9 -2
- package/dist/targets.d.ts +14 -0
- package/dist/targets.js +16 -0
- package/dist/vite.d.ts +7 -0
- package/dist/vite.js +81 -0
- package/dist/zod/index.d.ts +12 -3
- package/dist/zod/index.js +6 -5
- package/dist/zod/kinds.d.ts +3 -1
- package/dist/zod/kinds.js +11 -2
- package/dist/zod/rules.d.ts +1 -1
- package/docs/packages/mithaq/README.md +974 -0
- package/docs/packages/mithaq/src/asas/cli/README.md +283 -0
- package/docs/packages/mithaq/src/asas/runner/README.md +472 -0
- package/docs/packages/mithaq/src/drizzle/README.md +39 -0
- package/docs/packages/mithaq-admin/README.md +312 -0
- package/docs/templates/asas/README.md +1094 -0
- package/package.json +37 -8
- package/skills/add-field/SKILL.md +27 -0
- package/skills/add-kayan/SKILL.md +26 -0
- package/skills/eject-admin-piece/SKILL.md +28 -0
- package/skills/upgrade-mithaq/SKILL.md +35 -0
- package/r/mithaq-cells.json +0 -20
- package/r/mithaq-controls.json +0 -37
- package/r/mithaq-directional-icon.json +0 -15
- package/r/mithaq-expected-updated-at.json +0 -15
- package/r/mithaq-field-shell.json +0 -18
- package/r/mithaq-filter-operators.json +0 -15
- package/r/mithaq-form.json +0 -33
- package/r/mithaq-list-columns.json +0 -23
- package/r/mithaq-list-filters.json +0 -24
- package/r/mithaq-list-pagination.json +0 -24
- package/r/mithaq-list-query.json +0 -18
- package/r/mithaq-list-selection.json +0 -19
- package/r/mithaq-list.json +0 -33
- package/r/mithaq-nav.json +0 -24
- package/r/mithaq-nodes.json +0 -34
- package/r/mithaq-relationship-cell.json +0 -20
- package/r/mithaq-relationship-control.json +0 -20
- package/r/mithaq-rich-text.json +0 -40
- package/r/mithaq-screens-host.json +0 -96
- package/r/mithaq-storage.json +0 -44
- package/r/mithaq-upload.json +0 -29
- package/r/registry.json +0 -550
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drhomet/mithaq",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
"./generate-messages": "./dist/mithaq/generate-messages.js",
|
|
10
10
|
"./generate-screens": "./dist/screen/generate.js",
|
|
11
11
|
"./upgrade": "./dist/asas/cli/upgrade.js",
|
|
12
|
+
"./vite": "./dist/vite.js",
|
|
12
13
|
"./admin": "./dist/screen/client.js",
|
|
13
14
|
"./admin/react": "./dist/screen/react.js",
|
|
14
15
|
"./orpc": "./dist/asas/http/orpc.js",
|
|
15
|
-
"./rich-text": "./dist/rich-text/
|
|
16
|
+
"./rich-text": "./dist/rich-text/index.js",
|
|
17
|
+
"./targets": "./dist/targets.js",
|
|
16
18
|
"./drizzle": "./dist/drizzle/proxy/index.js",
|
|
17
19
|
"./drizzle/pg-core": "./dist/drizzle/proxy/pg-core.js",
|
|
18
20
|
"./drizzle/node-postgres": "./dist/drizzle/proxy/node-postgres.js"
|
|
@@ -22,34 +24,61 @@
|
|
|
22
24
|
},
|
|
23
25
|
"dependencies": {
|
|
24
26
|
"@babel/helper-validator-identifier": "^8.0.4",
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"@lexical/list": "0.52.0",
|
|
28
|
-
"@lexical/rich-text": "0.52.0",
|
|
27
|
+
"ajv": "8.20.0",
|
|
28
|
+
"ajv-formats": "3.0.1",
|
|
29
29
|
"drizzle-kit": "1.0.0-rc.4",
|
|
30
30
|
"drizzle-orm": "1.0.0-rc.4",
|
|
31
31
|
"jose": "6.2.12",
|
|
32
|
-
"lexical": "0.52.0",
|
|
33
32
|
"pg": "^8.16.3",
|
|
34
33
|
"zod": "^4.6.5"
|
|
35
34
|
},
|
|
36
35
|
"peerDependencies": {
|
|
36
|
+
"@lexical/extension": "0.52.0",
|
|
37
|
+
"@lexical/link": "0.52.0",
|
|
38
|
+
"@lexical/list": "0.52.0",
|
|
39
|
+
"@lexical/rich-text": "0.52.0",
|
|
37
40
|
"@orpc/openapi": "^1.15.1",
|
|
38
41
|
"@orpc/server": "^1.15.1",
|
|
39
42
|
"better-auth": "1.7.4",
|
|
40
|
-
"
|
|
43
|
+
"lexical": "0.52.0",
|
|
44
|
+
"react": "^19.3.0",
|
|
45
|
+
"vite": ">=8"
|
|
41
46
|
},
|
|
42
47
|
"peerDependenciesMeta": {
|
|
48
|
+
"@lexical/extension": {
|
|
49
|
+
"optional": true
|
|
50
|
+
},
|
|
51
|
+
"@lexical/link": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"@lexical/list": {
|
|
55
|
+
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"@lexical/rich-text": {
|
|
58
|
+
"optional": true
|
|
59
|
+
},
|
|
43
60
|
"@orpc/openapi": {
|
|
44
61
|
"optional": true
|
|
45
62
|
},
|
|
46
63
|
"@orpc/server": {
|
|
47
64
|
"optional": true
|
|
48
65
|
},
|
|
66
|
+
"lexical": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
49
69
|
"react": {
|
|
50
70
|
"optional": true
|
|
71
|
+
},
|
|
72
|
+
"vite": {
|
|
73
|
+
"optional": true
|
|
51
74
|
}
|
|
52
75
|
},
|
|
76
|
+
"files": [
|
|
77
|
+
"dist",
|
|
78
|
+
"docs",
|
|
79
|
+
"skills",
|
|
80
|
+
"LICENSES"
|
|
81
|
+
],
|
|
53
82
|
"repository": {
|
|
54
83
|
"type": "git",
|
|
55
84
|
"url": "git+https://github.com/drhomet/mithaq.git",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: add-field
|
|
3
|
+
description: Add, change or remove a field of an existing kayan in this mithaq project and write the migration for it. Use when asked to add a column, change a field's kind or rules, or write a migration.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Add a field and its migration
|
|
7
|
+
|
|
8
|
+
The guide is "A field" under "Add a feature to an existing kayan" in `README.md`, and "Migrations" in `AGENTS.md`. The installed mithaq is `packages/asas/node_modules/mithaq/`, or `node_modules/mithaq/` where the package manager hoists it to the root: every kind and rule is in its `docs/packages/mithaq/README.md`, and `mithaq migrate` in its `docs/packages/mithaq/src/asas/cli/README.md`.
|
|
9
|
+
|
|
10
|
+
1. Edit `fields` in the kayan's file. A kind or rule the language does not hold changes the vocabulary: stop and ask the developer.
|
|
11
|
+
2. A new `required` field on a table with rows needs a literal `defaultValue`, or goes in without `required` and becomes required in a later migration.
|
|
12
|
+
3. Add the field to the pages that should draw it, and to the roles' grants when they list fields one by one (`<kayan>.fields.<field>`).
|
|
13
|
+
4. From `packages/asas/`, with `DATABASE_URL` pointing at a database built from the migrations (never a pushed one; `AGENTS.md` "Migrations" says how to build one):
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
bunx mithaq generate screens # when a page draws it
|
|
17
|
+
bunx mithaq generate messages # then fill every empty translation
|
|
18
|
+
bunx mithaq migrate --name <slug>
|
|
19
|
+
bunx mithaq generate --check
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
5. When `mithaq migrate` stops and asks for an answer in `mithaq.hints.json`, it is a rename or a drop. A drop deletes rows: ask the developer before you answer it.
|
|
23
|
+
6. From the project root, `bunx --package typescript tsc -p .` and the same with `-p` naming each app.
|
|
24
|
+
|
|
25
|
+
Commit the migration folder.
|
|
26
|
+
|
|
27
|
+
In a project made with `--layout single`, read each path as `AGENTS.md` maps it; the installed mithaq is `node_modules/mithaq/`.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: add-kayan
|
|
3
|
+
description: Add a kayan, one data type with its table, operations, access and admin pages, to this mithaq project. Use when asked for a new entity, table, resource or list in the dashboard.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Add a kayan
|
|
7
|
+
|
|
8
|
+
The steps are "Adding a kayan" in `AGENTS.md`, and the guide's worked example is "Add a kayan" in `README.md`. The installed mithaq is `packages/asas/node_modules/mithaq/`, or `node_modules/mithaq/` where the package manager hoists it to the root; its `docs/packages/mithaq/README.md` lists every kind and rule a field can take.
|
|
9
|
+
|
|
10
|
+
1. Ask, unless the request says: does each row belong to one organization (place `organization`, the usual answer) or does every organization share it?
|
|
11
|
+
2. Follow `AGENTS.md` "Adding a kayan", copying `packages/features/tags/`. Use only the kinds and rules the language holds; a new one changes the vocabulary, so stop and ask the developer.
|
|
12
|
+
3. Grant its keys to the roles that use it, as step 4 there says. A kayan no role is granted answers no one.
|
|
13
|
+
4. From `packages/asas/`, with `DATABASE_URL` pointing at a database built from the migrations (never a pushed one; `AGENTS.md` "Migrations" says how to build one):
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
bunx mithaq generate screens # when a screen draws it
|
|
17
|
+
bunx mithaq generate messages # then fill every empty translation
|
|
18
|
+
bunx mithaq migrate --name add_<name>
|
|
19
|
+
bunx mithaq generate --check
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
5. From the project root, `bunx --package typescript tsc -p .` and `bunx --package typescript tsc -p apps/asas-tanstack` (and `apps/asas-backoffice` when the project has it).
|
|
23
|
+
|
|
24
|
+
Commit the migration folder. Never edit a file whose first line reads ``// Written by `mithaq <command>` ``; run the command again.
|
|
25
|
+
|
|
26
|
+
In a project made with `--layout single`, read each path as `AGENTS.md` maps it; the installed mithaq is `node_modules/mithaq/`.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: eject-admin-piece
|
|
3
|
+
description: Change how the admin draws something in this mithaq project, by replacing a control, cell or node in the components map or by ejecting a mithaq-admin piece. Use when asked to restyle or change an admin control, list, form, shell or page piece.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Change an admin piece
|
|
7
|
+
|
|
8
|
+
The installed mithaq is `packages/asas/node_modules/mithaq/`, or `node_modules/mithaq/` where the package manager hoists it to the root. The guide is the admin's, its `docs/packages/mithaq-admin/README.md` ("Add or replace a control", "Replace a node", "Eject a piece"), and `eject` in its `docs/packages/mithaq/src/asas/cli/README.md`.
|
|
9
|
+
|
|
10
|
+
1. Reach for the components map first: a control, a cell or a node is replaced in the app's `src/components.ts` without ejecting anything.
|
|
11
|
+
2. Otherwise eject the piece, in the app that draws it (`apps/asas-tanstack` or `apps/asas-backoffice`):
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
bunx mithaq eject <item> # an unknown name lists every item
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
It writes the piece into `src/components/mithaq/`, drawn in place of the package's, and names any shadcn primitive or npm package to add. Restart the dev server.
|
|
18
|
+
3. Edit the ejected file in place; it is the project's now. Keep its last line, the `// mithaq-admin` marker, which `upgrade --check` reads.
|
|
19
|
+
4. Check, in the app:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bunx mithaq upgrade --check # the held files against the installed mithaq-admin
|
|
23
|
+
bunx --package typescript tsc -p .
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
To go back to the package's piece, delete the file, or run `bunx mithaq eject --prune`, which deletes every ejected file nobody edited.
|
|
27
|
+
|
|
28
|
+
In a project made with `--layout single`, read each path as `AGENTS.md` maps it; the installed mithaq is `node_modules/mithaq/`.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: upgrade-mithaq
|
|
3
|
+
description: Upgrade this project to a newer mithaq and mithaq-admin. Use when asked to update or upgrade mithaq, or when the installed version is behind the one a task needs.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Upgrade mithaq
|
|
7
|
+
|
|
8
|
+
Run each command from the project root. The installed mithaq, `$M` below, is `packages/asas/node_modules/mithaq/`, or `node_modules/mithaq/` where the package manager hoists it to the root, as in a project made with `--layout single`; each block finds it first.
|
|
9
|
+
|
|
10
|
+
1. Before you install, save the developer's own edits to the skills this version shipped. Skip this when `$M/skills` does not exist: that version shipped none.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
M=packages/asas/node_modules/mithaq; [ -d "$M" ] || M=node_modules/mithaq
|
|
14
|
+
ls "$M/skills" > /tmp/mithaq-shipped-skills
|
|
15
|
+
for skill in $(cat /tmp/mithaq-shipped-skills); do diff -ru "$M/skills/$skill" ".claude/skills/$skill"; done > /tmp/mithaq-skill-edits.diff
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
2. Set `mithaq` and `mithaq-admin` to the same new version in every `package.json` that names them (`npm:@drhomet/mithaq@<version>` and `npm:@drhomet/mithaq-admin@<version>`), and install.
|
|
19
|
+
3. Read "Upgrading" in the new version's project guide, `$M/docs/templates/asas/README.md`: it says what a project made before each round changes. Apply every note for the rounds since this project's version, in order. A note that changes the vocabulary or drops data needs the developer's word first.
|
|
20
|
+
4. Replace the shipped skills with the new version's. A skill the old version shipped and the new one does not is deleted; a skill the developer wrote, which no version shipped, is left alone.
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
M=packages/asas/node_modules/mithaq; [ -d "$M" ] || M=node_modules/mithaq
|
|
24
|
+
for skill in $(cat /tmp/mithaq-shipped-skills 2>/dev/null); do rm -rf ".claude/skills/$skill"; done
|
|
25
|
+
mkdir -p .claude/skills && cp -R "$M/skills/." .claude/skills/
|
|
26
|
+
git status --short -- .claude/skills
|
|
27
|
+
git diff -- .claude/skills
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Then put back each of the developer's edits in `/tmp/mithaq-skill-edits.diff` that the new text lost, and ask the developer where an edit no longer fits the new text.
|
|
31
|
+
5. From `packages/asas/`, with `DATABASE_URL` exported, run `bunx mithaq generate --check`. It also checks the lock and the migrations; rerun `generate auth`, `screens` or `messages` as it says.
|
|
32
|
+
6. Run `bunx mithaq upgrade --check` in each folder holding a `components.json`: `packages/mithaq`, `apps/asas-tanstack` and, when the project has it, `apps/asas-backoffice`. Each file it reports as `differs` came from mithaq-admin: diff it against the package's and merge by hand, keeping the project's own edits.
|
|
33
|
+
7. From the project root, `bunx --package typescript tsc -p .` and the same with `-p` naming each app, then build and start each app once.
|
|
34
|
+
|
|
35
|
+
In a project made with `--layout single`, read each path as `AGENTS.md` maps it.
|
package/r/mithaq-cells.json
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
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
|
-
}
|
package/r/mithaq-controls.json
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
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
|
-
}
|