@cosmicdrift/kumiko-renderer-web 0.56.1 → 0.57.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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("
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
145
|
-
|
|
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
|
}
|
package/src/primitives/index.tsx
CHANGED
|
@@ -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
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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"
|