@cosmicdrift/kumiko-renderer 0.155.0 → 0.156.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",
3
- "version": "0.155.0",
3
+ "version": "0.156.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,10 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.155.0",
19
- "@cosmicdrift/kumiko-headless": "0.155.0",
20
- "react": "^19.2.6"
18
+ "@cosmicdrift/kumiko-framework": "0.156.0",
19
+ "@cosmicdrift/kumiko-headless": "0.156.0",
20
+ "react": "^19.2.6",
21
+ "temporal-polyfill": "^0.3.2"
21
22
  },
22
23
  "devDependencies": {
23
24
  "@testing-library/react": "^16.3.2",
@@ -87,6 +87,12 @@ export type KumikoScreenProps = {
87
87
  // createKumikoApp does exactly that; override to open a drawer,
88
88
  // inline-expand, etc.
89
89
  readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
90
+ // Copy-Link-Action auf entityEdit-Update-Screens (Issue #912). Bereits
91
+ // vollständig gebunden (URL-Bau + Clipboard) — createKumikoApp/RoutedScreen
92
+ // liefert die Default-Impl, dieses platform-neutrale Package baut selbst
93
+ // keine URLs/Clipboard-Calls (guard-renderer-boundaries). Nur in
94
+ // update-mode sichtbar (entityId muss gesetzt sein).
95
+ readonly onCopyLink?: () => Promise<void> | void;
90
96
  };
91
97
 
92
98
  // Build the qualified name the registry would stamp on screen ingest:
@@ -112,6 +118,7 @@ export function KumikoScreen({
112
118
  translate,
113
119
  entityId,
114
120
  onRowClick,
121
+ onCopyLink,
115
122
  }: KumikoScreenProps): ReactNode {
116
123
  const { Banner, Text } = usePrimitives();
117
124
  const screen = useMemo(
@@ -138,6 +145,7 @@ export function KumikoScreen({
138
145
  screen={screen}
139
146
  translate={translate}
140
147
  {...(entityId !== undefined && { entityId })}
148
+ {...(onCopyLink !== undefined && { onCopyLink })}
141
149
  />
142
150
  );
143
151
  case "entityList":
@@ -306,11 +314,13 @@ function EntityEditScreen({
306
314
  screen,
307
315
  translate,
308
316
  entityId,
317
+ onCopyLink,
309
318
  }: {
310
319
  readonly schema: FeatureSchema;
311
320
  readonly screen: EntityEditScreenDefinition;
312
321
  readonly translate?: Translate;
313
322
  readonly entityId?: string;
323
+ readonly onCopyLink?: () => Promise<void> | void;
314
324
  }): ReactNode {
315
325
  const { Banner, Text } = usePrimitives();
316
326
  const entity = schema.entities[screen.entity];
@@ -333,6 +343,7 @@ function EntityEditScreen({
333
343
  entity={entity}
334
344
  entityId={entityId}
335
345
  {...(translate !== undefined && { translate })}
346
+ {...(onCopyLink !== undefined && { onCopyLink })}
336
347
  />
337
348
  );
338
349
  }
@@ -402,12 +413,14 @@ function EntityEditUpdateBody({
402
413
  entity,
403
414
  entityId,
404
415
  translate,
416
+ onCopyLink,
405
417
  }: {
406
418
  readonly schema: FeatureSchema;
407
419
  readonly screen: EntityEditScreenDefinition;
408
420
  readonly entity: EntityDefinition;
409
421
  readonly entityId: string;
410
422
  readonly translate?: Translate;
423
+ readonly onCopyLink?: () => Promise<void> | void;
411
424
  }): ReactNode {
412
425
  const { Banner, Text } = usePrimitives();
413
426
  const detailQn = `${toKebab(schema.featureName)}:query:${toKebab(screen.entity)}:detail`;
@@ -452,6 +465,7 @@ function EntityEditUpdateBody({
452
465
  record={record}
453
466
  onReload={detailQuery.refetch}
454
467
  {...(translate !== undefined && { translate })}
468
+ {...(onCopyLink !== undefined && { onCopyLink })}
455
469
  />
456
470
  );
457
471
  }
@@ -464,6 +478,7 @@ function EntityEditUpdateForm({
464
478
  record,
465
479
  onReload,
466
480
  translate,
481
+ onCopyLink,
467
482
  }: {
468
483
  readonly schema: FeatureSchema;
469
484
  readonly screen: EntityEditScreenDefinition;
@@ -472,6 +487,7 @@ function EntityEditUpdateForm({
472
487
  readonly record: Readonly<Record<string, unknown>>;
473
488
  readonly onReload: () => Promise<void> | void;
474
489
  readonly translate?: Translate;
490
+ readonly onCopyLink?: () => Promise<void> | void;
475
491
  }): ReactNode {
476
492
  // Seed the form with the server values for the entity's declared
477
493
  // fields; anything else (id, tenant_id, created_at…) stays out of
@@ -547,6 +563,7 @@ function EntityEditUpdateForm({
547
563
  onReload={() => void onReload()}
548
564
  {...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
549
565
  {...(translate !== undefined && { translate })}
566
+ {...(onCopyLink !== undefined && { onCopyLink })}
550
567
  />
551
568
  );
552
569
  }
@@ -26,9 +26,11 @@ import type {
26
26
  EntityEditScreenDefinition,
27
27
  ProjectionDetailScreenDefinition,
28
28
  } from "@cosmicdrift/kumiko-framework/ui-types";
29
- import { isExtensionEditSection, normalizeEditField } from "@cosmicdrift/kumiko-framework/ui-types";
30
-
31
- const PROJECTION_DETAIL_PSEUDO_ENTITY = "__projection-detail__";
29
+ import {
30
+ isExtensionEditSection,
31
+ normalizeEditField,
32
+ PROJECTION_DETAIL_ENTITY as PROJECTION_DETAIL_PSEUDO_ENTITY,
33
+ } from "@cosmicdrift/kumiko-framework/ui-types";
32
34
 
33
35
  /** Minimale EntityDefinition aus den Layout-Feldern: jedes Feld ein Text-
34
36
  * Feld — computeEditViewModel liest nur `fields[<f>].type`, Text reicht für
@@ -76,6 +76,13 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
76
76
  readonly onDelete?: () => Promise<void> | void;
77
77
  readonly onCancel?: () => void;
78
78
  readonly onReload?: () => void;
79
+ /** Copy-Link-Action (Issue #912) — nur in update-mode gesetzt (create-mode
80
+ * hat noch keine entity-id, also keinen Permalink). Der Callback ist
81
+ * bereits vollständig gebunden (URL-Bau + Clipboard passiert außerhalb,
82
+ * in `@cosmicdrift/kumiko-renderer-web`'s RoutedScreen — dieses
83
+ * platform-neutrale Package darf kein `navigator`/`window` anfassen,
84
+ * siehe guard-renderer-boundaries). undefined = kein Button. */
85
+ readonly onCopyLink?: () => Promise<void> | void;
79
86
  /** i18n-key für den Submit-Button. Default: "kumiko.actions.save".
80
87
  * Action-Forms (Tier 2.7d) übergeben hier ihren screen.submitLabel,
81
88
  * damit "Speichern" durch domain-spezifischere Strings ersetzt
@@ -185,6 +192,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
185
192
  onDelete,
186
193
  onCancel,
187
194
  onReload,
195
+ onCopyLink,
188
196
  submitLabel,
189
197
  labelAppendix,
190
198
  fieldAppendix,
@@ -201,6 +209,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
201
209
  const translate = translateProp ?? t;
202
210
 
203
211
  const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
212
+ const [linkCopied, setLinkCopied] = useState(false);
204
213
  const [isSubmitting, setIsSubmitting] = useState(false);
205
214
  const [formError, setFormError] = useState<DispatcherError | null>(null);
206
215
  // Composed-Save: Extension-Sections melden hier ihren dirty-State (damit der
@@ -336,6 +345,19 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
336
345
  // gegen Fehlklicks. Save bleibt rechts (primary affordance).
337
346
  const formActions = (
338
347
  <>
348
+ {onCopyLink !== undefined && (
349
+ <Button
350
+ type="button"
351
+ variant="secondary"
352
+ testId="render-edit-copy-link"
353
+ onClick={async () => {
354
+ await onCopyLink();
355
+ setLinkCopied(true);
356
+ }}
357
+ >
358
+ {translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink")}
359
+ </Button>
360
+ )}
339
361
  {onDelete !== undefined && (
340
362
  <Button
341
363
  type="button"
@@ -1,6 +1,11 @@
1
+ import { Temporal } from "temporal-polyfill";
2
+
1
3
  // Shared timestamp formatter for operator screens (audit log, job runs) —
2
4
  // falls back to the raw ISO string on an unparseable value instead of "Invalid Date".
3
5
  export function formatWhen(value: string): string {
4
- const d = new Date(value);
5
- return Number.isNaN(d.getTime()) ? value : d.toLocaleString();
6
+ try {
7
+ return Temporal.Instant.from(value).toLocaleString();
8
+ } catch {
9
+ return value;
10
+ }
6
11
  }
@@ -149,6 +149,7 @@ export function useCompletion(debounceMs: number = DEFAULT_DEBOUNCE_MS): UseComp
149
149
  clearTimer();
150
150
  if (text.length === 0) {
151
151
  reset();
152
+ // skip: empty text, state already reset above
152
153
  return;
153
154
  }
154
155
  timerRef.current = setTimeout(() => {
@@ -87,14 +87,14 @@ export function useQuery<TData = unknown>(
87
87
 
88
88
  setLoading(true);
89
89
  const result = await dispatcher.query<TData>(type, payload, { signal: ctrl.signal });
90
- // Don't update state if a newer fetch has already taken over.
90
+ // skip: a newer fetch already superseded this one, don't clobber its state
91
91
  if (ctrl.signal.aborted) return;
92
92
  if (result.isSuccess) {
93
93
  setData(result.data);
94
94
  setError(null);
95
95
  } else {
96
96
  // A cancelled request comes back with code "aborted" from the
97
- // dispatcher — skip the state update, another run replaces it.
97
+ // skip: aborted request, a newer run already replaces this result
98
98
  if (result.error.code === "aborted") return;
99
99
  setError(result.error);
100
100
  }
@@ -104,6 +104,7 @@ export function useQuery<TData = unknown>(
104
104
  useEffect(() => {
105
105
  if (!enabled) {
106
106
  setLoading(false);
107
+ // skip: query disabled, loading state already cleared above
107
108
  return;
108
109
  }
109
110
  void run();
@@ -117,8 +118,10 @@ export function useQuery<TData = unknown>(
117
118
  // Subscription-Lifecycle genau einmal durchwalzt.
118
119
  const subscribeLive = useLiveEvents();
119
120
  useEffect(() => {
121
+ // skip: live mode or query disabled, no SSE subscription needed
120
122
  if (!live || !enabled) return;
121
123
  const entity = entityFromQueryType(type);
124
+ // skip: query type has no mapped entity, nothing to subscribe to
122
125
  if (entity === undefined) return;
123
126
  return subscribeLive(entity, () => {
124
127
  void run();
@@ -20,6 +20,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
20
20
  "kumiko.actions.reload": "Neu laden",
21
21
  "kumiko.actions.create": "Neu",
22
22
  "kumiko.actions.edit": "Bearbeiten",
23
+ "kumiko.actions.copyLink": "Link kopieren",
24
+ "kumiko.actions.copyLinkCopied": "Kopiert!",
23
25
 
24
26
  // Version — Update-Awareness-Banner (UpdateChecker).
25
27
  "kumiko.version.update-available": "Eine neue Version ist verfügbar.",
@@ -155,6 +157,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
155
157
  "kumiko.actions.reload": "Reload",
156
158
  "kumiko.actions.create": "New",
157
159
  "kumiko.actions.edit": "Edit",
160
+ "kumiko.actions.copyLink": "Copy link",
161
+ "kumiko.actions.copyLinkCopied": "Copied!",
158
162
 
159
163
  "kumiko.version.update-available": "A new version is available.",
160
164