@cosmicdrift/kumiko-renderer 0.234.0 → 0.235.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 +6 -4
- package/src/__tests__/i18n.test.tsx +39 -0
- package/src/app/kumiko-screen.tsx +8 -6
- package/src/app/secrets-edit-body.tsx +190 -0
- package/src/i18n.tsx +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.235.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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.235.1",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.235.1",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -24,8 +24,10 @@
|
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@testing-library/react": "^16.3.2",
|
|
26
26
|
"@types/react": "^19.2.14",
|
|
27
|
+
"@types/react-dom": "^19.2.3",
|
|
27
28
|
"jsdom": "^29.1.1",
|
|
28
|
-
"
|
|
29
|
+
"react-dom": "^19.2.6",
|
|
30
|
+
"@cosmicdrift/kumiko-locale-de": "0.235.1"
|
|
29
31
|
},
|
|
30
32
|
"repository": {
|
|
31
33
|
"type": "git",
|
|
@@ -2,12 +2,14 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
|
|
3
3
|
import { act, render, renderHook } from "@testing-library/react";
|
|
4
4
|
import type { ReactNode } from "react";
|
|
5
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
5
6
|
import {
|
|
6
7
|
createStaticLocaleResolver,
|
|
7
8
|
LocaleProvider,
|
|
8
9
|
type TranslationsByLocale,
|
|
9
10
|
translationsByLocaleFromKeys,
|
|
10
11
|
useLocale,
|
|
12
|
+
useOptionalTranslation,
|
|
11
13
|
useTranslation,
|
|
12
14
|
} from "../i18n";
|
|
13
15
|
|
|
@@ -171,3 +173,40 @@ describe("useLocale", () => {
|
|
|
171
173
|
expect(result.current.locale()).toBe("de");
|
|
172
174
|
});
|
|
173
175
|
});
|
|
176
|
+
describe("useTranslation — SSR", () => {
|
|
177
|
+
// kumiko-framework#2595: getServerSnapshot hardcoded "en", so any
|
|
178
|
+
// server render came out English regardless of the resolver's actual
|
|
179
|
+
// locale — untestable via renderToStaticMarkup until getServerSnapshot
|
|
180
|
+
// read the resolver instead of a constant.
|
|
181
|
+
test(`renderToStaticMarkup with a non-English resolver renders the resolver's locale, not "en"`, () => {
|
|
182
|
+
const resolver = createStaticLocaleResolver({ locale: "de" });
|
|
183
|
+
const bundles: TranslationsByLocale[] = [{ de: { greet: "Hallo" }, en: { greet: "Hello" } }];
|
|
184
|
+
function Probe(): ReactNode {
|
|
185
|
+
const t = useTranslation();
|
|
186
|
+
return <span>{t("greet")}</span>;
|
|
187
|
+
}
|
|
188
|
+
const html = renderToStaticMarkup(
|
|
189
|
+
<LocaleProvider resolver={resolver} fallbackBundles={bundles}>
|
|
190
|
+
<Probe />
|
|
191
|
+
</LocaleProvider>,
|
|
192
|
+
);
|
|
193
|
+
expect(html).toContain("Hallo");
|
|
194
|
+
expect(html).not.toContain("Hello");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test(`renderToStaticMarkup via useOptionalTranslation resolves the resolver's locale, not "en"`, () => {
|
|
198
|
+
const resolver = createStaticLocaleResolver({ locale: "de" });
|
|
199
|
+
const bundles: TranslationsByLocale[] = [{ de: { greet: "Hallo" }, en: { greet: "Hello" } }];
|
|
200
|
+
function Probe(): ReactNode {
|
|
201
|
+
const t = useOptionalTranslation();
|
|
202
|
+
return <span>{t?.("greet")}</span>;
|
|
203
|
+
}
|
|
204
|
+
const html = renderToStaticMarkup(
|
|
205
|
+
<LocaleProvider resolver={resolver} fallbackBundles={bundles}>
|
|
206
|
+
<Probe />
|
|
207
|
+
</LocaleProvider>,
|
|
208
|
+
);
|
|
209
|
+
expect(html).toContain("Hallo");
|
|
210
|
+
expect(html).not.toContain("Hello");
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -61,6 +61,7 @@ import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projec
|
|
|
61
61
|
import { lastSegment, toKebab } from "./qn";
|
|
62
62
|
import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id";
|
|
63
63
|
import { screenAccessAllows } from "./screen-access";
|
|
64
|
+
import { SecretsEditBody } from "./secrets-edit-body";
|
|
64
65
|
import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
|
|
65
66
|
|
|
66
67
|
function evalRowExtractor(
|
|
@@ -262,6 +263,8 @@ export function KumikoScreen({
|
|
|
262
263
|
return <ActionFormBody schema={schema} screen={screen} translate={translate} />;
|
|
263
264
|
case "configEdit":
|
|
264
265
|
return <ConfigEditBody schema={schema} screen={screen} translate={translate} />;
|
|
266
|
+
case "secretsEdit":
|
|
267
|
+
return <SecretsEditBody screen={screen} translate={translate} />;
|
|
265
268
|
case "custom":
|
|
266
269
|
return <CustomScreenBody screenId={screen.id} featureName={schema.featureName} />;
|
|
267
270
|
}
|
|
@@ -2223,15 +2226,14 @@ function ProjectionDetailBody({
|
|
|
2223
2226
|
const idParam = screen.idParam ?? "id";
|
|
2224
2227
|
const isTabsMode = screen.layout.mode === "tabs";
|
|
2225
2228
|
const activeSection = useMemo(() => {
|
|
2226
|
-
if (!isTabsMode) return undefined;
|
|
2229
|
+
if (!isTabsMode || Tabs === undefined) return undefined;
|
|
2227
2230
|
const tabParam = nav.searchParams["tab"];
|
|
2228
2231
|
return (
|
|
2229
2232
|
screen.layout.sections.find((section) => section.id === tabParam) ?? screen.layout.sections[0]
|
|
2230
2233
|
);
|
|
2231
|
-
}, [isTabsMode, screen.layout.sections, nav.searchParams]);
|
|
2232
|
-
// Tabs is an optional Core-Primitive
|
|
2233
|
-
//
|
|
2234
|
-
// app hasn't upgraded its createKumikoApp wiring yet.
|
|
2234
|
+
}, [isTabsMode, Tabs, screen.layout.sections, nav.searchParams]);
|
|
2235
|
+
// Tabs is an optional Core-Primitive: without it the screen falls back to
|
|
2236
|
+
// the stacked all-sections layout instead of silently truncating to section 1.
|
|
2235
2237
|
useEffect(() => {
|
|
2236
2238
|
if (isTabsMode && Tabs === undefined) {
|
|
2237
2239
|
// biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
|
|
@@ -2534,7 +2536,7 @@ function ProjectionDetailBody({
|
|
|
2534
2536
|
customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
|
|
2535
2537
|
{...(headerActions !== undefined && { actions: headerActions })}
|
|
2536
2538
|
{...(translate !== undefined && { translate })}
|
|
2537
|
-
{...(
|
|
2539
|
+
{...(hasTabs && { hideSectionTitles: true })}
|
|
2538
2540
|
{...((hasHeader || hasMetrics || hasTabs) && { headerRegion: headerContent })}
|
|
2539
2541
|
valueDisplay={screen.valueDisplay ?? "text"}
|
|
2540
2542
|
/>
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { SecretsEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import type { Translate } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { type ReactNode, useCallback, useMemo, useState } from "react";
|
|
4
|
+
import { useDispatcher } from "../context/dispatcher-context";
|
|
5
|
+
import { useQuery } from "../hooks/use-query";
|
|
6
|
+
import { useTranslation } from "../i18n";
|
|
7
|
+
import { usePrimitives } from "../primitives";
|
|
8
|
+
import { dispatcherErrorText } from "./write-failed-error";
|
|
9
|
+
|
|
10
|
+
type SecretListRow = {
|
|
11
|
+
readonly key: string;
|
|
12
|
+
readonly redactedPreview: string | null;
|
|
13
|
+
readonly hint: string | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type SecretsEditBodyProps = {
|
|
17
|
+
readonly screen: SecretsEditScreenDefinition;
|
|
18
|
+
readonly translate?: Translate;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// secrets:query:list never returns plaintext (only a redacted preview), so
|
|
22
|
+
// unlike ConfigEditBody there is no server value to pre-fill a draft with —
|
|
23
|
+
// every input starts at "" and stays that way unless the user types into it.
|
|
24
|
+
export function SecretsEditBody({ screen, translate }: SecretsEditBodyProps): ReactNode {
|
|
25
|
+
const { Banner, Form, Section, Field, Input, Button, Text } = usePrimitives();
|
|
26
|
+
const t = useTranslation();
|
|
27
|
+
const effectiveTranslate = translate ?? t;
|
|
28
|
+
const dispatcher = useDispatcher();
|
|
29
|
+
const listQuery = useQuery<readonly SecretListRow[]>("secrets:query:list", {});
|
|
30
|
+
|
|
31
|
+
const [drafts, setDrafts] = useState<Readonly<Record<string, string>>>({});
|
|
32
|
+
const [submitting, setSubmitting] = useState(false);
|
|
33
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
34
|
+
|
|
35
|
+
const rowsByQualifiedKey = useMemo(() => {
|
|
36
|
+
const out = new Map<string, SecretListRow>();
|
|
37
|
+
for (const row of listQuery.data ?? []) out.set(row.key, row);
|
|
38
|
+
return out;
|
|
39
|
+
}, [listQuery.data]);
|
|
40
|
+
|
|
41
|
+
const setDraft = useCallback((fieldId: string, value: string) => {
|
|
42
|
+
setDrafts((prev) => ({ ...prev, [fieldId]: value }));
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const handleSubmit = useCallback(async (): Promise<void> => {
|
|
46
|
+
const commands = Object.entries(screen.secretKeys).flatMap(([fieldId, qualified]) => {
|
|
47
|
+
const value = drafts[fieldId]?.trim();
|
|
48
|
+
return value ? [{ type: "secrets:write:set", payload: { key: qualified, value } }] : [];
|
|
49
|
+
});
|
|
50
|
+
if (commands.length === 0) return;
|
|
51
|
+
setSubmitting(true);
|
|
52
|
+
setSubmitError(null);
|
|
53
|
+
const result = await dispatcher.batch(commands);
|
|
54
|
+
setSubmitting(false);
|
|
55
|
+
if (!result.isSuccess) {
|
|
56
|
+
setSubmitError(dispatcherErrorText(result.error, effectiveTranslate));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
setDrafts({});
|
|
60
|
+
await listQuery.refetch();
|
|
61
|
+
}, [dispatcher, drafts, screen.secretKeys, listQuery.refetch, effectiveTranslate]);
|
|
62
|
+
|
|
63
|
+
const handleDelete = useCallback(
|
|
64
|
+
async (qualified: string): Promise<void> => {
|
|
65
|
+
const result = await dispatcher.write("secrets:write:delete", { key: qualified });
|
|
66
|
+
if (!result.isSuccess) {
|
|
67
|
+
setSubmitError(dispatcherErrorText(result.error, effectiveTranslate));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
await listQuery.refetch();
|
|
71
|
+
},
|
|
72
|
+
[dispatcher, listQuery.refetch, effectiveTranslate],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (listQuery.loading && listQuery.data === null) {
|
|
76
|
+
return (
|
|
77
|
+
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
78
|
+
Loading…
|
|
79
|
+
</Banner>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (listQuery.error) {
|
|
83
|
+
return (
|
|
84
|
+
<Banner padded variant="error" testId="kumiko-screen-error">
|
|
85
|
+
{dispatcherErrorText(listQuery.error, effectiveTranslate)}
|
|
86
|
+
</Banner>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<Form
|
|
92
|
+
onSubmit={() => {
|
|
93
|
+
void handleSubmit();
|
|
94
|
+
}}
|
|
95
|
+
testId="secrets-edit-form"
|
|
96
|
+
actions={
|
|
97
|
+
<Button
|
|
98
|
+
type="submit"
|
|
99
|
+
variant="primary"
|
|
100
|
+
loading={submitting}
|
|
101
|
+
disabled={submitting}
|
|
102
|
+
testId="secrets-edit-submit"
|
|
103
|
+
>
|
|
104
|
+
{effectiveTranslate("kumiko.actions.save")}
|
|
105
|
+
</Button>
|
|
106
|
+
}
|
|
107
|
+
>
|
|
108
|
+
{submitError !== null && (
|
|
109
|
+
<Banner variant="error" testId="secrets-edit-error">
|
|
110
|
+
{submitError}
|
|
111
|
+
</Banner>
|
|
112
|
+
)}
|
|
113
|
+
{screen.sections.map((section, index) => (
|
|
114
|
+
<Section
|
|
115
|
+
key={section.title ?? `section-${index}`}
|
|
116
|
+
{...(section.title !== undefined && { title: effectiveTranslate(section.title) })}
|
|
117
|
+
>
|
|
118
|
+
{section.fields.map((fieldId) => {
|
|
119
|
+
const qualified = screen.secretKeys[fieldId];
|
|
120
|
+
if (qualified === undefined) return null;
|
|
121
|
+
const row = rowsByQualifiedKey.get(qualified);
|
|
122
|
+
const hintKey = screen.fieldHints?.[fieldId];
|
|
123
|
+
const isRequired = screen.requiredFields?.includes(fieldId) ?? false;
|
|
124
|
+
return (
|
|
125
|
+
<Field
|
|
126
|
+
key={fieldId}
|
|
127
|
+
id={fieldId}
|
|
128
|
+
label={effectiveTranslate(screen.fieldLabels[fieldId] ?? fieldId)}
|
|
129
|
+
required={isRequired}
|
|
130
|
+
testId={`field-${fieldId}`}
|
|
131
|
+
fieldAppendix={
|
|
132
|
+
<>
|
|
133
|
+
{hintKey !== undefined && (
|
|
134
|
+
<Text variant="small">{effectiveTranslate(hintKey)}</Text>
|
|
135
|
+
)}
|
|
136
|
+
{row !== undefined ? (
|
|
137
|
+
<>
|
|
138
|
+
<Text variant="small" testId={`secret-preview-${fieldId}`}>
|
|
139
|
+
{row.redactedPreview ?? effectiveTranslate("config.secrets.set")}
|
|
140
|
+
</Text>
|
|
141
|
+
<Button
|
|
142
|
+
type="button"
|
|
143
|
+
variant="danger-ghost"
|
|
144
|
+
size="sm"
|
|
145
|
+
onClick={() => handleDelete(qualified)}
|
|
146
|
+
testId={`secret-delete-${fieldId}`}
|
|
147
|
+
>
|
|
148
|
+
{effectiveTranslate("config.secrets.delete")}
|
|
149
|
+
</Button>
|
|
150
|
+
</>
|
|
151
|
+
) : (
|
|
152
|
+
<>
|
|
153
|
+
<Text variant="small" testId={`secret-not-set-${fieldId}`}>
|
|
154
|
+
{effectiveTranslate("config.secrets.notSet")}
|
|
155
|
+
</Text>
|
|
156
|
+
{isRequired && (
|
|
157
|
+
<Text variant="small" testId={`required-marker-${fieldId}`}>
|
|
158
|
+
{effectiveTranslate("config.secrets.required")}
|
|
159
|
+
</Text>
|
|
160
|
+
)}
|
|
161
|
+
</>
|
|
162
|
+
)}
|
|
163
|
+
</>
|
|
164
|
+
}
|
|
165
|
+
>
|
|
166
|
+
<Text variant="small">
|
|
167
|
+
{effectiveTranslate(
|
|
168
|
+
row !== undefined
|
|
169
|
+
? "config.secrets.replacePlaceholder"
|
|
170
|
+
: "config.secrets.placeholder",
|
|
171
|
+
)}
|
|
172
|
+
</Text>
|
|
173
|
+
<Input
|
|
174
|
+
kind="password"
|
|
175
|
+
id={fieldId}
|
|
176
|
+
name={fieldId}
|
|
177
|
+
value={drafts[fieldId] ?? ""}
|
|
178
|
+
onChange={(v) => setDraft(fieldId, v)}
|
|
179
|
+
autoComplete="new-password"
|
|
180
|
+
disabled={submitting}
|
|
181
|
+
testId={`secret-input-${fieldId}`}
|
|
182
|
+
/>
|
|
183
|
+
</Field>
|
|
184
|
+
);
|
|
185
|
+
})}
|
|
186
|
+
</Section>
|
|
187
|
+
))}
|
|
188
|
+
</Form>
|
|
189
|
+
);
|
|
190
|
+
}
|
package/src/i18n.tsx
CHANGED
|
@@ -120,7 +120,7 @@ export function useLocale(): LocaleResolver {
|
|
|
120
120
|
useSyncExternalStore(
|
|
121
121
|
ctx.resolver.subscribe,
|
|
122
122
|
() => ctx.resolver.locale(),
|
|
123
|
-
() =>
|
|
123
|
+
() => ctx.resolver.locale(),
|
|
124
124
|
);
|
|
125
125
|
return ctx.resolver;
|
|
126
126
|
}
|
|
@@ -147,7 +147,7 @@ export function useTranslation(): (
|
|
|
147
147
|
const locale = useSyncExternalStore(
|
|
148
148
|
ctx.resolver.subscribe,
|
|
149
149
|
() => ctx.resolver.locale(),
|
|
150
|
-
() =>
|
|
150
|
+
() => ctx.resolver.locale(),
|
|
151
151
|
);
|
|
152
152
|
|
|
153
153
|
// `t` MUSS referenz-stabil sein solange sich Resolver/Bundles/Locale
|
|
@@ -200,7 +200,7 @@ export function useOptionalLocale(): string | undefined {
|
|
|
200
200
|
useSyncExternalStore(
|
|
201
201
|
(onStoreChange) => (ctx ? ctx.resolver.subscribe(onStoreChange) : () => {}),
|
|
202
202
|
() => (ctx ? ctx.resolver.locale() : "en"),
|
|
203
|
-
() => "en",
|
|
203
|
+
() => (ctx ? ctx.resolver.locale() : "en"),
|
|
204
204
|
);
|
|
205
205
|
return ctx === undefined ? undefined : ctx.resolver.locale();
|
|
206
206
|
}
|
|
@@ -213,7 +213,7 @@ export function useOptionalTranslation():
|
|
|
213
213
|
const locale = useSyncExternalStore(
|
|
214
214
|
(onStoreChange) => (ctx ? ctx.resolver.subscribe(onStoreChange) : () => {}),
|
|
215
215
|
() => (ctx ? ctx.resolver.locale() : "en"),
|
|
216
|
-
() => "en",
|
|
216
|
+
() => (ctx ? ctx.resolver.locale() : "en"),
|
|
217
217
|
);
|
|
218
218
|
const t = useCallback(
|
|
219
219
|
(key: string, params?: Readonly<Record<string, unknown>>): string => {
|