@cosmicdrift/kumiko-renderer 0.373.0 → 0.374.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.
@@ -0,0 +1,12 @@
1
+ import type { RowAction } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { Translate } from "@cosmicdrift/kumiko-headless";
3
+ import type { RenderEditAction } from "../components/render-edit-types.ts";
4
+ /** Header actions on a record shown by a screen that has no drawer host
5
+ * (dashboards): writeHandler, navigate and clientAction, `onWriteSuccess` after a success. */
6
+ export declare function useRecordActions(options: {
7
+ readonly actions: readonly RowAction[] | undefined;
8
+ readonly record: Readonly<Record<string, unknown>>;
9
+ readonly recordId: string;
10
+ readonly translate: Translate;
11
+ readonly onWriteSuccess: () => void | Promise<void>;
12
+ }): readonly RenderEditAction[] | undefined;
@@ -0,0 +1,30 @@
1
+ import { useMemo } from "react";
2
+ import { useOptionalDispatcher } from "../context/dispatcher-context.js";
3
+ import { useClientActionRuntime } from "./client-actions.js";
4
+ import { useNav } from "./nav.js";
5
+ import { useReturnHost } from "./return-to.js";
6
+ import { buildRecordActions } from "./row-actions.js";
7
+ const NO_DRAWER = () => { };
8
+ /** Header actions on a record shown by a screen that has no drawer host
9
+ * (dashboards): writeHandler, navigate and clientAction, `onWriteSuccess` after a success. */
10
+ export function useRecordActions(options) {
11
+ const { actions, record, recordId, translate, onWriteSuccess } = options;
12
+ const nav = useNav();
13
+ const host = useReturnHost();
14
+ const dispatcher = useOptionalDispatcher();
15
+ const clientActions = useClientActionRuntime();
16
+ return useMemo(() => actions === undefined
17
+ ? undefined
18
+ : buildRecordActions({
19
+ actions,
20
+ record,
21
+ translate,
22
+ nav,
23
+ host,
24
+ dispatcher,
25
+ openDrawer: NO_DRAWER,
26
+ onWriteSuccess,
27
+ defaultWritePayloadId: recordId,
28
+ clientActions,
29
+ }), [actions, record, recordId, translate, nav, host, dispatcher, onWriteSuccess, clientActions]);
30
+ }
@@ -26,6 +26,8 @@ export type GridCellForFieldProps = {
26
26
  readonly flow?: boolean;
27
27
  readonly layout?: FieldProps["layout"];
28
28
  readonly accent?: boolean;
29
+ /** Passed through to RenderField — see RenderFieldProps.fillDisabled. */
30
+ readonly fillDisabled?: boolean;
29
31
  };
30
32
  export declare function fieldCellWidth(field: EditFieldViewModel): FieldCellWidth;
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;
33
+ export declare function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, fillDisabled, }: 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, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, }) {
32
+ export function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, fillDisabled, }) {
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 }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), allIssues: allIssues, valueDisplay: valueDisplay, row: row, ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }) }) }));
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 }), ...(fillDisabled === true && { fillDisabled }) }) }));
38
38
  }
@@ -2,11 +2,12 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { evalFieldCondition, normalizeListColumn, parseRefTarget, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { useAppFeatures } from "../app/app-features-context.js";
5
+ import { useClientActionRuntime } from "../app/client-actions.js";
5
6
  import { buildFilterFacets, buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "../app/list-facets.js";
6
7
  import { useNav } from "../app/nav.js";
7
8
  import { ReferenceFacetBridges, } from "../app/reference-facet-bridge.js";
8
9
  import { navigateWithReturnTo, useReturnHost } from "../app/return-to.js";
9
- import { buildDefaultEditRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, runProjectionRowNavigate, } from "../app/row-actions.js";
10
+ import { buildDefaultEditRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, findRowClickAction, runDrawerRowAction, runProjectionRowNavigate, } from "../app/row-actions.js";
10
11
  import { findEditScreenFor } from "../app/screen-access.js";
11
12
  import { dispatcherErrorText } from "../app/write-failed-error.js";
12
13
  import { useOptionalDispatcher } from "../context/dispatcher-context.js";
@@ -95,6 +96,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
95
96
  const nav = useNav();
96
97
  const host = useReturnHost();
97
98
  const dispatcher = useOptionalDispatcher();
99
+ const clientActions = useClientActionRuntime();
98
100
  const appFeatures = useAppFeatures();
99
101
  const userRoles = useUserRoles();
100
102
  const defaultEditScreen = useMemo(() => {
@@ -196,7 +198,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
196
198
  // A row-body click target comes from EITHER the legacy `rowClick` field OR
197
199
  // a `rowActions` entry marked rowClick:true — the boot-validator rejects
198
200
  // both being set, so this order is just a fallback, not a precedence rule.
199
- const rowClickAction = section.rowActions?.find((a) => a.kind === "navigate" && a.rowClick === true);
201
+ const rowClickAction = findRowClickAction(section.rowActions);
200
202
  const onRowClick = rowClick !== undefined
201
203
  ? (row) => {
202
204
  const id = String(row.values[rowClick.idColumn ?? "id"] ?? "");
@@ -205,7 +207,14 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
205
207
  navigateWithReturnTo(nav, { entity: rowClick.entity, id }, host);
206
208
  }
207
209
  : rowClickAction !== undefined
208
- ? (row) => runProjectionRowNavigate(nav, rowClickAction, row, host)
210
+ ? (row) => {
211
+ if (rowClickAction.kind === "navigate") {
212
+ runProjectionRowNavigate(nav, rowClickAction, row, host);
213
+ }
214
+ else if (onOpenDrawer !== undefined) {
215
+ runDrawerRowAction(rowClickAction, onOpenDrawer, row);
216
+ }
217
+ }
209
218
  : undefined;
210
219
  // Same execution path as projectionList's rowActions (kumiko-screen.tsx) —
211
220
  // a relatedList row has the identical "no guaranteed id field" shape a
@@ -220,10 +229,12 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
220
229
  openDrawer: onOpenDrawer,
221
230
  defaultEditRowAction,
222
231
  host,
232
+ clientActions,
223
233
  }), [
224
234
  section.rowActions,
225
235
  effectiveTranslate,
226
236
  dispatcher,
237
+ clientActions,
227
238
  nav,
228
239
  refetchSelfAndHost,
229
240
  onOpenDrawer,
@@ -283,6 +294,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
283
294
  dispatcher,
284
295
  openDrawer: onOpenDrawer ?? (() => { }),
285
296
  onWriteSuccess: refetchSelfAndHost,
297
+ clientActions,
286
298
  })?.[0];
287
299
  }, [
288
300
  section.emptyState,
@@ -291,6 +303,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
291
303
  nav,
292
304
  host,
293
305
  dispatcher,
306
+ clientActions,
294
307
  onOpenDrawer,
295
308
  refetchSelfAndHost,
296
309
  ]);
@@ -1430,7 +1430,7 @@ export function RenderEdit(props) {
1430
1430
  appendixPlacement: fieldAppendixPlacement,
1431
1431
  }), ...(fieldDescription !== undefined && {
1432
1432
  description: fieldDescription(field.field),
1433
- }), allIssues: snapshot.errors, valueDisplay: valueDisplay, row: snapshot.values, ...((!isCreate || markChangedFields) &&
1433
+ }), allIssues: snapshot.errors, valueDisplay: valueDisplay, row: snapshot.values, ...(disabled && { fillDisabled: true }), ...((!isCreate || markChangedFields) &&
1434
1434
  field.field in snapshot.changes && { changed: true }) }, field.field))) }, gridKey));
1435
1435
  // Tabs mode: the tab panel is the surface, so field sections render
1436
1436
  // as unframed padded panels; group cards inside stay framed.
@@ -39,6 +39,9 @@ export type RenderFieldProps = {
39
39
  readonly changed?: boolean;
40
40
  readonly layout?: FieldProps["layout"];
41
41
  readonly accent?: boolean;
42
+ /** The surrounding form is locked as a whole: the fill button stays visible
43
+ * but inert, unlike a single readOnly field whose fill button keeps working. */
44
+ readonly fillDisabled?: boolean;
42
45
  };
43
46
  export declare const AllFieldsRequiredProvider: import("react").Provider<boolean>;
44
- export declare function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, layout, accent, }: RenderFieldProps): ReactNode;
47
+ export declare function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, layout, accent, fillDisabled, }: RenderFieldProps): ReactNode;
@@ -2,13 +2,15 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { isFormatSpec, SYSTEM_REFERENCE_LABELS, } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { applyFormatSpec, currencyDecimals, EMPTY_VALUE_PLACEHOLDER, } from "@cosmicdrift/kumiko-headless";
4
4
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
5
- import { createContext, Fragment, useCallback, useContext, useMemo, useState, } from "react";
5
+ import { createContext, Fragment, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
6
6
  import { useAppFeatures } from "../app/app-features-context.js";
7
7
  import { useColumnRenderer } from "../app/column-renderers.js";
8
8
  import { extensionSectionName } from "../app/extension-sections.js";
9
9
  import { toKebab } from "../app/qn.js";
10
10
  import { useReferenceLinkResolver } from "../app/reference-link.js";
11
+ import { evalRowExtractor } from "../app/row-actions.js";
11
12
  import { screenAccessAllows } from "../app/screen-access.js";
13
+ import { useOptionalDispatcher } from "../context/dispatcher-context.js";
12
14
  import { useUserRoles } from "../context/user-roles-context.js";
13
15
  import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
14
16
  import { useQuery } from "../hooks/use-query.js";
@@ -23,7 +25,7 @@ import { QueryOptionsSelect } from "./query-options-select.js";
23
25
  import { ReferenceCreateDialog } from "./reference-create-dialog.js";
24
26
  const AllFieldsRequiredContext = createContext(false);
25
27
  export const AllFieldsRequiredProvider = AllFieldsRequiredContext.Provider;
26
- export function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay = "form", row, changed, layout, accent, }) {
28
+ export function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay = "form", row, changed, layout, accent, fillDisabled, }) {
27
29
  const { Field, Input, Banner, Text, JsonView } = usePrimitives();
28
30
  // App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
29
31
  // navigator.language (Browser-Sprache) zurück statt der gewählten
@@ -48,7 +50,20 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
48
50
  // Komponente gemountet werden (nicht als pure render-Call).
49
51
  const control = field.readOnly && field.renderer !== undefined ? (_jsx(FieldRendererOutput, { field: field, renderer: field.renderer, appLocale: appLocale, ...(row !== undefined && { row }) })) : field.type === "embedded" && field.embeddedListCells !== undefined ? (_jsx(EmbeddedListField, { field: field, id: id, onChange: onChange, allIssues: allIssues ?? {}, featureName: featureName ?? "" })) : field.type === "reference" ? (readOnlyText ? (_jsx(ReadOnlyReferenceValue, { field: field, featureName: featureName ?? "" })) : (_jsx(ReferenceInput, { field: field, id: id, hasError: hasError, onChange: onChange, Input: Input, featureName: featureName ?? "" }))) : field.type === "select" && field.selectOptionsQuery !== undefined && !readOnlyText ? (_jsx(QueryOptionsSelect, { field: field, query: field.selectOptionsQuery.query, payload: field.selectOptionsQuery.payload, row: row ?? { [field.field]: field.value }, id: id, hasError: hasError, onChange: onChange })) : field.type === "select" &&
50
52
  field.selectOptionsAvailabilityQuery !== undefined &&
51
- !readOnlyText ? (_jsx(AvailabilityOptionsSelect, { field: field, query: field.selectOptionsAvailabilityQuery.query, payload: field.selectOptionsAvailabilityQuery.payload, row: row ?? { [field.field]: field.value }, id: id, hasError: hasError, onChange: onChange })) : readOnlyText && !isComplexFieldType(field.type) && !isFileFieldType(field.type) ? (_jsx(Text, { testId: `field-value-${field.field}`, children: readOnlyDisplayText(field, appLocale, t) })) : field.type === "text" && field.writeOnly === true && !readOnlyText ? (_jsx(WriteOnlyTextInput, { field: field, id: id, hasError: hasError, onChange: onChange, changed: changed === true })) : (renderInput({
53
+ !readOnlyText ? (_jsx(AvailabilityOptionsSelect, { field: field, query: field.selectOptionsAvailabilityQuery.query, payload: field.selectOptionsAvailabilityQuery.payload, row: row ?? { [field.field]: field.value }, id: id, hasError: hasError, onChange: onChange })) : readOnlyText && !isComplexFieldType(field.type) && !isFileFieldType(field.type) ? (_jsx(Text, { testId: `field-value-${field.field}`, children: readOnlyDisplayText(field, appLocale, t) })) : field.type === "text" && field.writeOnly === true && !readOnlyText ? (_jsx(WriteOnlyTextInput, { field: field, id: id, hasError: hasError, onChange: onChange, changed: changed === true })) : field.fillAction !== undefined ? (_jsx(FillActionControl, { field: field, fillAction: field.fillAction, row: row ?? { [field.field]: field.value }, onChange: onChange, disabled: fillDisabled === true, Banner: Banner, renderControl: (fill) => renderInput({
54
+ field,
55
+ id,
56
+ hasError,
57
+ onChange,
58
+ Input,
59
+ appLocale,
60
+ Banner,
61
+ Text,
62
+ JsonView,
63
+ t,
64
+ row,
65
+ fill,
66
+ }) })) : (renderInput({
52
67
  field,
53
68
  id,
54
69
  hasError,
@@ -63,6 +78,63 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
63
78
  }));
64
79
  return (_jsx(Field, { id: id, label: field.label, required: field.required && field.type !== "boolean" && !allFieldsRequired, ...(issues !== undefined && { issues }), ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), testId: `field-${field.field}`, children: control }));
65
80
  }
81
+ const NUMERIC_FIELD_TYPES = new Set(["number", "bigInt", "decimal"]);
82
+ // A text field takes a number as its string form; a numeric field takes only a
83
+ // number — a string would be sent as a wrong-typed value.
84
+ function fillValueFor(fieldType, result) {
85
+ if (NUMERIC_FIELD_TYPES.has(fieldType))
86
+ return typeof result === "number" ? result : undefined;
87
+ if (typeof result === "string")
88
+ return result;
89
+ return typeof result === "number" ? String(result) : undefined;
90
+ }
91
+ // Owns the query state of a fill button. A separate component because RenderField
92
+ // returns early for hidden fields and the dispatcher hook must not be conditional.
93
+ function FillActionControl({ field, fillAction, row, onChange, disabled, Banner, renderControl, }) {
94
+ const dispatcher = useOptionalDispatcher();
95
+ const t = useTranslation();
96
+ const [busy, setBusy] = useState(false);
97
+ const [failed, setFailed] = useState(false);
98
+ // A newer click or an unmount cancels the in-flight query so a late answer
99
+ // cannot overwrite a value the user has since typed.
100
+ const activeRun = useRef(null);
101
+ useEffect(() => () => activeRun.current?.abort(), []);
102
+ const run = async () => {
103
+ if (dispatcher === undefined) {
104
+ setFailed(true);
105
+ return;
106
+ }
107
+ activeRun.current?.abort();
108
+ const ctrl = new AbortController();
109
+ activeRun.current = ctrl;
110
+ setBusy(true);
111
+ setFailed(false);
112
+ const payload = fillAction.params === undefined ? {} : evalRowExtractor(fillAction.params, row);
113
+ const result = await dispatcher.query(fillAction.query, payload, {
114
+ signal: ctrl.signal,
115
+ });
116
+ if (ctrl.signal.aborted)
117
+ return;
118
+ setBusy(false);
119
+ if (!result.isSuccess) {
120
+ setFailed(true);
121
+ return;
122
+ }
123
+ const value = fillValueFor(field.type, propertyOf(result.data, fillAction.field));
124
+ if (value === undefined) {
125
+ setFailed(true);
126
+ return;
127
+ }
128
+ onChange(value);
129
+ };
130
+ return (_jsxs(_Fragment, { children: [renderControl({
131
+ label: fillAction.label,
132
+ onPress: () => void run(),
133
+ busy,
134
+ disabled,
135
+ testId: `field-fill-${field.field}`,
136
+ }), failed && (_jsx(Banner, { variant: "error", testId: `field-fill-error-${field.field}`, children: t("kumiko.field.fillAction.failed") }))] }));
137
+ }
66
138
  // Wire value of a writeOnly field is `true` (set) / `null` (empty), never the
67
139
  // secret. Typing sets a new value; an untouched or emptied input submits
68
140
  // nothing / "" which the server reads as "keep". `null` after a loaded `true`
@@ -330,7 +402,7 @@ function resolveFieldIcon(field) {
330
402
  // SelectFieldDef.options). Structural types without a widget (embedded,
331
403
  // jsonb, multiSelect) render read-only instead of a data-destroying text
332
404
  // fallback (#1834); truly unknown scalar types still fall back to text.
333
- function renderInput({ field, id, hasError, onChange, Input, appLocale, Banner, Text, JsonView, t, row, }) {
405
+ function renderInput({ field, id, hasError, onChange, Input, appLocale, Banner, Text, JsonView, t, row, fill, }) {
334
406
  const common = {
335
407
  id,
336
408
  name: field.field,
@@ -338,6 +410,14 @@ function renderInput({ field, id, hasError, onChange, Input, appLocale, Banner,
338
410
  required: field.required,
339
411
  hasError,
340
412
  };
413
+ const placeholderProp = field.placeholder !== undefined ? { placeholder: field.placeholder } : {};
414
+ const prefix = resolveAffix(field.prefix, row);
415
+ const suffix = resolveAffix(field.suffix, row);
416
+ const affixProps = {
417
+ ...(prefix !== undefined && { prefix }),
418
+ ...(suffix !== undefined && { suffix }),
419
+ ...(fill !== undefined && { fill }),
420
+ };
341
421
  switch (field.type) {
342
422
  // decimal/bigInt are both plain numbers on the wire (fieldToZod:
343
423
  // z.number() / z.number().int().safe()) — the number input's onChange
@@ -347,11 +427,11 @@ function renderInput({ field, id, hasError, onChange, Input, appLocale, Banner,
347
427
  case "bigInt": {
348
428
  const icon = resolveFieldIcon(field);
349
429
  const unit = resolveNumberUnit(field.unit, row);
350
- return (_jsx(Input, { kind: "number", ...common, value: numberValue(field.value), onChange: (v) => onChange(v), locale: appLocale, ...(field.grouping !== undefined && { grouping: field.grouping }), ...(field.type === "bigInt" && { integer: true }), ...(icon !== undefined && { icon }), ...(unit !== undefined && { unit }) }));
430
+ return (_jsx(Input, { kind: "number", ...common, value: numberValue(field.value), onChange: (v) => onChange(v), locale: appLocale, ...(field.grouping !== undefined && { grouping: field.grouping }), ...(field.type === "bigInt" && { integer: true }), ...placeholderProp, ...affixProps, ...(icon !== undefined && { icon }), ...(unit !== undefined && { unit }) }));
351
431
  }
352
432
  case "decimal": {
353
433
  const icon = resolveFieldIcon(field);
354
- return (_jsx(Input, { kind: "number", ...common, value: numberValue(field.value), onChange: (v) => onChange(v), locale: appLocale, ...(field.grouping !== undefined && { grouping: field.grouping }), ...(icon !== undefined && { icon }) }));
434
+ return (_jsx(Input, { kind: "number", ...common, value: numberValue(field.value), onChange: (v) => onChange(v), locale: appLocale, ...(field.grouping !== undefined && { grouping: field.grouping }), ...placeholderProp, ...affixProps, ...(icon !== undefined && { icon }) }));
355
435
  }
356
436
  case "tz":
357
437
  return (_jsx(Input, { kind: "tz", ...common, value: stringValue(field.value), onChange: (v) => onChange(v) }));
@@ -421,10 +501,10 @@ function renderInput({ field, id, hasError, onChange, Input, appLocale, Banner,
421
501
  // only supplies an optional `{ rows }` override for it (#1925).
422
502
  if (field.type === "longText" || (field.type === "text" && field.multiline)) {
423
503
  const rows = typeof field.multiline === "object" ? field.multiline.rows : undefined;
424
- return (_jsx(Input, { kind: "textarea", ...common, value: stringValue(field.value), onChange: (v) => onChange(v), ...(rows !== undefined && { rows }) }));
504
+ return (_jsx(Input, { kind: "textarea", ...common, value: stringValue(field.value), onChange: (v) => onChange(v), ...(rows !== undefined && { rows }), ...placeholderProp }));
425
505
  }
426
506
  const icon = resolveFieldIcon(field);
427
- return (_jsx(Input, { kind: "text", ...common, value: stringValue(field.value), onChange: (v) => onChange(v), ...(icon !== undefined && { icon }), ...(field.autoComplete !== undefined && { autoComplete: field.autoComplete }) }));
507
+ return (_jsx(Input, { kind: "text", ...common, value: stringValue(field.value), onChange: (v) => onChange(v), ...placeholderProp, ...affixProps, ...(icon !== undefined && { icon }), ...(field.autoComplete !== undefined && { autoComplete: field.autoComplete }) }));
428
508
  }
429
509
  }
430
510
  }
@@ -479,6 +559,17 @@ function resolveNumberUnit(unit, row) {
479
559
  const trimmed = sibling.trim();
480
560
  return trimmed.length > 0 && trimmed.length <= MAX_SIBLING_UNIT_LENGTH ? trimmed : undefined;
481
561
  }
562
+ // String affix → used as-is (the view model already translated it). Field
563
+ // affix → the live form value; missing, empty or non-scalar means no affix.
564
+ function resolveAffix(affix, row) {
565
+ if (affix === undefined)
566
+ return undefined;
567
+ if (typeof affix === "string")
568
+ return affix;
569
+ const value = row?.[affix.field];
570
+ const text = typeof value === "number" ? String(value) : value;
571
+ return typeof text === "string" && text.trim().length > 0 ? text.trim() : undefined;
572
+ }
482
573
  function stringArrayOf(value) {
483
574
  return Array.isArray(value)
484
575
  ? value.filter((item) => typeof item === "string")
@@ -14,6 +14,7 @@ export const kumikoDefaultTranslations = {
14
14
  "kumiko.actions.delete": "Delete",
15
15
  "kumiko.actions.delete-confirm": "Confirm delete?",
16
16
  "kumiko.actions.reload": "Reload",
17
+ "kumiko.actionForm.success": "Done.",
17
18
  "kumiko.actions.retry": "Try again",
18
19
  "kumiko.actions.create": "New",
19
20
  "kumiko.actions.edit": "Edit",
@@ -39,6 +40,7 @@ export const kumikoDefaultTranslations = {
39
40
  "kumiko.field.locatedTzHint": "Time local to the given location",
40
41
  "kumiko.field.reference-created-no-id": "Record was created but could not be selected automatically. Please select it manually.",
41
42
  "kumiko.field.unsupported": "This field type can't be edited here yet.",
43
+ "kumiko.field.fillAction.failed": "Could not fetch a suggestion.",
42
44
  "kumiko.field.writeOnly.remove": "Remove stored value",
43
45
  "kumiko.field.writeOnly.set": "Set",
44
46
  "kumiko.pii.erased": "Deleted",
@@ -128,6 +130,8 @@ export const kumikoDefaultTranslations = {
128
130
  "kumiko.dashboard.panel.error.isolated": "The other panels are not affected.",
129
131
  "kumiko.dashboard.panel.error.retry": "Try again",
130
132
  "kumiko.dashboard.panel.error.title": "{label} could not be loaded",
133
+ "kumiko.dashboard.progress.unlimited": "unlimited",
134
+ "kumiko.dashboard.record.missing": "Open this page from a record to see its dashboard.",
131
135
  "kumiko.dashboard.time-range": "Time range",
132
136
  "kumiko.screen.unavailable": "This page is not available right now.",
133
137
  "kumiko.dashboard.brush-end": "Window end",
@@ -192,6 +196,9 @@ export const kumikoDefaultTranslations = {
192
196
  "kumiko.aiText.style.expand": "Expand",
193
197
  "kumiko.aiText.capExceeded": "Monthly AI limit reached.",
194
198
  "kumiko.rowAction.failed": "Action failed",
199
+ "kumiko.clientAction.success": "Done.",
200
+ "kumiko.clientAction.unregistered": "This action is not available in this app.",
201
+ "kumiko.clientAction.failed": "The action could not be completed.",
195
202
  "kumiko.contentEditor.insertVariable": "Insert {name}",
196
203
  "kumiko.contentEditor.preview": "Preview",
197
204
  "kumiko.contentEditor.editMode": "Edit",
package/dist/index.d.ts CHANGED
@@ -2,6 +2,8 @@ export { type Formality, formalLocaleTag } from "@cosmicdrift/kumiko-framework/u
2
2
  export { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./app/action-form-shim.ts";
3
3
  export type { AppFeaturesProviderProps } from "./app/app-features-context.tsx";
4
4
  export { AppFeaturesProvider, useAppFeatures } from "./app/app-features-context.tsx";
5
+ export type { ActionNotice, ActionNotifier, ClientActionHandler, ClientActionOutcome, ClientActionRuntime, ClientActionsMap, } from "./app/client-actions.tsx";
6
+ export { ActionNoticeProvider, ClientActionsProvider, runClientAction, useClientActionHandler, useClientActionRuntime, } from "./app/client-actions.tsx";
5
7
  export type { ColumnRendererComponent, ColumnRendererProps, ColumnRenderersMap, ColumnRenderersProviderProps, } from "./app/column-renderers.tsx";
6
8
  export { ColumnRenderersProvider, useColumnRenderer } from "./app/column-renderers.tsx";
7
9
  export type { ContentEditorComponent, ContentEditorProps, ContentEditorsMap, ContentEditorsProviderProps, } from "./app/content-editors.tsx";
@@ -18,6 +20,7 @@ export type { ExtensionSectionComponent, ExtensionSectionProps, ExtensionSection
18
20
  export { ExtensionSectionsProvider, extensionSectionName, useExtensionSectionComponent, } from "./app/extension-sections.tsx";
19
21
  export type { AppSchema, FeatureSchema, QualifiedContentCollection, WorkspaceSchema, } from "./app/feature-schema.ts";
20
22
  export { isAppSchema, toAppSchema } from "./app/feature-schema.ts";
23
+ export { HeaderActionsBar } from "./app/header-actions-bar.tsx";
21
24
  export type { KumikoScreenProps } from "./app/kumiko-screen.tsx";
22
25
  export { KumikoScreen, qualifyNavId, qualifyScreenId, ScreenMissingState, } from "./app/kumiko-screen.tsx";
23
26
  export type { DateRangeBound, DateRangeValue, ResolvedDateRangeFacet } from "./app/list-facets.ts";
@@ -29,6 +32,7 @@ export type { ReturnHost, ReturnTo } from "./app/return-to.tsx";
29
32
  export { navigateToReturn, navigateToReturnOr, navigateWithReturnTo, RETURN_TO_PARAM, ReturnHostProvider, resolveReturnTarget, splitReturnTo, useReturnHost, useReturnTarget, } from "./app/return-to.tsx";
30
33
  export type { EmbeddedScreenTarget } from "./app/use-embedded-screen.ts";
31
34
  export { useEmbeddedScreen } from "./app/use-embedded-screen.ts";
35
+ export { useRecordActions } from "./app/use-record-actions.ts";
32
36
  export type { VariableChipsProps } from "./app/variable-chips.tsx";
33
37
  export { VariableChips } from "./app/variable-chips.tsx";
34
38
  export { evalVisibleWhen, type VisibleWhenVerdict } from "./app/visible-when.ts";
@@ -75,7 +79,7 @@ export { createStaticLocaleResolver, FormalityProvider, LocaleProvider, mergeTra
75
79
  export { kumikoDefaultTranslations } from "./i18n-defaults.ts";
76
80
  export { InsideDrawerProvider, useInsideDrawer } from "./inside-drawer.ts";
77
81
  export { PageHeaderCompactProvider, PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "./page-header-slot.ts";
78
- export type { ActionMenuItemSpec, ActionOverflowMenuProps, AppPrimitives, BannerProps, ButtonProps, CardOptions, CardProps, CardSlots, CopyButtonProps, CorePrimitives, DataTableDateRangeFacet, DataTableFacet, DataTableFacetChip, DataTableProps, DataTableRowAction, DataTableRowActionMode, DataTableRowGrouping, DataTableSort, DataTableSortDir, DialogProps, DrawerProps, EmbeddedListCellType, EmbeddedListColumn, EmbeddedListInputProps, EmbeddedListTotal, FieldCellWidth, FieldProps, FillContainerProps, FooterActionMarker, FooterActionRole, FormProps, FormSectionNavItem, FormWidth, GridCellProps, GridProps, HeadingProps, InputProps, JsonViewProps, LightboxImage, LightboxProps, LinkProps, MetricBandProps, MetricProps, ModalProps, PageHeaderProps, PickedFile, PrimitivesProviderProps, PrimitivesRegistry, ProgressProps, ProgressTone, PromoPanelAction, PromoPanelProps, RuntimeRenderer, SecretRevealProps, SecretRevealValue, SectionProps, ShareButtonProps, StatusBadgeProps, StatusTone, StepBarProps, StickyActionBarProps, StickyPrimaryActionMarker, TabsProps, TextProps, TrailingInputAction, WizardStepGroupProps, } from "./primitives.tsx";
82
+ export type { ActionMenuItemSpec, ActionOverflowMenuProps, AppPrimitives, BannerProps, ButtonProps, CardOptions, CardProps, CardSlots, CopyButtonProps, CorePrimitives, DataTableDateRangeFacet, DataTableFacet, DataTableFacetChip, DataTableProps, DataTableRowAction, DataTableRowActionMode, DataTableRowGrouping, DataTableSort, DataTableSortDir, DialogProps, DrawerProps, EmbeddedListCellType, EmbeddedListColumn, EmbeddedListInputProps, EmbeddedListTotal, FieldCellWidth, FieldProps, FillContainerProps, FooterActionMarker, FooterActionRole, FormProps, FormSectionNavItem, FormWidth, GridCellProps, GridProps, HeadingProps, InputFillAction, InputProps, JsonViewProps, LightboxImage, LightboxProps, LinkProps, MetricBandProps, MetricProps, ModalProps, PageHeaderProps, PickedFile, PrimitivesProviderProps, PrimitivesRegistry, ProgressProps, ProgressTone, PromoPanelAction, PromoPanelProps, RuntimeRenderer, SecretRevealProps, SecretRevealValue, SectionProps, ShareButtonProps, StatusBadgeProps, StatusTone, StepBarProps, StickyActionBarProps, StickyPrimaryActionMarker, TabsProps, TextProps, TrailingInputAction, WizardStepGroupProps, } from "./primitives.tsx";
79
83
  export { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, PrimitivesProvider, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, useOptionalPrimitives, usePrimitives, } from "./primitives.tsx";
80
84
  export type { SessionEndedSignal, SessionEndedSignalProviderProps, } from "./session/session-ended.tsx";
81
85
  export { createSessionEndedSignal, isSessionEndedError, SESSION_ENDED_ERROR_CODES, SessionEndedSignalProvider, useSessionEndedSignal, withSessionEndedDetection, } from "./session/session-ended.tsx";
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@
11
11
  export { formalLocaleTag } from "@cosmicdrift/kumiko-framework/ui-types";
12
12
  export { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./app/action-form-shim.js";
13
13
  export { AppFeaturesProvider, useAppFeatures } from "./app/app-features-context.js";
14
+ export { ActionNoticeProvider, ClientActionsProvider, runClientAction, useClientActionHandler, useClientActionRuntime, } from "./app/client-actions.js";
14
15
  export { ColumnRenderersProvider, useColumnRenderer } from "./app/column-renderers.js";
15
16
  export { CONTENT_EDITOR_ELEMENT_ID, ContentEditorsProvider, TextareaContentEditor, useContentEditor, } from "./app/content-editors.js";
16
17
  export { ContentPreview, substituteVariables } from "./app/content-preview.js";
@@ -19,12 +20,14 @@ export { DashboardBodyProvider, useDashboardBody } from "./app/dashboard-body.js
19
20
  export { ExtensionFormRegistryProvider, useExtensionFormSubmit, } from "./app/extension-form-submit.js";
20
21
  export { ExtensionSectionsProvider, extensionSectionName, useExtensionSectionComponent, } from "./app/extension-sections.js";
21
22
  export { isAppSchema, toAppSchema } from "./app/feature-schema.js";
23
+ export { HeaderActionsBar } from "./app/header-actions-bar.js";
22
24
  export { KumikoScreen, qualifyNavId, qualifyScreenId, ScreenMissingState, } from "./app/kumiko-screen.js";
23
25
  export { buildDateRangePayload, clampDateRange, readDateRange, resolveDateRangeFacets, } from "./app/list-facets.js";
24
26
  export { formatPath, hasDetailScreen, NavProvider, parsePath, resolveTarget, useNav, useNavigateWithInitialValues, useOptionalNav, } from "./app/nav.js";
25
27
  export { lastSegment } from "./app/qn.js";
26
28
  export { navigateToReturn, navigateToReturnOr, navigateWithReturnTo, RETURN_TO_PARAM, ReturnHostProvider, resolveReturnTarget, splitReturnTo, useReturnHost, useReturnTarget, } from "./app/return-to.js";
27
29
  export { useEmbeddedScreen } from "./app/use-embedded-screen.js";
30
+ export { useRecordActions } from "./app/use-record-actions.js";
28
31
  export { VariableChips } from "./app/variable-chips.js";
29
32
  export { evalVisibleWhen } from "./app/visible-when.js";
30
33
  export { dispatcherErrorText, WriteFailedError } from "./app/write-failed-error.js";
@@ -174,6 +174,17 @@ export type FieldProps = {
174
174
  /** Discriminated union — jede Input-Sorte hat ihre eigene value/onChange
175
175
  * Signatur. Custom-Impls dispatchen intern, rendern anders (Toggle
176
176
  * statt Checkbox), oder nur einzelne kinds unterschiedlich. */
177
+ /** Button beside a single-line text/number input that fills it with a
178
+ * suggestion. Stays usable on a `disabled` input (the readOnly+fill case). */
179
+ export type InputFillAction = {
180
+ readonly label: string;
181
+ readonly onPress: () => void;
182
+ /** A suggestion is being fetched: the button shows a spinner and is inert. */
183
+ readonly busy?: boolean;
184
+ /** The surrounding form is locked as a whole. */
185
+ readonly disabled?: boolean;
186
+ readonly testId?: string;
187
+ };
177
188
  export type TrailingInputAction = {
178
189
  readonly icon: "clear" | "undo";
179
190
  readonly label: string;
@@ -211,6 +222,10 @@ export type InputProps = {
211
222
  readonly readOnly?: boolean;
212
223
  /** Closed FieldIconKey vocabulary (FIELD_ICONS registry, renderer-web). */
213
224
  readonly icon?: FieldIconKey;
225
+ /** Resolved inline text before/after the input (EditFieldSpec.prefix/suffix). */
226
+ readonly prefix?: string;
227
+ readonly suffix?: string;
228
+ readonly fill?: InputFillAction;
214
229
  readonly dataAttributes?: DataAttributes;
215
230
  /** Raw keydown access (e.g. Enter-to-submit at the field instead of
216
231
  * the surrounding Form). Web forwards it, native impls ignore it
@@ -276,6 +291,9 @@ export type InputProps = {
276
291
  /** Resolved display suffix (static or from a sibling field) — never
277
292
  * part of the numeric value. */
278
293
  readonly unit?: string;
294
+ readonly prefix?: string;
295
+ readonly suffix?: string;
296
+ readonly fill?: InputFillAction;
279
297
  } | {
280
298
  readonly kind: "range";
281
299
  readonly id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.373.0",
3
+ "version": "0.374.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,9 +15,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.373.0",
19
- "@cosmicdrift/kumiko-headless": "0.373.0",
20
- "@cosmicdrift/kumiko-types": "0.373.0",
18
+ "@cosmicdrift/kumiko-framework": "0.374.0",
19
+ "@cosmicdrift/kumiko-headless": "0.374.0",
20
+ "@cosmicdrift/kumiko-types": "0.374.0",
21
21
  "react": "^19.2.6",
22
22
  "temporal-polyfill": "^0.3.2",
23
23
  "zod": "^4.4.3"
@@ -28,7 +28,7 @@
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "jsdom": "^29.1.1",
30
30
  "react-dom": "^19.2.6",
31
- "@cosmicdrift/kumiko-locale-de": "0.373.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.374.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,28 @@
1
1
  [
2
+ {
3
+ "version": "0.374.0",
4
+ "type": "improvement",
5
+ "title": "Success box with links, form reset and field placeholder",
6
+ "detail": "The actionForm success box shows the default text \"Done.\" (kumiko.actionForm.success) next to successMessage links, resetOnSuccess remounts the form with its initial values, and EditFieldSpec.placeholder reaches text, number and textarea inputs."
7
+ },
8
+ {
9
+ "version": "0.374.0",
10
+ "type": "improvement",
11
+ "title": "Client action registry for row actions",
12
+ "detail": "ClientActionsProvider, ActionNoticeProvider and runClientAction execute clientAction row actions on list, related list and record actions, with confirm, toast feedback and refetch."
13
+ },
14
+ {
15
+ "version": "0.374.0",
16
+ "type": "improvement",
17
+ "title": "actionForm load states and drawer row click",
18
+ "detail": "An actionForm with load queries by the route, drawer or URL id and shows loading, error, not-found and missing-id states; loaded values count as prefill for submitPrefilled. A row click fires a rowClick drawer action in entityList, projectionList and relatedList."
19
+ },
20
+ {
21
+ "version": "0.374.0",
22
+ "type": "improvement",
23
+ "title": "Input prefix, suffix and fill button",
24
+ "detail": "Text and number inputs show prefix and suffix text, and a fillAction renders a button that fetches a suggestion into the field, also for a readOnly field. The Input primitive takes prefix, suffix and fill; renderer-web draws them beside the input."
25
+ },
2
26
  {
3
27
  "version": "0.372.0",
4
28
  "type": "fix",