@cosmicdrift/kumiko-renderer 0.194.0 → 0.195.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 +3 -3
- package/src/app/__tests__/content-editors.test.tsx +16 -2
- package/src/app/content-editors.tsx +12 -5
- package/src/app/content-preview.tsx +3 -1
- package/src/app/kumiko-screen.tsx +6 -1
- package/src/components/reference-create-dialog.tsx +1 -1
- package/src/components/render-edit.tsx +25 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.195.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,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.195.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.195.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -58,7 +58,15 @@ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
|
58
58
|
|
|
59
59
|
function Probe({ contentFormat }: { readonly contentFormat?: string }): ReactNode {
|
|
60
60
|
const Editor = useContentEditor(contentFormat);
|
|
61
|
-
return
|
|
61
|
+
return (
|
|
62
|
+
<Editor
|
|
63
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
64
|
+
value="hello"
|
|
65
|
+
onChange={() => {}}
|
|
66
|
+
variables={[]}
|
|
67
|
+
readOnly={false}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
describe("useContentEditor", () => {
|
|
@@ -102,7 +110,13 @@ describe("TextareaContentEditor", () => {
|
|
|
102
110
|
// non-admin viewing read-only content couldn't select/copy it. readOnly
|
|
103
111
|
// keeps the field focusable and selectable while blocking edits.
|
|
104
112
|
render(
|
|
105
|
-
<TextareaContentEditor
|
|
113
|
+
<TextareaContentEditor
|
|
114
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
115
|
+
value="draft"
|
|
116
|
+
onChange={() => {}}
|
|
117
|
+
variables={[]}
|
|
118
|
+
readOnly={true}
|
|
119
|
+
/>,
|
|
106
120
|
{ wrapper: Wrapper },
|
|
107
121
|
);
|
|
108
122
|
const el = screen.getByTestId("ca-textarea") as HTMLTextAreaElement;
|
|
@@ -12,6 +12,12 @@ import { type ComponentType, createContext, type ReactNode, useContext } from "r
|
|
|
12
12
|
import { usePrimitives } from "../primitives";
|
|
13
13
|
|
|
14
14
|
export type ContentEditorProps = {
|
|
15
|
+
/** DOM id every registered editor must render onto its own focusable
|
|
16
|
+
* root element. Callers that wrap an editor in a `Field` (label +
|
|
17
|
+
* htmlFor) pass a stable id here so the label stays associated with
|
|
18
|
+
* whatever element actually renders — a fixed constant would break as
|
|
19
|
+
* soon as a registered editor swaps in for the textarea fallback. */
|
|
20
|
+
readonly id: string;
|
|
15
21
|
readonly value: string;
|
|
16
22
|
readonly onChange: (value: string) => void;
|
|
17
23
|
/** Variable names insertable as chips (from the collection's
|
|
@@ -24,10 +30,10 @@ export type ContentEditorProps = {
|
|
|
24
30
|
|
|
25
31
|
export type ContentEditorComponent = ComponentType<ContentEditorProps>;
|
|
26
32
|
|
|
27
|
-
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
33
|
+
/** Default id value for callers that don't generate their own (e.g. a
|
|
34
|
+
* standalone TextareaContentEditor render outside a multi-editor page).
|
|
35
|
+
* Registered editors don't apply this automatically — every caller passes
|
|
36
|
+
* its own id via ContentEditorProps.id. */
|
|
31
37
|
export const CONTENT_EDITOR_ELEMENT_ID = "content-editor-textarea";
|
|
32
38
|
|
|
33
39
|
export type ContentEditorsMap = Readonly<Record<string, ContentEditorComponent>>;
|
|
@@ -50,6 +56,7 @@ export function ContentEditorsProvider({
|
|
|
50
56
|
* Uses the primitives Input like every other form field, so it renders on
|
|
51
57
|
* every platform without requiring the platform's DOM/native equivalent. */
|
|
52
58
|
export function TextareaContentEditor({
|
|
59
|
+
id,
|
|
53
60
|
value,
|
|
54
61
|
onChange,
|
|
55
62
|
readOnly,
|
|
@@ -58,7 +65,7 @@ export function TextareaContentEditor({
|
|
|
58
65
|
return (
|
|
59
66
|
<Input
|
|
60
67
|
kind="textarea"
|
|
61
|
-
id={
|
|
68
|
+
id={id}
|
|
62
69
|
name={CONTENT_EDITOR_ELEMENT_ID}
|
|
63
70
|
value={value}
|
|
64
71
|
onChange={onChange}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// renders formatted HTML and "plain"/"markdown" render as text through the
|
|
5
5
|
// exact same component, no separate render path per format.
|
|
6
6
|
|
|
7
|
-
import type
|
|
7
|
+
import { type ReactNode, useId } from "react";
|
|
8
8
|
import { useContentEditor } from "./content-editors";
|
|
9
9
|
|
|
10
10
|
const VARIABLE_PATTERN = /\{\{\s*(\w+)\s*\}\}/g;
|
|
@@ -36,6 +36,7 @@ export function ContentPreview({
|
|
|
36
36
|
contentFormat,
|
|
37
37
|
}: ContentPreviewProps): ReactNode {
|
|
38
38
|
const Editor = useContentEditor(contentFormat);
|
|
39
|
+
const id = useId();
|
|
39
40
|
// "rich" content is HTML (see ContentCollectionDefinition.contentFormat) —
|
|
40
41
|
// an example value substituted in raw could break the markup (`<`) or
|
|
41
42
|
// render as an unescaped entity (`&`). "plain"/"markdown" content is text,
|
|
@@ -48,6 +49,7 @@ export function ContentPreview({
|
|
|
48
49
|
: variables;
|
|
49
50
|
return (
|
|
50
51
|
<Editor
|
|
52
|
+
id={id}
|
|
51
53
|
value={substituteVariables(content, safeVariables)}
|
|
52
54
|
onChange={noop}
|
|
53
55
|
variables={[]}
|
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
} from "@cosmicdrift/kumiko-headless";
|
|
27
27
|
import { fieldLabelKey } from "@cosmicdrift/kumiko-headless";
|
|
28
28
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
29
|
+
import { extractCreatedId } from "../components/reference-create-dialog";
|
|
29
30
|
import { RenderEdit } from "../components/render-edit";
|
|
30
31
|
import { RenderList, type ToolbarActionButton } from "../components/render-list";
|
|
31
32
|
import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
|
|
@@ -461,7 +462,11 @@ function EntityEditCreateBody({
|
|
|
461
462
|
(result: SubmitResult<unknown>) => {
|
|
462
463
|
if (!result.isSuccess) return;
|
|
463
464
|
if (screen.redirect !== undefined) {
|
|
464
|
-
|
|
465
|
+
const entityId = extractCreatedId(result.data);
|
|
466
|
+
nav.navigate({
|
|
467
|
+
screenId: lastSegment(screen.redirect),
|
|
468
|
+
...(entityId !== undefined && { entityId }),
|
|
469
|
+
});
|
|
465
470
|
return;
|
|
466
471
|
}
|
|
467
472
|
navigateToList();
|
|
@@ -23,7 +23,7 @@ function entityWriteCommand(featureName: string, entity: string): string {
|
|
|
23
23
|
// The create handler's success payload is `{ kind: "save", id, ... }`
|
|
24
24
|
// (see event-store-executor-write.ts) but RenderEdit's onSubmit only
|
|
25
25
|
// types it as `unknown` — narrow defensively instead of casting through it.
|
|
26
|
-
function extractCreatedId(data: unknown): string | undefined {
|
|
26
|
+
export function extractCreatedId(data: unknown): string | undefined {
|
|
27
27
|
if (typeof data !== "object" || data === null) return undefined;
|
|
28
28
|
const id = (data as Record<string, unknown>)["id"];
|
|
29
29
|
return typeof id === "string" ? id : undefined;
|
|
@@ -25,7 +25,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro
|
|
|
25
25
|
import type { z } from "zod";
|
|
26
26
|
import { ExtensionFormRegistryProvider, useExtensionFormHost } from "../app/extension-form-submit";
|
|
27
27
|
import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections";
|
|
28
|
-
import {
|
|
28
|
+
import { useOptionalDispatcher } from "../context/dispatcher-context";
|
|
29
29
|
import { useDraftStorage } from "../context/draft-storage-context";
|
|
30
30
|
import { formatWhen } from "../format-when";
|
|
31
31
|
import { useForm } from "../hooks/use-form";
|
|
@@ -80,6 +80,14 @@ function newDraftPrefix(screenId: string): string {
|
|
|
80
80
|
return `${screenId}:new:`;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// `crypto.randomUUID` is missing in non-secure contexts and React Native/Hermes
|
|
84
|
+
// without a polyfill — guard like dispatcher-live.ts's generateRequestId.
|
|
85
|
+
function mintDraftId(): string {
|
|
86
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
87
|
+
if (typeof c?.randomUUID === "function") return c.randomUUID();
|
|
88
|
+
return `draft-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
83
91
|
// End-to-end renderer für einen entityEdit screen. Rendert aus-
|
|
84
92
|
// schließlich über Primitives — kein raw HTML. Ein Native-Renderer
|
|
85
93
|
// der dieselbe Primitives-Registry füllt kriegt das Form ohne weitere
|
|
@@ -343,10 +351,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
343
351
|
// ohnehin nur in einem mounted Kumiko-App-Tree läuft.
|
|
344
352
|
const t = useTranslation();
|
|
345
353
|
const translate = translateProp ?? t;
|
|
346
|
-
const dispatcher =
|
|
354
|
+
const dispatcher = useOptionalDispatcher();
|
|
347
355
|
|
|
348
356
|
const isWizard = screen.layout.mode === "wizard";
|
|
349
|
-
const draftEnabled = isWizard && screen.layout.draft === true;
|
|
357
|
+
const draftEnabled = isWizard && screen.layout.draft === true && dispatcher !== undefined;
|
|
350
358
|
const isCreateMode = entityIdProp === undefined || entityIdProp === null || entityIdProp === "";
|
|
351
359
|
const draftStorage = useDraftStorage();
|
|
352
360
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
|
@@ -502,14 +510,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
502
510
|
const patchAndScheduleDraftSave = useCallback(
|
|
503
511
|
(partial: Partial<TValues>) => {
|
|
504
512
|
controller.setValues(partial);
|
|
505
|
-
if (!draftEnabled) return;
|
|
513
|
+
if (!draftEnabled || dispatcher === undefined) return;
|
|
506
514
|
if (draftSaveTimerRef.current !== null) clearTimeout(draftSaveTimerRef.current);
|
|
507
515
|
draftSaveTimerRef.current = setTimeout(() => {
|
|
508
516
|
draftSaveTimerRef.current = null;
|
|
509
517
|
saveDraftRef.current(currentStepRef.current);
|
|
510
518
|
}, PATCH_DRAFT_SAVE_DEBOUNCE_MS);
|
|
511
519
|
},
|
|
512
|
-
[controller, draftEnabled],
|
|
520
|
+
[controller, draftEnabled, dispatcher],
|
|
513
521
|
);
|
|
514
522
|
|
|
515
523
|
// Runs before the onChange effect below (declaration order = React
|
|
@@ -556,7 +564,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
556
564
|
|
|
557
565
|
useEffect(() => {
|
|
558
566
|
// skip: this screen does not persist a draft.
|
|
559
|
-
if (!draftEnabled) return;
|
|
567
|
+
if (!draftEnabled || dispatcher === undefined) return;
|
|
560
568
|
// skip: create-mode with no draftId yet — nothing to restore, either
|
|
561
569
|
// the list-fallback effect below finds one or the first step change
|
|
562
570
|
// mints a fresh one.
|
|
@@ -603,7 +611,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
603
611
|
// skip: this screen does not persist a draft, this is edit-mode, a
|
|
604
612
|
// draftId is already known (from storage or an earlier adoption), or
|
|
605
613
|
// this mount already ran the list lookup once (didListRef).
|
|
606
|
-
if (
|
|
614
|
+
if (
|
|
615
|
+
!draftEnabled ||
|
|
616
|
+
!isCreateMode ||
|
|
617
|
+
draftId !== null ||
|
|
618
|
+
didListRef.current ||
|
|
619
|
+
dispatcher === undefined
|
|
620
|
+
)
|
|
621
|
+
return;
|
|
607
622
|
didListRef.current = true;
|
|
608
623
|
let cancelled = false;
|
|
609
624
|
void (async () => {
|
|
@@ -696,10 +711,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
696
711
|
// computed inline instead of read from the memoized `draftKey`.
|
|
697
712
|
function saveDraft(stepIndex: number): void {
|
|
698
713
|
// skip: this screen does not persist a draft.
|
|
699
|
-
if (!draftEnabled) return;
|
|
714
|
+
if (!draftEnabled || dispatcher === undefined) return;
|
|
700
715
|
let key = draftKey;
|
|
701
716
|
if (isCreateMode && draftId === null) {
|
|
702
|
-
const mintedId =
|
|
717
|
+
const mintedId = mintDraftId();
|
|
703
718
|
mintedDraftIdRef.current = mintedId;
|
|
704
719
|
draftStorage.setDraftId(screen.id, mintedId);
|
|
705
720
|
setDraftId(mintedId);
|
|
@@ -761,7 +776,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
761
776
|
|
|
762
777
|
async function discardDraft(): Promise<void> {
|
|
763
778
|
// skip: this screen does not persist a draft.
|
|
764
|
-
if (!draftEnabled) return;
|
|
779
|
+
if (!draftEnabled || dispatcher === undefined) return;
|
|
765
780
|
// A pending debounced patch-save must not fire after discard — it would
|
|
766
781
|
// resurrect the draft it just deleted.
|
|
767
782
|
if (draftSaveTimerRef.current !== null) {
|