@cosmicdrift/kumiko-renderer-web 0.56.1 → 0.57.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-web",
3
- "version": "0.56.1",
3
+ "version": "0.57.1",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -80,7 +80,11 @@ describe("ConfigCascadeView — i18n (Bug 7)", () => {
80
80
  describe("ConfigCascadeView — Scope-Filter (Bug 8)", () => {
81
81
  test("screenScope=tenant: Operator-Ebenen sind unsichtbar, EIN neutraler Standard-Fallback bleibt", async () => {
82
82
  const user = userEvent.setup();
83
- const view = render(<ConfigCascadeView cascade={tenantCascade()} screenScope="tenant" />);
83
+ // tenant-Override aufklappbar; das Panel muss die Operator-Ebenen
84
+ // trotzdem verbergen (Scope-Filter), nur Tenant + Standard zeigen.
85
+ const view = render(
86
+ <ConfigCascadeView cascade={tenantCascade({ tenantValue: "acme" })} screenScope="tenant" />,
87
+ );
84
88
  await user.click(screen.getByRole("button"));
85
89
 
86
90
  // Sichtbar: Tenant-Zeile + genau eine neutrale "Standard"-Zeile (Bug-Bash 3
@@ -96,20 +100,17 @@ describe("ConfigCascadeView — Scope-Filter (Bug 8)", () => {
96
100
  expect(view.container.textContent).toContain("fallback");
97
101
  });
98
102
 
99
- test("screenScope=tenant mit aktivem System-Wert: Standard-Zeile zeigt den effektiven Wert, nicht die Quelle", async () => {
100
- const user = userEvent.setup();
103
+ test("screenScope=tenant mit aktivem System-Wert: Standard-Zeile zeigt den effektiven Wert neutral, nicht aufklappbar", async () => {
101
104
  const view = render(
102
105
  <ConfigCascadeView cascade={tenantCascade({ systemActive: true })} screenScope="tenant" />,
103
106
  );
104
- // Collapsed-Header leakt die Operator-Quelle nicht
107
+ // Effektiver Wert sichtbar, Operator-Quelle neutral als "Standard"
108
+ // maskiert. Nur eine Wert-Ebene (der geerbte System-Wert) → kein
109
+ // Aufklappen, das Panel wäre nur eine Wiederholung.
105
110
  expect(view.container.textContent).toContain("Default");
106
111
  expect(view.container.textContent).toContain("system-smtp");
107
112
  expect(view.container.textContent).not.toContain("System");
108
-
109
- // … und expanded genauso: effektiver Wert sichtbar, Quelle neutral.
110
- await user.click(screen.getAllByRole("button")[0] as HTMLElement);
111
- expect(view.container.textContent).toContain("system-smtp");
112
- expect(view.container.textContent).not.toContain("System");
113
+ expect(view.queryByRole("button")).toBeNull();
113
114
  });
114
115
 
115
116
  test("screenScope=system: Operator sieht weiterhin die volle Cascade", async () => {
@@ -141,8 +142,7 @@ describe("ConfigCascadeView — Scope-Filter (Bug 8)", () => {
141
142
  expect(resets).toEqual([{ key: "branding.title", scope: "tenant" }]);
142
143
  });
143
144
 
144
- test("ohne eigene Überschreibung: kein Reset-Button", async () => {
145
- const user = userEvent.setup();
145
+ test("reines Default-Feld (kein Override, ein Wert): nicht aufklappbar, kein Button/Panel/Reset", async () => {
146
146
  const view = render(
147
147
  <ConfigCascadeView
148
148
  cascade={tenantCascade()}
@@ -151,7 +151,10 @@ describe("ConfigCascadeView — Scope-Filter (Bug 8)", () => {
151
151
  onReset={() => undefined}
152
152
  />,
153
153
  );
154
- await user.click(screen.getByRole("button"));
154
+ // Nichts aufzuklappen → statische Zeile statt Aufklapp-Button.
155
+ expect(view.queryByRole("button")).toBeNull();
156
+ expect(view.container.textContent).toContain("Default");
157
+ expect(view.container.textContent).toContain("fallback");
155
158
  expect(view.queryByText("Reset override (Tenant)")).toBeNull();
156
159
  });
157
160
  });
@@ -227,4 +227,74 @@ describe("KumikoScreen / configEdit", () => {
227
227
  expect(label?.querySelector('[data-testid="config-source-badge"]')).toBeNull();
228
228
  expect(label?.querySelector('[data-testid="config-cascade"]')).toBeNull();
229
229
  });
230
+
231
+ // Regression #430: nach "Speichern" zeigte die Cascade-Disclosure den
232
+ // alten Wert bis zum Page-Reload — customSubmit rebased nur den Form-State
233
+ // (controller.rebase), refetchte aber values/cascade NICHT (onReset tat es).
234
+ // Guard: nach erfolgreichem Save MUSS jede der beiden Queries erneut laufen.
235
+ test("save refetcht values + cascade — Disclosure nach Speichern aktuell, nicht erst nach Reload", async () => {
236
+ const queryCalls: Record<string, number> = {};
237
+ const dispatcher: Dispatcher = createMockDispatcher({
238
+ query: (async (qn: string) => {
239
+ queryCalls[qn] = (queryCalls[qn] ?? 0) + 1;
240
+ if (qn === "config:query:cascade") {
241
+ return {
242
+ isSuccess: true,
243
+ data: {
244
+ "demo:config:site-name": {
245
+ value: "Acme",
246
+ source: "tenant-row",
247
+ levels: [
248
+ {
249
+ source: "tenant-row",
250
+ label: "tenant-row",
251
+ value: "Acme",
252
+ isActive: true,
253
+ hasValue: true,
254
+ },
255
+ {
256
+ source: "default",
257
+ label: "default",
258
+ value: "fallback",
259
+ isActive: false,
260
+ hasValue: true,
261
+ },
262
+ ],
263
+ },
264
+ },
265
+ };
266
+ }
267
+ return {
268
+ isSuccess: true,
269
+ data: {
270
+ "demo:config:site-name": { value: "Acme", scope: "tenant", source: "tenant-row" },
271
+ },
272
+ };
273
+ }) as unknown as Dispatcher["query"],
274
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
275
+ });
276
+
277
+ const user = userEvent.setup();
278
+ render(
279
+ <DispatcherProvider dispatcher={dispatcher}>
280
+ <KumikoScreen schema={schema} qn="demo:screen:settings" />
281
+ </DispatcherProvider>,
282
+ );
283
+
284
+ await waitFor(() => screen.getByTestId("render-edit-form"));
285
+ const cascadeBefore = queryCalls["config:query:cascade"] ?? 0;
286
+ const valuesBefore = queryCalls["config:query:values"] ?? 0;
287
+
288
+ const siteInput = screen.getByTestId("field-siteName").querySelector("input");
289
+ if (!siteInput) throw new Error("expected siteName input");
290
+ await user.clear(siteInput);
291
+ await user.type(siteInput, "Globex");
292
+ await user.click(screen.getByTestId("render-edit-submit"));
293
+
294
+ // Ohne den refetch im customSubmit bleiben beide Counts flat (rot).
295
+ await waitFor(() => {
296
+ expect(queryCalls["config:query:cascade"] ?? 0).toBeGreaterThan(cascadeBefore);
297
+ });
298
+ expect(queryCalls["config:query:values"] ?? 0).toBeGreaterThan(valuesBefore);
299
+ });
230
300
  });
@@ -118,6 +118,14 @@ type ConfigCascadeViewProps = {
118
118
  readonly screenScope: ConfigScope;
119
119
  readonly onReset?: (key: string, scope: ConfigScope) => void;
120
120
  readonly qualifiedKey?: string;
121
+ // Component-Split (#429): "trigger" rendert nur die collapsed-Zeile
122
+ // (▶ + Quelle + Wert) für die Label-Row, "panel" nur die aufgeklappte
123
+ // Cascade + Reset für unter den Input. Im Split-Modus hält der Screen den
124
+ // expanded-State (beide Slots teilen ihn). Ohne `slot` rendert die
125
+ // Komponente beides mit eigenem State (Backward-Compat).
126
+ readonly slot?: "trigger" | "panel";
127
+ readonly expanded?: boolean;
128
+ readonly onToggle?: () => void;
121
129
  };
122
130
 
123
131
  export function ConfigCascadeView({
@@ -125,64 +133,97 @@ export function ConfigCascadeView({
125
133
  screenScope,
126
134
  onReset,
127
135
  qualifiedKey,
136
+ slot,
137
+ expanded: expandedProp,
138
+ onToggle,
128
139
  }: ConfigCascadeViewProps): ReactNode {
129
140
  const t = useTranslation();
130
- const [expanded, setExpanded] = useState(false);
141
+ const [localExpanded, setLocalExpanded] = useState(false);
131
142
 
132
143
  // Safety net: callers should already filter malformed cascades, but
133
144
  // a missing levels-array (e.g. from a partial mock) shouldn't crash
134
145
  // the screen.
135
146
  if (!Array.isArray(cascade?.levels)) return null;
136
147
 
148
+ const expanded = slot === undefined ? localExpanded : (expandedProp ?? false);
149
+ const toggle = slot === undefined ? () => setLocalExpanded((v) => !v) : onToggle;
150
+
137
151
  const screenScopeSource = scopeToSource(screenScope);
138
152
  const displayLevels = toDisplayLevels(cascade.levels, screenScopeSource);
139
153
  const activeDisplay = displayLevels.find((d) => d.level.isActive);
140
154
  const hasOverride = activeDisplay?.level.source === screenScopeSource;
155
+ // Aufklappbar nur wenn das Panel echten Mehrwert bringt: ein eigener
156
+ // Override (→ Reset) oder mehr als eine Ebene MIT Wert. Leere Ebenen
157
+ // (z.B. die ungesetzte tenant-row eines reinen Default-Felds) zählen
158
+ // nicht — sonst wäre ein Feld, das nur seinen Standard zeigt, fälschlich
159
+ // aufklappbar und das Panel nur eine Wiederholung des Triggers.
160
+ const valuedLevels = displayLevels.filter((d) => d.level.hasValue);
161
+ const expandable = hasOverride || valuedLevels.length > 1;
162
+
163
+ const triggerInner = activeDisplay ? (
164
+ <>
165
+ <SourceBadge
166
+ source={activeDisplay.badgeSource}
167
+ {...(activeDisplay.badgeLabelKey !== undefined && {
168
+ labelKey: activeDisplay.badgeLabelKey,
169
+ })}
170
+ />
171
+ <span className="text-gray-400">
172
+ {formatValue(activeDisplay.level.value, activeDisplay.level.hasValue)}
173
+ </span>
174
+ </>
175
+ ) : (
176
+ <span className="text-gray-400">{t("kumiko.config.cascade.noValue")}</span>
177
+ );
178
+
179
+ const trigger = expandable ? (
180
+ <button
181
+ type="button"
182
+ onClick={toggle}
183
+ className="flex items-center gap-1 text-gray-500 hover:text-gray-700 cursor-pointer"
184
+ >
185
+ <span className="text-[10px]">{expanded ? "▼" : "▶"}</span>
186
+ {triggerInner}
187
+ </button>
188
+ ) : (
189
+ <div className="flex items-center gap-1 text-gray-500">{triggerInner}</div>
190
+ );
141
191
 
192
+ const panel =
193
+ expanded && expandable ? (
194
+ <div className="mt-1 flex flex-col gap-0.5 pl-3 border-l-2 border-gray-100">
195
+ {displayLevels.map((display) => (
196
+ <CascadeLevelRow key={display.level.source} display={display} />
197
+ ))}
198
+
199
+ {hasOverride && onReset && qualifiedKey ? (
200
+ <button
201
+ type="button"
202
+ onClick={() => onReset(qualifiedKey, screenScope)}
203
+ className="mt-1 self-start text-[10px] text-orange-500 hover:text-orange-700 cursor-pointer underline"
204
+ >
205
+ {t("kumiko.config.cascade.resetTo", {
206
+ scope: t(SOURCE_I18N_KEY[screenScopeSource]),
207
+ })}
208
+ </button>
209
+ ) : null}
210
+ </div>
211
+ ) : null;
212
+
213
+ if (slot === "trigger") {
214
+ return (
215
+ <div className="text-xs font-normal" data-testid="config-cascade">
216
+ {trigger}
217
+ </div>
218
+ );
219
+ }
220
+ if (slot === "panel") return panel;
221
+
222
+ // Backward-Compat (ungeteilt): Trigger + Panel zusammen unter dem Input.
142
223
  return (
143
224
  <div className="mt-1 text-xs" data-testid="config-cascade">
144
- <button
145
- type="button"
146
- onClick={() => setExpanded(!expanded)}
147
- className="flex items-center gap-1 text-gray-500 hover:text-gray-700 cursor-pointer"
148
- >
149
- <span className="text-[10px]">{expanded ? "▼" : "▶"}</span>
150
- {activeDisplay ? (
151
- <>
152
- <SourceBadge
153
- source={activeDisplay.badgeSource}
154
- {...(activeDisplay.badgeLabelKey !== undefined && {
155
- labelKey: activeDisplay.badgeLabelKey,
156
- })}
157
- />
158
- <span className="text-gray-400">
159
- {formatValue(activeDisplay.level.value, activeDisplay.level.hasValue)}
160
- </span>
161
- </>
162
- ) : (
163
- <span className="text-gray-400">{t("kumiko.config.cascade.noValue")}</span>
164
- )}
165
- </button>
166
-
167
- {expanded ? (
168
- <div className="mt-1 flex flex-col gap-0.5 pl-3 border-l-2 border-gray-100">
169
- {displayLevels.map((display) => (
170
- <CascadeLevelRow key={display.level.source} display={display} />
171
- ))}
172
-
173
- {hasOverride && onReset && qualifiedKey ? (
174
- <button
175
- type="button"
176
- onClick={() => onReset(qualifiedKey, screenScope)}
177
- className="mt-1 self-start text-[10px] text-orange-500 hover:text-orange-700 cursor-pointer underline"
178
- >
179
- {t("kumiko.config.cascade.resetTo", {
180
- scope: t(SOURCE_I18N_KEY[screenScopeSource]),
181
- })}
182
- </button>
183
- ) : null}
184
- </div>
185
- ) : null}
225
+ {trigger}
226
+ {panel}
186
227
  </div>
187
228
  );
188
229
  }
@@ -149,24 +149,31 @@ function DefaultField({
149
149
  const hasError = issues !== undefined && issues.length > 0;
150
150
  return (
151
151
  <div data-testid={testId} className="flex flex-col gap-1.5">
152
- <LabelPrimitive.Root
153
- htmlFor={id}
154
- className={cn(
155
- // peer-disabled-Sentinel: shadcn-Pattern — wenn das assoziierte
156
- // Input disabled ist (peer + disabled-Klasse), wird das Label
157
- // mitgrayout. Funktioniert weil Radix-Label das nativ-htmlFor-
158
- // verlinkte Element als Peer betrachtet.
159
- "text-sm font-medium leading-none",
160
- "peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
161
- hasError ? "text-destructive" : "text-foreground",
162
- )}
163
- >
164
- {label}
165
- {labelAppendix !== undefined && <>{labelAppendix}</>}
166
- {required === true && <span className="ml-0.5 text-destructive">*</span>}
167
- </LabelPrimitive.Root>
152
+ <div className="flex items-center justify-between gap-2">
153
+ <LabelPrimitive.Root
154
+ htmlFor={id}
155
+ className={cn(
156
+ // peer-disabled-Sentinel: shadcn-Pattern wenn das assoziierte
157
+ // Input disabled ist (peer + disabled-Klasse), wird das Label
158
+ // mitgrayout. Funktioniert weil Radix-Label das nativ-htmlFor-
159
+ // verlinkte Element als Peer betrachtet.
160
+ "text-sm font-medium leading-none",
161
+ "peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
162
+ hasError ? "text-destructive" : "text-foreground",
163
+ )}
164
+ >
165
+ {label}
166
+ {required === true && <span className="ml-0.5 text-destructive">*</span>}
167
+ </LabelPrimitive.Root>
168
+ {/* appendix neben dem <label>, nicht darin — interaktiver Inhalt
169
+ (Disclosure-Button) gehört nicht in ein label-Element. */}
170
+ {labelAppendix !== undefined && labelAppendix}
171
+ </div>
172
+ {/* fieldAppendix (Cascade-Detail-Panel) über dem Input — das
173
+ aufgeklappte Detail gehört direkt unter seinen Trigger in der
174
+ Label-Row, nicht durch den Input davon getrennt. */}
175
+ {fieldAppendix !== undefined && fieldAppendix}
168
176
  {children}
169
- {fieldAppendix !== undefined && <div className="mt-1">{fieldAppendix}</div>}
170
177
  {hasError && (
171
178
  <div
172
179
  role="alert"