@cosmicdrift/kumiko-renderer 0.251.0 → 0.252.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.251.0",
3
+ "version": "0.252.1",
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,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.251.0",
19
- "@cosmicdrift/kumiko-headless": "0.251.0",
18
+ "@cosmicdrift/kumiko-framework": "0.252.1",
19
+ "@cosmicdrift/kumiko-headless": "0.252.1",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -27,7 +27,7 @@
27
27
  "@types/react-dom": "^19.2.3",
28
28
  "jsdom": "^29.1.1",
29
29
  "react-dom": "^19.2.6",
30
- "@cosmicdrift/kumiko-locale-de": "0.251.0"
30
+ "@cosmicdrift/kumiko-locale-de": "0.252.1"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
@@ -37,4 +37,21 @@ describe("synthesizeConfigEditScreen", () => {
37
37
  expect(result.fieldLabels).toEqual({ apiKey: "config.apiKey" });
38
38
  expect(result.access).toEqual({ roles: ["Admin"] });
39
39
  });
40
+
41
+ test("carries description through so RenderEdit can render it as the form subtitle", () => {
42
+ const withDescription = {
43
+ id: "settings",
44
+ layout: { sections: [] },
45
+ description: "These settings apply to every project in the workspace.",
46
+ } as unknown as ConfigEditScreenDefinition;
47
+ expect(synthesizeConfigEditScreen(withDescription).description).toBe(
48
+ "These settings apply to every project in the workspace.",
49
+ );
50
+
51
+ const withoutDescription = {
52
+ id: "settings",
53
+ layout: { sections: [] },
54
+ } as unknown as ConfigEditScreenDefinition;
55
+ expect(synthesizeConfigEditScreen(withoutDescription)).not.toHaveProperty("description");
56
+ });
40
57
  });
@@ -43,6 +43,7 @@ export function synthesizeConfigEditScreen(
43
43
  type: "entityEdit",
44
44
  entity: CONFIG_EDIT_PSEUDO_ENTITY,
45
45
  layout: screen.layout,
46
+ ...(screen.description !== undefined && { description: screen.description }),
46
47
  ...(screen.fieldLabels !== undefined && { fieldLabels: screen.fieldLabels }),
47
48
  ...(screen.access !== undefined && { access: screen.access }),
48
49
  };
@@ -93,6 +93,9 @@ export function SecretsEditBody({ screen, translate }: SecretsEditBodyProps): Re
93
93
  void handleSubmit();
94
94
  }}
95
95
  testId="secrets-edit-form"
96
+ {...(screen.description !== undefined && {
97
+ subtitle: effectiveTranslate(screen.description),
98
+ })}
96
99
  actions={
97
100
  <Button
98
101
  type="submit"
@@ -1,9 +1,18 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import type { Dispatcher, EditWriteFormSectionViewModel } from "@cosmicdrift/kumiko-headless";
2
+ import type {
3
+ EditWriteFormSection,
4
+ EntityDefinition,
5
+ EntityEditScreenDefinition,
6
+ } from "@cosmicdrift/kumiko-framework/ui-types";
7
+ import {
8
+ computeEditViewModel,
9
+ type Dispatcher,
10
+ type EditWriteFormSectionViewModel,
11
+ } from "@cosmicdrift/kumiko-headless";
3
12
  import { fireEvent, render, screen as rtlScreen, waitFor } from "@testing-library/react";
4
13
  import type { ComponentType, ReactNode } from "react";
5
14
  import { DispatcherProvider } from "../../context/dispatcher-context";
6
- import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
15
+ import { createStaticLocaleResolver, LocaleProvider, type TranslationsByLocale } from "../../i18n";
7
16
  import { kumikoDefaultTranslations } from "../../i18n-defaults";
8
17
  import {
9
18
  type BannerProps,
@@ -42,8 +51,14 @@ const testBanner: ComponentType<BannerProps> = ({ children, testId }) => (
42
51
 
43
52
  // Mirrors DefaultSection's real actions slot closely enough to let tests
44
53
  // assert the submit button lands in the footer, not the body (fw#2675).
45
- const testSection: ComponentType<SectionProps> = ({ testId, children, actions }) => (
54
+ // Also renders subtitle into the DOM (like DefaultSection's own subtitle
55
+ // slot) so description-passthrough tests prove real render output, not just
56
+ // a captured prop.
57
+ const testSection: ComponentType<SectionProps> = ({ testId, subtitle, children, actions }) => (
46
58
  <div data-testid={testId}>
59
+ {subtitle !== undefined && (
60
+ <p data-testid={testId !== undefined ? `${testId}-subtitle` : undefined}>{subtitle}</p>
61
+ )}
47
62
  <div data-testid={testId !== undefined ? `${testId}-body` : undefined}>{children}</div>
48
63
  {actions !== undefined && (
49
64
  <div data-testid={testId !== undefined ? `${testId}-actions` : undefined}>{actions}</div>
@@ -120,11 +135,16 @@ function renderWriteForm(
120
135
  section: EditWriteFormSectionViewModel,
121
136
  dispatcher: Dispatcher,
122
137
  onSubmitted: () => void,
138
+ extraTranslations?: TranslationsByLocale,
123
139
  ) {
124
140
  return render(
125
141
  <LocaleProvider
126
142
  resolver={createStaticLocaleResolver({ locale: "en-US" })}
127
- fallbackBundles={[kumikoDefaultTranslations]}
143
+ fallbackBundles={
144
+ extraTranslations !== undefined
145
+ ? [extraTranslations, kumikoDefaultTranslations]
146
+ : [kumikoDefaultTranslations]
147
+ }
128
148
  >
129
149
  <DispatcherProvider dispatcher={dispatcher}>
130
150
  <PrimitivesProvider value={testPrimitives()}>
@@ -223,3 +243,80 @@ describe("WriteFormSection", () => {
223
243
  expect(writes[0]?.payload).toEqual({ orderId: "order-42", note: "hi" });
224
244
  });
225
245
  });
246
+
247
+ // section.description exists on EditWriteFormSection to make a self-service
248
+ // write form usable without training (same doc-promise as EditFieldsSection),
249
+ // but WriteFormSection never read it — the ViewModel translated it correctly,
250
+ // the renderer just dropped it on the floor. Built through the real
251
+ // computeEditViewModel pipeline (not a hand-built ViewModel) to prove the
252
+ // full Spec -> ViewModel -> Renderer path, matching render-edit-screen-
253
+ // description.test.tsx's approach for the sibling entityEdit-level bug.
254
+ describe("WriteFormSection — section.description as subtitle", () => {
255
+ function computeNoteSection(
256
+ descriptionSpec: string,
257
+ translate: (key: string) => string,
258
+ ): EditWriteFormSectionViewModel {
259
+ const sectionSpec: EditWriteFormSection = {
260
+ kind: "writeForm",
261
+ title: "Add note",
262
+ description: descriptionSpec,
263
+ columns: 1,
264
+ handler: "orders:write:add-note",
265
+ fieldDefs: {
266
+ note: {
267
+ type: "text",
268
+ maxLength: 500,
269
+ required: true,
270
+ searchable: false,
271
+ sortable: false,
272
+ },
273
+ },
274
+ fields: ["note"],
275
+ };
276
+ const screen: EntityEditScreenDefinition = {
277
+ id: "order-detail",
278
+ type: "entityEdit",
279
+ entity: "order",
280
+ layout: { sections: [sectionSpec] },
281
+ };
282
+ const entity: EntityDefinition = { fields: {} };
283
+ const viewModel = computeEditViewModel({
284
+ screen,
285
+ entity,
286
+ values: {},
287
+ translate,
288
+ featureName: "orders",
289
+ });
290
+ const section = viewModel.sections[0];
291
+ if (section?.kind !== "writeForm") throw new Error("expected a writeForm section");
292
+ return section;
293
+ }
294
+
295
+ test("a plain-text description renders as the section's subtitle", () => {
296
+ const { dispatcher } = stubDispatcher();
297
+ const section = computeNoteSection("Explain why you're adding this note.", (key) => key);
298
+
299
+ renderWriteForm(section, dispatcher, noop);
300
+
301
+ expect(rtlScreen.getByTestId("write-form-Add note-subtitle").textContent).toBe(
302
+ "Explain why you're adding this note.",
303
+ );
304
+ });
305
+
306
+ test("a description that is a known i18n key renders translated, not as the raw key", () => {
307
+ const { dispatcher } = stubDispatcher();
308
+ const translations: Record<string, string> = {
309
+ "orders:write-form.explainer": "Notes are visible to the whole team.",
310
+ };
311
+ const section = computeNoteSection(
312
+ "orders:write-form.explainer",
313
+ (key) => translations[key] ?? key,
314
+ );
315
+
316
+ renderWriteForm(section, dispatcher, noop);
317
+
318
+ const subtitle = rtlScreen.getByTestId("write-form-Add note-subtitle");
319
+ expect(subtitle.textContent).toBe("Notes are visible to the whole team.");
320
+ expect(subtitle.textContent).not.toBe("orders:write-form.explainer");
321
+ });
322
+ });
@@ -1079,8 +1079,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1079
1079
  <Form
1080
1080
  onSubmit={() => void handleSubmit()}
1081
1081
  {...(hideSectionTitles !== true && { title: formTitle })}
1082
- {...(hideSectionTitles !== true &&
1083
- formSubtitle !== undefined && { subtitle: formSubtitle })}
1082
+ {...(formSubtitle !== undefined && { subtitle: formSubtitle })}
1084
1083
  {...(hideActions !== true && hasFormActions && { actions: formActions })}
1085
1084
  {...(hideActions !== true &&
1086
1085
  hasSecondaryFormActions && { secondaryActions: secondaryFormActions })}
@@ -155,6 +155,7 @@ export function WriteFormSection({
155
155
  return (
156
156
  <Section
157
157
  {...(!hideTitle && section.title !== undefined && { title: section.title })}
158
+ {...(section.description !== undefined && { subtitle: section.description })}
158
159
  {...(section.icon !== undefined && { icon: section.icon })}
159
160
  actions={submitButton}
160
161
  testId={`write-form-${section.title ?? "section"}`}