@cosmicdrift/kumiko-renderer-web 0.187.0 → 0.188.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.
@@ -21,6 +21,7 @@ import {
21
21
  } from "@cosmicdrift/kumiko-headless";
22
22
  import {
23
23
  createStaticLocaleResolver,
24
+ type DraftStorage,
24
25
  kumikoDefaultTranslations,
25
26
  type LiveEventSubscriber,
26
27
  LiveEventsProvider,
@@ -163,4 +164,27 @@ export function createMockDispatcher(options: MockDispatcherOptions = {}): Dispa
163
164
  };
164
165
  }
165
166
 
167
+ // ---------------------------------------------------------------------------
168
+ // Fake-DraftStorage-Helper
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /** In-memory DraftStorage for tests that need RenderEdit's create-mode
172
+ * draftId to survive a same-tab remount (issue #1913) — the same role
173
+ * `sessionStorage` plays for `createBrowserDraftStorage()` in production.
174
+ * Fresh Map per call, so tests stay isolated from one another; wrap the
175
+ * SAME instance around both renders of a "simulated reload" test to prove
176
+ * persistence across the remount. */
177
+ export function createFakeDraftStorage(): DraftStorage {
178
+ const store = new Map<string, string>();
179
+ return {
180
+ getDraftId: (screenId) => store.get(screenId) ?? null,
181
+ setDraftId: (screenId, draftId) => {
182
+ store.set(screenId, draftId);
183
+ },
184
+ clearDraftId: (screenId) => {
185
+ store.delete(screenId);
186
+ },
187
+ };
188
+ }
189
+
166
190
  export * from "@testing-library/react";
@@ -0,0 +1,73 @@
1
+ // fw#1910: the auto-wired entityEdit path never set RenderEdit's `schema`
2
+ // prop, so the wizard's per-step "Next" validation was a no-op — a
3
+ // required field left empty on step 1 still advanced to step 2. This test
4
+ // covers the fix end to end (KumikoScreen → EntityEditCreateBody →
5
+ // RenderEdit) with real DOM primitives, not a hand-rolled unit call.
6
+ import { describe, expect, test } from "bun:test";
7
+ import type {
8
+ EntityDefinition,
9
+ EntityEditScreenDefinition,
10
+ } from "@cosmicdrift/kumiko-framework/ui-types";
11
+ import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
12
+ import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
13
+ import userEvent from "@testing-library/user-event";
14
+ import { createMockDispatcher, render, screen } from "./test-utils";
15
+
16
+ const profileEntity = {
17
+ fields: {
18
+ fullName: { type: "text", required: true },
19
+ email: { type: "text", required: false },
20
+ },
21
+ } as unknown as EntityDefinition;
22
+
23
+ const wizardScreen: EntityEditScreenDefinition = {
24
+ id: "profile-edit",
25
+ type: "entityEdit",
26
+ entity: "profile",
27
+ layout: {
28
+ mode: "wizard",
29
+ sections: [
30
+ { title: "Step 1", fields: ["fullName"] },
31
+ { title: "Step 2", fields: ["email"] },
32
+ ],
33
+ },
34
+ };
35
+
36
+ const schema: FeatureSchema = {
37
+ featureName: "demo",
38
+ entities: { profile: profileEntity },
39
+ screens: [wizardScreen],
40
+ };
41
+
42
+ function renderWizard() {
43
+ return render(
44
+ <DispatcherProvider dispatcher={createMockDispatcher()}>
45
+ <KumikoScreen schema={schema} qn="demo:screen:profile-edit" />
46
+ </DispatcherProvider>,
47
+ );
48
+ }
49
+
50
+ describe("entityEdit wizard — presence validation on Next (fw#1910)", () => {
51
+ test("empty required field blocks the step transition and shows a field error", async () => {
52
+ renderWizard();
53
+
54
+ await userEvent.click(screen.getByTestId("render-edit-wizard-next"));
55
+
56
+ expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("1");
57
+ expect(screen.getByTestId("field-fullName-errors")).toBeTruthy();
58
+ // Step 2's field never mounts — the transition was blocked.
59
+ expect(document.querySelector("#kumiko-edit-email")).toBeNull();
60
+ });
61
+
62
+ test("filling the required field allows Next to advance to step 2", async () => {
63
+ const { container } = renderWizard();
64
+
65
+ const fullNameInput = container.querySelector("#kumiko-edit-fullName");
66
+ expect(fullNameInput).toBeTruthy();
67
+ await userEvent.type(fullNameInput as Element, "Ada Lovelace");
68
+ await userEvent.click(screen.getByTestId("render-edit-wizard-next"));
69
+
70
+ expect(screen.getByTestId("render-edit-wizard-step-label").textContent).toContain("2");
71
+ expect(screen.queryByTestId("field-fullName-errors")).toBeNull();
72
+ });
73
+ });
@@ -16,6 +16,8 @@ import {
16
16
  CustomScreensProvider,
17
17
  DashboardBodyProvider,
18
18
  DispatcherProvider,
19
+ type DraftStorage,
20
+ DraftStorageProvider,
19
21
  type ExtensionSectionComponent,
20
22
  ExtensionSectionsProvider,
21
23
  type FeatureSchema,
@@ -47,6 +49,7 @@ import { UpdateChecker } from "../version/update-checker";
47
49
  import { createBrowserLocaleResolver } from "./browser-locale";
48
50
  import { type ClientFeatureDefinition, stackWrappers } from "./client-plugin";
49
51
  import { WebDashboardBody } from "./dashboard-body";
52
+ import { createBrowserDraftStorage } from "./draft-storage";
50
53
  import { useBrowserNavApi } from "./nav";
51
54
  import { NavProvidersProvider } from "./nav-providers-context";
52
55
  import { type ResolverComponent, ResolversProvider } from "./resolvers-context";
@@ -133,6 +136,13 @@ export type CreateKumikoAppOptions = {
133
136
  readonly schema?: AppSchema | FeatureSchema;
134
137
  readonly rootId?: string;
135
138
  readonly dispatcher?: Dispatcher;
139
+ /** RenderEdit's create-mode draftId storage (issue #1913) — where a
140
+ * same-tab reload finds which of several parallel create-sessions on a
141
+ * screen to resume. Default: `createBrowserDraftStorage()`
142
+ * (sessionStorage-backed). Apps that don't want any client-side draftId
143
+ * persistence can pass a no-op impl; RenderEdit still resolves via its
144
+ * `form-draft:query:list` mount-time fallback either way. */
145
+ readonly draftStorage?: DraftStorage;
136
146
  readonly screenQn?: string;
137
147
  readonly translate?: Translate;
138
148
  readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
@@ -274,6 +284,7 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
274
284
  }
275
285
 
276
286
  const dispatcher = options.dispatcher ?? createLiveDispatcher();
287
+ const draftStorage = options.draftStorage ?? createBrowserDraftStorage();
277
288
  const primitives: PrimitivesRegistry = { ...defaultPrimitives, ...(options.primitives ?? {}) };
278
289
  const liveEvents = createEventSourceLiveEvents();
279
290
 
@@ -389,26 +400,28 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
389
400
  <PrimitivesProvider value={primitives}>
390
401
  <AppFeaturesProvider features={app.features}>
391
402
  <DispatcherProvider dispatcher={dispatcher}>
392
- <LiveEventsProvider value={liveEvents}>
393
- <DashboardBodyProvider value={WebDashboardBody}>
394
- <CustomScreensProvider value={customScreens}>
395
- <ColumnRenderersProvider value={columnRenderers}>
396
- <ContentEditorsProvider value={contentEditors}>
397
- <ExtensionSectionsProvider value={extensionSectionComponents}>
398
- <NavProvidersProvider value={navProviders} entities={navEntities}>
399
- <ResolversProvider resolvers={resolvers}>
400
- <ToastProvider>
401
- <UpdateChecker />
402
- {stackWrappers(providers, stackWrappers(gates, screenNode))}
403
- </ToastProvider>
404
- </ResolversProvider>
405
- </NavProvidersProvider>
406
- </ExtensionSectionsProvider>
407
- </ContentEditorsProvider>
408
- </ColumnRenderersProvider>
409
- </CustomScreensProvider>
410
- </DashboardBodyProvider>
411
- </LiveEventsProvider>
403
+ <DraftStorageProvider value={draftStorage}>
404
+ <LiveEventsProvider value={liveEvents}>
405
+ <DashboardBodyProvider value={WebDashboardBody}>
406
+ <CustomScreensProvider value={customScreens}>
407
+ <ColumnRenderersProvider value={columnRenderers}>
408
+ <ContentEditorsProvider value={contentEditors}>
409
+ <ExtensionSectionsProvider value={extensionSectionComponents}>
410
+ <NavProvidersProvider value={navProviders} entities={navEntities}>
411
+ <ResolversProvider resolvers={resolvers}>
412
+ <ToastProvider>
413
+ <UpdateChecker />
414
+ {stackWrappers(providers, stackWrappers(gates, screenNode))}
415
+ </ToastProvider>
416
+ </ResolversProvider>
417
+ </NavProvidersProvider>
418
+ </ExtensionSectionsProvider>
419
+ </ContentEditorsProvider>
420
+ </ColumnRenderersProvider>
421
+ </CustomScreensProvider>
422
+ </DashboardBodyProvider>
423
+ </LiveEventsProvider>
424
+ </DraftStorageProvider>
412
425
  </DispatcherProvider>
413
426
  </AppFeaturesProvider>
414
427
  </PrimitivesProvider>
@@ -0,0 +1,59 @@
1
+ // Browser-backed DraftStorage default for createKumikoApp (issue #1913).
2
+ //
3
+ // Persists a create-mode draftId per screen in `sessionStorage` — same-tab
4
+ // scope only (deliberately, unlike browser-locale.ts's localStorage): the
5
+ // draft row itself already survives a new tab / cleared storage via
6
+ // RenderEdit's `form-draft:query:list` mount-time fallback, so this is only
7
+ // about not losing the draftId on an accidental same-tab reload.
8
+
9
+ import type { DraftStorage } from "@cosmicdrift/kumiko-renderer";
10
+
11
+ export type CreateBrowserDraftStorageOptions = {
12
+ /** sessionStorage-key prefix, one entry per screenId gets appended.
13
+ * Default: `"kumiko:draft-id:"`. */
14
+ readonly storagePrefix?: string;
15
+ };
16
+
17
+ function keyFor(prefix: string, screenId: string): string {
18
+ return `${prefix}${screenId}`;
19
+ }
20
+
21
+ /** Default DraftStorage when createKumikoApp boots without one. Guards every
22
+ * sessionStorage call — Safari private mode and storage-disabled browsers
23
+ * throw on access, and a lost draftId just falls back to the `list` resume
24
+ * path (same UX as a genuinely cleared storage), not a broken form. */
25
+ export function createBrowserDraftStorage(
26
+ options: CreateBrowserDraftStorageOptions = {},
27
+ ): DraftStorage {
28
+ const prefix = options.storagePrefix ?? "kumiko:draft-id:";
29
+ return {
30
+ getDraftId: (screenId) => {
31
+ if (typeof sessionStorage === "undefined") return null;
32
+ try {
33
+ return sessionStorage.getItem(keyFor(prefix, screenId));
34
+ } catch {
35
+ return null;
36
+ }
37
+ },
38
+ setDraftId: (screenId, draftId) => {
39
+ // skip: no sessionStorage in this environment (SSR, disabled) — the
40
+ // draftId just stays in-memory-only for this render.
41
+ if (typeof sessionStorage === "undefined") return;
42
+ try {
43
+ sessionStorage.setItem(keyFor(prefix, screenId), draftId);
44
+ } catch {
45
+ // Persistence failure isn't fatal — the draft stays usable in the
46
+ // current tab, only a reload would lose the draftId.
47
+ }
48
+ },
49
+ clearDraftId: (screenId) => {
50
+ // skip: no sessionStorage in this environment — nothing to clear.
51
+ if (typeof sessionStorage === "undefined") return;
52
+ try {
53
+ sessionStorage.removeItem(keyFor(prefix, screenId));
54
+ } catch {
55
+ // skip: nothing to clean up if storage is unreachable.
56
+ }
57
+ },
58
+ };
59
+ }
package/src/index.ts CHANGED
@@ -36,6 +36,8 @@ export type {
36
36
  PrimitivesProviderProps,
37
37
  PrimitivesRegistry,
38
38
  RadiusTokens,
39
+ RenderEditChangeState,
40
+ RenderEditControls,
39
41
  RenderEditProps,
40
42
  RenderFieldProps,
41
43
  RenderListProps,
@@ -93,6 +95,8 @@ export type { CreateKumikoAppOptions } from "./app/create-app";
93
95
  export { createKumikoApp } from "./app/create-app";
94
96
  export type { CreatePublicSurfaceOptions, PublicRoute } from "./app/create-public-surface";
95
97
  export { createPublicSurface } from "./app/create-public-surface";
98
+ export type { CreateBrowserDraftStorageOptions } from "./app/draft-storage";
99
+ export { createBrowserDraftStorage } from "./app/draft-storage";
96
100
  export type { KumikoLinkProps } from "./app/nav";
97
101
  export { KumikoLink, useBrowserNavApi } from "./app/nav";
98
102
  export { PlainContentEditor } from "./app/plain-content-editor";
@@ -618,6 +618,19 @@ describe("EmbeddedListInput — desktop table width (solon#107)", () => {
618
618
  const headers = desktop.querySelectorAll("th");
619
619
  expect(headers[0]?.className).toContain("min-w-[10rem]");
620
620
  expect(headers[1]?.className).toContain("w-36");
621
- expect(headers[2]?.className).toContain("w-36");
621
+ expect(headers[2]?.className).toContain("w-44");
622
+ });
623
+
624
+ test("a money column is wider than a decimal/number column (framework#1880)", () => {
625
+ const columns: readonly EmbeddedListColumn[] = [
626
+ { field: "quantity", label: "Qty", type: "number", required: true, derived: false },
627
+ { field: "amount", label: "Amount", type: "money", required: false, derived: true },
628
+ ];
629
+ const rows = [{ quantity: 1, amount: 100 }];
630
+ renderWithLocale(<EmbeddedListInput {...baseProps({ columns, rows })} />);
631
+ const desktop = screen.getByTestId("lines-desktop");
632
+ const headers = desktop.querySelectorAll("th");
633
+ expect(headers[0]?.className).toContain("w-36");
634
+ expect(headers[1]?.className).toContain("w-44");
622
635
  });
623
636
  });
@@ -47,12 +47,13 @@ const FOCUSABLE_SELECTOR = "input:not([type=hidden]), button, [tabindex]";
47
47
 
48
48
  function columnWidthClass(type: EmbeddedListCellType): string {
49
49
  switch (type) {
50
- case "money":
51
50
  case "number":
52
51
  case "decimal":
53
52
  case "date":
54
53
  return "w-36";
55
- // Wider than a bare date carries a date field plus a time input.
54
+ // Wider than a bare number: MoneyInput appends stepper buttons, timestamp
55
+ // carries a date field plus a time input.
56
+ case "money":
56
57
  case "timestamp":
57
58
  return "w-44";
58
59
  case "boolean":
@@ -31,6 +31,7 @@ import {
31
31
  type HeadingProps,
32
32
  type InputProps,
33
33
  type LinkProps,
34
+ type ProgressProps,
34
35
  type SectionProps,
35
36
  type TextProps,
36
37
  useColumnRenderer,
@@ -80,6 +81,7 @@ import { Input as UiInput } from "../ui/input";
80
81
  import { Label as UiLabel } from "../ui/label";
81
82
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
82
83
  import { Textarea } from "../ui/textarea";
84
+ import { ProgressBar } from "../widgets/progress-bar";
83
85
  import { ComboboxInput } from "./combobox";
84
86
  import { DateInput } from "./date-input";
85
87
  import { DefaultDialog } from "./dialog";
@@ -1866,6 +1868,10 @@ function DefaultLink({
1866
1868
  );
1867
1869
  }
1868
1870
 
1871
+ function DefaultProgress({ value, testId }: ProgressProps): ReactNode {
1872
+ return <ProgressBar value={value} testId={testId} />;
1873
+ }
1874
+
1869
1875
  function DefaultHeading({ variant = "page", children, testId }: HeadingProps): ReactNode {
1870
1876
  // Page-Heading = h1, sehr selten in einer App (max 1 pro Screen).
1871
1877
  // Section-Heading = h2 mit uppercase + muted-foreground — derselbe
@@ -1959,4 +1965,5 @@ export const defaultPrimitives: CorePrimitives = {
1959
1965
  ConfigSourceBadge: DefaultConfigSourceBadge,
1960
1966
  ConfigCascadeView: DefaultConfigCascadeView,
1961
1967
  Link: DefaultLink,
1968
+ Progress: DefaultProgress,
1962
1969
  };
@@ -14,10 +14,15 @@
14
14
  // Klick — also 100 cents bei EUR/USD, 1 yen bei JPY). User der nur
15
15
  // Cent-genaue Steps will tippt halt im Focus-Modus.
16
16
 
17
+ import { currencyDecimals } from "@cosmicdrift/kumiko-headless";
17
18
  import { Minus, Plus } from "lucide-react";
18
19
  import { type ReactNode, useEffect, useRef, useState } from "react";
19
20
  import { cn } from "../lib/cn";
20
21
 
22
+ // Re-exported for backward compat — callers used to import this from here
23
+ // before it moved to headless (shared with RenderField, kumiko-framework#1923).
24
+ export { currencyDecimals };
25
+
21
26
  export type MoneyInputProps = {
22
27
  readonly id: string;
23
28
  readonly name: string;
@@ -165,15 +170,6 @@ export function MoneyInput({
165
170
  );
166
171
  }
167
172
 
168
- // Currency-Decimal-Stellen — überdeckt die wichtigsten Welt-Währungen.
169
- // Default 2 wenn Code unbekannt.
170
- export function currencyDecimals(code: string): number {
171
- if (code === "JPY" || code === "KRW" || code === "VND" || code === "ISK") return 0;
172
- if (code === "BHD" || code === "JOD" || code === "KWD" || code === "OMR" || code === "TND")
173
- return 3;
174
- return 2;
175
- }
176
-
177
173
  function guessLocale(): string {
178
174
  if (typeof navigator !== "undefined" && navigator.language) return navigator.language;
179
175
  return "en-US";