@cosmicdrift/kumiko-renderer 0.378.0 → 0.379.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/app/action-form-shim.js +1 -2
- package/dist/app/action-form-support.js +16 -4
- package/dist/app/kumiko-screen.js +49 -38
- package/dist/app/list-empty-content.d.ts +1 -0
- package/dist/app/list-empty-content.js +5 -0
- package/dist/app/row-actions.d.ts +7 -1
- package/dist/app/row-actions.js +6 -1
- package/dist/app/secret-mint-body.js +3 -2
- package/dist/components/embedded-list-field.js +16 -7
- package/dist/components/file-list-field.js +4 -0
- package/dist/components/grid-cell-for-field.d.ts +4 -1
- package/dist/components/grid-cell-for-field.js +6 -2
- package/dist/components/list-empty-state.d.ts +4 -1
- package/dist/components/list-empty-state.js +4 -1
- package/dist/components/reference-create-dialog.js +1 -1
- package/dist/components/related-list-section.js +4 -1
- package/dist/components/render-edit-logic.d.ts +1 -1
- package/dist/components/render-edit-logic.js +8 -2
- package/dist/components/render-edit-types.d.ts +3 -0
- package/dist/components/render-edit.js +25 -6
- package/dist/components/render-field.d.ts +3 -1
- package/dist/components/render-field.js +19 -7
- package/dist/components/render-list.d.ts +3 -1
- package/dist/components/render-list.js +29 -8
- package/dist/components/write-form-section.js +4 -1
- package/dist/field-issue-text.d.ts +4 -0
- package/dist/field-issue-text.js +14 -0
- package/dist/headless/dispatcher/index.d.ts +1 -0
- package/dist/headless/dispatcher/index.js +1 -0
- package/dist/headless/dispatcher/request-id.d.ts +1 -0
- package/dist/headless/dispatcher/request-id.js +15 -0
- package/dist/headless/form/form-controller.js +16 -1
- package/dist/headless/form/zod-bridge.js +7 -0
- package/dist/headless/index.d.ts +2 -1
- package/dist/headless/index.js +2 -1
- package/dist/headless/plural-key.d.ts +1 -0
- package/dist/headless/plural-key.js +5 -0
- package/dist/headless/view-model/edit.d.ts +4 -0
- package/dist/headless/view-model/edit.js +10 -5
- package/dist/headless/view-model/types.d.ts +10 -2
- package/dist/hooks/live-refetch-scheduler.d.ts +6 -0
- package/dist/hooks/live-refetch-scheduler.js +45 -0
- package/dist/hooks/use-query.js +8 -4
- package/dist/hooks/use-reference-lookup.d.ts +1 -0
- package/dist/hooks/use-reference-lookup.js +6 -3
- package/dist/i18n-defaults.js +22 -3
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/leave-guard-dialog.d.ts +2 -0
- package/dist/leave-guard-dialog.js +28 -0
- package/dist/leave-guard.d.ts +15 -0
- package/dist/leave-guard.js +39 -0
- package/dist/live/dispatcher-live.js +1 -16
- package/dist/primitives.d.ts +29 -0
- package/dist/sse/live-events.d.ts +3 -0
- package/dist/sse/live-events.js +3 -0
- package/dist/web/app/create-app.js +2 -2
- package/dist/web/app/dashboard-body.js +13 -1
- package/dist/web/app/nav.js +51 -9
- package/dist/web/layout/nav-tree.js +13 -3
- package/dist/web/layout/shell-header.js +3 -3
- package/dist/web/lib/file-upload-form-data.d.ts +2 -0
- package/dist/web/lib/file-upload-form-data.js +2 -0
- package/dist/web/lib/upload-error-key.d.ts +7 -0
- package/dist/web/lib/upload-error-key.js +23 -0
- package/dist/web/lib/upload-file-ref.d.ts +1 -1
- package/dist/web/primitives/embedded-list-input.js +5 -4
- package/dist/web/primitives/file-upload.d.ts +2 -1
- package/dist/web/primitives/file-upload.js +11 -7
- package/dist/web/primitives/files-dropzone.d.ts +16 -0
- package/dist/web/primitives/files-dropzone.js +52 -0
- package/dist/web/primitives/index.js +62 -29
- package/dist/web/primitives/segmented-select.js +8 -2
- package/dist/web/sse/live-events.d.ts +13 -0
- package/dist/web/sse/live-events.js +88 -23
- package/dist/web/widgets/upload-zone.d.ts +11 -2
- package/dist/web/widgets/upload-zone.js +30 -7
- package/package.json +5 -5
- package/src/changes.json +104 -0
|
@@ -254,10 +254,13 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
254
254
|
: { [section.parentParam ?? "id"]: parentId },
|
|
255
255
|
record,
|
|
256
256
|
host,
|
|
257
|
+
access: { appFeatures, userRoles },
|
|
257
258
|
...(onOpenDrawer !== undefined && {
|
|
258
259
|
openDrawer: (action) => onOpenDrawer(action, undefined),
|
|
259
260
|
}),
|
|
260
261
|
}), [
|
|
262
|
+
appFeatures,
|
|
263
|
+
userRoles,
|
|
261
264
|
section.toolbarActions,
|
|
262
265
|
section.parentParam,
|
|
263
266
|
section.parentFilter,
|
|
@@ -319,7 +322,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
319
322
|
const truncated = rowsQuery.data !== null &&
|
|
320
323
|
typeof rowsQuery.data.nextCursor === "string" &&
|
|
321
324
|
(rowsQuery.data.total === undefined || rowsQuery.data.total > rowsQuery.data.rows.length);
|
|
322
|
-
const content = rowsQuery.loading && rowsQuery.data === null ? (_jsx(Banner, { padded: true, variant: "loading", testId: "related-list-loading", children: "
|
|
325
|
+
const content = rowsQuery.loading && rowsQuery.data === null ? (_jsx(Banner, { padded: true, variant: "loading", testId: "related-list-loading", children: t("kumiko.widget.loading") })) : rowsQuery.error ? (_jsx(Banner, { padded: true, variant: "error", testId: "related-list-error", children: dispatcherErrorText(rowsQuery.error, effectiveTranslate) })) : (_jsxs(_Fragment, { children: [truncated && (_jsx(Banner, { variant: "info", testId: "related-list-truncated", children: effectiveTranslate("kumiko.list.related-list-truncated", {
|
|
323
326
|
count: sortedRows.length,
|
|
324
327
|
}) })), emptyStateActionError !== null && (_jsx(Banner, { variant: "error", testId: "related-list-empty-state-action-error", children: emptyStateActionError })), _jsx(PageHeaderSlotAvailableProvider, { value: false, children: _jsx(RenderList, { screen: listScreen, entity: entity, rows: sortedRows, featureName: listFeatureName ?? featureName, translate: effectiveTranslate, ...(section.description !== undefined && { description: section.description }), ...(section.itemNoun !== undefined && { itemNounKey: section.itemNoun }), sort: sort, onSortChange: setSort, ...(section.searchable === true && {
|
|
325
328
|
searchable: true,
|
|
@@ -3,7 +3,7 @@ export declare function hasEditableSection(sections: readonly EditSectionViewMod
|
|
|
3
3
|
export declare function resolveExtensionEntityId(entityIdProp: string | null | undefined, vmId: string | null): string | null;
|
|
4
4
|
export declare function shouldNotifyCaller(result: SubmitResult<unknown>, extensionsPersisted: boolean): boolean;
|
|
5
5
|
export declare function issueRootField(path: string): string;
|
|
6
|
-
export declare function hasIssueWithoutRenderedField(issuePaths: readonly string[], sections: readonly EditSectionViewModel[]): boolean;
|
|
6
|
+
export declare function hasIssueWithoutRenderedField(issuePaths: readonly string[], sections: readonly EditSectionViewModel[], derivedTargets?: ReadonlySet<string>): boolean;
|
|
7
7
|
export declare function hasIssueWithoutVisibleField(issuePaths: readonly string[], fields: readonly EditFieldViewModel[]): boolean;
|
|
8
8
|
export declare function findFirstErroringSectionIndex(sections: readonly EditSectionViewModel[], issuePaths: readonly string[]): number | undefined;
|
|
9
9
|
export declare function filterEditSections(sections: readonly EditSectionViewModel[], fieldsFilter: readonly string[] | undefined): readonly EditSectionViewModel[];
|
|
@@ -32,9 +32,15 @@ export function issueRootField(path) {
|
|
|
32
32
|
return path.split(".")[0] ?? path;
|
|
33
33
|
}
|
|
34
34
|
// No issues at all counts as unmatched, so the caller keeps the banner.
|
|
35
|
-
|
|
35
|
+
// `derivedTargets` are head fields the form computes (actionForm `derive`): an
|
|
36
|
+
// issue on one is a knock-on of another issue (an empty list leaves the total
|
|
37
|
+
// without a value), nobody can fix it there. It is ignored while another issue
|
|
38
|
+
// exists, so the banner does not bury the real one; alone it keeps the banner.
|
|
39
|
+
export function hasIssueWithoutRenderedField(issuePaths, sections, derivedTargets = new Set()) {
|
|
40
|
+
const fixablePaths = issuePaths.filter((path) => !derivedTargets.has(issueRootField(path)));
|
|
41
|
+
const paths = fixablePaths.length > 0 ? fixablePaths : issuePaths;
|
|
36
42
|
const renderedFields = sections.flatMap((s) => s.kind === "fields" && s.visible ? s.fields : []);
|
|
37
|
-
return hasIssueWithoutVisibleField(
|
|
43
|
+
return hasIssueWithoutVisibleField(paths, renderedFields);
|
|
38
44
|
}
|
|
39
45
|
export function hasIssueWithoutVisibleField(issuePaths, fields) {
|
|
40
46
|
if (issuePaths.length === 0)
|
|
@@ -207,6 +207,9 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
207
207
|
/** actionForm `derive`: derived fields are recomputed into the form state on every
|
|
208
208
|
* change and render read-only. */
|
|
209
209
|
readonly formDerivation?: FormDerivationScreen;
|
|
210
|
+
/** QN of the actionForm screen this form renders; its file fields upload as pending for that
|
|
211
|
+
* screen. Unset for entityEdit and for secretMint/confirm forms, which take no uploads. */
|
|
212
|
+
readonly actionFormScreenQn?: string;
|
|
210
213
|
/** Show the unsaved-changes footer (count, Discard, "Save changes") even
|
|
211
214
|
* without an entity id or a screen-height form — for hosts that edit an
|
|
212
215
|
* existing server-side record through `customSubmit` (configEdit, also when
|
|
@@ -16,6 +16,7 @@ import { formatWhen } from "../format-when.js";
|
|
|
16
16
|
import { useForm } from "../hooks/use-form.js";
|
|
17
17
|
import { useTranslation } from "../i18n.js";
|
|
18
18
|
import { useInsideDrawer } from "../inside-drawer.js";
|
|
19
|
+
import { useUnsavedInputSource } from "../leave-guard.js";
|
|
19
20
|
import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header-slot.js";
|
|
20
21
|
import { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
|
|
21
22
|
import { GridCellForField } from "./grid-cell-for-field.js";
|
|
@@ -30,7 +31,7 @@ const FORM_DRAFT_GET = "form-draft:query:get";
|
|
|
30
31
|
const FORM_DRAFT_SAVE = "form-draft:write:save";
|
|
31
32
|
const FORM_DRAFT_DISCARD = "form-draft:write:discard";
|
|
32
33
|
const FORM_DRAFT_LIST = "form-draft:query:list";
|
|
33
|
-
// Mirrors bundled-features/src/form-draft/constants.ts's FORM_DRAFT_KEY_MAX_LENGTH
|
|
34
|
+
// Mirrors bundled-features/src/form-draft/web/constants.ts's FORM_DRAFT_KEY_MAX_LENGTH
|
|
34
35
|
// — same hardcoding rationale as the QNs above (no dependency on that package).
|
|
35
36
|
const FORM_DRAFT_KEY_MAX_LENGTH = 256;
|
|
36
37
|
// Trailing-edge debounce for patch()-triggered draft saves (#1914). A single
|
|
@@ -49,7 +50,7 @@ function newDraftPrefix(screenId) {
|
|
|
49
50
|
return `${screenId}:new:`;
|
|
50
51
|
}
|
|
51
52
|
// `crypto.randomUUID` is missing in non-secure contexts and React Native/Hermes
|
|
52
|
-
// without a polyfill — guard like dispatcher-
|
|
53
|
+
// without a polyfill — guard like generateRequestId in headless/dispatcher/request-id.ts.
|
|
53
54
|
function mintDraftId() {
|
|
54
55
|
const webCrypto = propertyOf(globalThis, "crypto");
|
|
55
56
|
const randomUUID = propertyOf(webCrypto, "randomUUID");
|
|
@@ -230,7 +231,7 @@ function resolveTitleTemplate(template, values, translate, fallback) {
|
|
|
230
231
|
return translate(template, values);
|
|
231
232
|
}
|
|
232
233
|
export function RenderEdit(props) {
|
|
233
|
-
const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, refreshToken, onDirtyChange, onCopyLink, actions, menuActions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled: disabledProp = false, viewMode = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, submitBlocked = false, formDerivation, } = props;
|
|
234
|
+
const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, refreshToken, onDirtyChange, onCopyLink, actions, menuActions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled: disabledProp = false, viewMode = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, submitBlocked = false, formDerivation, actionFormScreenQn, } = props;
|
|
234
235
|
// View mode is a locked form, so everything keyed on `disabled` follows.
|
|
235
236
|
const disabled = disabledProp || viewMode;
|
|
236
237
|
const i18nScreenId = props.i18nScreenId ?? screen.id;
|
|
@@ -255,6 +256,7 @@ export function RenderEdit(props) {
|
|
|
255
256
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
|
256
257
|
const [linkCopied, setLinkCopied] = useState(false);
|
|
257
258
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
259
|
+
const [focusFirstErrorKey, setFocusFirstErrorKey] = useState(0);
|
|
258
260
|
const [formError, setFormError] = useState(null);
|
|
259
261
|
// Only a submit's validation/server error is cleared by editing; a delete
|
|
260
262
|
// rejection and a version conflict (reload is the way out) stay put.
|
|
@@ -305,9 +307,12 @@ export function RenderEdit(props) {
|
|
|
305
307
|
// never enters `fields`/`controller.getSnapshot().values` — there is no
|
|
306
308
|
// draft-blob-covered state left for persistExtensions() to compete over.
|
|
307
309
|
const [extensionDirty, setExtensionDirty] = useState(false);
|
|
310
|
+
// Mirror for the leave guard: it must see "saved" before React re-renders.
|
|
311
|
+
const extensionDirtyRef = useRef(false);
|
|
308
312
|
const [hasExtensionRegistrations, setHasExtensionRegistrations] = useState(false);
|
|
309
313
|
const [extensionErrorKey, setExtensionErrorKey] = useState(null);
|
|
310
314
|
const { registry: extensionFormRegistry, runAll: runExtensionSubmits } = useExtensionFormHost(({ anyDirty, hasRegistrations }) => {
|
|
315
|
+
extensionDirtyRef.current = anyDirty;
|
|
311
316
|
setExtensionDirty(anyDirty);
|
|
312
317
|
setHasExtensionRegistrations(hasRegistrations);
|
|
313
318
|
});
|
|
@@ -552,6 +557,9 @@ export function RenderEdit(props) {
|
|
|
552
557
|
useEffect(() => {
|
|
553
558
|
onDirtyChangeRef.current?.(hasUnsavedInput);
|
|
554
559
|
}, [hasUnsavedInput]);
|
|
560
|
+
// Draft wizards keep their input server-side, so leaving loses nothing.
|
|
561
|
+
const persistsDraft = isWizard && screen.layout.draft === true;
|
|
562
|
+
useUnsavedInputSource(() => !persistsDraft && (controller.getSnapshot().isDirty || extensionDirtyRef.current));
|
|
555
563
|
useEffect(() => {
|
|
556
564
|
// skip: this screen does not persist a draft.
|
|
557
565
|
if (!draftEnabled || dispatcher === undefined)
|
|
@@ -666,8 +674,10 @@ export function RenderEdit(props) {
|
|
|
666
674
|
mode: writeVerb,
|
|
667
675
|
...(!isCreateMode && entityIdProp != null && { entityId: entityIdProp }),
|
|
668
676
|
...(formDerivation !== undefined && { derivation: formDerivation }),
|
|
677
|
+
...(actionFormScreenQn !== undefined && { actionFormScreenQn }),
|
|
669
678
|
}), [
|
|
670
679
|
formDerivation,
|
|
680
|
+
actionFormScreenQn,
|
|
671
681
|
screen,
|
|
672
682
|
entity,
|
|
673
683
|
snapshot.values,
|
|
@@ -975,6 +985,7 @@ export function RenderEdit(props) {
|
|
|
975
985
|
// Same discard as the main path: an extension-only save is still a
|
|
976
986
|
// successful submit, so the draft must not survive it.
|
|
977
987
|
if (await persistExtensions()) {
|
|
988
|
+
extensionDirtyRef.current = false;
|
|
978
989
|
submittedRef.current = true;
|
|
979
990
|
await discardDraft();
|
|
980
991
|
}
|
|
@@ -1036,8 +1047,11 @@ export function RenderEdit(props) {
|
|
|
1036
1047
|
await discardDraft();
|
|
1037
1048
|
}
|
|
1038
1049
|
extensionsPersisted = await persistExtensions();
|
|
1050
|
+
if (extensionsPersisted)
|
|
1051
|
+
extensionDirtyRef.current = false;
|
|
1039
1052
|
}
|
|
1040
1053
|
else if (result.validationBlocked) {
|
|
1054
|
+
setFocusFirstErrorKey((key) => key + 1);
|
|
1041
1055
|
// Root-level `.refine()`/cross-field issues from controller.validate()
|
|
1042
1056
|
// — same "jump to the erroring step" treatment as a server field
|
|
1043
1057
|
// error below, sourced from the freshly-validated snapshot.
|
|
@@ -1050,6 +1064,8 @@ export function RenderEdit(props) {
|
|
|
1050
1064
|
else {
|
|
1051
1065
|
const fieldIssues = result.error.details?.fields ?? [];
|
|
1052
1066
|
const issuePaths = fieldIssues.map((i) => i.path);
|
|
1067
|
+
if (fieldIssues.length > 0)
|
|
1068
|
+
setFocusFirstErrorKey((key) => key + 1);
|
|
1053
1069
|
// controller.submit() writes server issues into the controller itself;
|
|
1054
1070
|
// customSubmit bypasses it, so push them here to reach the fields.
|
|
1055
1071
|
if (customSubmit !== undefined && fieldIssues.length > 0) {
|
|
@@ -1069,7 +1085,9 @@ export function RenderEdit(props) {
|
|
|
1069
1085
|
}
|
|
1070
1086
|
setExtraDeleteIssues([]);
|
|
1071
1087
|
setFormErrorSource("submit");
|
|
1072
|
-
setFormError(hasIssueWithoutRenderedField(issuePaths, filteredSections
|
|
1088
|
+
setFormError(hasIssueWithoutRenderedField(issuePaths, filteredSections, new Set(Object.keys(formDerivation?.derive ?? {})))
|
|
1089
|
+
? result.error
|
|
1090
|
+
: null);
|
|
1073
1091
|
}
|
|
1074
1092
|
// skip: on entity-success-but-extension-failure the extension-error
|
|
1075
1093
|
// banner is showing — don't notify (the caller navigates away on success
|
|
@@ -1304,7 +1322,7 @@ export function RenderEdit(props) {
|
|
|
1304
1322
|
!pageHeaderCompact &&
|
|
1305
1323
|
ActionOverflowMenu !== undefined && {
|
|
1306
1324
|
actions: (_jsx(ActionOverflowMenu, { label: translate("kumiko.list.row-actions.more"), items: headerMenuItems, testId: "render-edit-header-menu" })),
|
|
1307
|
-
}) })), _jsx(Form, { onSubmit: () => void handleSubmit(), ...(hideSectionTitles !== true &&
|
|
1325
|
+
}) })), _jsx(Form, { onSubmit: () => void handleSubmit(), ...(focusFirstErrorKey > 0 && { focusFirstErrorKey }), ...(hideSectionTitles !== true &&
|
|
1308
1326
|
!titleInShell &&
|
|
1309
1327
|
!titleInSettingsHeader &&
|
|
1310
1328
|
!recordTitleInHeaderRegion && { title: formTitle }), ...(formSubtitle !== undefined &&
|
|
@@ -1444,7 +1462,8 @@ export function RenderEdit(props) {
|
|
|
1444
1462
|
appendixPlacement: fieldAppendixPlacement,
|
|
1445
1463
|
}), ...(fieldDescription !== undefined && {
|
|
1446
1464
|
description: fieldDescription(field.field),
|
|
1447
|
-
}), allIssues: snapshot.errors, valueDisplay: valueDisplay, row: snapshot.values, ...(disabled && { fillDisabled: true }), ...(
|
|
1465
|
+
}), allIssues: snapshot.errors, valueDisplay: valueDisplay, row: snapshot.values, ...(disabled && { fillDisabled: true }), ...(hideSectionTitles !== true &&
|
|
1466
|
+
section.title !== undefined && { sectionTitle: section.title }), ...((!isCreate || markChangedFields) &&
|
|
1448
1467
|
field.field in snapshot.changes && { changed: true }) }, field.field))) }, gridKey));
|
|
1449
1468
|
// Tabs mode: the tab panel is the surface, so field sections render
|
|
1450
1469
|
// as unframed padded panels; group cards inside stay framed.
|
|
@@ -42,6 +42,8 @@ export type RenderFieldProps = {
|
|
|
42
42
|
/** The surrounding form is locked as a whole: the fill button stays visible
|
|
43
43
|
* but inert, unlike a single readOnly field whose fill button keeps working. */
|
|
44
44
|
readonly fillDisabled?: boolean;
|
|
45
|
+
/** Label stays for screen readers only (sr-only). */
|
|
46
|
+
readonly hideLabel?: boolean;
|
|
45
47
|
};
|
|
46
48
|
export declare const AllFieldsRequiredProvider: import("react").Provider<boolean>;
|
|
47
|
-
export declare function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, layout, accent, fillDisabled, }: RenderFieldProps): ReactNode;
|
|
49
|
+
export declare function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, layout, accent, fillDisabled, hideLabel, }: RenderFieldProps): ReactNode;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { isFormatSpec, SYSTEM_REFERENCE_LABELS, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import { isFormatSpec, SYSTEM_REFERENCE_LABELS, UNRESOLVED_REFERENCE_LABEL_KEYS, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
3
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
4
4
|
import { createContext, Fragment, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
5
5
|
import { useAppFeatures } from "../app/app-features-context.js";
|
|
@@ -26,7 +26,7 @@ import { QueryOptionsSelect } from "./query-options-select.js";
|
|
|
26
26
|
import { ReferenceCreateDialog } from "./reference-create-dialog.js";
|
|
27
27
|
const AllFieldsRequiredContext = createContext(false);
|
|
28
28
|
export const AllFieldsRequiredProvider = AllFieldsRequiredContext.Provider;
|
|
29
|
-
export function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay = "form", row, changed, layout, accent, fillDisabled, }) {
|
|
29
|
+
export function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay = "form", row, changed, layout, accent, fillDisabled, hideLabel, }) {
|
|
30
30
|
const { Field, Input, Banner, Text, JsonView } = usePrimitives();
|
|
31
31
|
// App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
|
|
32
32
|
// navigator.language (Browser-Sprache) zurück statt der gewählten
|
|
@@ -49,6 +49,11 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
|
|
|
49
49
|
// Reference-Field rendert eine eigene Component — sie nutzt
|
|
50
50
|
// useQuery() für den Live-Lookup, also muss sie als React-
|
|
51
51
|
// Komponente gemountet werden (nicht als pure render-Call).
|
|
52
|
+
// The list input prints its own list-level issues (empty state included), so
|
|
53
|
+
// the Field wrapper must not print them a second time.
|
|
54
|
+
const listPrintsOwnIssues = field.type === "embedded" &&
|
|
55
|
+
field.embeddedListCells !== undefined &&
|
|
56
|
+
!(field.readOnly && field.renderer !== undefined);
|
|
52
57
|
const control = field.readOnly && field.renderer !== undefined ? (_jsx(FieldRendererOutput, { field: field, renderer: field.renderer, appLocale: appLocale, ...(row !== undefined && { row }) })) : field.type === "embedded" && field.embeddedListCells !== undefined ? (_jsx(EmbeddedListField, { field: field, id: id, onChange: onChange, allIssues: allIssues ?? {}, featureName: featureName ?? "" })) : field.type === "reference" ? (readOnlyText ? (_jsx(ReadOnlyReferenceValue, { field: field, featureName: featureName ?? "" })) : (_jsx(ReferenceInput, { field: field, id: id, hasError: hasError, onChange: onChange, Input: Input, featureName: featureName ?? "" }))) : field.type === "select" && field.selectOptionsQuery !== undefined && !readOnlyText ? (_jsx(QueryOptionsSelect, { field: field, query: field.selectOptionsQuery.query, payload: field.selectOptionsQuery.payload, row: row ?? { [field.field]: field.value }, id: id, hasError: hasError, onChange: onChange })) : field.type === "select" &&
|
|
53
58
|
field.selectOptionsAvailabilityQuery !== undefined &&
|
|
54
59
|
!readOnlyText ? (_jsx(AvailabilityOptionsSelect, { field: field, query: field.selectOptionsAvailabilityQuery.query, payload: field.selectOptionsAvailabilityQuery.payload, row: row ?? { [field.field]: field.value }, id: id, hasError: hasError, onChange: onChange })) : readOnlyText && !isComplexFieldType(field.type) && !isFileFieldType(field.type) ? (_jsx(Text, { testId: `field-value-${field.field}`, children: readOnlyDisplayText(field, appLocale, t) })) : field.type === "text" && field.writeOnly === true && !readOnlyText ? (_jsx(WriteOnlyTextInput, { field: field, id: id, hasError: hasError, onChange: onChange, changed: changed === true })) : field.fillAction !== undefined ? (_jsx(FillActionControl, { field: field, fillAction: field.fillAction, row: row ?? { [field.field]: field.value }, onChange: onChange, disabled: fillDisabled === true, Banner: Banner, renderControl: (fill) => renderInput({
|
|
@@ -77,7 +82,7 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
|
|
|
77
82
|
t,
|
|
78
83
|
row,
|
|
79
84
|
}));
|
|
80
|
-
return (_jsx(Field, { id: id, label: field.label, required: field.required && field.type !== "boolean" && !allFieldsRequired, ...(issues !== undefined && { issues }), ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), testId: `field-${field.field}`, children: control }));
|
|
85
|
+
return (_jsx(Field, { id: id, label: field.label, required: field.required && field.type !== "boolean" && !allFieldsRequired, ...(issues !== undefined && !listPrintsOwnIssues && { issues }), ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), ...(hideLabel === true && { hideLabel }), testId: `field-${field.field}`, children: control }));
|
|
81
86
|
}
|
|
82
87
|
const NUMERIC_FIELD_TYPES = new Set(["number", "bigInt", "decimal"]);
|
|
83
88
|
// A text field takes a number as its string form; a numeric field takes only a
|
|
@@ -347,12 +352,17 @@ function ReadOnlyReferenceValue({ field, featureName, }) {
|
|
|
347
352
|
// instead of the raw id.
|
|
348
353
|
const systemLabel = SYSTEM_REFERENCE_LABELS[`${refFeature}:${refEntity}`];
|
|
349
354
|
const parts = ids.map((id) => {
|
|
350
|
-
if (systemLabel !== undefined && id === systemLabel.id) {
|
|
355
|
+
if (systemLabel !== undefined && (id === systemLabel.id || systemLabel.aliases?.includes(id))) {
|
|
351
356
|
return { id, label: t(systemLabel.labelKey), link: undefined };
|
|
352
357
|
}
|
|
353
358
|
const row = rows.find((r) => String(r["id"] ?? "") === id);
|
|
354
|
-
if (row === undefined)
|
|
355
|
-
|
|
359
|
+
if (row === undefined) {
|
|
360
|
+
const unknownKey = UNRESOLVED_REFERENCE_LABEL_KEYS[`${refFeature}:${refEntity}`];
|
|
361
|
+
const label = unknownKey !== undefined && !queryResult.loading && queryResult.error === null
|
|
362
|
+
? t(unknownKey)
|
|
363
|
+
: id;
|
|
364
|
+
return { id, label, link: undefined };
|
|
365
|
+
}
|
|
356
366
|
return { id, label: String(row[labelKey] ?? id), link: resolveReferenceLink(refEntity, id) };
|
|
357
367
|
});
|
|
358
368
|
return (_jsx(Text, { testId: `field-value-${field.field}`, children: parts.map(({ id, label, link }, index) => (_jsxs(Fragment, { children: [index > 0 && ", ", link === undefined ? (label) : (_jsx(Link, { href: link.href, onPress: link.onPress, testId: `field-link-${field.field}-${id}`, children: label }))] }, id))) }));
|
|
@@ -478,7 +488,9 @@ function renderInput({ field, id, hasError, onChange, Input, appLocale, Banner,
|
|
|
478
488
|
case "image": {
|
|
479
489
|
const kind = field.type === "image" ? "image" : "file";
|
|
480
490
|
const fileId = typeof field.value === "string" && field.value !== "" ? field.value : null;
|
|
481
|
-
return (_jsx(Input, { kind: kind, ...common, value: fileId, onChange: (v) => onChange(v), ...(field.accept !== undefined && { accept: field.accept }), ...(field.maxSize !== undefined && { maxSize: field.maxSize }), ...(field.entityType !== undefined && { entityType: field.entityType }), ...(field.
|
|
491
|
+
return (_jsx(Input, { kind: kind, ...common, value: fileId, onChange: (v) => onChange(v), ...(field.accept !== undefined && { accept: field.accept }), ...(field.maxSize !== undefined && { maxSize: field.maxSize }), ...(field.entityType !== undefined && { entityType: field.entityType }), ...(field.uploadActionForm !== undefined && {
|
|
492
|
+
uploadActionForm: field.uploadActionForm,
|
|
493
|
+
}), ...(field.entityId !== undefined && { entityId: field.entityId }), ...(field.fieldName !== undefined && { fieldName: field.fieldName }), ...(field.imageVariant !== undefined && { imageVariant: field.imageVariant }), ...(field.capture !== undefined && { capture: field.capture }) }));
|
|
482
494
|
}
|
|
483
495
|
case "files":
|
|
484
496
|
case "images":
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EntityDefinition, type EntityListScreenDefinition, type IconKey } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
import { type ReactNode } from "react";
|
|
3
3
|
import type { ListRowViewModel, Translate } from "../headless/index.ts";
|
|
4
4
|
import type { ListSort } from "../hooks/use-list-url-state.ts";
|
|
@@ -34,6 +34,8 @@ export type RenderListProps = {
|
|
|
34
34
|
readonly emptyContent?: ListEmptyStateContent;
|
|
35
35
|
/** Search or filters changed the result; the empty box then says "no matches". */
|
|
36
36
|
readonly narrowed?: boolean;
|
|
37
|
+
/** Nothing was narrowed by the user, but a default filter is still on; the empty box then offers "show all" instead of "create the first one". */
|
|
38
|
+
readonly defaultFilterActive?: boolean;
|
|
37
39
|
/** Plural-forms key for the footer count noun; default is the entity's `:noun` convention key. */
|
|
38
40
|
readonly itemNounKey?: string;
|
|
39
41
|
/** Aktueller Search-Term (vom URL-State / Parent). RenderList puffert
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { UNRESOLVED_REFERENCE_LABEL_KEYS, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
4
|
import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
|
|
4
5
|
import { useReferenceLinkResolver } from "../app/reference-link.js";
|
|
@@ -15,7 +16,7 @@ function isHeaderPromotableToolbarAction(action) {
|
|
|
15
16
|
return action.style === "primary" && !needsActionConfirm(action);
|
|
16
17
|
}
|
|
17
18
|
export function RenderList(props) {
|
|
18
|
-
const { screen, entity, rows, featureName, translate: translateProp, onRowClick, emptyState, onCreate, createLabel, searchable = false, searchPlaceholder, description, emptyContent, narrowed = false, itemNounKey, searchValue, onSearchChange, sort, onSortChange, pager, onReachEnd, loadingMore, hasMore, rowActions, rowActionMode, rowGrouping, rowTone, toolbarActions, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, } = props;
|
|
19
|
+
const { screen, entity, rows, featureName, translate: translateProp, onRowClick, emptyState, onCreate, createLabel, searchable = false, searchPlaceholder, description, emptyContent, narrowed = false, defaultFilterActive: defaultFilterActiveProp = false, itemNounKey, searchValue, onSearchChange, sort, onSortChange, pager, onReachEnd, loadingMore, hasMore, rowActions, rowActionMode, rowGrouping, rowTone, toolbarActions, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, } = props;
|
|
19
20
|
// Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
|
|
20
21
|
// wären Column-Header raw i18n-Keys.
|
|
21
22
|
const t = useTranslation();
|
|
@@ -101,6 +102,14 @@ export function RenderList(props) {
|
|
|
101
102
|
if (col.renderer !== undefined)
|
|
102
103
|
return col;
|
|
103
104
|
const map = referenceLookups[col.field];
|
|
105
|
+
const unknownKey = UNRESOLVED_REFERENCE_LABEL_KEYS[`${col.refFeature ?? featureName}:${col.refEntity ?? ""}`];
|
|
106
|
+
// No raw id for entities that opt in: a placeholder while the lookup
|
|
107
|
+
// loads, the "unknown" label once it found nothing.
|
|
108
|
+
const unresolvedLabel = (id) => unknownKey === undefined
|
|
109
|
+
? id
|
|
110
|
+
: map === undefined
|
|
111
|
+
? EMPTY_VALUE_PLACEHOLDER
|
|
112
|
+
: translate(unknownKey);
|
|
104
113
|
const labelField = col.refLabelField ?? "id";
|
|
105
114
|
const refEntity = col.refEntity ?? "";
|
|
106
115
|
// Only a row the user can see is linkable: resolved by the server
|
|
@@ -126,7 +135,7 @@ export function RenderList(props) {
|
|
|
126
135
|
return value.map((v) => {
|
|
127
136
|
const id = String(v);
|
|
128
137
|
const target = eagerById.get(id);
|
|
129
|
-
const label = String(target?.[labelField] ?? map?.get(id) ?? id);
|
|
138
|
+
const label = String(target?.[labelField] ?? map?.get(id) ?? unresolvedLabel(id));
|
|
130
139
|
// Only a row the user can see is linkable: server eagerload or lookup query.
|
|
131
140
|
const link = target !== undefined || map?.has(id) === true
|
|
132
141
|
? resolveReferenceLink(refEntity, id)
|
|
@@ -151,16 +160,23 @@ export function RenderList(props) {
|
|
|
151
160
|
if (Array.isArray(value)) {
|
|
152
161
|
if (value.length === 0)
|
|
153
162
|
return EMPTY_VALUE_PLACEHOLDER;
|
|
154
|
-
return value.map((v) => map?.get(String(v)) ?? String(v)).join(", ");
|
|
163
|
+
return value.map((v) => map?.get(String(v)) ?? unresolvedLabel(String(v))).join(", ");
|
|
155
164
|
}
|
|
156
165
|
if (value === null || value === undefined || value === "")
|
|
157
166
|
return EMPTY_VALUE_PLACEHOLDER;
|
|
158
167
|
const idStr = String(value);
|
|
159
|
-
return map?.get(idStr) ?? idStr;
|
|
168
|
+
return map?.get(idStr) ?? unresolvedLabel(idStr);
|
|
160
169
|
};
|
|
161
170
|
return { ...col, renderer, linkFor, linkItemsFor };
|
|
162
171
|
});
|
|
163
|
-
}, [
|
|
172
|
+
}, [
|
|
173
|
+
vm.columns,
|
|
174
|
+
referenceColumns,
|
|
175
|
+
referenceLookups,
|
|
176
|
+
resolveReferenceLink,
|
|
177
|
+
featureName,
|
|
178
|
+
translate,
|
|
179
|
+
]);
|
|
164
180
|
const enrichedVm = useMemo(() => ({
|
|
165
181
|
...vm,
|
|
166
182
|
columns: enrichedColumns,
|
|
@@ -205,8 +221,9 @@ export function RenderList(props) {
|
|
|
205
221
|
// Button. Caller kann via emptyState-Prop komplett überschreiben.
|
|
206
222
|
// Wenn weder onCreate noch ein Override gegeben ist, fällt die
|
|
207
223
|
// DataTable auf den DataTable-Default zurück (`kumiko.list.no-entries`).
|
|
224
|
+
const defaultFilterActive = defaultFilterActiveProp && onFilterReset !== undefined;
|
|
208
225
|
const composedEmptyState = emptyState ??
|
|
209
|
-
(onCreate !== undefined || emptyContent !== undefined || narrowed ? (_jsx(ListEmptyStateBody, { content: emptyContent, narrowed: narrowed, translate: translate, createButton: onCreate !== undefined && !narrowed && !createInPageHeader ? (_jsx(Button, { variant: "primary", icon: "plus", onClick: onCreate, testId: "render-list-empty-create", children: effectiveCreateLabel })) : undefined })) : undefined);
|
|
226
|
+
(onCreate !== undefined || emptyContent !== undefined || narrowed || defaultFilterActive ? (_jsx(ListEmptyStateBody, { content: emptyContent, narrowed: narrowed, defaultFilterActive: defaultFilterActive, ...(onFilterReset !== undefined && { onShowAll: onFilterReset }), translate: translate, createButton: onCreate !== undefined && !narrowed && !defaultFilterActive && !createInPageHeader ? (_jsx(Button, { variant: "primary", icon: "plus", onClick: onCreate, testId: "render-list-empty-create", children: effectiveCreateLabel })) : undefined })) : undefined);
|
|
210
227
|
// Title-Resolution: ein konventioneller i18n-Key `screen:<id>.title`.
|
|
211
228
|
// Wenn das Bundle den Key kennt, schöner Titel; sonst kommt der
|
|
212
229
|
// screenId selber raus (kein "Untitled"-Fallback — der App-Dev sieht
|
|
@@ -254,9 +271,13 @@ function ReferenceLookupBridge({ field, refEntity, labelField, featureName, onMa
|
|
|
254
271
|
// useEffect statt direktem call damit setState außerhalb des Render-
|
|
255
272
|
// Pfads passiert (sonst React-Warning "Cannot update a component
|
|
256
273
|
// while rendering a different component").
|
|
274
|
+
// Unpublished while loading or after a failed lookup: the renderer treats a missing map as
|
|
275
|
+
// "not resolved", an empty published map as "nothing found". Only the latter may say "Unknown".
|
|
276
|
+
const resolved = !lookup.loading && !lookup.failed;
|
|
257
277
|
useEffect(() => {
|
|
258
|
-
|
|
259
|
-
|
|
278
|
+
if (resolved)
|
|
279
|
+
onMap(field, lookup.map);
|
|
280
|
+
}, [field, lookup.map, resolved, onMap]);
|
|
260
281
|
return null;
|
|
261
282
|
}
|
|
262
283
|
// ToolbarActionView — pro Toolbar-Action ein Button (+ Confirm-Dialog
|
|
@@ -6,6 +6,7 @@ import { dispatcherErrorText } from "../app/write-failed-error.js";
|
|
|
6
6
|
import { I18N_KEY_PARAM } from "../headless/index.js";
|
|
7
7
|
import { useForm } from "../hooks/use-form.js";
|
|
8
8
|
import { useTranslation } from "../i18n.js";
|
|
9
|
+
import { useUnsavedInputSource } from "../leave-guard.js";
|
|
9
10
|
import { STICKY_PRIMARY_ACTION_PROP, usePrimitives } from "../primitives.js";
|
|
10
11
|
import { GridCellForField } from "./grid-cell-for-field.js";
|
|
11
12
|
import { hasIssueWithoutVisibleField, issuesAfterEdit } from "./render-edit-logic.js";
|
|
@@ -44,6 +45,7 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
|
|
|
44
45
|
submit: { type: section.handler, payloadMode: "values" },
|
|
45
46
|
schema,
|
|
46
47
|
});
|
|
48
|
+
useUnsavedInputSource(() => controller.getSnapshot().isDirty);
|
|
47
49
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
48
50
|
const [error, setError] = useState(null);
|
|
49
51
|
async function handleSubmit() {
|
|
@@ -100,7 +102,8 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
|
|
|
100
102
|
if (remaining !== errors)
|
|
101
103
|
controller.setErrors(remaining);
|
|
102
104
|
setError(null);
|
|
103
|
-
}, GridCell: GridCell, featureName: featureName, allIssues: snapshot.errors, valueDisplay: "form", row: snapshot.values
|
|
105
|
+
}, GridCell: GridCell, featureName: featureName, allIssues: snapshot.errors, valueDisplay: "form", row: snapshot.values, ...(hideTitle !== true &&
|
|
106
|
+
section.title !== undefined && { sectionTitle: section.title }) }, field.field))) }), error !== null && (_jsx(Banner, { variant: "error", testId: "write-form-section-error", children: error })), onFooterAction === undefined && (_jsx(Card, { options: { framed: false, footerBordered: false, footerInset: false }, slots: { footer: submitButton }, testId: "write-form-section-submit-row" }))] }));
|
|
104
107
|
// Section always flattens to a borderless divider when rendered inside
|
|
105
108
|
// RenderEdit's own <Form> in tabs mode (hideTitle) — an unframed Card panel
|
|
106
109
|
// stands in, like render-edit.tsx's tabs-mode fields/extension branches.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const INVALID_FORMAT_KEY = "errors.validation.invalid_format";
|
|
2
|
+
// Zod reports every string-format failure under one code; the per-format key
|
|
3
|
+
// (…invalid_format.email) carries a worked example, the generic key stays the
|
|
4
|
+
// fallback for formats without one.
|
|
5
|
+
export function fieldIssueText(t, issue) {
|
|
6
|
+
const format = issue.params?.["format"];
|
|
7
|
+
if (issue.i18nKey === INVALID_FORMAT_KEY && typeof format === "string") {
|
|
8
|
+
const formatKey = `${INVALID_FORMAT_KEY}.${format}`;
|
|
9
|
+
const text = t(formatKey, issue.params);
|
|
10
|
+
if (text !== formatKey)
|
|
11
|
+
return text;
|
|
12
|
+
}
|
|
13
|
+
return t(issue.i18nKey, issue.params);
|
|
14
|
+
}
|
|
@@ -1,2 +1,3 @@
|
|
|
1
|
+
export { generateRequestId } from "./request-id.ts";
|
|
1
2
|
export type { BatchResult, Command, Dispatcher, DispatcherError, DispatcherStatus, FieldIssue, PendingFile, PendingWrite, QueryOpts, QueryResult, StreamOpts, WriteOpts, WriteResult, } from "./types.ts";
|
|
2
3
|
export { StreamFrame, type StreamFrameEvent } from "./types.ts";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function generateRequestId(): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
// crypto.randomUUID where available (browser, Bun, Node); Math.random
|
|
3
|
+
// fallback for React-Native runtimes without the WebCrypto polyfill.
|
|
4
|
+
// Uniqueness only needs to hold per user within the server's dedup window —
|
|
5
|
+
// this is an idempotency key, not a security token.
|
|
6
|
+
export function generateRequestId() {
|
|
7
|
+
const webCrypto = propertyOf(globalThis, "crypto");
|
|
8
|
+
const randomUUID = propertyOf(webCrypto, "randomUUID");
|
|
9
|
+
if (typeof randomUUID === "function") {
|
|
10
|
+
const id = Reflect.apply(randomUUID, webCrypto, []);
|
|
11
|
+
if (typeof id === "string")
|
|
12
|
+
return id;
|
|
13
|
+
}
|
|
14
|
+
return `req-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
|
15
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generateRequestId } from "../dispatcher/index.js";
|
|
1
2
|
import { createStore } from "../store/index.js";
|
|
2
3
|
import { groupIssuesByPath, relevantFieldIssues, zodErrorToFieldIssues } from "./zod-bridge.js";
|
|
3
4
|
// Paths without the prefix (hook-level errors, "(root)") pass through unchanged.
|
|
@@ -91,6 +92,11 @@ function omitPayloadFields(payload, omitFields) {
|
|
|
91
92
|
return payload;
|
|
92
93
|
return Object.fromEntries(Object.entries(payload).filter(([key]) => !omitFields.includes(key)));
|
|
93
94
|
}
|
|
95
|
+
function isTransportFailure(result) {
|
|
96
|
+
return (!result.isSuccess &&
|
|
97
|
+
result.error.httpStatus === 0 &&
|
|
98
|
+
(result.error.code === "network_error" || result.error.code === "aborted"));
|
|
99
|
+
}
|
|
94
100
|
export function createFormController(options) {
|
|
95
101
|
// Shallow copy so mutating the input after creation doesn't bleed into
|
|
96
102
|
// the controller's internal state. `initial` stays fixed across the
|
|
@@ -115,6 +121,10 @@ export function createFormController(options) {
|
|
|
115
121
|
// coherent (rebasing twice in quick succession would mis-align the
|
|
116
122
|
// baseline with what the server actually saw).
|
|
117
123
|
let submitInFlight = null;
|
|
124
|
+
// Idempotency key of the last attempt that got no server answer. The server
|
|
125
|
+
// caches every answer (errors too) under the requestId, so the key may only
|
|
126
|
+
// be reused for an identical retry after a transport failure.
|
|
127
|
+
let unansweredAttempt = null;
|
|
118
128
|
function buildSnapshot() {
|
|
119
129
|
const changes = valuesDiff(values, initial);
|
|
120
130
|
const isDirty = Object.keys(changes).length > 0;
|
|
@@ -365,7 +375,12 @@ export function createFormController(options) {
|
|
|
365
375
|
};
|
|
366
376
|
}
|
|
367
377
|
const runWrite = async () => {
|
|
368
|
-
const
|
|
378
|
+
const fingerprint = JSON.stringify([submitCfg.type, payload]);
|
|
379
|
+
const requestId = unansweredAttempt?.fingerprint === fingerprint
|
|
380
|
+
? unansweredAttempt.requestId
|
|
381
|
+
: generateRequestId();
|
|
382
|
+
const result = await dispatcher.write(submitCfg.type, payload, { requestId });
|
|
383
|
+
unansweredAttempt = isTransportFailure(result) ? { requestId, fingerprint } : null;
|
|
369
384
|
if (result.isSuccess) {
|
|
370
385
|
// Remember intentional empty clears so post-rebase submits keep them (#1858).
|
|
371
386
|
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
|
@@ -84,6 +84,13 @@ function extractIssueParams(issue) {
|
|
|
84
84
|
if (bag[key] !== undefined)
|
|
85
85
|
out[key] = bag[key];
|
|
86
86
|
}
|
|
87
|
+
// A custom issue that overrides its i18n key also carries the values the text interpolates.
|
|
88
|
+
if (issue.code === "custom" && typeof issue.params?.[I18N_KEY_PARAM] === "string") {
|
|
89
|
+
for (const [key, value] of Object.entries(issue.params)) {
|
|
90
|
+
if (key !== I18N_KEY_PARAM && value !== undefined)
|
|
91
|
+
out[key] = value;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
87
94
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
88
95
|
}
|
|
89
96
|
// Groups a flat FieldIssue list into the `errors` map carried by a
|
package/dist/headless/index.d.ts
CHANGED
|
@@ -2,12 +2,13 @@ export type { ParsedRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
|
2
2
|
export { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
3
|
export type { AssetResolution, AssetResolveContext, AssetResolver, BadgeProps, ButtonProps, CardProps, DatePickerProps, IconProps, LocaleResolver, ModalProps, NumberInputProps, PrimitiveCommonProps, PrimitivesContract, SelectOption, SelectProps, TextInputProps, ToastIntent, ToastProps, ToggleProps, } from "./contracts/index.ts";
|
|
4
4
|
export type { BatchResult, Command, Dispatcher, DispatcherError, DispatcherStatus, FieldIssue, PendingFile, PendingWrite, QueryOpts, QueryResult, StreamOpts, WriteOpts, WriteResult, } from "./dispatcher/index.ts";
|
|
5
|
-
export { StreamFrame, type StreamFrameEvent } from "./dispatcher/index.ts";
|
|
5
|
+
export { generateRequestId, StreamFrame, type StreamFrameEvent } from "./dispatcher/index.ts";
|
|
6
6
|
export type { FieldConditionPredicate, FieldConditions, FieldConditionValue, FieldState, FormController, FormControllerOptions, FormSnapshot, FormValues, SubmitConfig, SubmitPayloadMode, SubmitResult, } from "./form/index.ts";
|
|
7
7
|
export { createFormController, groupIssuesByPath, I18N_KEY_PARAM, relevantFieldIssues, zodErrorToFieldIssues, } from "./form/index.ts";
|
|
8
8
|
export { applyFormatSpec, currencyDecimals, DOCUMENT_FALLBACK_CURRENCY, EMPTY_VALUE_PLACEHOLDER, escapeHtml, escapeHtmlAttr, escapeXml, formatDocumentDate, formatDocumentMoney, formatDocumentPercent, formatMoney, type HtmlValue, html, isSafeHref, RawHtml, raw, resolveSafeMoneyLocale, stripControlChars, toInstant, } from "./format/index.ts";
|
|
9
9
|
export type { NavDefinition, NavNode, NavRegistrySlice, NavTree, ResolveNavigationOptions, } from "./nav/index.ts";
|
|
10
10
|
export { resolveNavigation } from "./nav/index.ts";
|
|
11
|
+
export { pluralKey } from "./plural-key.ts";
|
|
11
12
|
export type { Store, WritableStore } from "./store/index.ts";
|
|
12
13
|
export { createStore, shallowEqual } from "./store/index.ts";
|
|
13
14
|
export type { CellLink, CellLinkItem, ComputeEditViewModelInput, ComputeListViewModelInput, DerivedCellRoundingTarget, EditExtensionSectionViewModel, EditFieldSpec, EditFieldsSectionViewModel, EditFieldViewModel, EditFormMode, EditRelatedListSectionViewModel, EditSectionSpec, EditSectionViewModel, EditViewModel, EditWriteFormSectionViewModel, EmbeddedDerivedOp, EmbeddedListCellViewModel, EmbeddedListIssueGroups, FieldConditionCtx, FieldRenderer, ListColumnFileDisplay, ListColumnSpec, ListColumnViewModel, ListRowViewModel, ListViewModel, RuntimeCellLink, RuntimeCellLinkItems, RuntimeRenderer, ScreenSlots, Translate, } from "./view-model/index.ts";
|
package/dist/headless/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Re-Export aus framework/ui-types damit Renderer-Code denselben
|
|
2
2
|
// Parser nutzt wie der Server-Boot-Validator (Cross-Feature-Refs).
|
|
3
3
|
export { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
4
|
-
export { StreamFrame } from "./dispatcher/index.js";
|
|
4
|
+
export { generateRequestId, StreamFrame } from "./dispatcher/index.js";
|
|
5
5
|
export { createFormController, groupIssuesByPath, I18N_KEY_PARAM, relevantFieldIssues, zodErrorToFieldIssues, } from "./form/index.js";
|
|
6
6
|
export { applyFormatSpec, currencyDecimals, DOCUMENT_FALLBACK_CURRENCY, EMPTY_VALUE_PLACEHOLDER, escapeHtml, escapeHtmlAttr, escapeXml, formatDocumentDate, formatDocumentMoney, formatDocumentPercent, formatMoney, html, isSafeHref, RawHtml, raw, resolveSafeMoneyLocale, stripControlChars, toInstant, } from "./format/index.js";
|
|
7
7
|
export { resolveNavigation } from "./nav/index.js";
|
|
8
|
+
export { pluralKey } from "./plural-key.js";
|
|
8
9
|
export { createStore, shallowEqual } from "./store/index.js";
|
|
9
10
|
export { buildOptionLabels, computeDerivedCellValue, computeEditViewModel, computeListViewModel, computeRelatedListSectionViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./view-model/index.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function pluralKey(baseKey: string, count: number): string;
|
|
@@ -15,6 +15,10 @@ export type ComputeEditViewModelInput<TValues extends Readonly<Record<string, un
|
|
|
15
15
|
/** actionForm `derive`: computed fields render read-only (and never block submit
|
|
16
16
|
* as required) unless their derivation currently leaves them to the user. */
|
|
17
17
|
readonly derivation?: FormDerivationScreen;
|
|
18
|
+
/** Qualified name of the actionForm screen this form belongs to. File fields then upload
|
|
19
|
+
* as pending for that screen (`uploadActionForm`) instead of for an entity. Set explicitly:
|
|
20
|
+
* secretMint and confirm forms share the pseudo-entity but cannot take uploads. */
|
|
21
|
+
readonly actionFormScreenQn?: string;
|
|
18
22
|
};
|
|
19
23
|
export type EditFormMode = "create" | "update";
|
|
20
24
|
export declare function computeRelatedListSectionViewModel(sectionSpec: EditRelatedListSection, translate: Translate): EditRelatedListSectionViewModel;
|