@cosmicdrift/kumiko-renderer 0.333.0 → 0.334.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.
@@ -1,4 +1,5 @@
1
1
  import type { ConfigEditScreenDefinition, EntityDefinition, EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import * as z from "zod";
2
3
  /** Baut eine minimale EntityDefinition aus den Inline-Fields des
3
4
  * ConfigEdit-Screens. RenderEdit + computeEditViewModel iterieren
4
5
  * über entity.fields zur Render-Zeit; alle weiteren EntityDefinition-
@@ -9,3 +10,9 @@ export declare function synthesizeConfigEditEntity(fields: ConfigEditScreenDefin
9
10
  * damit der Type-Constraint hält; entity wird auf den Pseudo-Namen
10
11
  * gepinnt — RenderEdit liest das Feld nicht. */
11
12
  export declare function synthesizeConfigEditScreen(screen: ConfigEditScreenDefinition): EntityEditScreenDefinition;
13
+ /** Client-side range check for number keys with declared bounds, so an
14
+ * out-of-range value surfaces on its field instead of as the server's
15
+ * form-level "Validation failed." banner. Only bounds: required/format stay
16
+ * server-authoritative like in buildFormSchema. `.passthrough()` keeps every
17
+ * form key visible to the superRefine. */
18
+ export declare function buildConfigEditSchema(fields: ConfigEditScreenDefinition["fields"]): z.ZodType;
@@ -12,6 +12,8 @@
12
12
  // zukünftiger Boot-Validator die schema.entities-Map cross-referenziert,
13
13
  // brechen die Type-Lies hier silent. Dann ist Zeit für eine echte
14
14
  // RenderConfigEdit-Komponente die direkt fields nimmt.
15
+ import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
16
+ import * as z from "zod";
15
17
  const CONFIG_EDIT_PSEUDO_ENTITY = "__config-edit__";
16
18
  /** Baut eine minimale EntityDefinition aus den Inline-Fields des
17
19
  * ConfigEdit-Screens. RenderEdit + computeEditViewModel iterieren
@@ -35,3 +37,40 @@ export function synthesizeConfigEditScreen(screen) {
35
37
  ...(screen.access !== undefined && { access: screen.access }),
36
38
  };
37
39
  }
40
+ /** Client-side range check for number keys with declared bounds, so an
41
+ * out-of-range value surfaces on its field instead of as the server's
42
+ * form-level "Validation failed." banner. Only bounds: required/format stay
43
+ * server-authoritative like in buildFormSchema. `.passthrough()` keeps every
44
+ * form key visible to the superRefine. */
45
+ export function buildConfigEditSchema(fields) {
46
+ return z
47
+ .object({})
48
+ .passthrough()
49
+ .superRefine((values, ctx) => {
50
+ // @cast-boundary form-values
51
+ const record = values;
52
+ for (const [name, field] of Object.entries(fields)) {
53
+ if (field.type !== "number")
54
+ continue;
55
+ const value = record[name];
56
+ if (typeof value !== "number" || !Number.isFinite(value))
57
+ continue;
58
+ if (field.min !== undefined && value < field.min) {
59
+ ctx.addIssue({
60
+ code: "custom",
61
+ minimum: field.min,
62
+ params: { [I18N_KEY_PARAM]: "kumiko.validation.number.min" },
63
+ path: [name],
64
+ });
65
+ }
66
+ if (field.max !== undefined && value > field.max) {
67
+ ctx.addIssue({
68
+ code: "custom",
69
+ maximum: field.max,
70
+ params: { [I18N_KEY_PARAM]: "kumiko.validation.number.max" },
71
+ path: [name],
72
+ });
73
+ }
74
+ }
75
+ });
76
+ }
@@ -1,14 +1,16 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { evalFieldCondition, isWriteFormEditSection, metricField, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
3
- import { computeRelatedListSectionViewModel, fieldLabelKey, fieldOptionLabelKey, isSafeHref, } from "@cosmicdrift/kumiko-headless";
3
+ import { buildOptionLabels, computeRelatedListSectionViewModel, fieldLabelKey, fieldOptionLabelKey, isSafeHref, } from "@cosmicdrift/kumiko-headless";
4
4
  import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
5
5
  import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
6
6
  import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
7
+ import { QueryOptionLabel, resolveOptionsQueryPayload, } from "../components/query-options-select.js";
7
8
  import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
8
9
  import { RenderEdit, resolveRecordTitle, } from "../components/render-edit.js";
9
10
  import { needsActionConfirm, RenderEditActionButton, RenderEditActionConfirmDialog, } from "../components/render-edit-action-button.js";
10
11
  import { RenderList } from "../components/render-list.js";
11
12
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context.js";
13
+ import { useIsEmbeddedScreen } from "../context/embedded-screen-context.js";
12
14
  import { useUserRoles } from "../context/user-roles-context.js";
13
15
  import { PAGE_SIZE_OPTIONS, useListUrlState } from "../hooks/use-list-url-state.js";
14
16
  import { useQuery } from "../hooks/use-query.js";
@@ -19,7 +21,7 @@ import { statusToneForOptionTone, statusToneForValue, usePrimitives, } from "../
19
21
  import { screenFillsHeight } from "../screen-fills-height.js";
20
22
  import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim.js";
21
23
  import { useAppFeatures } from "./app-features-context.js";
22
- import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim.js";
24
+ import { buildConfigEditSchema, synthesizeConfigEditEntity, synthesizeConfigEditScreen, } from "./config-edit-shim.js";
23
25
  import { useCustomScreenComponent } from "./custom-screens.js";
24
26
  import { useDashboardBody } from "./dashboard-body.js";
25
27
  import { EntityListExpandedRow } from "./entity-list-expanded-row.js";
@@ -32,6 +34,7 @@ import { synthesizeProjectionDetailEntity, synthesizeProjectionDetailScreen, } f
32
34
  import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim.js";
33
35
  import { lastSegment, toKebab } from "./qn.js";
34
36
  import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id.js";
37
+ import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
35
38
  import { ReferenceFacetBridges } from "./reference-facet-bridge.js";
36
39
  import { navigateToReturn, navigateToReturnOr, navigateWithReturnTo, ReturnHostProvider, useReturnHost, useReturnTarget, } from "./return-to.js";
37
40
  import { buildDefaultEditRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, evalRowExtractor, isWriteHandlerRowAction, navigateActionSearchParams, refetchAfterWrite, runProjectionRowNavigate, } from "./row-actions.js";
@@ -967,6 +970,7 @@ function EntityListScreen({ schema, screen, translate, onRowClick, }) {
967
970
  return (_jsx(EntityListBody, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }), ...(onRowClick !== undefined && { onRowClick }) }));
968
971
  }
969
972
  function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
973
+ const isEmbeddedScreen = useIsEmbeddedScreen();
970
974
  const featureName = schema.featureName;
971
975
  const onCreate = useNavigateToCreateFor(schema, screen.entity, screen.createScreen);
972
976
  const { Banner } = usePrimitives();
@@ -1392,7 +1396,9 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1392
1396
  // Bei Infinite-Mode: rows kommen aus accumulated (über mehrere
1393
1397
  // useQuery-Calls gesammelt), bei pages/false aus dem aktuellen Result.
1394
1398
  const renderRows = useInfinite ? accumulated : (rowsQuery.data?.rows ?? []);
1395
- return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, onOptions: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount }), _jsx(RenderList, { screen: screen, entity: entity, rows: renderRows, featureName: featureName, searchable: searchable, searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, screenPadding: true, ...(screenFillsHeight(screen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions }), ...(useInfinite && {
1399
+ return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, onOptions: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount }), _jsx(RenderList, { screen: screen, entity: entity, rows: renderRows, featureName: featureName, searchable: searchable, searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
1400
+ ? { screenPadding: false, chromeless: true }
1401
+ : { screenPadding: true }), ...(screenFillsHeight(screen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions }), ...(useInfinite && {
1396
1402
  onReachEnd: loadMore,
1397
1403
  loadingMore: rowsQuery.loading,
1398
1404
  hasMore,
@@ -1420,6 +1426,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1420
1426
  // declared here. Pages-mode pagination only — infinite-scroll accumulation
1421
1427
  // isn't wired for projectionList.
1422
1428
  function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1429
+ const isEmbeddedScreen = useIsEmbeddedScreen();
1423
1430
  const { Banner, Text } = usePrimitives();
1424
1431
  const t = useTranslation();
1425
1432
  const nav = useNav();
@@ -1591,7 +1598,9 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1591
1598
  onPageSizeChange: urlState.setPageSize,
1592
1599
  }
1593
1600
  : undefined;
1594
- return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleProjectionFacetOptions }), _jsx(RenderList, { screen: listScreen, entity: entity, rows: rowsQuery.data?.rows ?? [], featureName: schema.featureName, searchable: searchable, searchValue: urlState.q, onSearchChange: urlState.setQ, sort: activeSort, onSortChange: urlState.setSort, screenPadding: true, ...(screenFillsHeight(listScreen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(toolbarActions !== undefined && { toolbarActions }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && {
1601
+ return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleProjectionFacetOptions }), _jsx(RenderList, { screen: listScreen, entity: entity, rows: rowsQuery.data?.rows ?? [], featureName: schema.featureName, searchable: searchable, searchValue: urlState.q, onSearchChange: urlState.setQ, sort: activeSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
1602
+ ? { screenPadding: false, chromeless: true }
1603
+ : { screenPadding: true }), ...(screenFillsHeight(listScreen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(toolbarActions !== undefined && { toolbarActions }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && {
1595
1604
  filterFacets,
1596
1605
  onFilterChange: urlState.setFilter,
1597
1606
  }), ...(dateRangeFacets.length > 0 && {
@@ -2189,8 +2198,13 @@ function unwrapMoneyValue(value) {
2189
2198
  }
2190
2199
  return value;
2191
2200
  }
2201
+ const CONFIG_SCOPE_SOURCE = {
2202
+ user: "user-row",
2203
+ tenant: "tenant-row",
2204
+ system: "system-row",
2205
+ };
2192
2206
  function ConfigEditBody({ schema, screen, translate, }) {
2193
- const { Banner, ConfigCascadeView } = usePrimitives();
2207
+ const { ConfigCascadeView } = usePrimitives();
2194
2208
  const t = useTranslation();
2195
2209
  const effectiveTranslate = translate ?? t;
2196
2210
  const dispatcher = useDispatcher();
@@ -2201,6 +2215,7 @@ function ConfigEditBody({ schema, screen, translate, }) {
2201
2215
  const cascadeQuery = useQuery("config:query:cascade", {});
2202
2216
  const synthEntity = useMemo(() => synthesizeConfigEditEntity(screen.fields), [screen.fields]);
2203
2217
  const synthScreen = useMemo(() => synthesizeConfigEditScreen(screen), [screen]);
2218
+ const boundsSchema = useMemo(() => buildConfigEditSchema(screen.fields), [screen.fields]);
2204
2219
  // Initial-Values: pro Field-Name den Wert aus `values[qualifiedKey]`
2205
2220
  // abholen. Fehlt der Key auf dem Server (= noch nie gesetzt), nutzen
2206
2221
  // wir den Field-Default (createTextField/createNumberField/...).
@@ -2271,7 +2286,20 @@ function ConfigEditBody({ schema, screen, translate, }) {
2271
2286
  const qualified = screen.configKeys[shortName];
2272
2287
  if (qualified === undefined)
2273
2288
  continue;
2274
- const ftype = screen.fields[shortName]?.type;
2289
+ const fieldDef = screen.fields[shortName];
2290
+ const ftype = fieldDef?.type;
2291
+ // A query-backed select is cleared to "" when its dependent value became
2292
+ // invalid. config:write:set would store that as an empty override (the
2293
+ // badge shows "Mandant" with no value); reset falls back to the inherited
2294
+ // value. set can't do it: deleting the row is irreversible, reset is
2295
+ // the high-risk handler.
2296
+ if (ftype === "select" && fieldDef?.optionsQuery !== undefined && value === "") {
2297
+ commands.push({
2298
+ type: "config:write:reset",
2299
+ payload: { key: qualified, scope: screen.scope },
2300
+ });
2301
+ continue;
2302
+ }
2275
2303
  commands.push({
2276
2304
  type: "config:write:set",
2277
2305
  payload: {
@@ -2302,41 +2330,40 @@ function ConfigEditBody({ schema, screen, translate, }) {
2302
2330
  valuesQuery.refetch,
2303
2331
  cascadeQuery.refetch,
2304
2332
  ]);
2305
- // Cascade-Disclosure (#429): Trigger sitzt in der Label-Row, das Panel
2306
- // unter dem Input — zwei getrennte Render-Slots teilen sich den
2307
- // expanded-State, der daher hier (pro Feld) statt in der Komponente lebt.
2308
- const [expandedFields, setExpandedFields] = useState(new Set());
2309
- const toggleExpanded = useCallback((fieldName) => {
2310
- setExpandedFields((prev) => {
2311
- const next = new Set(prev);
2312
- if (next.has(fieldName))
2313
- next.delete(fieldName);
2314
- else
2315
- next.add(fieldName);
2316
- return next;
2317
- });
2318
- }, []);
2319
- if (valuesQuery.loading && valuesQuery.data === null) {
2320
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
2321
- }
2333
+ if (valuesQuery.loading && valuesQuery.data === null)
2334
+ return _jsx(QueryLoadingBanner, {});
2322
2335
  if (valuesQuery.error) {
2323
- return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", children: dispatcherErrorText(valuesQuery.error, effectiveTranslate) }));
2336
+ return (_jsx(QueryErrorBanner, { error: valuesQuery.error, translate: effectiveTranslate, onRetry: valuesQuery.refetch }));
2324
2337
  }
2325
- if (initial === null) {
2326
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
2327
- }
2328
- return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, customSubmit: customSubmit, ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), labelAppendix: (fieldName) => {
2338
+ if (initial === null)
2339
+ return _jsx(QueryLoadingBanner, {});
2340
+ // Cascade values of a select show the option label the select itself shows,
2341
+ // not the stored value (a UUID for optionsQuery selects).
2342
+ const cascadeValueRenderer = (fieldName) => {
2343
+ const fieldDef = screen.fields[fieldName];
2344
+ if (fieldDef?.type !== "select")
2345
+ return undefined;
2346
+ if (fieldDef.optionsQuery !== undefined) {
2347
+ const query = fieldDef.optionsQuery;
2348
+ const payload = resolveOptionsQueryPayload(fieldDef.optionsQueryPayload ?? {}, initial);
2349
+ return (value) => _jsx(QueryOptionLabel, { query: query, payload: payload, value: value });
2350
+ }
2351
+ if (fieldDef.options.length === 0)
2352
+ return undefined;
2353
+ return (value) => buildOptionLabels(effectiveTranslate, (option) => fieldOptionLabelKey(schema.featureName, synthScreen.entity, fieldName, option), [String(value)])[String(value)];
2354
+ };
2355
+ return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, schema: boundsSchema, customSubmit: customSubmit, dirtyFooter: true, validateOnChange: true, ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), fieldAppendixPlacement: "below-control", fieldAccent: (fieldName) => cascades[fieldName]?.levels.find((level) => level.isActive)?.source ===
2356
+ CONFIG_SCOPE_SOURCE[screen.scope], markChangedFields: true, ...(screen.fieldDescriptions !== undefined && {
2357
+ fieldDescription: (fieldName) => {
2358
+ const key = screen.fieldDescriptions?.[fieldName];
2359
+ return key === undefined ? undefined : effectiveTranslate(key);
2360
+ },
2361
+ }), fieldAppendix: (fieldName) => {
2329
2362
  const cascade = cascades[fieldName];
2330
2363
  if (cascade === undefined)
2331
2364
  return undefined;
2332
- return (_jsx(ConfigCascadeView, { slot: "trigger", cascade: cascade, screenScope: screen.scope, expanded: expandedFields.has(fieldName), onToggle: () => toggleExpanded(fieldName) }));
2333
- }, fieldAppendix: (fieldName) => {
2334
- const cascade = cascades[fieldName];
2335
- // Panel nur rendern wenn aufgeklappt — sonst kein leerer Abstand
2336
- // unter dem Input.
2337
- if (cascade === undefined || !expandedFields.has(fieldName))
2338
- return undefined;
2339
- return (_jsx(ConfigCascadeView, { slot: "panel", cascade: cascade, screenScope: screen.scope, expanded: true, qualifiedKey: screen.configKeys[fieldName], onReset: async (key, scope) => {
2365
+ const renderValue = cascadeValueRenderer(fieldName);
2366
+ return (_jsx(ConfigCascadeView, { cascade: cascade, screenScope: screen.scope, qualifiedKey: screen.configKeys[fieldName], required: screen.requiredFields?.includes(fieldName) ?? false, ...(renderValue !== undefined && { renderValue }), onReset: async (key, scope) => {
2340
2367
  await dispatcher.write("config:write:reset", { key, scope });
2341
2368
  await Promise.allSettled([valuesQuery.refetch?.(), cascadeQuery.refetch?.()]);
2342
2369
  } }));
@@ -0,0 +1,8 @@
1
+ import type { DispatcherError, Translate } from "@cosmicdrift/kumiko-headless";
2
+ import type { ReactNode } from "react";
3
+ export declare function QueryLoadingBanner(): ReactNode;
4
+ export declare function QueryErrorBanner({ error, translate, onRetry, }: {
5
+ readonly error: DispatcherError;
6
+ readonly translate: Translate;
7
+ readonly onRetry: () => void | Promise<void>;
8
+ }): ReactNode;
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useTranslation } from "../i18n.js";
3
+ import { usePrimitives } from "../primitives.js";
4
+ import { dispatcherErrorText } from "./write-failed-error.js";
5
+ export function QueryLoadingBanner() {
6
+ const { Banner } = usePrimitives();
7
+ const t = useTranslation();
8
+ return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: t("kumiko.widget.loading") }));
9
+ }
10
+ export function QueryErrorBanner({ error, translate, onRetry, }) {
11
+ const { Banner, Button } = usePrimitives();
12
+ const t = useTranslation();
13
+ return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", actions: _jsx(Button, { variant: "secondary", size: "sm", onClick: onRetry, testId: "kumiko-screen-retry", children: t("kumiko.actions.retry") }), children: dispatcherErrorText(error, translate) }));
14
+ }
@@ -113,7 +113,7 @@ export function SecretMintBody({ schema, screen, translate }) {
113
113
  finishMint();
114
114
  }, [finishMint]);
115
115
  if (done) {
116
- return (_jsx(Card, { children: _jsx(Banner, { variant: "info", testId: "kumiko-screen-secret-mint-done", children: effectiveTranslate(confirm?.doneMessage ?? "kumiko.secretMint.done") }) }));
116
+ return (_jsx(Card, { options: { screenBody: true }, children: _jsx(Banner, { variant: "info", testId: "kumiko-screen-secret-mint-done", children: effectiveTranslate(confirm?.doneMessage ?? "kumiko.secretMint.done") }) }));
117
117
  }
118
118
  if (revealed !== null) {
119
119
  const values = screen.reveal.fields.flatMap((revealField) => {
@@ -134,7 +134,7 @@ export function SecretMintBody({ schema, screen, translate }) {
134
134
  },
135
135
  ];
136
136
  });
137
- return (_jsxs(Card, { testId: "kumiko-screen-secret-mint-card", children: [_jsx(Heading, { variant: "page", children: effectiveTranslate(screen.reveal.title ?? "kumiko.secretMint.title") }), _jsx(Banner, { variant: "warning", testId: "kumiko-screen-secret-mint-warning", children: effectiveTranslate(screen.reveal.warning ?? "kumiko.secretMint.warning") }), SecretReveal !== undefined ? (_jsx(SecretReveal, { values: values, copyLabel: effectiveTranslate("kumiko.secretMint.copy"), copiedLabel: effectiveTranslate("kumiko.secretMint.copied"), testId: "kumiko-screen-secret-mint-reveal" })) : (_jsx(Grid, { columns: 1, testId: "kumiko-screen-secret-mint-reveal", children: values.map((v) => (_jsxs(GridCell, { children: [_jsx(Text, { children: v.label }), _jsx(Text, { variant: "code", children: v.value })] }, v.label))) })), confirm !== undefined && confirmEntity !== undefined && confirmScreen !== undefined ? (_jsx(RenderEdit, { screen: confirmScreen, entity: confirmEntity, featureName: schema.featureName, initial: confirmInitial ?? {}, writeCommand: confirm.handler, payloadMode: "values", buildPayload: (snapshot) => ({ ...snapshot.values, ...carriedRef.current }), onSubmit: handleConfirmSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(translate !== undefined && { translate }), ...(confirm.submitLabel !== undefined && { submitLabel: confirm.submitLabel }) })) : (_jsx(Button, { type: "button", variant: "primary", onClick: finishMint, testId: "kumiko-screen-secret-mint-confirm", children: effectiveTranslate(screen.reveal.confirmLabel ?? "kumiko.secretMint.confirm") }))] }));
137
+ return (_jsxs(Card, { options: { screenBody: true }, testId: "kumiko-screen-secret-mint-card", children: [_jsx(Heading, { variant: "page", children: effectiveTranslate(screen.reveal.title ?? "kumiko.secretMint.title") }), _jsx(Banner, { variant: "warning", testId: "kumiko-screen-secret-mint-warning", children: effectiveTranslate(screen.reveal.warning ?? "kumiko.secretMint.warning") }), SecretReveal !== undefined ? (_jsx(SecretReveal, { values: values, copyLabel: effectiveTranslate("kumiko.secretMint.copy"), copiedLabel: effectiveTranslate("kumiko.secretMint.copied"), testId: "kumiko-screen-secret-mint-reveal" })) : (_jsx(Grid, { columns: 1, testId: "kumiko-screen-secret-mint-reveal", children: values.map((v) => (_jsxs(GridCell, { children: [_jsx(Text, { children: v.label }), _jsx(Text, { variant: "code", children: v.value })] }, v.label))) })), confirm !== undefined && confirmEntity !== undefined && confirmScreen !== undefined ? (_jsx(RenderEdit, { screen: confirmScreen, entity: confirmEntity, featureName: schema.featureName, initial: confirmInitial ?? {}, writeCommand: confirm.handler, payloadMode: "values", buildPayload: (snapshot) => ({ ...snapshot.values, ...carriedRef.current }), onSubmit: handleConfirmSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(translate !== undefined && { translate }), ...(confirm.submitLabel !== undefined && { submitLabel: confirm.submitLabel }) })) : (_jsx(Button, { type: "button", variant: "primary", onClick: finishMint, testId: "kumiko-screen-secret-mint-confirm", children: effectiveTranslate(screen.reveal.confirmLabel ?? "kumiko.secretMint.confirm") }))] }));
138
138
  }
139
- return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, extensionInitialValues: initial, writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(translate !== undefined && { translate }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) }));
139
+ return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, extensionInitialValues: initial, writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, fillScreenHeight: true, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(translate !== undefined && { translate }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) }));
140
140
  }
@@ -3,13 +3,23 @@ import { useCallback, useMemo, useState } from "react";
3
3
  import { useDispatcher } from "../context/dispatcher-context.js";
4
4
  import { useQuery } from "../hooks/use-query.js";
5
5
  import { useTranslation } from "../i18n.js";
6
- import { usePrimitives } from "../primitives.js";
6
+ import { STICKY_PRIMARY_ACTION_PROP, usePrimitives } from "../primitives.js";
7
+ import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
7
8
  import { dispatcherErrorText } from "./write-failed-error.js";
9
+ function groupSectionsByTitle(sections) {
10
+ const byTitle = new Map();
11
+ for (const section of sections) {
12
+ const title = section.title ?? "config.secrets.section";
13
+ // kumiko-lint-ignore section-fields-raw secretsEdit section fields are field-id strings, not EditFieldSpec
14
+ byTitle.set(title, [...(byTitle.get(title) ?? []), ...section.fields]);
15
+ }
16
+ return [...byTitle].map(([title, fields]) => ({ title, fields }));
17
+ }
8
18
  // secrets:query:list never returns plaintext (only a redacted preview), so
9
19
  // unlike ConfigEditBody there is no server value to pre-fill a draft with —
10
20
  // every input starts at "" and stays that way unless the user types into it.
11
21
  export function SecretsEditBody({ screen, translate }) {
12
- const { Banner, Form, Section, Field, Input, Button, Text } = usePrimitives();
22
+ const { Banner, Form, Section, Field, Input, Button, Text, Grid } = usePrimitives();
13
23
  const t = useTranslation();
14
24
  const effectiveTranslate = translate ?? t;
15
25
  const dispatcher = useDispatcher();
@@ -52,25 +62,44 @@ export function SecretsEditBody({ screen, translate }) {
52
62
  }
53
63
  await listQuery.refetch();
54
64
  }, [dispatcher, listQuery.refetch, effectiveTranslate]);
55
- if (listQuery.loading && listQuery.data === null) {
56
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
57
- }
65
+ if (listQuery.loading && listQuery.data === null)
66
+ return _jsx(QueryLoadingBanner, {});
58
67
  if (listQuery.error) {
59
- return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", children: dispatcherErrorText(listQuery.error, effectiveTranslate) }));
68
+ return (_jsx(QueryErrorBanner, { error: listQuery.error, translate: effectiveTranslate, onRetry: listQuery.refetch }));
60
69
  }
70
+ // The generator emits one section per feature; features without a declared
71
+ // title all fall under the generic "Secrets" heading and share one band.
72
+ const sectionGroups = groupSectionsByTitle(screen.sections);
73
+ const unsavedCount = Object.values(drafts).filter((value) => value.trim() !== "").length;
61
74
  return (_jsxs(Form, { onSubmit: () => {
62
75
  void handleSubmit();
63
- }, testId: "secrets-edit-form", ...(screen.description !== undefined && {
76
+ }, testId: "secrets-edit-form", width: "full", fillHeight: true, stickyActions: true, screenForm: true, unsavedCount: unsavedCount, ...(screen.description !== undefined && {
64
77
  subtitle: effectiveTranslate(screen.description),
65
- }), actions: _jsx(Button, { type: "submit", variant: "primary", loading: submitting, disabled: submitting, testId: "secrets-edit-submit", children: effectiveTranslate("kumiko.actions.save") }), children: [submitError !== null && (_jsx(Banner, { variant: "error", testId: "secrets-edit-error", children: submitError })), screen.sections.map((section, index) => (_jsx(Section, { ...(section.title !== undefined && { title: effectiveTranslate(section.title) }), children: section.fields.map((fieldId) => {
66
- const qualified = screen.secretKeys[fieldId];
67
- if (qualified === undefined)
68
- return null;
69
- const row = rowsByQualifiedKey.get(qualified);
70
- const hintKey = screen.fieldHints?.[fieldId];
71
- const isRequired = screen.requiredFields?.includes(fieldId) ?? false;
72
- return (_jsxs(Field, { id: fieldId, label: effectiveTranslate(screen.fieldLabels[fieldId] ?? fieldId), required: isRequired, testId: `field-${fieldId}`, fieldAppendix: _jsxs(_Fragment, { children: [hintKey !== undefined && (_jsx(Text, { variant: "small", children: effectiveTranslate(hintKey) })), row !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "small", testId: `secret-preview-${fieldId}`, children: row.redactedPreview ?? effectiveTranslate("config.secrets.set") }), _jsx(Button, { type: "button", variant: "danger-ghost", size: "sm", onClick: () => handleDelete(qualified), testId: `secret-delete-${fieldId}`, children: effectiveTranslate("config.secrets.delete") })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "small", testId: `secret-not-set-${fieldId}`, children: effectiveTranslate("config.secrets.notSet") }), isRequired && (_jsx(Text, { variant: "small", testId: `required-marker-${fieldId}`, children: effectiveTranslate("config.secrets.required") }))] }))] }), children: [_jsx(Text, { variant: "small", children: effectiveTranslate(row !== undefined
78
+ }), actions: _jsxs(_Fragment, { children: [unsavedCount > 0 && (_jsx(Button, { type: "button", variant: "ghost", disabled: submitting, onClick: () => setDrafts({}), testId: "secrets-edit-discard", [STICKY_PRIMARY_ACTION_PROP]: true, children: effectiveTranslate("kumiko.form.discard") })), _jsx(Button, { type: "submit", variant: "primary", loading: submitting, disabled: submitting || unsavedCount === 0, testId: "secrets-edit-submit", children: effectiveTranslate("kumiko.form.saveChanges") })] }), children: [submitError !== null && (_jsx(Banner, { variant: "error", testId: "secrets-edit-error", children: submitError })), sectionGroups.map((section) => (_jsx(Section, { layout: "settings-list", title: effectiveTranslate(section.title), subtitle: effectiveTranslate("config.secrets.description"), children: _jsx(Grid, { columns: 1, list: true, children: section.fields.map((fieldId) => {
79
+ const qualified = screen.secretKeys[fieldId];
80
+ if (qualified === undefined)
81
+ return null;
82
+ const row = rowsByQualifiedKey.get(qualified);
83
+ const hintKey = screen.fieldHints?.[fieldId];
84
+ const isRequired = screen.requiredFields?.includes(fieldId) ?? false;
85
+ return (_jsx(Field, { id: fieldId, layout: "row", label: effectiveTranslate(screen.fieldLabels[fieldId] ?? fieldId), required: isRequired, testId: `field-${fieldId}`, status: row !== undefined
86
+ ? {
87
+ tone: "ok",
88
+ label: effectiveTranslate("config.secrets.saved"),
89
+ testId: `secret-saved-${fieldId}`,
90
+ }
91
+ : {
92
+ tone: isRequired ? "bad" : "muted",
93
+ label: effectiveTranslate("config.secrets.notSet"),
94
+ testId: `secret-not-set-${fieldId}`,
95
+ }, ...(hintKey !== undefined && { description: effectiveTranslate(hintKey) }), ...(row !== undefined && {
96
+ fieldAppendix: (_jsxs(Grid, { columns: "auto", children: [_jsx(Text, { variant: "muted", testId: `secret-preview-${fieldId}`, children: row.redactedPreview !== null
97
+ ? effectiveTranslate("config.secrets.stored", {
98
+ preview: row.redactedPreview,
99
+ })
100
+ : effectiveTranslate("config.secrets.set") }), _jsx(Button, { type: "button", variant: "danger-ghost", size: "sm", onClick: () => handleDelete(qualified), testId: `secret-delete-${fieldId}`, children: effectiveTranslate("config.secrets.delete") })] })),
101
+ }), children: _jsx(Input, { kind: "password", id: fieldId, name: fieldId, value: drafts[fieldId] ?? "", onChange: (v) => setDraft(fieldId, v), placeholder: effectiveTranslate(row !== undefined
73
102
  ? "config.secrets.replacePlaceholder"
74
- : "config.secrets.placeholder") }), _jsx(Input, { kind: "password", id: fieldId, name: fieldId, value: drafts[fieldId] ?? "", onChange: (v) => setDraft(fieldId, v), autoComplete: "new-password", disabled: submitting, testId: `secret-input-${fieldId}` })] }, fieldId));
75
- }) }, section.title ?? `section-${index}`)))] }));
103
+ : "config.secrets.placeholder"), autoComplete: "new-password", disabled: submitting, testId: `secret-input-${fieldId}` }) }, fieldId));
104
+ }) }) }, section.title)))] }));
76
105
  }
@@ -1,6 +1,6 @@
1
1
  import type { EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headless";
2
2
  import type { ReactNode } from "react";
3
- import type { FieldCellWidth, usePrimitives } from "../primitives.js";
3
+ import type { FieldCellWidth, FieldProps, usePrimitives } from "../primitives.js";
4
4
  export type GridCellForFieldProps = {
5
5
  readonly field: EditFieldViewModel;
6
6
  readonly columns: number;
@@ -12,6 +12,9 @@ export type GridCellForFieldProps = {
12
12
  readonly featureName: string;
13
13
  readonly labelAppendix?: ReactNode;
14
14
  readonly fieldAppendix?: ReactNode;
15
+ readonly appendixPlacement?: FieldProps["appendixPlacement"];
16
+ readonly description?: ReactNode;
17
+ readonly status?: FieldProps["status"];
15
18
  /** Full issues-by-path map (FormSnapshot.errors) — passed through for
16
19
  * embedded-list fields, which bucket row-/cell-level issues themselves. */
17
20
  readonly allIssues: Readonly<Record<string, readonly FieldIssue[]>>;
@@ -21,6 +24,8 @@ export type GridCellForFieldProps = {
21
24
  readonly row: Readonly<Record<string, unknown>>;
22
25
  readonly changed?: boolean;
23
26
  readonly flow?: boolean;
27
+ readonly layout?: FieldProps["layout"];
28
+ readonly accent?: boolean;
24
29
  };
25
30
  export declare function fieldCellWidth(field: EditFieldViewModel): FieldCellWidth;
26
- export declare function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, allIssues, valueDisplay, row, changed, flow, }: GridCellForFieldProps): ReactNode;
31
+ export declare function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, }: GridCellForFieldProps): ReactNode;
@@ -29,10 +29,10 @@ export function fieldCellWidth(field) {
29
29
  return "full";
30
30
  return FIELD_CELL_WIDTH_BY_TYPE[field.type] ?? "text";
31
31
  }
32
- export function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, allIssues, valueDisplay, row, changed, flow, }) {
32
+ export function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, }) {
33
33
  // RenderField renders nothing for a hidden field, but the GridCell around it still claims the row.
34
34
  if (!field.visible)
35
35
  return null;
36
36
  const effectiveSpan = field.span !== undefined ? Math.min(field.span, columns) : 1;
37
- return (_jsx(GridCell, { span: effectiveSpan, ...(flow === true && { width: fieldCellWidth(field) }), children: _jsx(RenderField, { field: field, ...(issues !== undefined && { issues }), onChange: onChange, featureName: featureName, ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), allIssues: allIssues, valueDisplay: valueDisplay, row: row, ...(changed === true && { changed }) }) }));
37
+ return (_jsx(GridCell, { span: effectiveSpan, ...(flow === true && { width: fieldCellWidth(field) }), children: _jsx(RenderField, { field: field, ...(issues !== undefined && { issues }), onChange: onChange, featureName: featureName, ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), allIssues: allIssues, valueDisplay: valueDisplay, row: row, ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }) }) }));
38
38
  }
@@ -1,12 +1,21 @@
1
1
  import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
2
- import type { ReactNode } from "react";
2
+ import { type OptionsQueryPayload } from "@cosmicdrift/kumiko-types/fields";
3
+ import { type ReactNode } from "react";
4
+ type ResolvedOptionsQueryPayload = Readonly<Record<string, string | number | boolean>>;
5
+ export declare function resolveOptionsQueryPayload(payload: OptionsQueryPayload, row: Readonly<Record<string, unknown>>): ResolvedOptionsQueryPayload;
3
6
  type QueryOptionsSelectProps = {
4
7
  readonly field: EditFieldViewModel;
5
8
  readonly query: string;
6
- readonly payload: Readonly<Record<string, string | number | boolean>>;
9
+ readonly payload: OptionsQueryPayload;
10
+ readonly row: Readonly<Record<string, unknown>>;
7
11
  readonly id: string;
8
12
  readonly hasError: boolean;
9
13
  readonly onChange: (value: unknown) => void;
10
14
  };
11
- export declare function QueryOptionsSelect({ field, query, payload, id, hasError, onChange, }: QueryOptionsSelectProps): ReactNode;
15
+ export declare function QueryOptionsSelect({ field, query, payload, row, id, hasError, onChange, }: QueryOptionsSelectProps): ReactNode;
16
+ export declare function QueryOptionLabel({ query, payload, value, }: {
17
+ readonly query: string;
18
+ readonly payload: ResolvedOptionsQueryPayload;
19
+ readonly value: string | number | boolean;
20
+ }): ReactNode;
12
21
  export {};
@@ -1,14 +1,50 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { isOptionsQueryFieldRef } from "@cosmicdrift/kumiko-types/fields";
3
+ import { useEffect, useRef } from "react";
2
4
  import { useQuery } from "../hooks/use-query.js";
3
5
  import { usePrimitives } from "../primitives.js";
6
+ function isPayloadScalar(value) {
7
+ return ((typeof value === "string" && value !== "") ||
8
+ typeof value === "number" ||
9
+ typeof value === "boolean");
10
+ }
11
+ // An empty sibling value drops its key, so "nothing chosen yet" reaches the query
12
+ // handler as an absent property, not as "" / null.
13
+ export function resolveOptionsQueryPayload(payload, row) {
14
+ const resolved = {};
15
+ for (const [name, entry] of Object.entries(payload)) {
16
+ const value = isOptionsQueryFieldRef(entry) ? row[entry.field] : entry;
17
+ if (isPayloadScalar(value))
18
+ resolved[name] = value;
19
+ }
20
+ return resolved;
21
+ }
4
22
  // Select whose options come from a query (SelectFieldDef.optionsQuery), so it
5
23
  // has to be a mounted component for useQuery. useQuery re-fetches on the
6
24
  // serialized payload, hence no payload memoization here.
7
- export function QueryOptionsSelect({ field, query, payload, id, hasError, onChange, }) {
25
+ export function QueryOptionsSelect({ field, query, payload, row, id, hasError, onChange, }) {
8
26
  const { Input } = usePrimitives();
9
- const { data, loading } = useQuery(query, payload);
27
+ const resolvedPayload = resolveOptionsQueryPayload(payload, row);
28
+ const payloadKey = JSON.stringify(resolvedPayload);
29
+ const { data, loading } = useQuery(query, resolvedPayload);
10
30
  const current = typeof field.value === "string" ? field.value : "";
11
31
  const rows = data?.rows ?? [];
32
+ // useQuery keeps the previous data while a changed payload reloads, and a
33
+ // superseded response is aborted — so a fresh `data` object always belongs to
34
+ // the payload of the render it arrives in. The first load never clears (a stored
35
+ // value missing there stays visible); only a load after a payload change does.
36
+ const settledPayloadKey = useRef(null);
37
+ // biome-ignore lint/correctness/useExhaustiveDependencies: runs once per loaded result, not on every keystroke
38
+ useEffect(() => {
39
+ if (data === null)
40
+ return;
41
+ const previousKey = settledPayloadKey.current;
42
+ settledPayloadKey.current = payloadKey;
43
+ const payloadChanged = previousKey !== null && previousKey !== payloadKey;
44
+ if (payloadChanged && current !== "" && !data.rows.some((row) => row.value === current)) {
45
+ onChange("");
46
+ }
47
+ }, [data]);
12
48
  // A stored value missing from the result (still loading, or dropped from the
13
49
  // catalog) must stay visible instead of silently turning into "nothing selected".
14
50
  const options = current !== "" && !rows.some((row) => row.value === current)
@@ -19,3 +55,10 @@ export function QueryOptionsSelect({ field, query, payload, id, hasError, onChan
19
55
  const display = field.display === "radio" || field.display === "dropdown" ? field.display : "dropdown";
20
56
  return (_jsx(Input, { kind: "select", id: id, name: field.field, disabled: field.readOnly || loading, required: field.required, hasError: hasError, value: current, onChange: (v) => onChange(v), options: options, display: display }));
21
57
  }
58
+ // Badge text for a stored value of an optionsQuery select: the row's label once
59
+ // loaded, the raw value while loading or when the value is not in the result.
60
+ export function QueryOptionLabel({ query, payload, value, }) {
61
+ const { data } = useQuery(query, payload);
62
+ const label = data?.rows.find((row) => row.value === value)?.label;
63
+ return label ?? String(value);
64
+ }
@@ -306,11 +306,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
306
306
  filterValues: filters,
307
307
  onFilterChange,
308
308
  onFilterReset,
309
- }), ...(onRowClick !== undefined && { onRowClick }), ...(rowActions !== undefined && { rowActions }), ...(section.rowActionMode !== undefined && { rowActionMode: section.rowActionMode }), ...(rowGrouping !== undefined && { rowGrouping }), ...(rowTone !== undefined && { rowTone }), ...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons }), ...(emptyStateContent !== undefined && { emptyState: emptyStateContent }), ...(hideTitle === true && {
310
- scrollBody: true,
311
- screenPadding: false,
312
- chromeless: true,
313
- }), ...(embedded === true && { screenPadding: false, chromeless: true }) }) })] }));
309
+ }), ...(onRowClick !== undefined && { onRowClick }), ...(rowActions !== undefined && { rowActions }), ...(section.rowActionMode !== undefined && { rowActionMode: section.rowActionMode }), ...(rowGrouping !== undefined && { rowGrouping }), ...(rowTone !== undefined && { rowTone }), ...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons }), ...(emptyStateContent !== undefined && { emptyState: emptyStateContent }), screenPadding: false, ...(hideTitle === true && { scrollBody: true, chromeless: true }), ...(embedded === true && { chromeless: true }) }) })] }));
314
310
  // hideTitle (tabs mode) → the caller (render-edit.tsx) already wraps this
315
311
  // component's own return value in the same Card frame every other tab
316
312
  // kind gets — no card here, or the two would nest. `scrollBody` caps the
@@ -88,6 +88,17 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
88
88
  /** Per-field extra content below the input (e.g. ConfigCascadeView).
89
89
  * Called with the field name, returns a ReactNode or undefined. */
90
90
  readonly fieldAppendix?: (fieldName: string) => ReactNode | undefined;
91
+ /** Placement of every `fieldAppendix` relative to its control. Default
92
+ * above-control; below-control keeps the inputs of neighbouring fields
93
+ * on one line when an appendix expands. */
94
+ readonly fieldAppendixPlacement?: "above-control" | "below-control";
95
+ /** Per-field help text rendered under the label (translated by the caller). */
96
+ readonly fieldDescription?: (fieldName: string) => ReactNode | undefined;
97
+ /** Marks fields whose value is set at this level; drawn by `settings-list` rows. */
98
+ readonly fieldAccent?: (fieldName: string) => boolean;
99
+ /** Edit-mode "changed" markers also for screens without an entity id
100
+ * (configEdit loads its values from a query, not a record). */
101
+ readonly markChangedFields?: boolean;
91
102
  /** Controlled mode (issue #1887): fires on every values-snapshot change
92
103
  * (typing, `patch(...)` from outside) with the current values. `changes`
93
104
  * is the delta against the initial values — same semantics as
@@ -164,6 +175,15 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
164
175
  * screens with `fillHeight !== false`; dialogs and other embedded hosts
165
176
  * omit it and keep document-flow height. */
166
177
  readonly fillScreenHeight?: boolean;
178
+ /** Show the unsaved-changes footer (count, Discard, "Save changes") even
179
+ * without an entity id or a screen-height form — for hosts that edit an
180
+ * existing server-side record through `customSubmit` (configEdit, also when
181
+ * embedded as a dashboard panel). */
182
+ readonly dirtyFooter?: boolean;
183
+ /** Validate the edited field right after each change instead of only on
184
+ * submit, so a bounds violation shows at the field while the Save button
185
+ * looks enabled. Set where Save never blocks on a pre-check (configEdit). */
186
+ readonly validateOnChange?: boolean;
167
187
  /** Extra content rendered above the card, sharing its left padding and
168
188
  * width — for a host with its own header region (title/metrics/tabs)
169
189
  * that would otherwise render as unpadded siblings before RenderEdit.