@cosmicdrift/kumiko-renderer 0.327.0 → 0.328.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/dist/app/action-form-shim.d.ts +14 -0
- package/{src/app/action-form-shim.ts → dist/app/action-form-shim.js} +26 -44
- package/dist/app/app-features-context.d.ts +8 -0
- package/dist/app/app-features-context.js +16 -0
- package/dist/app/column-renderers.d.ts +30 -0
- package/dist/app/column-renderers.js +34 -0
- package/dist/app/config-edit-shim.d.ts +11 -0
- package/{src/app/config-edit-shim.ts → dist/app/config-edit-shim.js} +12 -25
- package/dist/app/content-editors.d.ts +38 -0
- package/dist/app/content-editors.js +26 -0
- package/dist/app/content-preview.d.ts +11 -0
- package/dist/app/content-preview.js +30 -0
- package/dist/app/custom-screens.d.ts +11 -0
- package/dist/app/custom-screens.js +20 -0
- package/dist/app/dashboard-body.d.ts +16 -0
- package/dist/app/dashboard-body.js +11 -0
- package/dist/app/extension-form-submit.d.ts +32 -0
- package/dist/app/extension-form-submit.js +73 -0
- package/dist/app/extension-sections.d.ts +72 -0
- package/dist/app/extension-sections.js +34 -0
- package/dist/app/feature-schema.d.ts +4 -0
- package/{src/app/feature-schema.ts → dist/app/feature-schema.js} +12 -22
- package/dist/app/form-schema.d.ts +4 -0
- package/dist/app/form-schema.js +85 -0
- package/dist/app/kumiko-screen.d.ts +43 -0
- package/dist/app/kumiko-screen.js +2206 -0
- package/dist/app/layout-fields.d.ts +3 -0
- package/dist/app/layout-fields.js +20 -0
- package/dist/app/list-facets.d.ts +48 -0
- package/dist/app/list-facets.js +185 -0
- package/dist/app/nav.d.ts +61 -0
- package/dist/app/nav.js +139 -0
- package/dist/app/projection-detail-shim.d.ts +8 -0
- package/dist/app/projection-detail-shim.js +71 -0
- package/dist/app/projection-list-shim.d.ts +13 -0
- package/{src/app/projection-list-shim.ts → dist/app/projection-list-shim.js} +26 -38
- package/dist/app/qn.d.ts +7 -0
- package/{src/app/qn.ts → dist/app/qn.js} +9 -11
- package/dist/app/qualify-screen-id.d.ts +2 -0
- package/{src/app/qualify-screen-id.ts → dist/app/qualify-screen-id.js} +5 -6
- package/dist/app/reference-facet-bridge.d.ts +10 -0
- package/dist/app/reference-facet-bridge.js +22 -0
- package/dist/app/return-to.d.ts +31 -0
- package/dist/app/return-to.js +188 -0
- package/dist/app/row-actions.d.ts +77 -0
- package/dist/app/row-actions.js +416 -0
- package/dist/app/screen-access.d.ts +8 -0
- package/dist/app/screen-access.js +48 -0
- package/dist/app/secret-mint-body.d.ts +10 -0
- package/dist/app/secret-mint-body.js +140 -0
- package/dist/app/secrets-edit-body.d.ts +8 -0
- package/dist/app/secrets-edit-body.js +76 -0
- package/dist/app/use-embedded-screen.d.ts +6 -0
- package/dist/app/use-embedded-screen.js +19 -0
- package/dist/app/variable-chips.d.ts +7 -0
- package/dist/app/variable-chips.js +16 -0
- package/dist/app/write-failed-error.d.ts +6 -0
- package/dist/app/write-failed-error.js +21 -0
- package/dist/components/embedded-list-field.d.ts +10 -0
- package/dist/components/embedded-list-field.js +259 -0
- package/dist/components/grid-cell-for-field.d.ts +25 -0
- package/dist/components/grid-cell-for-field.js +31 -0
- package/dist/components/multi-select-checkboxes.d.ts +14 -0
- package/dist/components/multi-select-checkboxes.js +36 -0
- package/dist/components/reference-create-dialog.d.ts +13 -0
- package/dist/components/reference-create-dialog.js +46 -0
- package/dist/components/related-list-section.d.ts +25 -0
- package/dist/components/related-list-section.js +273 -0
- package/dist/components/render-edit-action-button.d.ts +22 -0
- package/dist/components/render-edit-action-button.js +43 -0
- package/dist/components/render-edit-logic.d.ts +6 -0
- package/dist/components/render-edit-logic.js +63 -0
- package/dist/components/render-edit-types.d.ts +209 -0
- package/dist/components/render-edit-types.js +1 -0
- package/dist/components/render-edit.d.ts +6 -0
- package/dist/components/render-edit.js +1169 -0
- package/dist/components/render-field.d.ts +38 -0
- package/dist/components/render-field.js +544 -0
- package/dist/components/render-list.d.ts +117 -0
- package/dist/components/render-list.js +232 -0
- package/dist/components/write-form-section.d.ts +17 -0
- package/dist/components/write-form-section.js +104 -0
- package/dist/context/dispatcher-context.d.ts +10 -0
- package/{src/context/dispatcher-context.tsx → dist/context/dispatcher-context.js} +16 -29
- package/dist/context/draft-storage-context.d.ts +12 -0
- package/dist/context/draft-storage-context.js +18 -0
- package/dist/context/user-roles-context.d.ts +7 -0
- package/{src/context/user-roles-context.tsx → dist/context/user-roles-context.js} +7 -15
- package/dist/format-when.d.ts +1 -0
- package/{src/format-when.ts → dist/format-when.js} +7 -7
- package/dist/hooks/reference-limits.d.ts +3 -0
- package/{src/hooks/reference-limits.ts → dist/hooks/reference-limits.js} +0 -1
- package/dist/hooks/use-ai-text.d.ts +52 -0
- package/dist/hooks/use-ai-text.js +90 -0
- package/dist/hooks/use-disclosure.d.ts +7 -0
- package/dist/hooks/use-disclosure.js +8 -0
- package/dist/hooks/use-form.d.ts +7 -0
- package/dist/hooks/use-form.js +36 -0
- package/dist/hooks/use-list-url-state.d.ts +43 -0
- package/dist/hooks/use-list-url-state.js +131 -0
- package/dist/hooks/use-mutation.d.ts +9 -0
- package/dist/hooks/use-mutation.js +37 -0
- package/dist/hooks/use-query.d.ts +14 -0
- package/dist/hooks/use-query.js +118 -0
- package/dist/hooks/use-reference-lookup.d.ts +14 -0
- package/dist/hooks/use-reference-lookup.js +63 -0
- package/dist/hooks/use-store.d.ts +3 -0
- package/dist/hooks/use-store.js +38 -0
- package/dist/hooks/use-stream-handler.d.ts +14 -0
- package/dist/hooks/use-stream-handler.js +93 -0
- package/dist/i18n-defaults.d.ts +2 -0
- package/dist/i18n-defaults.js +201 -0
- package/dist/i18n.d.ts +64 -0
- package/dist/i18n.js +166 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.js +55 -0
- package/dist/inside-drawer.d.ts +2 -0
- package/dist/inside-drawer.js +9 -0
- package/dist/page-header-slot.d.ts +2 -0
- package/dist/page-header-slot.js +9 -0
- package/dist/primitives.d.ts +1351 -0
- package/dist/primitives.js +73 -0
- package/dist/screen-fills-height.d.ts +3 -0
- package/dist/screen-fills-height.js +3 -0
- package/dist/share-links.d.ts +4 -0
- package/dist/share-links.js +11 -0
- package/dist/sort-by-accessor.d.ts +5 -0
- package/dist/sort-by-accessor.js +16 -0
- package/dist/sse/live-events.d.ts +23 -0
- package/dist/sse/live-events.js +28 -0
- package/dist/tokens.d.ts +83 -0
- package/dist/tokens.js +80 -0
- package/package.json +16 -9
- package/src/changes.json +52 -0
- package/src/__tests__/error-i18n-defaults.test.ts +0 -82
- package/src/__tests__/format-when.test.ts +0 -12
- package/src/__tests__/i18n.test.tsx +0 -283
- package/src/__tests__/merge-search-params-into-initial.test.ts +0 -525
- package/src/__tests__/qn.test.ts +0 -83
- package/src/__tests__/share-links.test.ts +0 -26
- package/src/__tests__/sort-by-accessor.test.ts +0 -48
- package/src/__tests__/stringify-nav-params.test.ts +0 -33
- package/src/__tests__/use-list-url-state.test.tsx +0 -250
- package/src/app/__tests__/action-form-field-labels.test.tsx +0 -92
- package/src/app/__tests__/action-form-shim.test.ts +0 -137
- package/src/app/__tests__/build-list-query-payload.test.ts +0 -107
- package/src/app/__tests__/config-edit-shim.test.ts +0 -57
- package/src/app/__tests__/content-editors.test.tsx +0 -128
- package/src/app/__tests__/content-preview.test.tsx +0 -184
- package/src/app/__tests__/entity-edit-create-search-params.test.tsx +0 -236
- package/src/app/__tests__/entity-edit-jsonb-embedded-submit.test.tsx +0 -239
- package/src/app/__tests__/entity-list-create-singleton.test.tsx +0 -128
- package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +0 -424
- package/src/app/__tests__/entity-list-row-action-icon.test.tsx +0 -248
- package/src/app/__tests__/entity-list-row-action-kumiko-actions-view.test.tsx +0 -162
- package/src/app/__tests__/entity-list-row-action-refetch.test.tsx +0 -401
- package/src/app/__tests__/entity-list-screen-padding.test.tsx +0 -111
- package/src/app/__tests__/feature-schema.test.ts +0 -40
- package/src/app/__tests__/form-schema.test.ts +0 -247
- package/src/app/__tests__/list-daterange-facet.test.ts +0 -165
- package/src/app/__tests__/list-filter-facets.test.tsx +0 -521
- package/src/app/__tests__/nav.test.ts +0 -198
- package/src/app/__tests__/projection-detail-actions.test.tsx +0 -719
- package/src/app/__tests__/projection-detail-reference-field.test.tsx +0 -165
- package/src/app/__tests__/projection-detail-shim.test.ts +0 -188
- package/src/app/__tests__/projection-detail-singleton.test.tsx +0 -191
- package/src/app/__tests__/projection-list-bad-shape.test.tsx +0 -152
- package/src/app/__tests__/projection-list-reference-column.test.tsx +0 -219
- package/src/app/__tests__/projection-list-search-sort.test.tsx +0 -212
- package/src/app/__tests__/qualified-names.test.ts +0 -37
- package/src/app/__tests__/return-to.test.ts +0 -396
- package/src/app/__tests__/row-actions-navigate-tab.test.ts +0 -223
- package/src/app/__tests__/row-default-edit-action.test.tsx +0 -324
- package/src/app/__tests__/screen-access-allows.test.ts +0 -34
- package/src/app/__tests__/secret-mint-body.test.tsx +0 -404
- package/src/app/__tests__/variable-chips.test.tsx +0 -70
- package/src/app/app-features-context.tsx +0 -25
- package/src/app/column-renderers.tsx +0 -68
- package/src/app/content-editors.tsx +0 -85
- package/src/app/content-preview.tsx +0 -67
- package/src/app/custom-screens.tsx +0 -29
- package/src/app/dashboard-body.tsx +0 -33
- package/src/app/extension-form-submit.tsx +0 -123
- package/src/app/extension-sections.tsx +0 -111
- package/src/app/form-schema.ts +0 -91
- package/src/app/kumiko-screen.tsx +0 -3614
- package/src/app/layout-fields.ts +0 -31
- package/src/app/list-facets.ts +0 -259
- package/src/app/nav.tsx +0 -258
- package/src/app/projection-detail-shim.ts +0 -85
- package/src/app/reference-facet-bridge.tsx +0 -58
- package/src/app/return-to.tsx +0 -234
- package/src/app/row-actions.ts +0 -639
- package/src/app/screen-access.ts +0 -63
- package/src/app/secret-mint-body.tsx +0 -259
- package/src/app/secrets-edit-body.tsx +0 -194
- package/src/app/use-embedded-screen.ts +0 -27
- package/src/app/variable-chips.tsx +0 -39
- package/src/app/write-failed-error.ts +0 -27
- package/src/components/__tests__/embedded-list-field.test.tsx +0 -531
- package/src/components/__tests__/multi-select-checkboxes.test.tsx +0 -162
- package/src/components/__tests__/related-list-section-empty-state.test.tsx +0 -143
- package/src/components/__tests__/related-list-section.test.tsx +0 -1669
- package/src/components/__tests__/render-edit-action-button.test.tsx +0 -205
- package/src/components/__tests__/render-edit-hidden-section.test.tsx +0 -128
- package/src/components/__tests__/render-edit-logic.test.ts +0 -314
- package/src/components/__tests__/render-edit-screen-description.test.tsx +0 -152
- package/src/components/__tests__/render-edit-submit-actions.test.tsx +0 -460
- package/src/components/__tests__/render-field-app-locale.test.tsx +0 -142
- package/src/components/__tests__/render-field-enum-option.test.tsx +0 -99
- package/src/components/__tests__/render-field-icon-derivation.test.tsx +0 -119
- package/src/components/__tests__/render-field-json-format.test.tsx +0 -164
- package/src/components/__tests__/render-field-money-embedded-list-consistency.test.tsx +0 -212
- package/src/components/__tests__/render-field-money-roundtrip.test.tsx +0 -217
- package/src/components/__tests__/render-field-number-grouping.test.tsx +0 -76
- package/src/components/__tests__/render-field-number-unit.test.tsx +0 -227
- package/src/components/__tests__/render-field-password-format.test.tsx +0 -101
- package/src/components/__tests__/render-field-select-display.test.tsx +0 -134
- package/src/components/__tests__/render-field-unit-format.test.tsx +0 -107
- package/src/components/__tests__/render-field-unsupported-types.test.tsx +0 -195
- package/src/components/__tests__/render-list-toolbar-icon-only.test.tsx +0 -146
- package/src/components/__tests__/write-form-section.test.tsx +0 -375
- package/src/components/embedded-list-field.tsx +0 -354
- package/src/components/grid-cell-for-field.tsx +0 -60
- package/src/components/multi-select-checkboxes.tsx +0 -93
- package/src/components/reference-create-dialog.tsx +0 -91
- package/src/components/related-list-section.tsx +0 -467
- package/src/components/render-edit-action-button.tsx +0 -106
- package/src/components/render-edit-logic.ts +0 -85
- package/src/components/render-edit-types.ts +0 -216
- package/src/components/render-edit.tsx +0 -1700
- package/src/components/render-field.tsx +0 -942
- package/src/components/render-list.tsx +0 -561
- package/src/components/write-form-section.tsx +0 -199
- package/src/context/draft-storage-context.tsx +0 -39
- package/src/hooks/__tests__/use-ai-text.test.tsx +0 -177
- package/src/hooks/__tests__/use-disclosure.test.tsx +0 -25
- package/src/hooks/__tests__/use-form.test.tsx +0 -65
- package/src/hooks/__tests__/use-mutation.test.tsx +0 -77
- package/src/hooks/__tests__/use-stream-handler.test.tsx +0 -107
- package/src/hooks/use-ai-text.ts +0 -172
- package/src/hooks/use-disclosure.ts +0 -20
- package/src/hooks/use-form.ts +0 -78
- package/src/hooks/use-list-url-state.ts +0 -171
- package/src/hooks/use-mutation.ts +0 -61
- package/src/hooks/use-query.ts +0 -132
- package/src/hooks/use-reference-lookup.ts +0 -76
- package/src/hooks/use-store.ts +0 -48
- package/src/hooks/use-stream-handler.ts +0 -134
- package/src/i18n-defaults.ts +0 -220
- package/src/i18n.tsx +0 -286
- package/src/index.ts +0 -272
- package/src/primitives.tsx +0 -1446
- package/src/share-links.ts +0 -12
- package/src/sort-by-accessor.ts +0 -20
- package/src/sse/live-events.tsx +0 -56
- package/src/tokens.tsx +0 -170
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Tier 2.7e-4: Renderer-Side Eagerload für Reference-Felder.
|
|
2
|
+
//
|
|
3
|
+
// Pro Reference-Spalte einer Liste wird einmal `<feature>:query:
|
|
4
|
+
// <refEntity>:list` (limit:200) gerufen, eine Map<uuid, displayValue>
|
|
5
|
+
// gebaut, und der Renderer nutzt sie als Cell-Display-Renderer.
|
|
6
|
+
//
|
|
7
|
+
// Strategy-Trade-offs:
|
|
8
|
+
// - Server-side Drizzle-Joins wären effizienter (eine Query statt
|
|
9
|
+
// N+1), würden aber die executor-API um lookupTables erweitern
|
|
10
|
+
// müssen. Für das MVP geht der Renderer-Side-Pfad vor.
|
|
11
|
+
// - Limit:200 ist eine harte UX-Grenze: bei mehr Entries in der
|
|
12
|
+
// referenced Entity zeigen die letzten Rows nur noch UUIDs.
|
|
13
|
+
// Searchable-Combobox (Tier 2.1c) + Server-Eagerload mit
|
|
14
|
+
// ID-Whitelist heben das später auf.
|
|
15
|
+
//
|
|
16
|
+
// Dieser Hook ist call-stable: Lookup-Map wird durch useQuery's
|
|
17
|
+
// internen Cache shared zwischen List + Edit-Form für die gleiche
|
|
18
|
+
// Entity, Live-Updates kommen via SSE (use-query-live).
|
|
19
|
+
import { REFERENCE_LOOKUP_SOURCES, SYSTEM_REFERENCE_LABELS, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
20
|
+
import { useMemo } from "react";
|
|
21
|
+
import { toKebab } from "../app/qn.js";
|
|
22
|
+
import { useTranslation } from "../i18n.js";
|
|
23
|
+
import { REFERENCE_LIST_LOOKUP_LIMIT } from "./reference-limits.js";
|
|
24
|
+
import { useQuery } from "./use-query.js";
|
|
25
|
+
/** Where a reference target's label rows come from: the central override for
|
|
26
|
+
* that `feature:entity` if one exists, else the entity-convention list
|
|
27
|
+
* handler plus the caller's own label field. */
|
|
28
|
+
export function referenceLookupSource(refFeature, refEntity, labelField) {
|
|
29
|
+
const override = REFERENCE_LOOKUP_SOURCES[`${refFeature}:${refEntity}`];
|
|
30
|
+
if (override !== undefined)
|
|
31
|
+
return override;
|
|
32
|
+
return {
|
|
33
|
+
queryQn: `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`,
|
|
34
|
+
labelKey: labelField,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Empty while the query loads (or fails), so callers fall back to the raw id.
|
|
38
|
+
* `featureName` is the reference's target feature, not the screen's. */
|
|
39
|
+
export function useReferenceLookup(featureName, refEntity, labelField) {
|
|
40
|
+
const { queryQn, labelKey } = referenceLookupSource(featureName, refEntity, labelField);
|
|
41
|
+
const result = useQuery(queryQn, {
|
|
42
|
+
limit: REFERENCE_LIST_LOOKUP_LIMIT,
|
|
43
|
+
});
|
|
44
|
+
const translate = useTranslation();
|
|
45
|
+
const systemLabel = SYSTEM_REFERENCE_LABELS[`${featureName}:${refEntity}`];
|
|
46
|
+
const map = useMemo(() => {
|
|
47
|
+
const out = new Map();
|
|
48
|
+
for (const row of result.data?.rows ?? []) {
|
|
49
|
+
const id = row["id"];
|
|
50
|
+
if (id === undefined || id === null)
|
|
51
|
+
continue;
|
|
52
|
+
const idStr = String(id);
|
|
53
|
+
const label = row[labelKey] ?? id;
|
|
54
|
+
out.set(idStr, String(label));
|
|
55
|
+
}
|
|
56
|
+
// System-scope ids (e.g. SYSTEM_TENANT_ID) never have a backing row, so
|
|
57
|
+
// the bulk lookup above never covers them — inject the label directly.
|
|
58
|
+
if (systemLabel !== undefined)
|
|
59
|
+
out.set(systemLabel.id, translate(systemLabel.labelKey));
|
|
60
|
+
return out;
|
|
61
|
+
}, [result.data, labelKey, systemLabel, translate]);
|
|
62
|
+
return { map, loading: result.loading };
|
|
63
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useRef, useSyncExternalStore } from "react";
|
|
2
|
+
// React-Bindings für Kumikos Store-Primitive (`createStore` aus
|
|
3
|
+
// @cosmicdrift/kumiko-headless). Beide Hooks sind dünne Wrapper um React's
|
|
4
|
+
// useSyncExternalStore — die ganze Subscribe/Notify-Mechanik lebt im
|
|
5
|
+
// Store selbst, hier wird nur React verdrahtet.
|
|
6
|
+
//
|
|
7
|
+
// useStore: gibt den ganzen Snapshot zurück. Re-rendert wenn der
|
|
8
|
+
// Store seine Identität wechselt. Object.is-Gate im Store selbst
|
|
9
|
+
// verhindert no-op-Updates.
|
|
10
|
+
//
|
|
11
|
+
// useStoreSelector: gibt eine abgeleitete Sicht zurück. Selectors die
|
|
12
|
+
// Object-/Array-Literale zurückgeben (z.B. `s => ({ a, b })`) erzeugen
|
|
13
|
+
// pro Render eine neue Identität — useSyncExternalStore würde das als
|
|
14
|
+
// "Change" lesen und in eine Re-Render-Schleife laufen. Der dritte
|
|
15
|
+
// Arg `equals` dient genau dafür: gleiche Auswahl → cached
|
|
16
|
+
// Identität zurück, kein Re-Render.
|
|
17
|
+
// @wrapper-known semantic-alias
|
|
18
|
+
export function useStore(store) {
|
|
19
|
+
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
20
|
+
}
|
|
21
|
+
export function useStoreSelector(store, select, equals = Object.is) {
|
|
22
|
+
// Cache der letzten Auswahl. Beim nächsten getSnapshot-Call vergleicht
|
|
23
|
+
// der Wrapper die neu berechnete Auswahl mit dem Cache und gibt — falls
|
|
24
|
+
// gleich — die cached Identität zurück. Das ist der Trick, der
|
|
25
|
+
// Selector-Returns wie `s => ({ a, b })` stabil hält.
|
|
26
|
+
const lastSelected = useRef(undefined);
|
|
27
|
+
const initialized = useRef(false);
|
|
28
|
+
const getSelectedSnapshot = () => {
|
|
29
|
+
const next = select(store.getSnapshot());
|
|
30
|
+
if (initialized.current && equals(lastSelected.current, next)) {
|
|
31
|
+
return lastSelected.current;
|
|
32
|
+
}
|
|
33
|
+
lastSelected.current = next;
|
|
34
|
+
initialized.current = true;
|
|
35
|
+
return next;
|
|
36
|
+
};
|
|
37
|
+
return useSyncExternalStore(store.subscribe, getSelectedSnapshot, getSelectedSnapshot);
|
|
38
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DispatcherError } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
export type StreamStatus = "idle" | "streaming" | "done" | "error";
|
|
3
|
+
export type UseStreamHandlerResult<TChunk> = {
|
|
4
|
+
readonly chunks: readonly TChunk[];
|
|
5
|
+
readonly status: StreamStatus;
|
|
6
|
+
readonly error: DispatcherError | null;
|
|
7
|
+
readonly start: (payload?: unknown) => Promise<void>;
|
|
8
|
+
readonly abort: () => void;
|
|
9
|
+
readonly reset: () => void;
|
|
10
|
+
};
|
|
11
|
+
export type UseStreamHandlerOptions = {
|
|
12
|
+
readonly autoStart?: boolean;
|
|
13
|
+
};
|
|
14
|
+
export declare function useStreamHandler<TChunk = unknown>(type: string, payload?: unknown, options?: UseStreamHandlerOptions): UseStreamHandlerResult<TChunk>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useDispatcher } from "../context/dispatcher-context.js";
|
|
3
|
+
export function useStreamHandler(type, payload = {}, options = {}) {
|
|
4
|
+
const dispatcher = useDispatcher();
|
|
5
|
+
const { autoStart = false } = options;
|
|
6
|
+
const [chunks, setChunks] = useState([]);
|
|
7
|
+
const [status, setStatus] = useState("idle");
|
|
8
|
+
const [error, setError] = useState(null);
|
|
9
|
+
const activeCtrl = useRef(null);
|
|
10
|
+
const payloadRef = useRef(payload);
|
|
11
|
+
payloadRef.current = payload;
|
|
12
|
+
const abort = useCallback(() => {
|
|
13
|
+
activeCtrl.current?.abort();
|
|
14
|
+
activeCtrl.current = null;
|
|
15
|
+
// User cancel — exit streaming so UI can re-enable the start button.
|
|
16
|
+
setStatus((s) => (s === "streaming" ? "idle" : s));
|
|
17
|
+
}, []);
|
|
18
|
+
const reset = useCallback(() => {
|
|
19
|
+
abort();
|
|
20
|
+
setChunks([]);
|
|
21
|
+
setStatus("idle");
|
|
22
|
+
setError(null);
|
|
23
|
+
}, [abort]);
|
|
24
|
+
// Shared by every abort-detection branch below: only touch status if this
|
|
25
|
+
// run is still the active one (or activeCtrl was already cleared by abort()).
|
|
26
|
+
const settleAborted = useCallback((ctrl) => {
|
|
27
|
+
if (activeCtrl.current === null || activeCtrl.current === ctrl)
|
|
28
|
+
setStatus("idle");
|
|
29
|
+
}, []);
|
|
30
|
+
const start = useCallback(async (overridePayload) => {
|
|
31
|
+
activeCtrl.current?.abort();
|
|
32
|
+
const ctrl = new AbortController();
|
|
33
|
+
activeCtrl.current = ctrl;
|
|
34
|
+
setChunks([]);
|
|
35
|
+
setError(null);
|
|
36
|
+
setStatus("streaming");
|
|
37
|
+
const body = overridePayload !== undefined ? overridePayload : payloadRef.current;
|
|
38
|
+
try {
|
|
39
|
+
for await (const chunk of dispatcher.stream(type, body, { signal: ctrl.signal })) {
|
|
40
|
+
// skip: a newer start() already superseded this run
|
|
41
|
+
if (ctrl.signal.aborted) {
|
|
42
|
+
settleAborted(ctrl);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
setChunks((prev) => [...prev, chunk]);
|
|
46
|
+
}
|
|
47
|
+
// skip: aborted after last chunk, don't mark done
|
|
48
|
+
if (ctrl.signal.aborted) {
|
|
49
|
+
settleAborted(ctrl);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
setStatus("done");
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
// skip: abort is not an error toast
|
|
56
|
+
if (ctrl.signal.aborted) {
|
|
57
|
+
settleAborted(ctrl);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const mapped = asDispatcherError(e);
|
|
61
|
+
// skip: dispatcher-mapped abort (fetch cancelled)
|
|
62
|
+
if (mapped.code === "aborted") {
|
|
63
|
+
settleAborted(ctrl);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
setError(mapped);
|
|
67
|
+
setStatus("error");
|
|
68
|
+
}
|
|
69
|
+
}, [dispatcher, type, settleAborted]);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
// skip: autoStart off — streams are usually user-triggered
|
|
72
|
+
if (!autoStart)
|
|
73
|
+
return;
|
|
74
|
+
void start();
|
|
75
|
+
}, [autoStart, start]);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
return () => {
|
|
78
|
+
activeCtrl.current?.abort();
|
|
79
|
+
};
|
|
80
|
+
}, []);
|
|
81
|
+
return { chunks, status, error, start, abort, reset };
|
|
82
|
+
}
|
|
83
|
+
function asDispatcherError(e) {
|
|
84
|
+
if (e && typeof e === "object" && "code" in e && "message" in e && "i18nKey" in e) {
|
|
85
|
+
return e;
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
code: "stream_error",
|
|
89
|
+
httpStatus: 0,
|
|
90
|
+
i18nKey: "errors.unknown",
|
|
91
|
+
message: e instanceof Error ? e.message : String(e),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Framework-Default-Bundle. Strings die die Renderer-Components hart
|
|
2
|
+
// brauchen (Save/Cancel/Delete-Buttons, Empty-States, Search-Placeholder,
|
|
3
|
+
// Nav-Toggle-aria-Labels, Validation-Reasons). createKumikoApp hängt das
|
|
4
|
+
// als ALLERLETZTEN Fallback in den LocaleProvider — Apps können
|
|
5
|
+
// einzelne Keys via clientFeatures.translations überschreiben.
|
|
6
|
+
//
|
|
7
|
+
// Convention: alle Keys mit `kumiko.`-Prefix damit sie nicht mit
|
|
8
|
+
// App-Keys kollidieren. Sub-Pfade gruppieren nach Bereich (actions /
|
|
9
|
+
// list / nav / form / validation).
|
|
10
|
+
export const kumikoDefaultTranslations = {
|
|
11
|
+
en: {
|
|
12
|
+
"kumiko.actions.save": "Save",
|
|
13
|
+
"kumiko.actions.cancel": "Cancel",
|
|
14
|
+
"kumiko.actions.delete": "Delete",
|
|
15
|
+
"kumiko.actions.delete-confirm": "Confirm delete?",
|
|
16
|
+
"kumiko.actions.reload": "Reload",
|
|
17
|
+
"kumiko.actions.create": "New",
|
|
18
|
+
"kumiko.actions.edit": "Edit",
|
|
19
|
+
"kumiko.actions.view": "View",
|
|
20
|
+
"kumiko.actions.copyLink": "Copy link",
|
|
21
|
+
"kumiko.actions.copyLinkCopied": "Copied!",
|
|
22
|
+
"kumiko.actions.next": "Next",
|
|
23
|
+
"kumiko.actions.back": "Back",
|
|
24
|
+
"kumiko.actions.finish": "Finish",
|
|
25
|
+
"kumiko.wizard.step": "Step {current} of {total}",
|
|
26
|
+
"kumiko.wizard.step-with-title": "Step {current} of {total} · {title}",
|
|
27
|
+
"kumiko.version.update-available": "A new version is available.",
|
|
28
|
+
"kumiko.toast.learn-more": "Learn more",
|
|
29
|
+
"kumiko.field.open-calendar": "Open calendar",
|
|
30
|
+
"kumiko.field.dateField.placeholderYear": "Y",
|
|
31
|
+
"kumiko.field.dateField.placeholderMonth": "M",
|
|
32
|
+
"kumiko.field.dateField.placeholderDay": "D",
|
|
33
|
+
"kumiko.field.time": "Time",
|
|
34
|
+
"kumiko.field.timezone": "Time zone",
|
|
35
|
+
"kumiko.field.locatedTzHint": "Time local to the given location",
|
|
36
|
+
"kumiko.field.reference-created-no-id": "Record was created but could not be selected automatically. Please select it manually.",
|
|
37
|
+
"kumiko.field.unsupported": "This field type can't be edited here yet.",
|
|
38
|
+
"kumiko.field.embedded-list.add-row": "Add row",
|
|
39
|
+
"kumiko.field.embedded-list.remove-row": "Remove row",
|
|
40
|
+
"kumiko.field.embedded-list.duplicate-row": "Duplicate row",
|
|
41
|
+
"kumiko.field.embedded-list.move-up": "Move up",
|
|
42
|
+
"kumiko.field.embedded-list.move-down": "Move down",
|
|
43
|
+
"kumiko.field.embedded-list.empty": "No rows yet.",
|
|
44
|
+
"kumiko.field.embedded-list.empty-cta": "Add first row",
|
|
45
|
+
"kumiko.field.embedded-list.paste-rows-truncated": "{count} pasted row(s) were dropped (max row count reached).",
|
|
46
|
+
"kumiko.field.embedded-list.paste-cells-unmatched": "{count} cell(s) had no matching option and were left unchanged.",
|
|
47
|
+
"kumiko.field.multiSelect.select-all": "Select all",
|
|
48
|
+
"kumiko.field.multiSelect.deselect-all": "Deselect all",
|
|
49
|
+
"kumiko.list.search-placeholder": "Search…",
|
|
50
|
+
"kumiko.list.empty.title": "No entries yet.",
|
|
51
|
+
"kumiko.list.empty.hint": "Create the first one to get started.",
|
|
52
|
+
"kumiko.list.no-entries": "No entries.",
|
|
53
|
+
"kumiko.list.end-of-list": "— End of list —",
|
|
54
|
+
"kumiko.list.sort.label": "Sort",
|
|
55
|
+
"kumiko.list.sort.unsorted": "Unsorted",
|
|
56
|
+
"kumiko.list.filter.reset": "Reset",
|
|
57
|
+
"kumiko.list.filter.toggle": "Filters",
|
|
58
|
+
"kumiko.list.related-list-truncated": "Showing the first {count} entries. More are available but not loaded — this list does not paginate.",
|
|
59
|
+
"kumiko.reference.system-tenant": "System",
|
|
60
|
+
"kumiko.reference.system-user": "System",
|
|
61
|
+
"kumiko.pager.status": "{from}–{to} of {total}",
|
|
62
|
+
"kumiko.pager.pageOf": "Page {page} of {pages}",
|
|
63
|
+
"kumiko.form.all-required": "All fields are required.",
|
|
64
|
+
"kumiko.pager.status.noun": "{from}–{to} of {total} {noun}",
|
|
65
|
+
"kumiko.pager.pageSize": "{size} per page",
|
|
66
|
+
"kumiko.pager.pageSizeLabel": "Entries per page",
|
|
67
|
+
"kumiko.list.count.noun": "{count} {noun}",
|
|
68
|
+
"kumiko.list.count.one": "1 entry",
|
|
69
|
+
"kumiko.list.count.other": "{count} entries",
|
|
70
|
+
"kumiko.list.row-actions.more": "More actions",
|
|
71
|
+
"kumiko.list.sort.by": "Sorted by {column}",
|
|
72
|
+
"kumiko.form.changed": "changed",
|
|
73
|
+
"kumiko.form.on-this-page": "On this page",
|
|
74
|
+
"kumiko.form.unsaved.one": "1 unsaved change",
|
|
75
|
+
"kumiko.form.unsaved.other": "{count} unsaved changes",
|
|
76
|
+
"kumiko.wizard.next-with-title": "Next: {title}",
|
|
77
|
+
"kumiko.wizard.save-and-close": "Save and close",
|
|
78
|
+
"kumiko.pager.previousPage": "Previous page",
|
|
79
|
+
"kumiko.pager.nextPage": "Next page",
|
|
80
|
+
"kumiko.combobox.search-placeholder": "Search…",
|
|
81
|
+
"kumiko.combobox.empty": "No matches.",
|
|
82
|
+
"kumiko.combobox.loading": "Loading…",
|
|
83
|
+
"kumiko.combobox.placeholder": "—",
|
|
84
|
+
"kumiko.dashboard.filter.all": "All",
|
|
85
|
+
"kumiko.app-boot.loading": "Loading…",
|
|
86
|
+
"kumiko.app-boot.unauthorized": "You need to sign in to see this.",
|
|
87
|
+
"kumiko.app-boot.failed": "Couldn't load the app. Check your connection and try again.",
|
|
88
|
+
"kumiko.app-boot.no-open-screen": "This app has no screen that's accessible without a role restriction.",
|
|
89
|
+
"kumiko.widget.loading": "Loading…",
|
|
90
|
+
"kumiko.widget.error.title": "Couldn't load.",
|
|
91
|
+
"kumiko.widget.upload.uploading": "Uploading…",
|
|
92
|
+
"kumiko.widget.upload.done": "Uploaded",
|
|
93
|
+
"kumiko.widget.upload.error": "Failed",
|
|
94
|
+
"kumiko.widget.upload.rejected-type": "File type not allowed",
|
|
95
|
+
"kumiko.widget.step-bar.done": "Done",
|
|
96
|
+
"kumiko.widget.drawer.restore": "Restore drawer width",
|
|
97
|
+
"kumiko.widget.drawer.maximize": "Maximize drawer width",
|
|
98
|
+
"kumiko.widget.drawer.resize": "Resize drawer",
|
|
99
|
+
"kumiko.widget.floatingPanel.move": "Move panel",
|
|
100
|
+
"kumiko.widget.floatingPanel.resizeTop": "Resize panel from the top edge",
|
|
101
|
+
"kumiko.widget.floatingPanel.resizeRight": "Resize panel from the right edge",
|
|
102
|
+
"kumiko.widget.floatingPanel.resizeBottom": "Resize panel from the bottom edge",
|
|
103
|
+
"kumiko.widget.floatingPanel.resizeLeft": "Resize panel from the left edge",
|
|
104
|
+
"kumiko.planCard.current": "Current plan",
|
|
105
|
+
"kumiko.planCard.priceUnavailable": "Price not available",
|
|
106
|
+
"kumiko.nav.expand": "Expand",
|
|
107
|
+
"kumiko.nav.collapse": "Collapse",
|
|
108
|
+
"kumiko.nav.search": "Search navigation…",
|
|
109
|
+
"kumiko.nav.language": "Language",
|
|
110
|
+
"kumiko.workspace.switch": "Switch workspace",
|
|
111
|
+
// Workspace-Switcher fallback label when activeId points at no visible workspace.
|
|
112
|
+
"kumiko.workspace.select": "Select workspace",
|
|
113
|
+
"kumiko.dialog.confirm": "Confirm",
|
|
114
|
+
"kumiko.dialog.cancel": "Cancel",
|
|
115
|
+
"kumiko.dialog.close": "Close",
|
|
116
|
+
"kumiko.drawer.discard.title": "Discard changes?",
|
|
117
|
+
"kumiko.drawer.discard.body": "Your input in this form will be lost.",
|
|
118
|
+
"kumiko.drawer.discard.confirm": "Discard",
|
|
119
|
+
"kumiko.drawer.discard.cancel": "Keep editing",
|
|
120
|
+
"kumiko.aiText.acceptHint": "Tab = accept, Esc = discard",
|
|
121
|
+
"kumiko.aiText.correct": "Correct",
|
|
122
|
+
"kumiko.aiText.translate": "Translate",
|
|
123
|
+
"kumiko.aiText.rewrite": "Rewrite",
|
|
124
|
+
"kumiko.aiText.diff.before": "Before",
|
|
125
|
+
"kumiko.aiText.diff.after": "After",
|
|
126
|
+
"kumiko.aiText.diff.generating": "Generating…",
|
|
127
|
+
"kumiko.aiText.style.formal": "Formal",
|
|
128
|
+
"kumiko.aiText.style.casual": "Casual",
|
|
129
|
+
"kumiko.aiText.style.concise": "Concise",
|
|
130
|
+
"kumiko.aiText.style.expand": "Expand",
|
|
131
|
+
"kumiko.aiText.capExceeded": "Monthly AI limit reached.",
|
|
132
|
+
"kumiko.rowAction.failed": "Action failed",
|
|
133
|
+
"kumiko.contentEditor.insertVariable": "Insert {name}",
|
|
134
|
+
"kumiko.contentEditor.preview": "Preview",
|
|
135
|
+
"kumiko.contentEditor.editMode": "Edit",
|
|
136
|
+
"kumiko.contentEditor.bold": "Bold",
|
|
137
|
+
"kumiko.contentEditor.italic": "Italic",
|
|
138
|
+
"kumiko.contentEditor.heading1": "Heading 1",
|
|
139
|
+
"kumiko.contentEditor.heading2": "Heading 2",
|
|
140
|
+
"kumiko.contentEditor.bulletList": "Bullet list",
|
|
141
|
+
"kumiko.contentEditor.orderedList": "Numbered list",
|
|
142
|
+
"kumiko.config.source.user": "My value",
|
|
143
|
+
"kumiko.config.source.tenant": "Tenant",
|
|
144
|
+
"kumiko.config.source.system": "System",
|
|
145
|
+
"kumiko.config.source.appOverride": "App override",
|
|
146
|
+
"kumiko.config.source.computed": "Computed",
|
|
147
|
+
"kumiko.config.source.default": "Default",
|
|
148
|
+
"kumiko.config.source.missing": "Missing",
|
|
149
|
+
"kumiko.config.cascade.noValue": "No value set",
|
|
150
|
+
"kumiko.config.cascade.activeMarker": "active",
|
|
151
|
+
"kumiko.config.cascade.resetTo": "Reset override ({scope})",
|
|
152
|
+
"kumiko.form.error.generic": "Something went wrong.",
|
|
153
|
+
"kumiko.form.error.version-conflict": "Record was modified in the meantime. Reload and try again.",
|
|
154
|
+
"kumiko.form.extension.save-failed": "A custom field could not be saved.",
|
|
155
|
+
"kumiko.form.draft.resume-multiple": "Found multiple open drafts for this form. Which one do you want to resume?",
|
|
156
|
+
"kumiko.form.draft.resume-single": "Found an open draft for this form. Do you want to resume it?",
|
|
157
|
+
"kumiko.form.draft.start-new": "Start new",
|
|
158
|
+
"kumiko.secretMint.title": "Your new secret",
|
|
159
|
+
"kumiko.secretMint.warning": "Copy this now — it is shown only this once and cannot be retrieved later.",
|
|
160
|
+
"kumiko.secretMint.confirm": "I have copied it",
|
|
161
|
+
"kumiko.secretMint.copy": "Copy",
|
|
162
|
+
"kumiko.secretMint.copied": "Copied",
|
|
163
|
+
"kumiko.secretMint.done": "Done.",
|
|
164
|
+
"kumiko.validation.required": "Required.",
|
|
165
|
+
"kumiko.validation.invalid": "Invalid value.",
|
|
166
|
+
"kumiko.validation.too-short": "Too short (at least {min} characters).",
|
|
167
|
+
"kumiko.validation.too-long": "Too long (at most {max} characters).",
|
|
168
|
+
"kumiko.validation.out-of-range": "Value out of allowed range.",
|
|
169
|
+
"errors.validation.invalid_type": "Invalid value.",
|
|
170
|
+
"errors.validation.too_small": "Too small or too short (minimum: {minimum}).",
|
|
171
|
+
"errors.validation.too_big": "Too big or too long (maximum: {maximum}).",
|
|
172
|
+
"errors.validation.invalid_format": "Invalid format.",
|
|
173
|
+
"errors.validation.not_multiple_of": "Must be a multiple of {divisor}.",
|
|
174
|
+
"errors.validation.unrecognized_keys": "Unknown fields.",
|
|
175
|
+
"errors.validation.invalid_union": "Invalid value.",
|
|
176
|
+
"errors.validation.invalid_key": "Invalid key.",
|
|
177
|
+
"errors.validation.invalid_element": "Invalid entry.",
|
|
178
|
+
"errors.validation.invalid_value": "Invalid choice.",
|
|
179
|
+
"errors.validation.custom": "Invalid value.",
|
|
180
|
+
"errors.validation.unexpected_field": "Unknown field.",
|
|
181
|
+
"errors.validation.out_of_bounds": "Value out of allowed range.",
|
|
182
|
+
"errors.validation.invalid_option": "Invalid choice.",
|
|
183
|
+
"errors.validation.failed": "Validation failed.",
|
|
184
|
+
"errors.feature.disabled": "This feature is currently unavailable.",
|
|
185
|
+
"errors.access.denied": "You don't have permission to do this.",
|
|
186
|
+
"errors.notFound": "Not found.",
|
|
187
|
+
"errors.conflict": "Conflict — the operation could not be completed.",
|
|
188
|
+
"errors.versionConflict": "The record was modified in the meantime. Reload and try again.",
|
|
189
|
+
"errors.preconditionFailed": "The record changed in the meantime. Reload and try again.",
|
|
190
|
+
"errors.uniqueViolation": "This entry already exists.",
|
|
191
|
+
"errors.unprocessable": "The request could not be processed.",
|
|
192
|
+
"errors.unconfigured": "This feature isn't configured yet.",
|
|
193
|
+
"errors.internal": "Something went wrong. Please try again later.",
|
|
194
|
+
"errors.rate_limited": "Too many requests. Please try again shortly.",
|
|
195
|
+
"errors.cap.exceeded": "Limit reached. Upgrade your plan or wait for the next period.",
|
|
196
|
+
"errors.download.urlMissing": "Download unavailable — please try again.",
|
|
197
|
+
"auth.errors.originNotAllowed": "Requests from this origin are not allowed.",
|
|
198
|
+
"dispatcher.errors.network": "Network error. Please check your connection and try again.",
|
|
199
|
+
"dispatcher.errors.aborted": "Request was cancelled.",
|
|
200
|
+
},
|
|
201
|
+
};
|
package/dist/i18n.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type Formality, type TranslationValue } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { type ReactNode } from "react";
|
|
4
|
+
/** Map of i18n key → TranslationValue. Strings may contain `{name}`
|
|
5
|
+
* placeholders (same semantics as i18next t); plural entries resolve via
|
|
6
|
+
* `resolveTranslationValue`. */
|
|
7
|
+
export type TranslationBundle = Readonly<Record<string, TranslationValue>>;
|
|
8
|
+
/** Map von Locale-Code (BCP-47, z.B. `"de"`, `"en-US"`) → Bundle. */
|
|
9
|
+
export type TranslationsByLocale = Readonly<Record<string, TranslationBundle>>;
|
|
10
|
+
/** Key-first shape for `r.translations({ keys })` — each key maps locale → TranslationValue. */
|
|
11
|
+
export type TranslationsByKey = Readonly<Record<string, Readonly<Record<string, TranslationValue>>>>;
|
|
12
|
+
/** Pivot key-first server translations to locale-first client bundles. */
|
|
13
|
+
export declare function translationsByLocaleFromKeys(source: TranslationsByKey): TranslationsByLocale;
|
|
14
|
+
/** Merged zwei TranslationsByLocale-Maps — der override gewinnt pro Key,
|
|
15
|
+
* die Locales werden zusammengeführt. Standard-Baustein für Client-
|
|
16
|
+
* Plugins, die App-Overrides über ihre Default-Bundles legen. */
|
|
17
|
+
export declare function mergeTranslations(base: TranslationsByLocale, override: TranslationsByLocale): TranslationsByLocale;
|
|
18
|
+
export type LocaleProviderProps = {
|
|
19
|
+
readonly resolver: LocaleResolver;
|
|
20
|
+
/** Default bundles supplied by feature plugins. Lookup order per key:
|
|
21
|
+
* (1) app resolver, (2) these bundles in array order, (3) key as-is.
|
|
22
|
+
* Apps can thus override individual keys without swapping out whole
|
|
23
|
+
* feature bundles. */
|
|
24
|
+
readonly fallbackBundles?: readonly TranslationsByLocale[];
|
|
25
|
+
/** Falls back to fallbackLocale when neither the current-locale nor the
|
|
26
|
+
* key lookup hits in a plugin bundle. Default: `"en"`. */
|
|
27
|
+
readonly fallbackLocale?: string;
|
|
28
|
+
readonly children: ReactNode;
|
|
29
|
+
};
|
|
30
|
+
export declare function LocaleProvider({ resolver, fallbackBundles, fallbackLocale, children, }: LocaleProviderProps): ReactNode;
|
|
31
|
+
/** Liefert den aktuellen LocaleResolver und abonniert automatisch
|
|
32
|
+
* Locale-Änderungen — der aufrufende Component re-rendert sobald die
|
|
33
|
+
* Sprache gewechselt wird. Wirft wenn kein Provider im Baum ist. */
|
|
34
|
+
export declare function useLocale(): LocaleResolver;
|
|
35
|
+
/** `t("key", params)` resolves in this order:
|
|
36
|
+
* 1. App resolver (e.g. i18next)
|
|
37
|
+
* 2. Plugin fallback bundles for the current locale (under a "formal"
|
|
38
|
+
* FormalityProvider, `<locale>-x-formal` across all bundles first)
|
|
39
|
+
* 3. Plugin fallback bundles for fallbackLocale
|
|
40
|
+
* 4. The key as-is
|
|
41
|
+
* `{name}` interpolation applies to every source, fallback strings included. */
|
|
42
|
+
export declare function useTranslation(): (key: string, params?: Readonly<Record<string, unknown>>) => string;
|
|
43
|
+
/** Wording for the subtree: `formal` makes `t` prefer the `<locale>-x-formal`
|
|
44
|
+
* bundle entries (e.g. "Sie" on public pages) over plain `<locale>`. */
|
|
45
|
+
export declare function FormalityProvider({ formality, children, }: {
|
|
46
|
+
readonly formality: Formality;
|
|
47
|
+
readonly children: ReactNode;
|
|
48
|
+
}): ReactNode;
|
|
49
|
+
export declare function useFormality(): Formality;
|
|
50
|
+
/** Non-throwing LocaleContext read — undefined outside LocaleProvider.
|
|
51
|
+
* Used by DataTable FormatCell so plain tables without a provider do not crash. */
|
|
52
|
+
export declare function useOptionalLocale(): string | undefined;
|
|
53
|
+
/** Non-throwing time-zone read — "UTC" outside LocaleProvider, matching
|
|
54
|
+
* createStaticLocaleResolver's own default. */
|
|
55
|
+
export declare function useOptionalTimeZone(): string;
|
|
56
|
+
/** Non-throwing translate — undefined outside LocaleProvider. */
|
|
57
|
+
export declare function useOptionalTranslation(): ((key: string, params?: Readonly<Record<string, unknown>>) => string) | undefined;
|
|
58
|
+
/** Default-Resolver für Apps ohne eigene i18n-Schicht. Gibt jeden Key
|
|
59
|
+
* unverändert zurück — die Plugin-Fallback-Bundles erledigen dann die
|
|
60
|
+
* echte Übersetzung. Nützlich auch als Basis für Tests. */
|
|
61
|
+
export declare function createStaticLocaleResolver(options?: {
|
|
62
|
+
readonly locale?: string;
|
|
63
|
+
readonly timeZone?: string;
|
|
64
|
+
}): LocaleResolver;
|
package/dist/i18n.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Locale handling for React consumers of the Kumiko renderer. A thin layer
|
|
3
|
+
// around the platform-agnostic `LocaleResolver` contract from
|
|
4
|
+
// @cosmicdrift/kumiko-headless: provider, hooks, a default no-op resolver,
|
|
5
|
+
// and a fallback-bundle merge for feature-supplied translations.
|
|
6
|
+
//
|
|
7
|
+
// Architecture:
|
|
8
|
+
// 1. The app supplies exactly one `LocaleResolver` via `<LocaleProvider>`
|
|
9
|
+
// (or none at all → the default resolver returns keys as-is).
|
|
10
|
+
// 2. Feature plugins may bring fallback bundles: when the app resolver
|
|
11
|
+
// can't resolve a key, `useTranslation` tries the plugin bundles.
|
|
12
|
+
// This keeps feature UI independent of the app's own i18next instance
|
|
13
|
+
// and works out of the box, while staying fully overridable.
|
|
14
|
+
// 3. Re-render on locale change via `useSyncExternalStore` on the
|
|
15
|
+
// resolver's `subscribe()` — app code can switch language mid-session
|
|
16
|
+
// without a reload.
|
|
17
|
+
import { formalLocaleTag, resolveTranslationValue, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
18
|
+
import { createContext, useCallback, useContext, useMemo, useSyncExternalStore, } from "react";
|
|
19
|
+
/** Pivot key-first server translations to locale-first client bundles. */
|
|
20
|
+
export function translationsByLocaleFromKeys(source) {
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const [key, byLocale] of Object.entries(source)) {
|
|
23
|
+
for (const [locale, value] of Object.entries(byLocale)) {
|
|
24
|
+
out[locale] ??= {};
|
|
25
|
+
out[locale][key] = value;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
/** Merged zwei TranslationsByLocale-Maps — der override gewinnt pro Key,
|
|
31
|
+
* die Locales werden zusammengeführt. Standard-Baustein für Client-
|
|
32
|
+
* Plugins, die App-Overrides über ihre Default-Bundles legen. */
|
|
33
|
+
export function mergeTranslations(base, override) {
|
|
34
|
+
const locales = new Set([...Object.keys(base), ...Object.keys(override)]);
|
|
35
|
+
const merged = {};
|
|
36
|
+
for (const locale of locales) {
|
|
37
|
+
merged[locale] = { ...(base[locale] ?? {}), ...(override[locale] ?? {}) };
|
|
38
|
+
}
|
|
39
|
+
return merged;
|
|
40
|
+
}
|
|
41
|
+
const LocaleContext = createContext(undefined);
|
|
42
|
+
const FormalityContext = createContext("informal");
|
|
43
|
+
// Stabile Referenz statt `fallbackBundles = []` als Default-Parameter:
|
|
44
|
+
// ein Literal-Default wird bei JEDEM Aufruf neu allokiert und würde die
|
|
45
|
+
// useMemo-Referenzprüfung im Provider unten aushebeln, sobald der
|
|
46
|
+
// Aufrufer fallbackBundles weglässt.
|
|
47
|
+
const EMPTY_FALLBACK_BUNDLES = [];
|
|
48
|
+
export function LocaleProvider({ resolver, fallbackBundles = EMPTY_FALLBACK_BUNDLES, fallbackLocale = "en", children, }) {
|
|
49
|
+
// Without memoization every re-render of the provider (e.g. because an
|
|
50
|
+
// ancestor component re-renders) builds a new context-value object —
|
|
51
|
+
// every consumer of useTranslation()/useLocale() then sees a new `ctx`
|
|
52
|
+
// reference and, even with useCallback memoization, a new `t`.
|
|
53
|
+
// Consequence: `t` in a useEffect dependency array triggers an infinite
|
|
54
|
+
// loop (see admin-shell Overview screens, prod incident).
|
|
55
|
+
const value = useMemo(() => ({ resolver, fallbackBundles, fallbackLocale }), [resolver, fallbackBundles, fallbackLocale]);
|
|
56
|
+
return _jsx(LocaleContext.Provider, { value: value, children: children });
|
|
57
|
+
}
|
|
58
|
+
/** Liefert den aktuellen LocaleResolver und abonniert automatisch
|
|
59
|
+
* Locale-Änderungen — der aufrufende Component re-rendert sobald die
|
|
60
|
+
* Sprache gewechselt wird. Wirft wenn kein Provider im Baum ist. */
|
|
61
|
+
export function useLocale() {
|
|
62
|
+
const ctx = useContext(LocaleContext);
|
|
63
|
+
if (ctx === undefined) {
|
|
64
|
+
throw new Error("useLocale must be used inside <LocaleProvider>");
|
|
65
|
+
}
|
|
66
|
+
// Subscribe + current locale-snapshot. Wir brauchen den Rückgabewert
|
|
67
|
+
// selbst nicht — wichtig ist nur der re-render-trigger.
|
|
68
|
+
useSyncExternalStore(ctx.resolver.subscribe, () => ctx.resolver.locale(), () => ctx.resolver.locale());
|
|
69
|
+
return ctx.resolver;
|
|
70
|
+
}
|
|
71
|
+
/** `t("key", params)` resolves in this order:
|
|
72
|
+
* 1. App resolver (e.g. i18next)
|
|
73
|
+
* 2. Plugin fallback bundles for the current locale (under a "formal"
|
|
74
|
+
* FormalityProvider, `<locale>-x-formal` across all bundles first)
|
|
75
|
+
* 3. Plugin fallback bundles for fallbackLocale
|
|
76
|
+
* 4. The key as-is
|
|
77
|
+
* `{name}` interpolation applies to every source, fallback strings included. */
|
|
78
|
+
export function useTranslation() {
|
|
79
|
+
const ctx = useContext(LocaleContext);
|
|
80
|
+
if (ctx === undefined) {
|
|
81
|
+
throw new Error("useTranslation must be used inside <LocaleProvider>");
|
|
82
|
+
}
|
|
83
|
+
// Re-Render bei Sprach-Wechsel. `ctx.resolver.subscribe` ist bereits
|
|
84
|
+
// eine stable-reference aus dem Resolver, daher hier keine eigene
|
|
85
|
+
// Memoization der Subscribe-Callback nötig.
|
|
86
|
+
const locale = useSyncExternalStore(ctx.resolver.subscribe, () => ctx.resolver.locale(), () => ctx.resolver.locale());
|
|
87
|
+
// `t` MUSS referenz-stabil sein solange sich Resolver/Bundles/Locale
|
|
88
|
+
// nicht ändern — Consumer nutzen `t` regelmäßig in useEffect-Deps
|
|
89
|
+
// (z.B. um Queries neu zu laden wenn sich die Sprache ändert). Ein neu
|
|
90
|
+
// erzeugtes `t` pro Render führt sonst zu einem Render/Effect-Endlos-
|
|
91
|
+
// Loop (siehe admin-shell Overview-Screens, Prod-Incident 2026-07-07).
|
|
92
|
+
const formality = useContext(FormalityContext);
|
|
93
|
+
return useCallback((key, params) => translateWithFallbacks(ctx, locale, formality, key, params), [ctx, locale, formality]);
|
|
94
|
+
}
|
|
95
|
+
/** Wording for the subtree: `formal` makes `t` prefer the `<locale>-x-formal`
|
|
96
|
+
* bundle entries (e.g. "Sie" on public pages) over plain `<locale>`. */
|
|
97
|
+
export function FormalityProvider({ formality, children, }) {
|
|
98
|
+
return _jsx(FormalityContext.Provider, { value: formality, children: children });
|
|
99
|
+
}
|
|
100
|
+
export function useFormality() {
|
|
101
|
+
return useContext(FormalityContext);
|
|
102
|
+
}
|
|
103
|
+
// Lookup order: (1) app resolver — it returns the key itself when it has no
|
|
104
|
+
// entry, which is the invitation to try the plugin bundles; (2) the bundles in
|
|
105
|
+
// array order per locale tier; (3) the key as-is. Under "formal" the formal
|
|
106
|
+
// tier is searched across ALL bundles before any plain tier, so a plain "de"
|
|
107
|
+
// app override never puts "du" on a formal page when locale-de has a formal
|
|
108
|
+
// variant; an app rewording such a key must also ship its "de-x-formal" text.
|
|
109
|
+
function translateWithFallbacks(ctx, locale, formality, key, params) {
|
|
110
|
+
const resolved = ctx.resolver.translate(key, params);
|
|
111
|
+
if (resolved !== key)
|
|
112
|
+
return resolved;
|
|
113
|
+
for (const localesToTry of bundleLocaleTiers(locale, ctx.fallbackLocale, formality)) {
|
|
114
|
+
for (const bundle of ctx.fallbackBundles) {
|
|
115
|
+
for (const localeToTry of localesToTry) {
|
|
116
|
+
const value = bundle[localeToTry]?.[key];
|
|
117
|
+
if (value !== undefined)
|
|
118
|
+
return resolveTranslationValue(value, localeToTry, params);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return key;
|
|
123
|
+
}
|
|
124
|
+
function bundleLocaleTiers(locale, fallbackLocale, formality) {
|
|
125
|
+
const languageRoot = locale.split("-")[0] ?? locale;
|
|
126
|
+
const plainTier = [locale, languageRoot, fallbackLocale];
|
|
127
|
+
if (formality === "informal")
|
|
128
|
+
return [plainTier];
|
|
129
|
+
return [[formalLocaleTag(locale), formalLocaleTag(languageRoot)], plainTier];
|
|
130
|
+
}
|
|
131
|
+
/** Non-throwing LocaleContext read — undefined outside LocaleProvider.
|
|
132
|
+
* Used by DataTable FormatCell so plain tables without a provider do not crash. */
|
|
133
|
+
export function useOptionalLocale() {
|
|
134
|
+
const ctx = useContext(LocaleContext);
|
|
135
|
+
useSyncExternalStore((onStoreChange) => (ctx ? ctx.resolver.subscribe(onStoreChange) : () => { }), () => (ctx ? ctx.resolver.locale() : "en"), () => (ctx ? ctx.resolver.locale() : "en"));
|
|
136
|
+
return ctx === undefined ? undefined : ctx.resolver.locale();
|
|
137
|
+
}
|
|
138
|
+
/** Non-throwing time-zone read — "UTC" outside LocaleProvider, matching
|
|
139
|
+
* createStaticLocaleResolver's own default. */
|
|
140
|
+
export function useOptionalTimeZone() {
|
|
141
|
+
const ctx = useContext(LocaleContext);
|
|
142
|
+
return useSyncExternalStore((onStoreChange) => (ctx ? ctx.resolver.subscribe(onStoreChange) : () => { }), () => (ctx ? ctx.resolver.timeZone() : "UTC"), () => (ctx ? ctx.resolver.timeZone() : "UTC"));
|
|
143
|
+
}
|
|
144
|
+
/** Non-throwing translate — undefined outside LocaleProvider. */
|
|
145
|
+
export function useOptionalTranslation() {
|
|
146
|
+
const ctx = useContext(LocaleContext);
|
|
147
|
+
const locale = useSyncExternalStore((onStoreChange) => (ctx ? ctx.resolver.subscribe(onStoreChange) : () => { }), () => (ctx ? ctx.resolver.locale() : "en"), () => (ctx ? ctx.resolver.locale() : "en"));
|
|
148
|
+
const formality = useContext(FormalityContext);
|
|
149
|
+
const t = useCallback((key, params) => ctx === undefined ? key : translateWithFallbacks(ctx, locale, formality, key, params), [ctx, locale, formality]);
|
|
150
|
+
return ctx === undefined ? undefined : t;
|
|
151
|
+
}
|
|
152
|
+
/** Default-Resolver für Apps ohne eigene i18n-Schicht. Gibt jeden Key
|
|
153
|
+
* unverändert zurück — die Plugin-Fallback-Bundles erledigen dann die
|
|
154
|
+
* echte Übersetzung. Nützlich auch als Basis für Tests. */
|
|
155
|
+
export function createStaticLocaleResolver(options = {}) {
|
|
156
|
+
const locale = options.locale ?? "en";
|
|
157
|
+
const timeZone = options.timeZone ?? "UTC";
|
|
158
|
+
return {
|
|
159
|
+
translate: (key) => key,
|
|
160
|
+
locale: () => locale,
|
|
161
|
+
timeZone: () => timeZone,
|
|
162
|
+
// No-op subscribe: unsere Locale ist statisch, es gibt nie ein
|
|
163
|
+
// Change-Event. Unsubscribe ist ebenfalls no-op.
|
|
164
|
+
subscribe: () => () => { },
|
|
165
|
+
};
|
|
166
|
+
}
|