@cosmicdrift/kumiko-renderer 0.367.0 → 0.369.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.
@@ -93,8 +93,7 @@ export function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCo
93
93
  return _jsx(ReturnHostProvider, { value: ownHost, children: body });
94
94
  }
95
95
  function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, children, }) {
96
- const { Banner, PageHeader } = usePrimitives();
97
- const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
96
+ const { Banner } = usePrimitives();
98
97
  const result = useQuery(visibleWhen.query, {}, { live: true });
99
98
  const wasShown = useRef(false);
100
99
  const verdict = evalVisibleWhenAfterShown(visibleWhen, result, wasShown.current);
@@ -107,9 +106,19 @@ function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, c
107
106
  if (verdict === "loading") {
108
107
  return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
109
108
  }
110
- // The route still names the locked screen; its breadcrumb must not label what stands in for it.
111
- const hideLockedBreadcrumb = PageHeader !== undefined && pageHeaderSlotAvailable ? _jsx(PageHeader, { hideBreadcrumb: true }) : null;
112
- return (_jsxs(_Fragment, { children: [hideLockedBreadcrumb, fallback !== undefined ? (_jsx(FallbackScreen, { featureName: featureName, fallback: fallback, translate: translate })) : (_jsx(ScreenUnavailableBanner, {}))] }));
109
+ if (fallback !== undefined) {
110
+ return _jsx(FallbackScreen, { featureName: featureName, fallback: fallback, translate: translate });
111
+ }
112
+ return (_jsxs(_Fragment, { children: [_jsx(LockedScreenHeader, {}), _jsx(ScreenUnavailableBanner, {})] }));
113
+ }
114
+ // The route still names the locked screen; its breadcrumb must not label what stands in for it.
115
+ // With a stand-in the header is labelled with that screen instead, none if it has no title.
116
+ function LockedScreenHeader({ standInScreenId }) {
117
+ const { PageHeader } = usePrimitives();
118
+ const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
119
+ if (PageHeader === undefined || !pageHeaderSlotAvailable)
120
+ return null;
121
+ return standInScreenId !== undefined ? (_jsx(PageHeader, { hideBreadcrumb: true, breadcrumbScreenId: standInScreenId })) : (_jsx(PageHeader, { hideBreadcrumb: true }));
113
122
  }
114
123
  function ScreenMissingHomeButton({ homeScreenId, testId, }) {
115
124
  const { Button } = usePrimitives();
@@ -144,9 +153,10 @@ function FallbackScreen({ featureName, fallback, translate, }) {
144
153
  const target = useEmbeddedScreen(featureName, fallback);
145
154
  // Also undefined when the user lacks access to the fallback screen, which is
146
155
  // not a framework error.
147
- if (target === undefined)
148
- return _jsx(ScreenUnavailableBanner, {});
149
- return _jsx(KumikoScreen, { schema: target.schema, qn: target.qn, translate: translate });
156
+ if (target === undefined) {
157
+ return (_jsxs(_Fragment, { children: [_jsx(LockedScreenHeader, {}), _jsx(ScreenUnavailableBanner, {})] }));
158
+ }
159
+ return (_jsxs(_Fragment, { children: [_jsx(LockedScreenHeader, { standInScreenId: lastSegment(target.qn) }), _jsx(KumikoScreen, { schema: target.schema, qn: target.qn, translate: translate })] }));
150
160
  }
151
161
  function renderScreenBody({ schema, screen, translate, entityId, onRowClick, onCopyLink, }) {
152
162
  switch (screen.type) {
@@ -2645,6 +2655,13 @@ function buildConfigEditDefaults(fields) {
2645
2655
  }
2646
2656
  return defaults;
2647
2657
  }
2658
+ // A field hidden by its `visible` condition (a provider's settings while another
2659
+ // provider is picked) is not saved: the form saves what it shows.
2660
+ function hiddenLayoutFieldNames(screen, values) {
2661
+ return new Set(layoutEditFields(screen)
2662
+ .filter((spec) => spec.visible !== undefined && !evalFieldCondition(spec.visible, values))
2663
+ .map((spec) => spec.field));
2664
+ }
2648
2665
  function isEmptyNumericFormValue(value) {
2649
2666
  if (value === undefined || value === null || value === "")
2650
2667
  return true;
@@ -2660,6 +2677,8 @@ function ConfigEditBody({ schema, screen, translate, }) {
2660
2677
  // unsere Form-Field-Werte.
2661
2678
  const valuesQuery = useQuery("config:query:values", {});
2662
2679
  const cascadeQuery = useQuery("config:query:cascade", {});
2680
+ const secretKeys = screen.secretKeys;
2681
+ const secretsQuery = useQuery("secrets:query:list", {}, { enabled: secretKeys !== undefined });
2663
2682
  const synthEntity = useMemo(() => synthesizeConfigEditEntity(screen.fields), [screen.fields]);
2664
2683
  const synthScreen = useMemo(() => synthesizeConfigEditScreen(screen), [screen]);
2665
2684
  const boundsSchema = useMemo(() => buildConfigEditSchema(screen.fields), [screen.fields]);
@@ -2671,9 +2690,17 @@ function ConfigEditBody({ schema, screen, translate, }) {
2671
2690
  const initial = useMemo(() => {
2672
2691
  if (valuesQuery.data === null)
2673
2692
  return null;
2693
+ if (secretKeys !== undefined && secretsQuery.data === null)
2694
+ return null;
2695
+ const storedSecretKeys = new Set((secretsQuery.data ?? []).map((row) => row.key));
2674
2696
  const out = {};
2675
2697
  const defaults = buildConfigEditDefaults(screen.fields);
2676
2698
  for (const [shortName, fieldDef] of Object.entries(screen.fields)) {
2699
+ const secretKey = secretKeys?.[shortName];
2700
+ if (secretKey !== undefined) {
2701
+ out[shortName] = storedSecretKeys.has(secretKey) ? true : null;
2702
+ continue;
2703
+ }
2677
2704
  const qualified = screen.configKeys[shortName];
2678
2705
  if (qualified === undefined) {
2679
2706
  // Author hat ein Field deklariert ohne Mapping — nimm Default.
@@ -2709,7 +2736,14 @@ function ConfigEditBody({ schema, screen, translate, }) {
2709
2736
  }
2710
2737
  }
2711
2738
  return out;
2712
- }, [valuesQuery.data, screen.fields, screen.configKeys, screen.scope]);
2739
+ }, [
2740
+ valuesQuery.data,
2741
+ secretsQuery.data,
2742
+ secretKeys,
2743
+ screen.fields,
2744
+ screen.configKeys,
2745
+ screen.scope,
2746
+ ]);
2713
2747
  // Cascade-Lookup: qualifiedKey → ConfigCascade für die
2714
2748
  // Cascade-Anzeige unter jedem Feld. Defensive `levels`-Shape-Check
2715
2749
  // damit fremde Query-Mocks (z.B. configEdit-Unit-Tests die nur
@@ -2739,7 +2773,23 @@ function ConfigEditBody({ schema, screen, translate, }) {
2739
2773
  const customSubmit = useCallback(async (snapshot) => {
2740
2774
  const commands = [];
2741
2775
  const commandFieldNames = [];
2776
+ const hiddenFieldNames = hiddenLayoutFieldNames(synthScreen, snapshot.values);
2742
2777
  for (const [shortName, value] of Object.entries(snapshot.changes)) {
2778
+ if (hiddenFieldNames.has(shortName))
2779
+ continue;
2780
+ const secretKey = secretKeys?.[shortName];
2781
+ if (secretKey !== undefined) {
2782
+ // Same wire shape as a writeOnly config field: null removes, a string replaces.
2783
+ if (value === null) {
2784
+ commandFieldNames.push(shortName);
2785
+ commands.push({ type: "secrets:write:delete", payload: { key: secretKey } });
2786
+ }
2787
+ else if (typeof value === "string" && value.trim() !== "") {
2788
+ commandFieldNames.push(shortName);
2789
+ commands.push({ type: "secrets:write:set", payload: { key: secretKey, value } });
2790
+ }
2791
+ continue;
2792
+ }
2743
2793
  const qualified = screen.configKeys[shortName];
2744
2794
  if (qualified === undefined)
2745
2795
  continue;
@@ -2811,20 +2861,28 @@ function ConfigEditBody({ schema, screen, translate, }) {
2811
2861
  // bleibt die Anzeige stale bis Reload — gleiche refetch-Calls wie onReset.
2812
2862
  // allSettled: der Write ist schon committed, ein gescheitertes Refetch
2813
2863
  // (stale Anzeige) darf das Erfolgsergebnis nicht in einen Fehler kippen.
2814
- await Promise.allSettled([valuesQuery.refetch?.(), cascadeQuery.refetch?.()]);
2864
+ await Promise.allSettled([
2865
+ valuesQuery.refetch?.(),
2866
+ cascadeQuery.refetch?.(),
2867
+ ...(secretKeys !== undefined ? [secretsQuery.refetch?.()] : []),
2868
+ ]);
2815
2869
  return { validationBlocked: false, isSuccess: true, data: undefined };
2816
2870
  }, [
2817
2871
  dispatcher,
2872
+ synthScreen,
2873
+ secretKeys,
2818
2874
  screen.configKeys,
2819
2875
  screen.fields,
2820
2876
  screen.scope,
2821
2877
  valuesQuery.refetch,
2822
2878
  cascadeQuery.refetch,
2879
+ secretsQuery.refetch,
2823
2880
  ]);
2824
2881
  if (valuesQuery.loading && valuesQuery.data === null)
2825
2882
  return _jsx(QueryLoadingBanner, {});
2826
- if (valuesQuery.error) {
2827
- return (_jsx(QueryErrorBanner, { error: valuesQuery.error, translate: effectiveTranslate, onRetry: valuesQuery.refetch }));
2883
+ const failedQuery = valuesQuery.error ? valuesQuery : secretsQuery.error ? secretsQuery : null;
2884
+ if (failedQuery?.error) {
2885
+ return (_jsx(QueryErrorBanner, { error: failedQuery.error, translate: effectiveTranslate, onRetry: failedQuery.refetch }));
2828
2886
  }
2829
2887
  if (initial === null)
2830
2888
  return _jsx(QueryLoadingBanner, {});
@@ -1353,6 +1353,9 @@ export type PageHeaderProps = {
1353
1353
  /** Hide the shell header's breadcrumb while mounted, for a screen that stands in
1354
1354
  * for the one the route names (a locked screen's fallback). */
1355
1355
  readonly hideBreadcrumb?: boolean;
1356
+ /** Label the breadcrumb with this screen (id as in the route) instead of the one
1357
+ * the route names, for a locked screen's fallback. Wins over `hideBreadcrumb`. */
1358
+ readonly breadcrumbScreenId?: string;
1356
1359
  };
1357
1360
  /** One item in an `ActionOverflowMenu` (A7: header/row actions beyond the
1358
1361
  * primary one collapse into this menu instead of a wall of buttons). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.367.0",
3
+ "version": "0.369.0",
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,9 +15,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.367.0",
19
- "@cosmicdrift/kumiko-headless": "0.367.0",
20
- "@cosmicdrift/kumiko-types": "0.367.0",
18
+ "@cosmicdrift/kumiko-framework": "0.369.0",
19
+ "@cosmicdrift/kumiko-headless": "0.369.0",
20
+ "@cosmicdrift/kumiko-types": "0.369.0",
21
21
  "react": "^19.2.6",
22
22
  "temporal-polyfill": "^0.3.2",
23
23
  "zod": "^4.4.3"
@@ -28,7 +28,7 @@
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "jsdom": "^29.1.1",
30
30
  "react-dom": "^19.2.6",
31
- "@cosmicdrift/kumiko-locale-de": "0.367.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.369.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.368.0",
4
+ "type": "fix",
5
+ "title": "A screen that falls back via visibleWhen shows the fallback's title",
6
+ "detail": "When visibleWhen locks a screen and its fallback renders instead, the header no longer stays empty. It shows the title of the fallback screen (resolved like any other screen title, from its nav label), never the locked screen's. A fallback without a title still leaves the header empty. PageHeader takes a new breadcrumbScreenId prop for this."
7
+ },
2
8
  {
3
9
  "version": "0.367.0",
4
10
  "type": "fix",