@xemahq/ui-kernel 0.6.1 → 0.8.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/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/biome-host/frontend-biome.d.ts +6 -3
- package/dist/lib/biome-host/frontend-biome.d.ts.map +1 -1
- package/dist/lib/biome-host/frontend-biome.js +9 -0
- package/dist/lib/biome-host/frontend-biome.js.map +1 -1
- package/dist/offline/id.d.ts +2 -0
- package/dist/offline/id.d.ts.map +1 -0
- package/dist/offline/id.js +15 -0
- package/dist/offline/id.js.map +1 -0
- package/dist/offline/index.d.ts +9 -0
- package/dist/offline/index.d.ts.map +1 -0
- package/dist/offline/index.js +26 -0
- package/dist/offline/index.js.map +1 -0
- package/dist/offline/mutation.d.ts +15 -0
- package/dist/offline/mutation.d.ts.map +1 -0
- package/dist/offline/mutation.js +37 -0
- package/dist/offline/mutation.js.map +1 -0
- package/dist/offline/offline-status-badge.d.ts +7 -0
- package/dist/offline/offline-status-badge.d.ts.map +1 -0
- package/dist/offline/offline-status-badge.js +21 -0
- package/dist/offline/offline-status-badge.js.map +1 -0
- package/dist/offline/online-controller.d.ts +18 -0
- package/dist/offline/online-controller.d.ts.map +1 -0
- package/dist/offline/online-controller.js +79 -0
- package/dist/offline/online-controller.js.map +1 -0
- package/dist/offline/outbox.d.ts +57 -0
- package/dist/offline/outbox.d.ts.map +1 -0
- package/dist/offline/outbox.js +257 -0
- package/dist/offline/outbox.js.map +1 -0
- package/dist/offline/react.d.ts +13 -0
- package/dist/offline/react.d.ts.map +1 -0
- package/dist/offline/react.js +42 -0
- package/dist/offline/react.js.map +1 -0
- package/dist/offline/storage.d.ts +22 -0
- package/dist/offline/storage.d.ts.map +1 -0
- package/dist/offline/storage.js +90 -0
- package/dist/offline/storage.js.map +1 -0
- package/dist/offline/types.d.ts +38 -0
- package/dist/offline/types.d.ts.map +1 -0
- package/dist/offline/types.js +11 -0
- package/dist/offline/types.js.map +1 -0
- package/dist/ui/branding/app-branding-provider.d.ts +1 -1
- package/dist/ui/branding/app-branding-provider.d.ts.map +1 -1
- package/dist/ui/capture/document-preview.d.ts +12 -0
- package/dist/ui/capture/document-preview.d.ts.map +1 -0
- package/dist/ui/capture/document-preview.js +31 -0
- package/dist/ui/capture/document-preview.js.map +1 -0
- package/dist/ui/capture/field-spec-to-zod.d.ts +5 -0
- package/dist/ui/capture/field-spec-to-zod.d.ts.map +1 -0
- package/dist/ui/capture/field-spec-to-zod.js +79 -0
- package/dist/ui/capture/field-spec-to-zod.js.map +1 -0
- package/dist/ui/capture/field-spec.d.ts +54 -0
- package/dist/ui/capture/field-spec.d.ts.map +1 -0
- package/dist/ui/capture/field-spec.js +62 -0
- package/dist/ui/capture/field-spec.js.map +1 -0
- package/dist/ui/capture/index.d.ts +11 -0
- package/dist/ui/capture/index.d.ts.map +1 -0
- package/dist/ui/capture/index.js +31 -0
- package/dist/ui/capture/index.js.map +1 -0
- package/dist/ui/capture/schema-field-input.d.ts +20 -0
- package/dist/ui/capture/schema-field-input.d.ts.map +1 -0
- package/dist/ui/capture/schema-field-input.js +127 -0
- package/dist/ui/capture/schema-field-input.js.map +1 -0
- package/dist/ui/capture/schema-form.d.ts +18 -0
- package/dist/ui/capture/schema-form.d.ts.map +1 -0
- package/dist/ui/capture/schema-form.js +26 -0
- package/dist/ui/capture/schema-form.js.map +1 -0
- package/dist/ui/capture/use-prefill.d.ts +18 -0
- package/dist/ui/capture/use-prefill.d.ts.map +1 -0
- package/dist/ui/capture/use-prefill.js +94 -0
- package/dist/ui/capture/use-prefill.js.map +1 -0
- package/dist/ui/capture/use-schema-validation.d.ts +11 -0
- package/dist/ui/capture/use-schema-validation.d.ts.map +1 -0
- package/dist/ui/capture/use-schema-validation.js +69 -0
- package/dist/ui/capture/use-schema-validation.js.map +1 -0
- package/dist/ui/capture/use-wizard.d.ts +28 -0
- package/dist/ui/capture/use-wizard.d.ts.map +1 -0
- package/dist/ui/capture/use-wizard.js +63 -0
- package/dist/ui/capture/use-wizard.js.map +1 -0
- package/dist/ui/capture/value-format.d.ts +4 -0
- package/dist/ui/capture/value-format.d.ts.map +1 -0
- package/dist/ui/capture/value-format.js +23 -0
- package/dist/ui/capture/value-format.js.map +1 -0
- package/dist/ui/capture/wizard.d.ts +19 -0
- package/dist/ui/capture/wizard.d.ts.map +1 -0
- package/dist/ui/capture/wizard.js +34 -0
- package/dist/ui/capture/wizard.js.map +1 -0
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/primitives/badge.d.ts +1 -1
- package/dist/ui/primitives/button.d.ts +1 -1
- package/dist/ui/primitives/sheet.d.ts +1 -1
- package/package.json +4 -3
- package/src/index.ts +9 -0
- package/src/lib/biome-host/define-web-biome.spec.ts +221 -0
- package/src/lib/biome-host/frontend-biome.ts +32 -5
- package/src/offline/id.ts +16 -0
- package/src/offline/index.ts +52 -0
- package/src/offline/mutation.ts +75 -0
- package/src/offline/offline-status-badge.tsx +51 -0
- package/src/offline/online-controller.ts +105 -0
- package/src/offline/outbox.test.ts +156 -0
- package/src/offline/outbox.ts +344 -0
- package/src/offline/react.tsx +71 -0
- package/src/offline/storage.ts +107 -0
- package/src/offline/types.ts +77 -0
- package/src/ui/branding/app-branding-provider.tsx +1 -1
- package/src/ui/capture/document-preview.tsx +101 -0
- package/src/ui/capture/field-spec-to-zod.test.ts +81 -0
- package/src/ui/capture/field-spec-to-zod.ts +104 -0
- package/src/ui/capture/field-spec.test.ts +49 -0
- package/src/ui/capture/field-spec.ts +181 -0
- package/src/ui/capture/index.ts +43 -0
- package/src/ui/capture/schema-field-input.tsx +314 -0
- package/src/ui/capture/schema-form.tsx +108 -0
- package/src/ui/capture/use-prefill.ts +150 -0
- package/src/ui/capture/use-schema-validation.ts +113 -0
- package/src/ui/capture/use-wizard.ts +134 -0
- package/src/ui/capture/value-format.ts +22 -0
- package/src/ui/capture/wizard.tsx +100 -0
- package/src/ui/index.ts +3 -0
- package/src/lib/biome-host/define-web-biome.test.js +0 -93
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// @xemahq/ui-kernel/offline — types
|
|
3
|
+
//
|
|
4
|
+
// A generic offline field-capture kit: an IndexedDB-backed outbox that queues
|
|
5
|
+
// mutations while disconnected and drains them (with retry + idempotency) on
|
|
6
|
+
// reconnect. Provider-agnostic — any mutation (an app-forge record submit, a
|
|
7
|
+
// status change, …) is enqueued by a stable `kind` whose executor is registered
|
|
8
|
+
// once. The motivating case is field/trade-fair capture with no signal.
|
|
9
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
10
|
+
|
|
11
|
+
/** Lifecycle of a queued mutation. */
|
|
12
|
+
export enum OutboxEntryStatus {
|
|
13
|
+
/** Waiting to be sent (offline, or not yet attempted). */
|
|
14
|
+
Pending = 'pending',
|
|
15
|
+
/** Currently being sent. */
|
|
16
|
+
Inflight = 'inflight',
|
|
17
|
+
/** Failed transiently; will be retried (attempts < maxAttempts). */
|
|
18
|
+
Retrying = 'retrying',
|
|
19
|
+
/** Exhausted retries — needs manual retry or discard (dead-letter). */
|
|
20
|
+
Failed = 'failed',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One queued mutation. `payload` is opaque to the outbox; the executor for
|
|
24
|
+
* `kind` interprets it. */
|
|
25
|
+
export interface OutboxEntry<TPayload = unknown> {
|
|
26
|
+
/** Stable entry id (also the default idempotency key). */
|
|
27
|
+
id: string;
|
|
28
|
+
/** Registered executor key (e.g. "app-forge:create-record"). */
|
|
29
|
+
kind: string;
|
|
30
|
+
payload: TPayload;
|
|
31
|
+
/** Sent to the backend so a redelivered mutation is not double-applied. */
|
|
32
|
+
idempotencyKey: string;
|
|
33
|
+
status: OutboxEntryStatus;
|
|
34
|
+
attempts: number;
|
|
35
|
+
createdAt: number;
|
|
36
|
+
updatedAt: number;
|
|
37
|
+
/** Last error message (for the dead-letter UI). */
|
|
38
|
+
lastError?: string;
|
|
39
|
+
/** Optional human label for the pending-items UI. */
|
|
40
|
+
label?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Persistence backend for the outbox. IndexedDB in the browser; in-memory for
|
|
44
|
+
* SSR / tests. */
|
|
45
|
+
export interface OutboxStorage {
|
|
46
|
+
getAll(): Promise<OutboxEntry[]>;
|
|
47
|
+
put(entry: OutboxEntry): Promise<void>;
|
|
48
|
+
delete(id: string): Promise<void>;
|
|
49
|
+
clear(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Context passed to an executor when a queued mutation is sent. */
|
|
53
|
+
export interface OutboxExecuteContext {
|
|
54
|
+
/** Pass to the backend (e.g. as an `Idempotency-Key` header). */
|
|
55
|
+
idempotencyKey: string;
|
|
56
|
+
/** How many times this entry has now been attempted (1-based). */
|
|
57
|
+
attempt: number;
|
|
58
|
+
entryId: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Sends one queued mutation. Throwing schedules a retry (see
|
|
62
|
+
* {@link OutboxOptions.isRetriable}). */
|
|
63
|
+
export type OutboxExecutor<TPayload = unknown> = (
|
|
64
|
+
payload: TPayload,
|
|
65
|
+
ctx: OutboxExecuteContext,
|
|
66
|
+
) => Promise<void>;
|
|
67
|
+
|
|
68
|
+
/** Snapshot of outbox state for UI (offline badge, pending count, dead-letter). */
|
|
69
|
+
export interface OutboxSnapshot {
|
|
70
|
+
entries: OutboxEntry[];
|
|
71
|
+
pendingCount: number;
|
|
72
|
+
failedCount: number;
|
|
73
|
+
/** True while a drain is in progress. */
|
|
74
|
+
draining: boolean;
|
|
75
|
+
/** Current connectivity (from the outbox's online controller). */
|
|
76
|
+
online: boolean;
|
|
77
|
+
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
21
21
|
import * as React from 'react';
|
|
22
22
|
|
|
23
|
-
import type { BrandingConfig } from '@xemahq/kernel-contracts/app-
|
|
23
|
+
import type { BrandingConfig } from '@xemahq/kernel-contracts/app-platform';
|
|
24
24
|
|
|
25
25
|
import { cn } from '../cn';
|
|
26
26
|
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// <DocumentPreview> — read-only "final document" view for the review step
|
|
3
|
+
//
|
|
4
|
+
// Renders a structured record as the document it will become — grouped by
|
|
5
|
+
// section, one labelled row per field, nested objects/lists expanded inline.
|
|
6
|
+
// Presentational + theme-aware (semantic tokens, light/dark) + responsive.
|
|
7
|
+
// Feed it the SAME schema you gave `<SchemaForm>` so the review mirrors input.
|
|
8
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
9
|
+
import { type ReactNode } from 'react';
|
|
10
|
+
|
|
11
|
+
import { cn } from '../cn';
|
|
12
|
+
|
|
13
|
+
import { CaptureFieldType, type CaptureFieldSpec, type CaptureSchema, type CaptureSection, type CaptureValues, toSections } from './field-spec';
|
|
14
|
+
import { formatScalar } from './value-format';
|
|
15
|
+
|
|
16
|
+
export interface DocumentPreviewProps {
|
|
17
|
+
readonly fields?: readonly CaptureFieldSpec[];
|
|
18
|
+
readonly sections?: readonly CaptureSection[];
|
|
19
|
+
readonly schema?: CaptureSchema;
|
|
20
|
+
readonly values: CaptureValues;
|
|
21
|
+
readonly title?: string;
|
|
22
|
+
/** Text shown for an unset field (default "—"). */
|
|
23
|
+
readonly emptyText?: string;
|
|
24
|
+
readonly className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @example
|
|
29
|
+
* <DocumentPreview title="Review" sections={sections} values={values} />
|
|
30
|
+
*/
|
|
31
|
+
export function DocumentPreview({ fields, sections, schema, values, title, emptyText = '—', className }: DocumentPreviewProps) {
|
|
32
|
+
const resolved = toSections(sections ?? (fields ? fields : schema) ?? []);
|
|
33
|
+
return (
|
|
34
|
+
<article className={cn('rounded-xl border border-border bg-card p-5 text-card-foreground shadow-sm sm:p-6', className)}>
|
|
35
|
+
{title ? <h2 className="mb-4 text-title font-semibold tracking-tight text-ink">{title}</h2> : null}
|
|
36
|
+
<div className="space-y-6">
|
|
37
|
+
{resolved.map((section) => (
|
|
38
|
+
<section key={section.key}>
|
|
39
|
+
{section.title ? <h3 className="mb-2 border-b border-rule pb-1 text-caption font-semibold uppercase tracking-wider text-ink-3">{section.title}</h3> : null}
|
|
40
|
+
<dl className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-[minmax(8rem,12rem)_1fr]">
|
|
41
|
+
{section.fields.map((field) => (
|
|
42
|
+
<FieldRow key={field.key} field={field} value={values[field.key]} emptyText={emptyText} />
|
|
43
|
+
))}
|
|
44
|
+
</dl>
|
|
45
|
+
</section>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
</article>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function FieldRow({ field, value, emptyText }: { field: CaptureFieldSpec; value: unknown; emptyText: string }) {
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
<dt className="text-body-1 font-medium text-ink-3 sm:text-right">{field.label}</dt>
|
|
56
|
+
<dd className="min-w-0 text-body-1 text-ink">{renderValue(field, value, emptyText)}</dd>
|
|
57
|
+
</>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderValue(field: CaptureFieldSpec, value: unknown, emptyText: string): ReactNode {
|
|
62
|
+
if (field.type === CaptureFieldType.Object) {
|
|
63
|
+
const record = (value && typeof value === 'object' && !Array.isArray(value) ? value : null) as CaptureValues | null;
|
|
64
|
+
if (!record) return <span className="text-ink-3">{emptyText}</span>;
|
|
65
|
+
return (
|
|
66
|
+
<dl className="space-y-1 rounded-md border border-rule bg-paper-elev/40 p-2">
|
|
67
|
+
{(field.fields ?? []).map((child) => (
|
|
68
|
+
<div key={child.key} className="flex flex-wrap gap-2">
|
|
69
|
+
<dt className="text-caption font-medium text-ink-3">{child.label}:</dt>
|
|
70
|
+
<dd className="text-caption text-ink">{renderValue(child, record[child.key], emptyText)}</dd>
|
|
71
|
+
</div>
|
|
72
|
+
))}
|
|
73
|
+
</dl>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (field.type === CaptureFieldType.List) {
|
|
77
|
+
const items = (Array.isArray(value) ? value : []) as CaptureValues[];
|
|
78
|
+
if (items.length === 0) return <span className="text-ink-3">{emptyText}</span>;
|
|
79
|
+
return (
|
|
80
|
+
<ol className="space-y-2">
|
|
81
|
+
{items.map((item, index) => (
|
|
82
|
+
<li key={index} className="rounded-md border border-rule bg-paper-elev/40 p-2">
|
|
83
|
+
<p className="mb-1 text-caption font-medium text-ink-3">
|
|
84
|
+
{field.label} #{index + 1}
|
|
85
|
+
</p>
|
|
86
|
+
<dl className="space-y-1">
|
|
87
|
+
{(field.fields ?? []).map((child) => (
|
|
88
|
+
<div key={child.key} className="flex flex-wrap gap-2">
|
|
89
|
+
<dt className="text-caption font-medium text-ink-3">{child.label}:</dt>
|
|
90
|
+
<dd className="text-caption text-ink">{renderValue(child, item[child.key], emptyText)}</dd>
|
|
91
|
+
</div>
|
|
92
|
+
))}
|
|
93
|
+
</dl>
|
|
94
|
+
</li>
|
|
95
|
+
))}
|
|
96
|
+
</ol>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const text = formatScalar(field, value, emptyText);
|
|
100
|
+
return <span className={cn(text === emptyText && 'text-ink-3')}>{text}</span>;
|
|
101
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Pure-logic proofs for the CaptureFieldSpec → zod adapter. Runs under the
|
|
2
|
+
// host-web vitest runner (this package carries no vitest dep — see tsconfig
|
|
3
|
+
// exclude), mirroring the repo convention used by i18n-port.test.ts.
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { CaptureFieldType, type CaptureFieldSpec } from './field-spec';
|
|
7
|
+
import { fieldSpecToZod, isEmptyValue } from './field-spec-to-zod';
|
|
8
|
+
|
|
9
|
+
describe('isEmptyValue', () => {
|
|
10
|
+
it('treats undefined/null/empty-string/empty-array as absent', () => {
|
|
11
|
+
expect(isEmptyValue(undefined)).toBe(true);
|
|
12
|
+
expect(isEmptyValue(null)).toBe(true);
|
|
13
|
+
expect(isEmptyValue('')).toBe(true);
|
|
14
|
+
expect(isEmptyValue([])).toBe(true);
|
|
15
|
+
expect(isEmptyValue('x')).toBe(false);
|
|
16
|
+
expect(isEmptyValue(0)).toBe(false);
|
|
17
|
+
expect(isEmptyValue(false)).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('fieldSpecToZod', () => {
|
|
22
|
+
const fields: CaptureFieldSpec[] = [
|
|
23
|
+
{ key: 'title', label: 'Title', type: CaptureFieldType.String, required: true },
|
|
24
|
+
{ key: 'count', label: 'Count', type: CaptureFieldType.Number, required: false, min: 0 },
|
|
25
|
+
{ key: 'status', label: 'Status', type: CaptureFieldType.Enum, enumValues: ['open', 'closed'], required: true },
|
|
26
|
+
{
|
|
27
|
+
key: 'tags',
|
|
28
|
+
label: 'Tags',
|
|
29
|
+
type: CaptureFieldType.List,
|
|
30
|
+
min: 1,
|
|
31
|
+
fields: [{ key: 'name', label: 'Name', type: CaptureFieldType.String, required: true }],
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
it('accepts a valid record', () => {
|
|
36
|
+
const r = fieldSpecToZod(fields).safeParse({ title: 'Hi', count: 3, status: 'open', tags: [{ name: 'a' }] });
|
|
37
|
+
expect(r.success).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects an empty required string', () => {
|
|
41
|
+
const r = fieldSpecToZod(fields).safeParse({ title: '', status: 'open', tags: [{ name: 'a' }] });
|
|
42
|
+
expect(r.success).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('allows an omitted optional field', () => {
|
|
46
|
+
const r = fieldSpecToZod(fields).safeParse({ title: 'Hi', status: 'closed', tags: [{ name: 'a' }] });
|
|
47
|
+
expect(r.success).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('rejects an enum value outside the set', () => {
|
|
51
|
+
const r = fieldSpecToZod(fields).safeParse({ title: 'Hi', status: 'bogus', tags: [{ name: 'a' }] });
|
|
52
|
+
expect(r.success).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('enforces list min and nested required item fields', () => {
|
|
56
|
+
expect(fieldSpecToZod(fields).safeParse({ title: 'Hi', status: 'open', tags: [] }).success).toBe(false);
|
|
57
|
+
expect(fieldSpecToZod(fields).safeParse({ title: 'Hi', status: 'open', tags: [{ name: '' }] }).success).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('media fields (image/file/audio)', () => {
|
|
62
|
+
const media: CaptureFieldSpec[] = [
|
|
63
|
+
{ key: 'photo', label: 'Photo', type: CaptureFieldType.Image, required: true },
|
|
64
|
+
{ key: 'attachment', label: 'Attachment', type: CaptureFieldType.File },
|
|
65
|
+
{ key: 'memo', label: 'Voice memo', type: CaptureFieldType.Audio },
|
|
66
|
+
];
|
|
67
|
+
const dataUrl = 'data:image/png;base64,AAAA';
|
|
68
|
+
|
|
69
|
+
it('accepts a captured data-URL for a required media field', () => {
|
|
70
|
+
expect(fieldSpecToZod(media).safeParse({ photo: dataUrl }).success).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('rejects an empty required media field', () => {
|
|
74
|
+
expect(fieldSpecToZod(media).safeParse({ photo: '' }).success).toBe(false);
|
|
75
|
+
expect(fieldSpecToZod(media).safeParse({}).success).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('allows omitted optional media fields', () => {
|
|
79
|
+
expect(fieldSpecToZod(media).safeParse({ photo: dataUrl }).success).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// CaptureFieldSpec → Zod adapter
|
|
3
|
+
//
|
|
4
|
+
// Bridges the JSON-Schema-like field spec (name/type/required/enum, and the
|
|
5
|
+
// nested object/list composites) to a `zod` schema so `useSchemaValidation`
|
|
6
|
+
// works with app definitions, not only hand-written zod. Deterministic and
|
|
7
|
+
// pure — no side effects, safe to memoize on the spec identity.
|
|
8
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
|
|
11
|
+
import { CaptureFieldType, type CaptureFieldSpec, type CaptureSchema, type CaptureSection, toFieldList } from './field-spec';
|
|
12
|
+
|
|
13
|
+
/** `''`, `null`, `undefined` all mean "absent" at the capture boundary. */
|
|
14
|
+
export function isEmptyValue(value: unknown): boolean {
|
|
15
|
+
return value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function scalarSchema(field: CaptureFieldSpec): z.ZodTypeAny {
|
|
19
|
+
switch (field.type) {
|
|
20
|
+
case CaptureFieldType.Number: {
|
|
21
|
+
let s = z.number({ message: `${field.label} must be a number` });
|
|
22
|
+
if (typeof field.min === 'number') s = s.min(field.min, { message: `${field.label} must be ≥ ${field.min}` });
|
|
23
|
+
if (typeof field.max === 'number') s = s.max(field.max, { message: `${field.label} must be ≤ ${field.max}` });
|
|
24
|
+
return s;
|
|
25
|
+
}
|
|
26
|
+
case CaptureFieldType.Boolean:
|
|
27
|
+
return z.boolean({ message: `${field.label} must be true or false` });
|
|
28
|
+
case CaptureFieldType.Decimal:
|
|
29
|
+
// NUMERIC carried as a string to avoid float precision loss.
|
|
30
|
+
return z.string().regex(/^-?\d+(\.\d+)?$/, { message: `${field.label} must be a decimal number` });
|
|
31
|
+
case CaptureFieldType.Enum: {
|
|
32
|
+
const values = [...(field.enumValues ?? [])];
|
|
33
|
+
if (values.length === 0) return z.string();
|
|
34
|
+
return z.enum(values as [string, ...string[]]);
|
|
35
|
+
}
|
|
36
|
+
case CaptureFieldType.Date:
|
|
37
|
+
return z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { message: `${field.label} must be a date` });
|
|
38
|
+
case CaptureFieldType.Datetime:
|
|
39
|
+
return z.string().min(1, { message: `${field.label} must be a date/time` });
|
|
40
|
+
// string / text / user / reference — plain text.
|
|
41
|
+
default:
|
|
42
|
+
return z.string();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fieldSchema(field: CaptureFieldSpec): z.ZodTypeAny {
|
|
47
|
+
let base: z.ZodTypeAny;
|
|
48
|
+
if (field.type === CaptureFieldType.Object) {
|
|
49
|
+
base = objectSchema(field.fields ?? []);
|
|
50
|
+
} else if (field.type === CaptureFieldType.List) {
|
|
51
|
+
let arr = z.array(objectSchema(field.fields ?? []));
|
|
52
|
+
if (typeof field.min === 'number') arr = arr.min(field.min, { message: `${field.label} needs at least ${field.min} item(s)` });
|
|
53
|
+
if (typeof field.max === 'number') arr = arr.max(field.max, { message: `${field.label} allows at most ${field.max} item(s)` });
|
|
54
|
+
base = arr;
|
|
55
|
+
} else {
|
|
56
|
+
base = scalarSchema(field);
|
|
57
|
+
if (
|
|
58
|
+
field.required &&
|
|
59
|
+
(field.type === CaptureFieldType.String ||
|
|
60
|
+
field.type === CaptureFieldType.Text ||
|
|
61
|
+
field.type === CaptureFieldType.User ||
|
|
62
|
+
field.type === CaptureFieldType.Reference ||
|
|
63
|
+
field.type === CaptureFieldType.Datetime ||
|
|
64
|
+
// Media fields carry a data-URL string; required = a capture was made.
|
|
65
|
+
field.type === CaptureFieldType.Image ||
|
|
66
|
+
field.type === CaptureFieldType.File ||
|
|
67
|
+
field.type === CaptureFieldType.Audio)
|
|
68
|
+
) {
|
|
69
|
+
base = (base as z.ZodString).min(1, { message: `${field.label} is required` });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!field.required) {
|
|
74
|
+
// Optional: `.optional()` makes the object KEY absent-safe (zod v4 does not
|
|
75
|
+
// infer that from a union with z.undefined()); the union additionally
|
|
76
|
+
// accepts the empty-string / null sentinels the inputs emit for "cleared".
|
|
77
|
+
return z.union([base, z.literal(''), z.null()]).optional();
|
|
78
|
+
}
|
|
79
|
+
return base;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function objectSchema(fields: readonly CaptureFieldSpec[]): z.ZodObject<z.ZodRawShape> {
|
|
83
|
+
const shape: Record<string, z.ZodTypeAny> = {};
|
|
84
|
+
for (const f of fields) shape[f.key] = fieldSchema(f);
|
|
85
|
+
// Non-strict: unknown keys are ignored (the server is the strict fence).
|
|
86
|
+
return z.object(shape);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build a `zod` object schema from a capture schema (flat fields, sections, or
|
|
91
|
+
* a `CaptureSchema`). The result validates the whole values record.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* const schema = fieldSpecToZod([
|
|
95
|
+
* { key: 'title', label: 'Title', type: 'string', required: true },
|
|
96
|
+
* { key: 'tags', label: 'Tags', type: 'list', fields: [{ key: 'name', label: 'Name', type: 'string', required: true }] },
|
|
97
|
+
* ]);
|
|
98
|
+
* schema.safeParse({ title: 'Hi', tags: [{ name: 'a' }] }); // { success: true }
|
|
99
|
+
*/
|
|
100
|
+
export function fieldSpecToZod(
|
|
101
|
+
schema: CaptureSchema | readonly CaptureFieldSpec[] | readonly CaptureSection[],
|
|
102
|
+
): z.ZodObject<z.ZodRawShape> {
|
|
103
|
+
return objectSchema(toFieldList(schema));
|
|
104
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Pure-logic proofs for the schema-face normalizers. Runs under host-web
|
|
2
|
+
// vitest (this package carries no vitest dep — see tsconfig exclude).
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { CaptureFieldType, groupFieldsBySection, toFieldList, toSections, type CaptureFieldSpec, type CaptureSection } from './field-spec';
|
|
6
|
+
|
|
7
|
+
const fields: CaptureFieldSpec[] = [
|
|
8
|
+
{ key: 'a', label: 'A', type: CaptureFieldType.String, section: 'one' },
|
|
9
|
+
{ key: 'b', label: 'B', type: CaptureFieldType.Number, section: 'one' },
|
|
10
|
+
{ key: 'c', label: 'C', type: CaptureFieldType.Boolean, section: 'two' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const sections: CaptureSection[] = [
|
|
14
|
+
{ key: 's1', title: 'S1', fields: [{ key: 'a', label: 'A', type: CaptureFieldType.String }] },
|
|
15
|
+
{ key: 's2', title: 'S2', fields: [{ key: 'b', label: 'B', type: CaptureFieldType.String }] },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
describe('toFieldList', () => {
|
|
19
|
+
it('returns a flat field list unchanged', () => {
|
|
20
|
+
expect(toFieldList(fields).map((f) => f.key)).toEqual(['a', 'b', 'c']);
|
|
21
|
+
});
|
|
22
|
+
it('flattens sections to their fields', () => {
|
|
23
|
+
expect(toFieldList(sections).map((f) => f.key)).toEqual(['a', 'b']);
|
|
24
|
+
});
|
|
25
|
+
it('reads the { fields } schema face', () => {
|
|
26
|
+
expect(toFieldList({ fields }).map((f) => f.key)).toEqual(['a', 'b', 'c']);
|
|
27
|
+
});
|
|
28
|
+
it('reads the { sections } schema face', () => {
|
|
29
|
+
expect(toFieldList({ sections }).map((f) => f.key)).toEqual(['a', 'b']);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('groupFieldsBySection', () => {
|
|
34
|
+
it('splits a flat list by each field section key, preserving order', () => {
|
|
35
|
+
const grouped = groupFieldsBySection(fields);
|
|
36
|
+
expect(grouped.map((s) => s.key)).toEqual(['one', 'two']);
|
|
37
|
+
expect(grouped[0].fields.map((f) => f.key)).toEqual(['a', 'b']);
|
|
38
|
+
expect(grouped[1].fields.map((f) => f.key)).toEqual(['c']);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('toSections', () => {
|
|
43
|
+
it('passes explicit sections through', () => {
|
|
44
|
+
expect(toSections(sections).map((s) => s.title)).toEqual(['S1', 'S2']);
|
|
45
|
+
});
|
|
46
|
+
it('derives sections from a flat list', () => {
|
|
47
|
+
expect(toSections(fields).map((s) => s.key)).toEqual(['one', 'two']);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// Guided-capture field spec — the ONE schema shape the capture kit consumes
|
|
3
|
+
//
|
|
4
|
+
// This is the frontend-agnostic superset of app-forge-web's `AppField`. The
|
|
5
|
+
// wire/definition layer (app-forge-api, its renderer's `AppFieldType`) owns 10
|
|
6
|
+
// closed field types; this kit mirrors those 10 verbatim (same string values,
|
|
7
|
+
// so an `AppField` is structurally assignable to `CaptureFieldSpec`) and ADDS
|
|
8
|
+
// two structural composites — `object` and `list` — plus optional `section`
|
|
9
|
+
// grouping. A workbench therefore extends ONE renderer (`<SchemaForm>`) instead
|
|
10
|
+
// of the three parallel schema→form implementations that exist today.
|
|
11
|
+
//
|
|
12
|
+
// Value shape produced/consumed:
|
|
13
|
+
// scalar field → string | number | boolean (per type)
|
|
14
|
+
// object field → Record<string, unknown> (nested record)
|
|
15
|
+
// list field → unknown[] (array of item records)
|
|
16
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Closed set of capture field types. The first ten are value-identical to
|
|
20
|
+
* app-forge-web's `AppFieldType` (`string`/`text`/`number`/`decimal`/`boolean`/
|
|
21
|
+
* `date`/`datetime`/`enum`/`reference`/`user`) so definitions authored for the
|
|
22
|
+
* App Platform pass straight through. `object`/`list` are the structural
|
|
23
|
+
* composites this kit adds for nested + repeatable capture.
|
|
24
|
+
*
|
|
25
|
+
* The string VALUES are the contract — keep the first ten identical to
|
|
26
|
+
* `AppFieldType` in `@xemahq/kernel-contracts/app-definition` (the single
|
|
27
|
+
* source of truth for App Platform field types). Adding a value here that
|
|
28
|
+
* `AppFieldType` lacks is fine (superset); changing an existing shared value is
|
|
29
|
+
* not. This kit can't import the enum (it adds members), so the shared values
|
|
30
|
+
* are mirrored deliberately.
|
|
31
|
+
*/
|
|
32
|
+
export enum CaptureFieldType {
|
|
33
|
+
String = 'string',
|
|
34
|
+
Text = 'text',
|
|
35
|
+
Number = 'number',
|
|
36
|
+
Decimal = 'decimal',
|
|
37
|
+
Boolean = 'boolean',
|
|
38
|
+
Date = 'date',
|
|
39
|
+
Datetime = 'datetime',
|
|
40
|
+
Enum = 'enum',
|
|
41
|
+
Reference = 'reference',
|
|
42
|
+
User = 'user',
|
|
43
|
+
Object = 'object',
|
|
44
|
+
List = 'list',
|
|
45
|
+
// ── Media capture (field-capture kit) ──
|
|
46
|
+
// Value is a data-URL string (base64), so it stays JSON-serializable and
|
|
47
|
+
// flows unchanged through the record model + the offline outbox. `Image`
|
|
48
|
+
// opens the device camera on mobile (`capture`), `File` attaches any file,
|
|
49
|
+
// `Audio` records a voice memo via MediaRecorder.
|
|
50
|
+
Image = 'image',
|
|
51
|
+
File = 'file',
|
|
52
|
+
Audio = 'audio',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A single capture field. Structural superset of app-forge's `AppField`:
|
|
57
|
+
* `key`/`label`/`type`/`required`/`enumValues`/`referenceEntity` carry the
|
|
58
|
+
* exact same meaning, so `AppField[]` can be handed to this kit unchanged. The
|
|
59
|
+
* extra props are additive and all optional.
|
|
60
|
+
*
|
|
61
|
+
* `type` accepts the {@link CaptureFieldType} enum OR its bare string value, so
|
|
62
|
+
* `{ type: 'string' }` (app-forge shape) and `{ type: CaptureFieldType.String }`
|
|
63
|
+
* both type-check.
|
|
64
|
+
*/
|
|
65
|
+
export interface CaptureFieldSpec {
|
|
66
|
+
readonly key: string;
|
|
67
|
+
readonly label: string;
|
|
68
|
+
readonly type: CaptureFieldType | `${CaptureFieldType}`;
|
|
69
|
+
readonly required?: boolean;
|
|
70
|
+
/** Helper text rendered under the label. */
|
|
71
|
+
readonly description?: string;
|
|
72
|
+
/** Placeholder for text-like inputs. */
|
|
73
|
+
readonly placeholder?: string;
|
|
74
|
+
/** Only for `enum` — the allowed values. */
|
|
75
|
+
readonly enumValues?: readonly string[];
|
|
76
|
+
/** Only for `reference` — key of the target entity (opaque to this kit). */
|
|
77
|
+
readonly referenceEntity?: string;
|
|
78
|
+
/** Only for `object`/`list` — the nested item shape. */
|
|
79
|
+
readonly fields?: readonly CaptureFieldSpec[];
|
|
80
|
+
/** `number`/`decimal`: numeric bounds. `list`: min/max item count. */
|
|
81
|
+
readonly min?: number;
|
|
82
|
+
readonly max?: number;
|
|
83
|
+
/**
|
|
84
|
+
* `image`/`file`/`audio`: the `accept` filter for the file picker (e.g.
|
|
85
|
+
* `image/*`, `application/pdf`). Defaults per type when omitted.
|
|
86
|
+
*/
|
|
87
|
+
readonly accept?: string;
|
|
88
|
+
/**
|
|
89
|
+
* `image`/`file`: the HTML `capture` hint — `environment` opens the rear
|
|
90
|
+
* camera, `user` the front camera. Omitted = a normal file picker.
|
|
91
|
+
*/
|
|
92
|
+
readonly capture?: 'user' | 'environment';
|
|
93
|
+
/**
|
|
94
|
+
* Optional section grouping key for FLAT `CaptureFieldSpec[]` schemas. When a
|
|
95
|
+
* schema is expressed as {@link CaptureSection}[] this is ignored (grouping is
|
|
96
|
+
* explicit). See {@link groupFieldsBySection}.
|
|
97
|
+
*/
|
|
98
|
+
readonly section?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A titled group of fields — the explicit sectioned schema shape. */
|
|
102
|
+
export interface CaptureSection {
|
|
103
|
+
readonly key: string;
|
|
104
|
+
readonly title: string;
|
|
105
|
+
readonly description?: string;
|
|
106
|
+
readonly fields: readonly CaptureFieldSpec[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A capture schema is EITHER a flat field list OR a list of titled sections.
|
|
111
|
+
* `<SchemaForm>`, `useSchemaValidation`, and `<DocumentPreview>` all accept
|
|
112
|
+
* either face.
|
|
113
|
+
*/
|
|
114
|
+
export type CaptureSchema =
|
|
115
|
+
| { readonly fields: readonly CaptureFieldSpec[]; readonly sections?: undefined }
|
|
116
|
+
| { readonly sections: readonly CaptureSection[]; readonly fields?: undefined };
|
|
117
|
+
|
|
118
|
+
/** Structured record value the capture kit reads/writes. */
|
|
119
|
+
export type CaptureValues = Record<string, unknown>;
|
|
120
|
+
|
|
121
|
+
/** True if `x` is the sectioned face of a schema. */
|
|
122
|
+
export function isSectioned(
|
|
123
|
+
x: CaptureSchema | readonly CaptureFieldSpec[] | readonly CaptureSection[],
|
|
124
|
+
): x is { readonly sections: readonly CaptureSection[] } {
|
|
125
|
+
return !Array.isArray(x) && 'sections' in (x as object) && Array.isArray((x as { sections?: unknown }).sections);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Normalize any accepted schema face to the flat list of TOP-LEVEL fields.
|
|
130
|
+
* (Nested `object`/`list` item fields are NOT flattened — they belong to their
|
|
131
|
+
* parent's value.) Accepts a `CaptureFieldSpec[]`, a `CaptureSection[]`, or a
|
|
132
|
+
* `CaptureSchema`.
|
|
133
|
+
*/
|
|
134
|
+
export function toFieldList(
|
|
135
|
+
schema: CaptureSchema | readonly CaptureFieldSpec[] | readonly CaptureSection[],
|
|
136
|
+
): readonly CaptureFieldSpec[] {
|
|
137
|
+
if (Array.isArray(schema)) {
|
|
138
|
+
const arr = schema as readonly (CaptureFieldSpec | CaptureSection)[];
|
|
139
|
+
// A CaptureSection has `title`+`fields`; a CaptureFieldSpec has `type`.
|
|
140
|
+
if (arr.length > 0 && arr.every((e) => 'title' in e && 'fields' in e && !('type' in e))) {
|
|
141
|
+
return (arr as readonly CaptureSection[]).flatMap((s) => s.fields);
|
|
142
|
+
}
|
|
143
|
+
return arr as readonly CaptureFieldSpec[];
|
|
144
|
+
}
|
|
145
|
+
if (isSectioned(schema)) return schema.sections.flatMap((s) => s.fields);
|
|
146
|
+
return (schema as { fields: readonly CaptureFieldSpec[] }).fields;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve any accepted schema face to `CaptureSection[]`. A flat field list
|
|
151
|
+
* collapses to a single untitled section (or is split by each field's
|
|
152
|
+
* `section` key when present).
|
|
153
|
+
*/
|
|
154
|
+
export function toSections(
|
|
155
|
+
schema: CaptureSchema | readonly CaptureFieldSpec[] | readonly CaptureSection[],
|
|
156
|
+
fallbackTitle = '',
|
|
157
|
+
): readonly CaptureSection[] {
|
|
158
|
+
if (Array.isArray(schema) && schema.length > 0 && (schema as readonly object[]).every((e) => 'title' in e && 'fields' in e)) {
|
|
159
|
+
return schema as readonly CaptureSection[];
|
|
160
|
+
}
|
|
161
|
+
if (!Array.isArray(schema) && isSectioned(schema)) return schema.sections;
|
|
162
|
+
return groupFieldsBySection(toFieldList(schema), fallbackTitle);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Split a flat field list into sections by each field's `section` key. */
|
|
166
|
+
export function groupFieldsBySection(
|
|
167
|
+
fields: readonly CaptureFieldSpec[],
|
|
168
|
+
fallbackTitle = '',
|
|
169
|
+
): readonly CaptureSection[] {
|
|
170
|
+
const order: string[] = [];
|
|
171
|
+
const byKey = new Map<string, CaptureFieldSpec[]>();
|
|
172
|
+
for (const f of fields) {
|
|
173
|
+
const key = f.section ?? '';
|
|
174
|
+
if (!byKey.has(key)) {
|
|
175
|
+
byKey.set(key, []);
|
|
176
|
+
order.push(key);
|
|
177
|
+
}
|
|
178
|
+
byKey.get(key)!.push(f);
|
|
179
|
+
}
|
|
180
|
+
return order.map((key) => ({ key: key || '_', title: key || fallbackTitle, fields: byKey.get(key)! }));
|
|
181
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// @xemahq/ui-kernel — guided-capture UX kit (B5)
|
|
3
|
+
//
|
|
4
|
+
// Headless engines + presentation-thin primitives for schema-driven, guided
|
|
5
|
+
// data capture: a wizard state machine, live client-side validation, AI/context
|
|
6
|
+
// pre-fill, a single consolidated schema→form renderer, and a review-step
|
|
7
|
+
// document preview. Consolidates the three parallel schema→form implementations
|
|
8
|
+
// so a workbench extends ONE renderer.
|
|
9
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
10
|
+
|
|
11
|
+
// ── field-spec model (aligned superset of app-forge's AppFieldType) ──
|
|
12
|
+
export {
|
|
13
|
+
CaptureFieldType,
|
|
14
|
+
isSectioned,
|
|
15
|
+
toFieldList,
|
|
16
|
+
toSections,
|
|
17
|
+
groupFieldsBySection,
|
|
18
|
+
type CaptureFieldSpec,
|
|
19
|
+
type CaptureSection,
|
|
20
|
+
type CaptureSchema,
|
|
21
|
+
type CaptureValues,
|
|
22
|
+
} from './field-spec';
|
|
23
|
+
|
|
24
|
+
// ── schema → zod adapter ──
|
|
25
|
+
export { fieldSpecToZod, isEmptyValue } from './field-spec-to-zod';
|
|
26
|
+
|
|
27
|
+
// ── validation + "missing info" ──
|
|
28
|
+
export { useSchemaValidation, type SchemaValidationResult } from './use-schema-validation';
|
|
29
|
+
|
|
30
|
+
// ── pre-fill ──
|
|
31
|
+
export { usePrefill, type UsePrefillOptions, type UsePrefillResult } from './use-prefill';
|
|
32
|
+
|
|
33
|
+
// ── wizard engine + shell ──
|
|
34
|
+
export { useWizard, type WizardApi, type WizardStepDef, type UseWizardOptions } from './use-wizard';
|
|
35
|
+
export { Wizard, WizardStep, type WizardProps, type WizardStepProps } from './wizard';
|
|
36
|
+
|
|
37
|
+
// ── consolidated schema form ──
|
|
38
|
+
export { SchemaForm, type SchemaFormProps } from './schema-form';
|
|
39
|
+
export { SchemaFieldInput, type SchemaFieldInputProps, type RenderReference } from './schema-field-input';
|
|
40
|
+
export { asString, formatScalar } from './value-format';
|
|
41
|
+
|
|
42
|
+
// ── review-step document preview ──
|
|
43
|
+
export { DocumentPreview, type DocumentPreviewProps } from './document-preview';
|