@cosmicdrift/kumiko-renderer 0.333.0 → 0.335.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/config-edit-shim.d.ts +7 -0
- package/dist/app/config-edit-shim.js +39 -0
- package/dist/app/content-preview.js +7 -9
- package/dist/app/extension-sections.d.ts +6 -0
- package/dist/app/form-schema.d.ts +2 -1
- package/dist/app/form-schema.js +11 -2
- package/dist/app/kumiko-screen.d.ts +5 -0
- package/dist/app/kumiko-screen.js +259 -101
- package/dist/app/nav.d.ts +5 -0
- package/dist/app/nav.js +17 -4
- package/dist/app/query-state-banners.d.ts +8 -0
- package/dist/app/query-state-banners.js +14 -0
- package/dist/app/reference-facet-bridge.js +8 -1
- package/dist/app/row-actions.d.ts +12 -0
- package/dist/app/row-actions.js +24 -7
- package/dist/app/screen-access.js +13 -9
- package/dist/app/secret-mint-body.js +41 -7
- package/dist/app/secrets-edit-body.js +89 -33
- package/dist/components/embedded-list-field.js +2 -4
- package/dist/components/grid-cell-for-field.d.ts +7 -2
- package/dist/components/grid-cell-for-field.js +2 -2
- package/dist/components/multi-select-checkboxes.js +4 -1
- package/dist/components/query-options-select.d.ts +12 -3
- package/dist/components/query-options-select.js +45 -2
- package/dist/components/reference-create-dialog.js +9 -4
- package/dist/components/related-list-section.js +8 -10
- package/dist/components/render-edit-action-button.d.ts +1 -1
- package/dist/components/render-edit-action-button.js +5 -1
- package/dist/components/render-edit-logic.d.ts +2 -1
- package/dist/components/render-edit-logic.js +8 -5
- package/dist/components/render-edit-types.d.ts +30 -1
- package/dist/components/render-edit.js +248 -76
- package/dist/components/render-field.d.ts +7 -1
- package/dist/components/render-field.js +40 -15
- package/dist/components/render-list.js +6 -6
- package/dist/components/write-form-section.d.ts +5 -1
- package/dist/components/write-form-section.js +29 -25
- package/dist/context/embedded-screen-context.d.ts +5 -0
- package/dist/context/embedded-screen-context.js +12 -0
- package/dist/hooks/use-reference-lookup.d.ts +7 -0
- package/dist/hooks/use-reference-lookup.js +10 -0
- package/dist/i18n-defaults.js +30 -5
- package/dist/i18n.js +8 -4
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- package/dist/primitives.d.ts +70 -19
- package/dist/session/session-ended.d.ts +16 -0
- package/dist/session/session-ended.js +73 -0
- package/dist/sort-by-accessor.d.ts +6 -2
- package/dist/sort-by-accessor.js +13 -3
- package/package.json +5 -5
- package/src/changes.json +40 -0
|
@@ -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
|
|
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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useMemo } from "react";
|
|
3
|
-
import { buildInitialValues } from "../app/kumiko-screen.js";
|
|
3
|
+
import { buildInitialValues, tenantCurrencyMoneyFieldNames, useMoneyCurrencyOverrides, } from "../app/kumiko-screen.js";
|
|
4
4
|
import { toKebab } from "../app/qn.js";
|
|
5
5
|
import { useTranslation } from "../i18n.js";
|
|
6
6
|
import { usePrimitives } from "../primitives.js";
|
|
@@ -31,9 +31,14 @@ export function extractCreatedId(data) {
|
|
|
31
31
|
return extractIdField(data, "id");
|
|
32
32
|
}
|
|
33
33
|
export function ReferenceCreateDialog({ open, onClose, onCreated, featureName, screen, entity, }) {
|
|
34
|
-
const { Modal } = usePrimitives();
|
|
34
|
+
const { Banner, Modal } = usePrimitives();
|
|
35
35
|
const t = useTranslation();
|
|
36
|
-
const
|
|
36
|
+
const entityDefaultCurrency = entity.defaultCurrency ?? "EUR";
|
|
37
|
+
const tenantCurrencyFieldNames = useMemo(() => (open ? tenantCurrencyMoneyFieldNames(entity.fields) : []), [open, entity.fields]);
|
|
38
|
+
// Same tenant-currency resolution as the entityEdit create screen (fw#2933),
|
|
39
|
+
// so a `currency: { kind: "tenant" }` field seeds identically via this dialog.
|
|
40
|
+
const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(entity.fields, tenantCurrencyFieldNames, entityDefaultCurrency);
|
|
41
|
+
const initial = useMemo(() => buildInitialValues(entity.fields, entityDefaultCurrency, moneyCurrencyOverrides), [entity.fields, entityDefaultCurrency, moneyCurrencyOverrides]);
|
|
37
42
|
const writeCommand = entityWriteCommand(featureName, screen.entity);
|
|
38
43
|
const handleSubmitted = (result) => {
|
|
39
44
|
if (result.validationBlocked || !result.isSuccess)
|
|
@@ -42,5 +47,5 @@ export function ReferenceCreateDialog({ open, onClose, onCreated, featureName, s
|
|
|
42
47
|
};
|
|
43
48
|
if (!open)
|
|
44
49
|
return null;
|
|
45
|
-
return (_jsx(Modal, { open: open, onOpenChange: (next) => !next && onClose(), title: t("kumiko.actions.create"), children: _jsx(RenderEdit, { screen: screen, entity: entity, featureName: featureName, initial: initial, writeCommand: writeCommand, onSubmit: handleSubmitted, onCancel: onClose, ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) }) }));
|
|
50
|
+
return (_jsx(Modal, { open: open, onOpenChange: (next) => !next && onClose(), title: t("kumiko.actions.create"), children: currencyLoading ? (_jsx(Banner, { padded: true, variant: "loading", testId: "reference-create-dialog-loading", children: "Loading\u2026" })) : (_jsx(RenderEdit, { screen: screen, entity: entity, featureName: featureName, initial: initial, writeCommand: writeCommand, onSubmit: handleSubmitted, onCancel: onClose, ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) })) }));
|
|
46
51
|
}
|
|
@@ -12,7 +12,7 @@ import { dispatcherErrorText } from "../app/write-failed-error.js";
|
|
|
12
12
|
import { useOptionalDispatcher } from "../context/dispatcher-context.js";
|
|
13
13
|
import { useUserRoles } from "../context/user-roles-context.js";
|
|
14
14
|
import { useQuery } from "../hooks/use-query.js";
|
|
15
|
-
import {
|
|
15
|
+
import { useOptionalLocale, useTranslation } from "../i18n.js";
|
|
16
16
|
import { PageHeaderSlotAvailableProvider } from "../page-header-slot.js";
|
|
17
17
|
import { usePrimitives, } from "../primitives.js";
|
|
18
18
|
import { sortByAccessor } from "../sort-by-accessor.js";
|
|
@@ -84,10 +84,10 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
84
84
|
const [emptyStateActionError, setEmptyStateActionError] = useState(null);
|
|
85
85
|
const t = useTranslation();
|
|
86
86
|
const effectiveTranslate = translate ?? t;
|
|
87
|
-
const locale =
|
|
87
|
+
const locale = useOptionalLocale();
|
|
88
88
|
const rowGrouping = useMemo(() => section.groupBy === undefined
|
|
89
89
|
? undefined
|
|
90
|
-
: buildRowGrouping(section.groupBy, effectiveTranslate, locale), [section.groupBy, effectiveTranslate, locale]);
|
|
90
|
+
: buildRowGrouping(section.groupBy, effectiveTranslate, locale ?? "en"), [section.groupBy, effectiveTranslate, locale]);
|
|
91
91
|
const rowTone = useMemo(() => buildRowTone(section.rowTone), [section.rowTone]);
|
|
92
92
|
const nav = useNav();
|
|
93
93
|
const host = useReturnHost();
|
|
@@ -175,12 +175,14 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
175
175
|
const field = normalizeListColumn(col).field;
|
|
176
176
|
accessors[field] = (row) => {
|
|
177
177
|
const value = row[field];
|
|
178
|
-
|
|
178
|
+
if (value === null || value === undefined)
|
|
179
|
+
return null;
|
|
180
|
+
return typeof value === "number" ? value : String(value);
|
|
179
181
|
};
|
|
180
182
|
}
|
|
181
183
|
return accessors;
|
|
182
184
|
}, [section.columns]);
|
|
183
|
-
const sortedRows = useMemo(() => sortByAccessor(rowsQuery.data?.rows ?? [], sort, sortAccessors), [rowsQuery.data, sort, sortAccessors]);
|
|
185
|
+
const sortedRows = useMemo(() => sortByAccessor(rowsQuery.data?.rows ?? [], sort, sortAccessors, locale), [rowsQuery.data, sort, sortAccessors, locale]);
|
|
184
186
|
const rowClick = section.rowClick;
|
|
185
187
|
// A row-body click target comes from EITHER the legacy `rowClick` field OR
|
|
186
188
|
// a `rowActions` entry marked rowClick:true — the boot-validator rejects
|
|
@@ -306,11 +308,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
306
308
|
filterValues: filters,
|
|
307
309
|
onFilterChange,
|
|
308
310
|
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 }) }) })] }));
|
|
311
|
+
}), ...(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
312
|
// hideTitle (tabs mode) → the caller (render-edit.tsx) already wraps this
|
|
315
313
|
// component's own return value in the same Card frame every other tab
|
|
316
314
|
// kind gets — no card here, or the two would nest. `scrollBody` caps the
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import type { usePrimitives } from "../primitives.js";
|
|
3
3
|
import type { RenderEditAction } from "./render-edit-types.js";
|
|
4
|
-
export declare function needsActionConfirm(action: RenderEditAction): boolean;
|
|
4
|
+
export declare function needsActionConfirm(action: Pick<RenderEditAction, "confirm" | "confirmRequired" | "style">): boolean;
|
|
5
5
|
export declare function RenderEditActionConfirmDialog({ action, open, onOpenChange, onConfirm, Dialog, }: {
|
|
6
6
|
readonly action: RenderEditAction;
|
|
7
7
|
readonly open: boolean;
|
|
@@ -32,7 +32,11 @@ export function RenderEditActionButton({ action, iconOnly = false, hideIcon = fa
|
|
|
32
32
|
};
|
|
33
33
|
const variant = action.style ?? "secondary";
|
|
34
34
|
const showIconOnly = iconOnly && action.icon !== undefined;
|
|
35
|
-
return (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: variant, loading: busy, ...(action.icon !== undefined && (!hideIcon || showIconOnly) && { icon: action.icon }), ...(showIconOnly && {
|
|
35
|
+
return (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: variant, loading: busy, ...(action.icon !== undefined && (!hideIcon || showIconOnly) && { icon: action.icon }), ...(showIconOnly && {
|
|
36
|
+
size: "icon",
|
|
37
|
+
ariaLabel: action.label,
|
|
38
|
+
title: action.label,
|
|
39
|
+
}), onClick: () => {
|
|
36
40
|
if (needsActionConfirm(action)) {
|
|
37
41
|
setConfirmOpen(true);
|
|
38
42
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { EditSectionViewModel, SubmitResult } from "@cosmicdrift/kumiko-headless";
|
|
1
|
+
import type { EditFieldViewModel, EditSectionViewModel, SubmitResult } from "@cosmicdrift/kumiko-headless";
|
|
2
2
|
export declare function hasEditableSection(sections: readonly EditSectionViewModel[]): boolean;
|
|
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 hasIssueWithoutRenderedField(issuePaths: readonly string[], sections: readonly EditSectionViewModel[]): boolean;
|
|
6
|
+
export declare function hasIssueWithoutVisibleField(issuePaths: readonly string[], fields: readonly EditFieldViewModel[]): boolean;
|
|
6
7
|
export declare function filterEditSections(sections: readonly EditSectionViewModel[], fieldsFilter: readonly string[] | undefined): readonly EditSectionViewModel[];
|
|
@@ -30,13 +30,16 @@ export function shouldNotifyCaller(result, extensionsPersisted) {
|
|
|
30
30
|
// Matched on the first path segment (embedded-list rows are dotted, e.g. `tasks.2.title`).
|
|
31
31
|
// No issues at all counts as unmatched, so the caller keeps the banner.
|
|
32
32
|
export function hasIssueWithoutRenderedField(issuePaths, sections) {
|
|
33
|
+
const renderedFields = sections
|
|
34
|
+
.filter((s) => s.kind === "fields" && (s.visible || s.fields.length === 0))
|
|
35
|
+
.flatMap((s) => (s.kind === "fields" ? s.fields : []));
|
|
36
|
+
return hasIssueWithoutVisibleField(issuePaths, renderedFields);
|
|
37
|
+
}
|
|
38
|
+
export function hasIssueWithoutVisibleField(issuePaths, fields) {
|
|
33
39
|
if (issuePaths.length === 0)
|
|
34
40
|
return true;
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
.flatMap((s) => (s.kind === "fields" ? s.fields.filter((f) => f.visible) : []))
|
|
38
|
-
.map((f) => f.field));
|
|
39
|
-
return issuePaths.some((path) => !renderedFields.has(path.split(".")[0] ?? path));
|
|
41
|
+
const visibleFields = new Set(fields.filter((f) => f.visible).map((f) => f.field));
|
|
42
|
+
return issuePaths.some((path) => !visibleFields.has(path.split(".")[0] ?? path));
|
|
40
43
|
}
|
|
41
44
|
// Extension, relatedList and writeForm sections skip the `fields` filter
|
|
42
45
|
// (writeForm's fields belong to its own independent form, not the host's);
|
|
@@ -4,6 +4,10 @@ import type { ReactNode } from "react";
|
|
|
4
4
|
import type * as z from "zod";
|
|
5
5
|
export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
6
6
|
readonly screen: EntityEditScreenDefinition;
|
|
7
|
+
/** Screen id used for the `screen:<id>.title`/`.subtitle` lookups and as the
|
|
8
|
+
* title fallback, when `screen.id` is synthetic (e.g. a secretMint confirm
|
|
9
|
+
* form keeps its own id for the draft key). Defaults to `screen.id`. */
|
|
10
|
+
readonly i18nScreenId?: string;
|
|
7
11
|
readonly entity: EntityDefinition;
|
|
8
12
|
readonly featureName: string;
|
|
9
13
|
readonly initial: TValues;
|
|
@@ -88,12 +92,24 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
88
92
|
/** Per-field extra content below the input (e.g. ConfigCascadeView).
|
|
89
93
|
* Called with the field name, returns a ReactNode or undefined. */
|
|
90
94
|
readonly fieldAppendix?: (fieldName: string) => ReactNode | undefined;
|
|
95
|
+
/** Placement of every `fieldAppendix` relative to its control. Default
|
|
96
|
+
* above-control; below-control keeps the inputs of neighbouring fields
|
|
97
|
+
* on one line when an appendix expands. */
|
|
98
|
+
readonly fieldAppendixPlacement?: "above-control" | "below-control";
|
|
99
|
+
/** Per-field help text rendered under the label (translated by the caller). */
|
|
100
|
+
readonly fieldDescription?: (fieldName: string) => ReactNode | undefined;
|
|
101
|
+
/** Marks fields whose value is set at this level; drawn by `settings-list` rows. */
|
|
102
|
+
readonly fieldAccent?: (fieldName: string) => boolean;
|
|
103
|
+
/** Edit-mode "changed" markers also for screens without an entity id
|
|
104
|
+
* (configEdit loads its values from a query, not a record). */
|
|
105
|
+
readonly markChangedFields?: boolean;
|
|
91
106
|
/** Controlled mode (issue #1887): fires on every values-snapshot change
|
|
92
107
|
* (typing, `patch(...)` from outside) with the current values. `changes`
|
|
93
108
|
* is the delta against the initial values — same semantics as
|
|
94
109
|
* `payloadMode: "changes"` — so a caller never overwrites unseen fields.
|
|
95
110
|
* `valid` is a pure dry-run parse against `schema` (not a
|
|
96
|
-
* `controller.validate()` call)
|
|
111
|
+
* `controller.validate()` call) that ignores issues on hidden fields and
|
|
112
|
+
* outside the `fields` scope, like submit() does, so it does not paint field errors into
|
|
97
113
|
* the UI and can diverge from the currently rendered `snapshot.errors` —
|
|
98
114
|
* always `true` without `schema`. A caller that patches a fresh object
|
|
99
115
|
* reference on every call must not do so unconditionally: `setValues` is
|
|
@@ -164,6 +180,19 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
164
180
|
* screens with `fillHeight !== false`; dialogs and other embedded hosts
|
|
165
181
|
* omit it and keep document-flow height. */
|
|
166
182
|
readonly fillScreenHeight?: boolean;
|
|
183
|
+
/** Rendered first inside the form, above the sections, in the same column and
|
|
184
|
+
* rhythm — for content that belongs to the form but is no field (e.g. the
|
|
185
|
+
* secret a confirm step refers to). */
|
|
186
|
+
readonly leadContent?: ReactNode;
|
|
187
|
+
/** Show the unsaved-changes footer (count, Discard, "Save changes") even
|
|
188
|
+
* without an entity id or a screen-height form — for hosts that edit an
|
|
189
|
+
* existing server-side record through `customSubmit` (configEdit, also when
|
|
190
|
+
* embedded as a dashboard panel). */
|
|
191
|
+
readonly dirtyFooter?: boolean;
|
|
192
|
+
/** Validate the edited field right after each change instead of only on
|
|
193
|
+
* submit, so a bounds violation shows at the field while the Save button
|
|
194
|
+
* looks enabled. Set where Save never blocks on a pre-check (configEdit). */
|
|
195
|
+
readonly validateOnChange?: boolean;
|
|
167
196
|
/** Extra content rendered above the card, sharing its left padding and
|
|
168
197
|
* width — for a host with its own header region (title/metrics/tabs)
|
|
169
198
|
* that would otherwise render as unpadded siblings before RenderEdit.
|