@riducms/admin 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -9
- package/src/app.css +63 -4
- package/src/app.svelte +12 -14
- package/src/components/ui/date-value-control/date-value-control-loader.svelte +15 -4
- package/src/components/ui/date-value-control/date-value-control.svelte +17 -17
- package/src/components/ui/select/select-trigger.svelte +1 -1
- package/src/core/forms/embedded-fields.ts +132 -0
- package/src/core/forms/embedded-schema-draft.svelte.ts +335 -0
- package/src/core/forms/field-editor-authoring.ts +47 -0
- package/src/core/forms/field-editor-binding.ts +228 -0
- package/src/core/forms/field-issue-focus.ts +42 -0
- package/src/core/forms/field-occurrence.ts +122 -0
- package/src/core/forms/form-controller.svelte.ts +475 -29
- package/src/core/forms/form-issue-correlation.ts +159 -0
- package/src/core/forms/form-schema.ts +211 -17
- package/src/core/forms/form-validation.ts +183 -17
- package/src/core/forms/live-validation.svelte.ts +317 -0
- package/src/core/forms/plugin-field-authoring.ts +160 -0
- package/src/core/forms/plugin-field-binding.ts +258 -0
- package/src/core/forms/plugin-field-write.ts +57 -0
- package/src/core/i18n/admin-i18n.svelte.ts +8 -1
- package/src/core/i18n/localized-schema.ts +33 -10
- package/src/core/plugins/field-registry.ts +29 -49
- package/src/core/plugins/row-label-registry.ts +25 -5
- package/src/core/runtime/admin-runtime.svelte.ts +25 -13
- package/src/features/auth/account-route.svelte +10 -5
- package/src/features/auth/account-security-route.svelte +1 -1
- package/src/features/collections/collection-list-bulk-editor.svelte +6 -6
- package/src/features/collections/collection-list-controller.svelte.ts +5 -5
- package/src/features/collections/collection-list-filter-popover.svelte +6 -6
- package/src/features/collections/collection-list-route.svelte +13 -14
- package/src/features/collections/list-workspace.ts +22 -8
- package/src/features/documents/document-controller.svelte.ts +8 -2
- package/src/features/documents/document-route.svelte +36 -43
- package/src/features/reference-browser/reference-browser-workflow.svelte.ts +30 -15
- package/src/features/reference-browser/reference-browser.svelte +215 -189
- package/src/features/uploads/upload-metadata-fields.svelte +6 -6
- package/src/features/versions/version-history-route.svelte +1 -1
- package/src/fields/embedded-schema-draft-editor.svelte +74 -0
- package/src/fields/embedded-schema-fields.svelte +44 -0
- package/src/fields/field-clipboard.ts +76 -16
- package/src/fields/field-collapsible.svelte +49 -0
- package/src/fields/field-layout.svelte +2 -18
- package/src/fields/field-messages.svelte +13 -18
- package/src/fields/field-renderer.svelte +53 -14
- package/src/fields/join/join-field.svelte +8 -1
- package/src/fields/live-validation-feedback.svelte +21 -0
- package/src/fields/local-field-editor.svelte +52 -0
- package/src/fields/nested/nested-field.svelte +159 -91
- package/src/fields/nested/scoped-field.ts +7 -9
- package/src/fields/plugin-field.svelte +156 -0
- package/src/fields/point/point-field.svelte +12 -2
- package/src/fields/primitive-list/primitive-list-field.svelte +170 -0
- package/src/fields/primitive-list/primitive-number-input.ts +6 -0
- package/src/fields/radio/radio-field.svelte +13 -9
- package/src/fields/relationship/relationship-field.svelte +36 -12
- package/src/fields/relationship/relationship-query.ts +4 -2
- package/src/fields/relationship/relationship-quick-picker.svelte +38 -10
- package/src/fields/scalar/date-control-value.ts +5 -5
- package/src/fields/scalar/date-value.ts +5 -5
- package/src/fields/scalar/scalar-field.svelte +47 -41
- package/src/fields/select/select-field.svelte +24 -24
- package/src/fields/select/select-value.ts +3 -3
- package/src/fields/text/slug-field.svelte +24 -8
- package/src/fields/text/text-field.svelte +8 -6
- package/src/index.ts +4 -12
package/package.json
CHANGED
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
"@hvniel/svelte-router": "^0.0.6",
|
|
10
10
|
"@iconify/json": "^2.2.509",
|
|
11
11
|
"@internationalized/date": "^3.12.3",
|
|
12
|
-
"@riducms/plugin": "0.
|
|
13
|
-
"@riducms/protocol": "0.
|
|
14
|
-
"@riducms/sdk": "0.
|
|
15
|
-
"@riducms/translations": "0.
|
|
16
|
-
"@riducms/ui": "0.
|
|
12
|
+
"@riducms/plugin": "0.2.0",
|
|
13
|
+
"@riducms/protocol": "0.2.0",
|
|
14
|
+
"@riducms/sdk": "0.2.0",
|
|
15
|
+
"@riducms/translations": "0.2.0",
|
|
16
|
+
"@riducms/ui": "0.2.0",
|
|
17
17
|
"@types/prismjs": "^1.26.5",
|
|
18
18
|
"bits-ui": "^2.18.1",
|
|
19
19
|
"prismjs": "^1.30.0",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"description": "Framework-owned Svelte 5 authoring application embedded by Ridu servers.",
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@hvniel/lexical-svelte": "^0.1.1",
|
|
25
|
-
"@riducms/build": "0.
|
|
25
|
+
"@riducms/build": "0.2.0",
|
|
26
26
|
"svelte": "^5.56.8"
|
|
27
27
|
},
|
|
28
28
|
"exports": {
|
|
@@ -51,12 +51,14 @@
|
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
53
|
"build": "vite build",
|
|
54
|
-
"check": "svelte-check --tsconfig ./tsconfig.app.json &&
|
|
54
|
+
"check": "svelte-check --tsconfig ./tsconfig.app.json && svelte-check --tsconfig ./tsconfig.test.json",
|
|
55
55
|
"dev": "vite",
|
|
56
56
|
"lint": "tsc -p tsconfig.node.json && bun ../scripts/check-admin-ui-policy.ts",
|
|
57
57
|
"preview": "vite preview",
|
|
58
|
-
"test": "bun test"
|
|
58
|
+
"test": "bun run test:unit && bun run test:components",
|
|
59
|
+
"test:components": "vitest run --config vitest.config.ts",
|
|
60
|
+
"test:unit": "bun test tests"
|
|
59
61
|
},
|
|
60
62
|
"type": "module",
|
|
61
|
-
"version": "0.
|
|
63
|
+
"version": "0.2.0"
|
|
62
64
|
}
|
package/src/app.css
CHANGED
|
@@ -232,15 +232,23 @@ a {
|
|
|
232
232
|
outline-offset: 2px;
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
[data-slot="input"]:focus-visible,
|
|
236
|
-
[data-slot="textarea"]:focus-visible,
|
|
237
|
-
[data-slot="command-input"]:focus-visible,
|
|
238
|
-
select:focus-visible {
|
|
235
|
+
[data-slot="input"]:not([aria-invalid="true"]):focus-visible,
|
|
236
|
+
[data-slot="textarea"]:not([aria-invalid="true"]):focus-visible,
|
|
237
|
+
[data-slot="command-input"]:not([aria-invalid="true"]):focus-visible,
|
|
238
|
+
select:not([aria-invalid="true"]):focus-visible {
|
|
239
239
|
border-color: color-mix(in oklab, var(--ring) 78%, transparent);
|
|
240
240
|
box-shadow: none;
|
|
241
241
|
outline: none;
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
[data-slot="input"][aria-invalid="true"]:focus-visible,
|
|
245
|
+
[data-slot="textarea"][aria-invalid="true"]:focus-visible,
|
|
246
|
+
[data-slot="command-input"][aria-invalid="true"]:focus-visible,
|
|
247
|
+
select[aria-invalid="true"]:focus-visible {
|
|
248
|
+
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--destructive) 65%, transparent);
|
|
249
|
+
outline: none;
|
|
250
|
+
}
|
|
251
|
+
|
|
244
252
|
.font-serif {
|
|
245
253
|
font-family: var(--font-serif);
|
|
246
254
|
}
|
|
@@ -308,6 +316,57 @@ select:focus-visible {
|
|
|
308
316
|
line-height: 1.5;
|
|
309
317
|
}
|
|
310
318
|
|
|
319
|
+
/*
|
|
320
|
+
* Validation feedback is a status surface, not an interactive tooltip. Keep it
|
|
321
|
+
* anchored to the control without covering its hit target or replacing help copy.
|
|
322
|
+
*/
|
|
323
|
+
.ridu-field-error-tooltip {
|
|
324
|
+
position: absolute;
|
|
325
|
+
z-index: 20;
|
|
326
|
+
inset-inline-end: 0;
|
|
327
|
+
inset-block-end: calc(100% + 0.4rem);
|
|
328
|
+
max-inline-size: min(80%, 42rem);
|
|
329
|
+
margin: 0;
|
|
330
|
+
border-radius: calc(var(--radius) - 0.0625rem);
|
|
331
|
+
padding: 0.32rem 0.55rem;
|
|
332
|
+
background: var(--destructive);
|
|
333
|
+
box-shadow: 0 0.3rem 1rem oklch(0 0 0 / 18%);
|
|
334
|
+
color: var(--destructive-foreground);
|
|
335
|
+
font-size: 0.8125rem;
|
|
336
|
+
font-weight: 500;
|
|
337
|
+
line-height: 1.35;
|
|
338
|
+
list-style: none;
|
|
339
|
+
overflow-wrap: anywhere;
|
|
340
|
+
pointer-events: none;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.ridu-field-error-tooltip::after {
|
|
344
|
+
position: absolute;
|
|
345
|
+
inset-block-start: 100%;
|
|
346
|
+
inset-inline-end: 0.7rem;
|
|
347
|
+
inline-size: 0;
|
|
348
|
+
block-size: 0;
|
|
349
|
+
border: 0.32rem solid transparent;
|
|
350
|
+
border-block-start-color: var(--destructive);
|
|
351
|
+
content: "";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.ridu-field-error-tooltip > :where(ul, ol) {
|
|
355
|
+
margin: 0;
|
|
356
|
+
padding: 0;
|
|
357
|
+
list-style: none;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.ridu-field-error-tooltip li + li {
|
|
361
|
+
margin-block-start: 0.2rem;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
@media (max-width: 34rem) {
|
|
365
|
+
.ridu-field-error-tooltip {
|
|
366
|
+
max-inline-size: calc(100% - 0.5rem);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
311
370
|
/*
|
|
312
371
|
* Tone lives in the text and the wash, matching `banner`: no rails, chips, or icons.
|
|
313
372
|
* `--normal-*` are sonner's own theming variables; its dark-theme rules paint the
|
package/src/app.svelte
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { createBrowserRouter, RouterProvider } from "@hvniel/svelte-router";
|
|
3
|
-
import { setAdminI18n
|
|
4
|
-
import type { TranslationLanguage } from "@riducms/translations";
|
|
3
|
+
import { setAdminI18n } from "@riducms/plugin";
|
|
5
4
|
import { TooltipProvider } from "@riducms/ui";
|
|
6
5
|
import { untrack } from "svelte";
|
|
7
6
|
|
|
@@ -17,25 +16,24 @@
|
|
|
17
16
|
import type { AdminClient } from "@admin/core/api/admin-client";
|
|
18
17
|
import DevelopmentTools from "@admin/features/development/development-tools.svelte";
|
|
19
18
|
|
|
19
|
+
import type { AdminConfig } from "@riducms/plugin/admin";
|
|
20
20
|
interface Props {
|
|
21
|
+
adminConfig?: AdminConfig;
|
|
21
22
|
clientFactory: () => AdminClient;
|
|
22
23
|
adminBasePath?: string;
|
|
23
|
-
plugins?: readonly AdminPlugin[];
|
|
24
|
-
fieldPlugins?: readonly FieldPlugin[];
|
|
25
|
-
languages?: readonly TranslationLanguage[];
|
|
26
24
|
}
|
|
27
|
-
|
|
28
|
-
let {
|
|
29
|
-
clientFactory,
|
|
30
|
-
adminBasePath = "/admin",
|
|
31
|
-
plugins = [],
|
|
32
|
-
fieldPlugins = [],
|
|
33
|
-
languages,
|
|
34
|
-
}: Props = $props();
|
|
25
|
+
let { clientFactory, adminBasePath = "/admin", adminConfig = {} }: Props = $props();
|
|
35
26
|
// The runtime owns the client and plugin registry selected when this admin instance is mounted.
|
|
36
27
|
// svelte-ignore state_referenced_locally
|
|
37
28
|
const runtime = setAdminRuntime(
|
|
38
|
-
new AdminRuntime(
|
|
29
|
+
new AdminRuntime(
|
|
30
|
+
clientFactory(),
|
|
31
|
+
adminConfig.plugins,
|
|
32
|
+
adminConfig.languages,
|
|
33
|
+
undefined,
|
|
34
|
+
adminConfig.fields,
|
|
35
|
+
adminConfig
|
|
36
|
+
)
|
|
39
37
|
);
|
|
40
38
|
setAdminI18n(runtime.i18n);
|
|
41
39
|
const notifications = setNotificationCenter(new NotificationCenter());
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type {
|
|
2
|
+
import type { SchemaDateFormat } from "@riducms/protocol";
|
|
3
|
+
import { fieldControlARIA } from "@riducms/ui";
|
|
3
4
|
import type { Component } from "svelte";
|
|
4
5
|
|
|
5
6
|
type ControlSize = "field" | "compact" | "toolbar";
|
|
6
7
|
type DateValueControlProps = {
|
|
7
8
|
id: string;
|
|
8
9
|
name?: string;
|
|
9
|
-
appearance?:
|
|
10
|
+
appearance?: SchemaDateFormat;
|
|
10
11
|
value?: string;
|
|
11
12
|
disabled?: boolean;
|
|
12
13
|
readonly?: boolean;
|
|
13
14
|
required?: boolean;
|
|
14
15
|
invalid?: boolean;
|
|
16
|
+
hasDescription?: boolean;
|
|
15
17
|
label?: string;
|
|
16
|
-
describedBy?: string;
|
|
17
18
|
class?: string;
|
|
18
19
|
size?: ControlSize;
|
|
19
20
|
onValueChange: (value: string) => void;
|
|
@@ -21,6 +22,9 @@
|
|
|
21
22
|
|
|
22
23
|
let props: DateValueControlProps = $props();
|
|
23
24
|
let Control = $state<Component<DateValueControlProps> | undefined>(undefined);
|
|
25
|
+
const controlARIA = $derived(
|
|
26
|
+
fieldControlARIA(props.id, props.hasDescription ?? false, props.invalid ?? false)
|
|
27
|
+
);
|
|
24
28
|
|
|
25
29
|
void import("@admin/components/ui/date-value-control/date-value-control.svelte").then(
|
|
26
30
|
({ default: component }) => (Control = component)
|
|
@@ -30,7 +34,14 @@
|
|
|
30
34
|
{#if Control !== undefined}
|
|
31
35
|
<Control {...props} />
|
|
32
36
|
{:else}
|
|
33
|
-
<div
|
|
37
|
+
<div
|
|
38
|
+
id={props.id}
|
|
39
|
+
class={props.class}
|
|
40
|
+
role="status"
|
|
41
|
+
aria-label={props.label}
|
|
42
|
+
aria-busy="true"
|
|
43
|
+
{...controlARIA}
|
|
44
|
+
>
|
|
34
45
|
Loading date control…
|
|
35
46
|
</div>
|
|
36
47
|
{/if}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { DateValue, Time } from "@internationalized/date";
|
|
3
|
-
import type {
|
|
3
|
+
import type { SchemaDateFormat } from "@riducms/protocol";
|
|
4
4
|
import { DateField, TimeField } from "bits-ui";
|
|
5
5
|
import CalendarIcon from "~icons/lucide/calendar";
|
|
6
6
|
|
|
7
7
|
import { buttonVariants } from "@admin/components/ui/button";
|
|
8
8
|
import DateValueCalendar from "@admin/components/ui/date-value-control/date-value-calendar.svelte";
|
|
9
9
|
import { Popover, PopoverContent, PopoverTrigger } from "@admin/components/ui/popover";
|
|
10
|
-
import { cn } from "@riducms/ui";
|
|
10
|
+
import { cn, fieldControlARIA } from "@riducms/ui";
|
|
11
11
|
import { getAdminI18n } from "@riducms/plugin";
|
|
12
12
|
import {
|
|
13
13
|
datePickerFormValue,
|
|
@@ -20,28 +20,28 @@
|
|
|
20
20
|
let {
|
|
21
21
|
id,
|
|
22
22
|
name,
|
|
23
|
-
appearance = "
|
|
23
|
+
appearance = "date",
|
|
24
24
|
value = "",
|
|
25
25
|
disabled = false,
|
|
26
26
|
readonly = false,
|
|
27
27
|
required = false,
|
|
28
28
|
invalid = false,
|
|
29
|
+
hasDescription = false,
|
|
29
30
|
label,
|
|
30
|
-
describedBy,
|
|
31
31
|
class: className,
|
|
32
32
|
size = "field",
|
|
33
33
|
onValueChange,
|
|
34
34
|
}: {
|
|
35
35
|
id: string;
|
|
36
36
|
name?: string;
|
|
37
|
-
appearance?:
|
|
37
|
+
appearance?: SchemaDateFormat;
|
|
38
38
|
value?: string;
|
|
39
39
|
disabled?: boolean;
|
|
40
40
|
readonly?: boolean;
|
|
41
41
|
required?: boolean;
|
|
42
42
|
invalid?: boolean;
|
|
43
|
+
hasDescription?: boolean;
|
|
43
44
|
label?: string;
|
|
44
|
-
describedBy?: string;
|
|
45
45
|
class?: string;
|
|
46
46
|
size?: ControlSize;
|
|
47
47
|
onValueChange: (value: string) => void;
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
|
|
51
51
|
const controlClass = $derived(
|
|
52
52
|
cn(
|
|
53
|
-
"flex w-full items-center rounded-lg border border-control-border bg-control text-foreground-strong transition-[background-color,border-color,color] duration-150 hover:border-control-border-hover focus-within:border-primary/65 aria-invalid
|
|
53
|
+
"flex w-full items-center rounded-lg border border-control-border bg-control text-foreground-strong transition-[background-color,border-color,color] duration-150 hover:border-control-border-hover focus-within:border-primary/65 aria-invalid:!border-destructive/65 data-[disabled]:cursor-not-allowed data-[disabled]:border-control-border-disabled data-[disabled]:bg-control-disabled data-[disabled]:opacity-45 data-[readonly]:border-control-border-disabled data-[readonly]:bg-control-disabled",
|
|
54
54
|
size === "field" && "h-10.5 px-3.25 text-[14.5px]",
|
|
55
55
|
size === "compact" && "h-9 px-3 text-[13px]",
|
|
56
56
|
size === "toolbar" && "h-[34px] px-2.5 text-[12.5px]",
|
|
@@ -58,9 +58,11 @@
|
|
|
58
58
|
)
|
|
59
59
|
);
|
|
60
60
|
const selectedDate = $derived(datePickerValue(value, appearance, i18n.timeZone));
|
|
61
|
+
const controlARIA = $derived(fieldControlARIA(id, hasDescription, invalid));
|
|
61
62
|
let open = $state(false);
|
|
62
63
|
|
|
63
64
|
function updateDate(next: DateValue | undefined) {
|
|
65
|
+
if (disabled || readonly) return;
|
|
64
66
|
onValueChange(datePickerFormValue(next, appearance, i18n.timeZone));
|
|
65
67
|
}
|
|
66
68
|
|
|
@@ -70,18 +72,19 @@
|
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
function updateTime(next: Time | undefined) {
|
|
75
|
+
if (disabled || readonly) return;
|
|
73
76
|
onValueChange(timeFieldFormValue(next));
|
|
74
77
|
}
|
|
75
78
|
</script>
|
|
76
79
|
|
|
77
|
-
{#if appearance === "
|
|
80
|
+
{#if appearance === "time"}
|
|
78
81
|
<TimeField.Root
|
|
79
82
|
value={timeFieldValue(value)}
|
|
80
83
|
locale={i18n.language}
|
|
81
84
|
{disabled}
|
|
82
85
|
{readonly}
|
|
83
86
|
{required}
|
|
84
|
-
errorMessageId={
|
|
87
|
+
errorMessageId={controlARIA["aria-errormessage"]}
|
|
85
88
|
onValueChange={updateTime}
|
|
86
89
|
>
|
|
87
90
|
<TimeField.Input
|
|
@@ -90,10 +93,8 @@
|
|
|
90
93
|
class={cn(controlClass, "gap-0.5 select-none")}
|
|
91
94
|
data-disabled={disabled ? "" : undefined}
|
|
92
95
|
data-readonly={readonly ? "" : undefined}
|
|
93
|
-
|
|
96
|
+
{...controlARIA}
|
|
94
97
|
aria-label={label}
|
|
95
|
-
aria-describedby={describedBy}
|
|
96
|
-
aria-errormessage={invalid ? describedBy : undefined}
|
|
97
98
|
>
|
|
98
99
|
{#snippet children({ segments })}
|
|
99
100
|
{#each segments as { part, value: segmentValue }, index (`${part}:${index}`)}
|
|
@@ -113,26 +114,25 @@
|
|
|
113
114
|
<DateField.Root
|
|
114
115
|
value={selectedDate}
|
|
115
116
|
locale={i18n.language}
|
|
116
|
-
granularity={appearance === "
|
|
117
|
+
granularity={appearance === "date-time" ? "minute" : "day"}
|
|
117
118
|
{disabled}
|
|
118
119
|
{readonly}
|
|
119
120
|
{required}
|
|
120
|
-
errorMessageId={
|
|
121
|
+
errorMessageId={controlARIA["aria-errormessage"]}
|
|
121
122
|
onValueChange={updateDate}
|
|
122
123
|
>
|
|
123
124
|
<div
|
|
124
125
|
class={controlClass}
|
|
125
126
|
data-disabled={disabled ? "" : undefined}
|
|
126
127
|
data-readonly={readonly ? "" : undefined}
|
|
127
|
-
aria-invalid={invalid}
|
|
128
|
+
aria-invalid={controlARIA["aria-invalid"]}
|
|
128
129
|
>
|
|
129
130
|
<DateField.Input
|
|
130
131
|
{id}
|
|
131
132
|
{name}
|
|
132
133
|
class="flex min-w-0 flex-1 items-center gap-0.5 select-none"
|
|
133
134
|
aria-label={label}
|
|
134
|
-
|
|
135
|
-
aria-errormessage={invalid ? describedBy : undefined}
|
|
135
|
+
{...controlARIA}
|
|
136
136
|
>
|
|
137
137
|
{#snippet children({ segments })}
|
|
138
138
|
{#each segments as { part, value: segmentValue }, index (`${part}:${index}`)}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
data-slot="select-trigger"
|
|
20
20
|
data-size={size}
|
|
21
21
|
class={cn(
|
|
22
|
-
"gap-2 rounded-[3px] border border-control-border bg-control px-3 text-[13.5px] text-foreground-strong transition-[background-color,border-color,color] duration-150 hover:border-control-border-hover focus-visible:border-primary/65 aria-invalid
|
|
22
|
+
"gap-2 rounded-[3px] border border-control-border bg-control px-3 text-[13.5px] text-foreground-strong transition-[background-color,border-color,color] duration-150 hover:border-control-border-hover focus-visible:border-primary/65 aria-invalid:!border-destructive/65 data-[placeholder]:text-foreground-placeholder data-[size=field]:h-10 data-[size=compact]:h-9 data-[size=toolbar]:h-[34px] data-[size=toolbar]:px-2.5 data-[size=toolbar]:text-[12.5px] *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-3.5 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:border-control-border-disabled disabled:bg-control-disabled disabled:opacity-45 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
|
23
23
|
className
|
|
24
24
|
)}
|
|
25
25
|
{...restProps}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { resolveBlockTypes } from "@riducms/protocol";
|
|
2
|
+
import type {
|
|
3
|
+
SchemaBlockType,
|
|
4
|
+
SchemaEmbeddedTree,
|
|
5
|
+
SchemaEmbeddedTreeCase,
|
|
6
|
+
SchemaField,
|
|
7
|
+
ValidationIssue,
|
|
8
|
+
} from "@riducms/protocol";
|
|
9
|
+
|
|
10
|
+
export interface EmbeddedOccurrence {
|
|
11
|
+
tree: SchemaEmbeddedTree;
|
|
12
|
+
case: SchemaEmbeddedTreeCase;
|
|
13
|
+
block: SchemaBlockType;
|
|
14
|
+
payload: Record<string, unknown>;
|
|
15
|
+
path: string;
|
|
16
|
+
identity: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Only descriptor-owned tree edges are visited. Payloads return to ordinary field traversal. */
|
|
20
|
+
export function embeddedOccurrences(field: SchemaField, value: unknown, path = field.path) {
|
|
21
|
+
const occurrences: EmbeddedOccurrence[] = [];
|
|
22
|
+
const issues: ValidationIssue[] = [];
|
|
23
|
+
let work = 0;
|
|
24
|
+
const fail = (path: string, message: string, code = "invalid_embedded") =>
|
|
25
|
+
issues.push({ path, message, code });
|
|
26
|
+
if (value === null || value === undefined) return { occurrences, issues };
|
|
27
|
+
for (const tree of field.plugin?.embeddedTrees ?? []) {
|
|
28
|
+
if (tree.version !== 1) {
|
|
29
|
+
fail(path, `Unsupported embedded tree metadata version ${tree.version}`);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
let root: unknown = value;
|
|
33
|
+
let rootPath = path;
|
|
34
|
+
for (const segment of tree.root) {
|
|
35
|
+
root = isEmbeddedRecord(root) ? root[segment] : undefined;
|
|
36
|
+
rootPath += `.${segment}`;
|
|
37
|
+
}
|
|
38
|
+
const keys = new Set<string>();
|
|
39
|
+
const visit = (node: unknown, nodePath: string, depth: number) => {
|
|
40
|
+
if (++work > 10_000 || depth > 64) {
|
|
41
|
+
fail(nodePath, "Embedded field traversal budget exceeded", "embedded_budget");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (!isEmbeddedRecord(node)) {
|
|
45
|
+
fail(nodePath, "Embedded tree node must be an object");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const branch = tree.cases.find((candidate) => candidate.tagValue === node[tree.tag]);
|
|
49
|
+
if (branch !== undefined) {
|
|
50
|
+
const payload = node[branch.payload];
|
|
51
|
+
const payloadPath = `${nodePath}.${branch.payload}`;
|
|
52
|
+
if (!isEmbeddedRecord(payload)) {
|
|
53
|
+
fail(payloadPath, "Embedded payload must be an object");
|
|
54
|
+
} else {
|
|
55
|
+
const block = resolveBlockTypes(branch).find(
|
|
56
|
+
(candidate) => candidate.slug === payload[branch.discriminator]
|
|
57
|
+
);
|
|
58
|
+
const identity = payload[branch.identity];
|
|
59
|
+
if (identity !== undefined && (typeof identity !== "string" || identity.trim() === ""))
|
|
60
|
+
fail(
|
|
61
|
+
`${payloadPath}.${branch.identity}`,
|
|
62
|
+
"Embedded identity must be a non-empty string",
|
|
63
|
+
"invalid_key"
|
|
64
|
+
);
|
|
65
|
+
if (typeof identity === "string") {
|
|
66
|
+
if (keys.has(identity))
|
|
67
|
+
fail(
|
|
68
|
+
`${payloadPath}.${branch.identity}`,
|
|
69
|
+
"Embedded identity must be unique",
|
|
70
|
+
"duplicate_key"
|
|
71
|
+
);
|
|
72
|
+
keys.add(identity);
|
|
73
|
+
}
|
|
74
|
+
if (block === undefined)
|
|
75
|
+
fail(
|
|
76
|
+
`${payloadPath}.${branch.discriminator}`,
|
|
77
|
+
"Restore the missing embedded schema or migrate the document before saving.",
|
|
78
|
+
"unknown_block_schema"
|
|
79
|
+
);
|
|
80
|
+
else
|
|
81
|
+
occurrences.push({
|
|
82
|
+
tree,
|
|
83
|
+
case: branch,
|
|
84
|
+
block,
|
|
85
|
+
payload,
|
|
86
|
+
path: payloadPath,
|
|
87
|
+
identity: typeof identity === "string" ? identity : undefined,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const children = node[tree.children];
|
|
92
|
+
if (children === undefined || children === null) return;
|
|
93
|
+
if (!Array.isArray(children)) {
|
|
94
|
+
fail(`${nodePath}.${tree.children}`, "Embedded children must be an array");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
for (const [index, child] of children.entries()) {
|
|
98
|
+
if (work > 10_000) break;
|
|
99
|
+
visit(child, `${nodePath}.${tree.children}.${index}`, depth + 1);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
if (Array.isArray(root)) {
|
|
103
|
+
for (const [index, node] of root.entries()) {
|
|
104
|
+
if (work > 10_000) break;
|
|
105
|
+
visit(node, `${rootPath}.${index}`, tree.root.length + 1);
|
|
106
|
+
}
|
|
107
|
+
} else visit(root, rootPath, tree.root.length + 1);
|
|
108
|
+
}
|
|
109
|
+
return { occurrences, issues };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Mutates only schema-owned payloads in an already detached value. */
|
|
113
|
+
export function transformEmbeddedPayloads(
|
|
114
|
+
field: SchemaField,
|
|
115
|
+
value: unknown,
|
|
116
|
+
path: string,
|
|
117
|
+
transform: (occurrence: EmbeddedOccurrence) => Record<string, unknown>
|
|
118
|
+
) {
|
|
119
|
+
const result = embeddedOccurrences(field, value, path);
|
|
120
|
+
if (result.issues.length > 0)
|
|
121
|
+
throw new Error(`${result.issues[0]?.path}: ${result.issues[0]?.message}`);
|
|
122
|
+
for (const occurrence of result.occurrences) {
|
|
123
|
+
const transformed = { ...transform(occurrence) };
|
|
124
|
+
for (const key of Object.keys(occurrence.payload)) delete occurrence.payload[key];
|
|
125
|
+
Object.assign(occurrence.payload, transformed);
|
|
126
|
+
}
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function isEmbeddedRecord(value: unknown): value is Record<string, unknown> {
|
|
131
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
132
|
+
}
|