@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.
- package/dist/app/config-edit-shim.d.ts +7 -0
- package/dist/app/config-edit-shim.js +39 -0
- package/dist/app/kumiko-screen.js +63 -36
- package/dist/app/query-state-banners.d.ts +8 -0
- package/dist/app/query-state-banners.js +14 -0
- package/dist/app/secret-mint-body.js +3 -3
- package/dist/app/secrets-edit-body.js +46 -17
- package/dist/components/grid-cell-for-field.d.ts +7 -2
- package/dist/components/grid-cell-for-field.js +2 -2
- package/dist/components/query-options-select.d.ts +12 -3
- package/dist/components/query-options-select.js +45 -2
- package/dist/components/related-list-section.js +1 -5
- package/dist/components/render-edit-types.d.ts +20 -0
- package/dist/components/render-edit.js +64 -26
- package/dist/components/render-field.d.ts +7 -1
- package/dist/components/render-field.js +32 -4
- package/dist/components/write-form-section.d.ts +5 -1
- package/dist/components/write-form-section.js +17 -5
- package/dist/context/embedded-screen-context.d.ts +5 -0
- package/dist/context/embedded-screen-context.js +12 -0
- package/dist/i18n-defaults.js +30 -5
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/primitives.d.ts +54 -12
- package/package.json +5 -5
|
@@ -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,
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
2306
|
-
|
|
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(
|
|
2336
|
+
return (_jsx(QueryErrorBanner, { error: valuesQuery.error, translate: effectiveTranslate, onRetry: valuesQuery.refetch }));
|
|
2324
2337
|
}
|
|
2325
|
-
if (initial === null)
|
|
2326
|
-
return
|
|
2327
|
-
|
|
2328
|
-
|
|
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
|
-
|
|
2333
|
-
|
|
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
|
|
57
|
-
}
|
|
65
|
+
if (listQuery.loading && listQuery.data === null)
|
|
66
|
+
return _jsx(QueryLoadingBanner, {});
|
|
58
67
|
if (listQuery.error) {
|
|
59
|
-
return (_jsx(
|
|
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.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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")
|
|
75
|
-
|
|
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
|
|
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:
|
|
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
|
|
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.
|