@cosmicdrift/kumiko-bundled-features 0.174.1 → 0.176.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 +8 -10
- package/src/__tests__/boot-seed-contract.integration.test.ts +18 -14
- package/src/legal-pages/README.md +26 -25
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +93 -61
- package/src/legal-pages/feature.ts +40 -31
- package/src/legal-pages/web/__tests__/client-plugin.test.ts +2 -2
- package/src/legal-pages/web/client-plugin.ts +3 -3
- package/src/seo/__tests__/robots-txt.test.ts +21 -0
- package/src/seo/__tests__/seo.integration.test.ts +13 -13
- package/src/seo/robots-txt.ts +21 -1
- package/src/template-resolver/README.md +30 -1
- package/src/{text-content → template-resolver}/__tests__/seed-legal-content.integration.test.ts +32 -21
- package/src/template-resolver/__tests__/text-blocks.integration.test.ts +563 -0
- package/src/template-resolver/api.ts +11 -7
- package/src/template-resolver/constants.ts +22 -0
- package/src/template-resolver/feature.ts +16 -1
- package/src/template-resolver/handlers/by-slug.query.ts +54 -0
- package/src/template-resolver/handlers/by-tenant.query.ts +57 -0
- package/src/template-resolver/handlers/set.write.ts +110 -0
- package/src/template-resolver/handlers/shared.ts +13 -2
- package/src/template-resolver/handlers/upsert-system.write.ts +2 -0
- package/src/template-resolver/handlers/upsert-tenant.write.ts +2 -0
- package/src/template-resolver/index.ts +7 -0
- package/src/template-resolver/qualified-names.ts +3 -0
- package/src/template-resolver/seeding.ts +126 -2
- package/src/template-resolver/table.ts +11 -2
- package/src/{text-content → template-resolver}/web/__tests__/client-plugin.test.tsx +26 -19
- package/src/{text-content → template-resolver}/web/__tests__/editor-read-only.test.tsx +10 -10
- package/src/{text-content → template-resolver}/web/__tests__/group-blocks.test.ts +34 -34
- package/src/template-resolver/web/client-plugin.tsx +352 -0
- package/src/template-resolver/web/index.ts +8 -0
- package/src/text-content/README.md +0 -197
- package/src/text-content/__tests__/text-content.integration.test.ts +0 -503
- package/src/text-content/api.ts +0 -86
- package/src/text-content/changes.json +0 -14
- package/src/text-content/constants.ts +0 -21
- package/src/text-content/feature.ts +0 -53
- package/src/text-content/handlers/by-slug.query.ts +0 -55
- package/src/text-content/handlers/by-tenant.query.ts +0 -60
- package/src/text-content/handlers/set.write.ts +0 -141
- package/src/text-content/index.ts +0 -14
- package/src/text-content/seeding.ts +0 -139
- package/src/text-content/table.ts +0 -53
- package/src/text-content/web/client-plugin.tsx +0 -391
- package/src/text-content/web/index.ts +0 -8
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
-
// mock.module
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// mock.module replaces imports for all consumers — static imports
|
|
3
|
+
// before mock.module still see the mocked version because Bun
|
|
4
|
+
// intercepts at the loader level. useShellUser is a mock object here.
|
|
5
5
|
import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
|
|
6
6
|
import {
|
|
7
7
|
createStaticLocaleResolver,
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
12
12
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
13
13
|
import type { ReactNode } from "react";
|
|
14
|
-
import {
|
|
14
|
+
import { textBlocksClient } from "../client-plugin";
|
|
15
15
|
|
|
16
16
|
mock.module("@cosmicdrift/kumiko-bundled-features/auth-email-password/web", () => ({
|
|
17
17
|
useShellUser: mock(),
|
|
@@ -24,9 +24,9 @@ mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
|
24
24
|
useQuery: mock(() => ({
|
|
25
25
|
data: {
|
|
26
26
|
slug: "imprint",
|
|
27
|
-
|
|
27
|
+
locale: "de",
|
|
28
28
|
title: "Impressum",
|
|
29
|
-
|
|
29
|
+
content: "Inhalt",
|
|
30
30
|
folder: "legal",
|
|
31
31
|
},
|
|
32
32
|
loading: false,
|
|
@@ -38,14 +38,14 @@ mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
|
38
38
|
const { useDispatcher } = await import("@cosmicdrift/kumiko-renderer");
|
|
39
39
|
|
|
40
40
|
const TARGET = {
|
|
41
|
-
featureId: "
|
|
41
|
+
featureId: "template-resolver",
|
|
42
42
|
action: "edit",
|
|
43
|
-
args: { slug: "imprint",
|
|
43
|
+
args: { slug: "imprint", locale: "de" },
|
|
44
44
|
} as const;
|
|
45
45
|
|
|
46
46
|
function getEditor() {
|
|
47
|
-
const def =
|
|
48
|
-
const Editor = def.resolvers?.["
|
|
47
|
+
const def = textBlocksClient();
|
|
48
|
+
const Editor = def.resolvers?.["template-resolver:edit"];
|
|
49
49
|
if (!Editor) throw new Error("Editor not registered");
|
|
50
50
|
return Editor;
|
|
51
51
|
}
|
|
@@ -2,16 +2,16 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import type { TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { type BlockSummary, groupBlocksByFolder } from "../client-plugin";
|
|
4
4
|
|
|
5
|
-
// TreeNode.children
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// TreeNode.children is `readonly TreeNode[] | TreeChildrenSubscribe` —
|
|
6
|
+
// in the provider output, the subscribe form is only meant for
|
|
7
|
+
// deferred children; groupBlocksByFolder produces exclusively static
|
|
8
|
+
// array children. TypeGuard instead of an as-cast (memory `[Type Assertions]`).
|
|
9
9
|
function childrenArray(children: TreeNode["children"] | undefined): readonly TreeNode[] {
|
|
10
10
|
if (!Array.isArray(children)) throw new Error("expected static children-array");
|
|
11
11
|
return children;
|
|
12
12
|
}
|
|
13
|
-
// V.1.5d
|
|
14
|
-
//
|
|
13
|
+
// V.1.5d helper: groupBlocksByFolder now returns a single "Content" wrapper
|
|
14
|
+
// folder. Tests want to check the content UNDER the wrapper.
|
|
15
15
|
function inside(result: readonly TreeNode[]): readonly TreeNode[] {
|
|
16
16
|
expect(result).toHaveLength(1);
|
|
17
17
|
const wrapper = result[0];
|
|
@@ -19,19 +19,19 @@ function inside(result: readonly TreeNode[]): readonly TreeNode[] {
|
|
|
19
19
|
expect(wrapper?.icon).toBe("folder");
|
|
20
20
|
return childrenArray(wrapper?.children);
|
|
21
21
|
}
|
|
22
|
-
// Helper: BlockSummary
|
|
22
|
+
// Helper: BlockSummary with defaults for the fields not relevant to the test.
|
|
23
23
|
function block(opts: {
|
|
24
24
|
slug: string;
|
|
25
25
|
folder?: string | null;
|
|
26
|
-
|
|
26
|
+
content?: string | null;
|
|
27
27
|
title?: string;
|
|
28
28
|
}): BlockSummary {
|
|
29
29
|
return {
|
|
30
30
|
slug: opts.slug,
|
|
31
|
-
|
|
31
|
+
locale: "de",
|
|
32
32
|
title: opts.title ?? opts.slug,
|
|
33
|
-
//
|
|
34
|
-
|
|
33
|
+
// Not ?? — null should be passed through (state="stub" test).
|
|
34
|
+
content: opts.content === undefined ? "irgendwas" : opts.content,
|
|
35
35
|
folder: opts.folder === undefined ? null : opts.folder,
|
|
36
36
|
updatedAt: "2026-05-19T00:00:00Z",
|
|
37
37
|
};
|
|
@@ -48,9 +48,9 @@ describe("groupBlocksByFolder", () => {
|
|
|
48
48
|
if (!root) return;
|
|
49
49
|
expect(root.label).toBe("imprint");
|
|
50
50
|
expect(root.target).toEqual({
|
|
51
|
-
featureId: "
|
|
51
|
+
featureId: "template-resolver",
|
|
52
52
|
action: "edit",
|
|
53
|
-
args: { slug: "imprint",
|
|
53
|
+
args: { slug: "imprint", locale: "de" },
|
|
54
54
|
});
|
|
55
55
|
expect(root.children).toBeUndefined();
|
|
56
56
|
expect(root.icon).toBe("file");
|
|
@@ -71,7 +71,7 @@ describe("groupBlocksByFolder", () => {
|
|
|
71
71
|
expect(child).toBeDefined();
|
|
72
72
|
if (!child) return;
|
|
73
73
|
expect(child.label).toBe("Hero");
|
|
74
|
-
expect(child.target?.args).toEqual({ slug: "hero",
|
|
74
|
+
expect(child.target?.args).toEqual({ slug: "hero", locale: "de" });
|
|
75
75
|
});
|
|
76
76
|
test("mehrere Slugs gleicher Folder → ein Folder mit mehreren children", () => {
|
|
77
77
|
const nodes = inside(
|
|
@@ -116,12 +116,12 @@ describe("groupBlocksByFolder", () => {
|
|
|
116
116
|
const folderLabels = nodes.map((n) => n.label);
|
|
117
117
|
expect(folderLabels).toEqual(["apple", "mango", "zebra"]);
|
|
118
118
|
});
|
|
119
|
-
test('
|
|
120
|
-
const nodes = inside(groupBlocksByFolder([block({ slug: "draft",
|
|
119
|
+
test('content=null → state="stub" (Designer-Hinweis dass Slug existiert aber leer)', () => {
|
|
120
|
+
const nodes = inside(groupBlocksByFolder([block({ slug: "draft", content: null })]));
|
|
121
121
|
expect(nodes[0]?.state).toBe("stub");
|
|
122
122
|
});
|
|
123
|
-
test('
|
|
124
|
-
const nodes = inside(groupBlocksByFolder([block({ slug: "imprint",
|
|
123
|
+
test('content=string → state="filled"', () => {
|
|
124
|
+
const nodes = inside(groupBlocksByFolder([block({ slug: "imprint", content: "content" })]));
|
|
125
125
|
expect(nodes[0]?.state).toBe("filled");
|
|
126
126
|
});
|
|
127
127
|
test("title leer → fallback auf slug als label", () => {
|
|
@@ -144,11 +144,11 @@ describe("groupBlocksByFolder", () => {
|
|
|
144
144
|
const marketingChildren = childrenArray(marketingFolder?.children);
|
|
145
145
|
expect(marketingChildren).toHaveLength(1);
|
|
146
146
|
expect(marketingChildren[0]?.label).toBe("Hero");
|
|
147
|
-
expect(marketingChildren[0]?.target?.args).toEqual({ slug: "hero",
|
|
147
|
+
expect(marketingChildren[0]?.target?.args).toEqual({ slug: "hero", locale: "de" });
|
|
148
148
|
});
|
|
149
149
|
test("V.1.6a shared folder-prefix → ein gemeinsamer parent", () => {
|
|
150
|
-
//
|
|
151
|
-
// page/hero + page/cta + page/marketing/banner → 1× page
|
|
150
|
+
// Two blocks with a nested path share the first segments.
|
|
151
|
+
// page/hero + page/cta + page/marketing/banner → 1× page folder with
|
|
152
152
|
// 3 children (2 leaves + 1 sub-folder).
|
|
153
153
|
const nodes = inside(
|
|
154
154
|
groupBlocksByFolder([
|
|
@@ -161,7 +161,7 @@ describe("groupBlocksByFolder", () => {
|
|
|
161
161
|
const pageFolder = nodes[0];
|
|
162
162
|
expect(pageFolder?.label).toBe("page");
|
|
163
163
|
const pageChildren = childrenArray(pageFolder?.children);
|
|
164
|
-
// Leaves first (Hero, CTA),
|
|
164
|
+
// Leaves first (Hero, CTA), then sub-folder (marketing alphabetically).
|
|
165
165
|
expect(pageChildren.map((c) => c.label)).toEqual(["Hero", "CTA", "marketing"]);
|
|
166
166
|
expect(pageChildren[2]?.icon).toBe("folder");
|
|
167
167
|
const marketingChildren = childrenArray(pageChildren[2]?.children);
|
|
@@ -169,11 +169,11 @@ describe("groupBlocksByFolder", () => {
|
|
|
169
169
|
expect(marketingChildren[0]?.label).toBe("Banner");
|
|
170
170
|
});
|
|
171
171
|
test("V.1.6a folder/leaf-collision: gleicher Name auf gleicher Ebene", () => {
|
|
172
|
-
// Edge
|
|
173
|
-
// + block
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
172
|
+
// Edge case (advisor-flagged): block with folder=null, slug="page"
|
|
173
|
+
// + block with folder="page" → "page" exists both as a leaf-root AND
|
|
174
|
+
// as a folder. Both stay visible; the folder has a chevron + folder
|
|
175
|
+
// icon, the leaf has a target and is clickable. The renderer pattern
|
|
176
|
+
// makes it visually clear that these are two different things.
|
|
177
177
|
const nodes = inside(
|
|
178
178
|
groupBlocksByFolder([
|
|
179
179
|
block({ slug: "page", folder: null, title: "Page-Root" }),
|
|
@@ -197,27 +197,27 @@ describe("groupBlocksByFolder", () => {
|
|
|
197
197
|
"00000000-0000-4000-8000-000000000000",
|
|
198
198
|
),
|
|
199
199
|
);
|
|
200
|
-
// Root-
|
|
200
|
+
// Root-leaf carries the override.
|
|
201
201
|
expect(nodes[0]?.target?.args).toEqual({
|
|
202
202
|
slug: "imprint",
|
|
203
|
-
|
|
203
|
+
locale: "de",
|
|
204
204
|
tenantIdOverride: "00000000-0000-4000-8000-000000000000",
|
|
205
205
|
});
|
|
206
|
-
//
|
|
206
|
+
// The leaf inside the folder too (passed through recursively).
|
|
207
207
|
const folderChild = childrenArray(nodes[1]?.children)[0];
|
|
208
208
|
expect(folderChild?.target?.args).toEqual({
|
|
209
209
|
slug: "hero",
|
|
210
|
-
|
|
210
|
+
locale: "de",
|
|
211
211
|
tenantIdOverride: "00000000-0000-4000-8000-000000000000",
|
|
212
212
|
});
|
|
213
213
|
});
|
|
214
214
|
test("ohne tenantIdOverride bleiben die Args frei davon (Default: Session-Tenant)", () => {
|
|
215
215
|
const nodes = inside(groupBlocksByFolder([block({ slug: "imprint", folder: null })]));
|
|
216
|
-
expect(nodes[0]?.target?.args).toEqual({ slug: "imprint",
|
|
216
|
+
expect(nodes[0]?.target?.args).toEqual({ slug: "imprint", locale: "de" });
|
|
217
217
|
});
|
|
218
218
|
test("Wrapper-Folder 'Content' umschließt alle blocks", () => {
|
|
219
|
-
// V.1.5d
|
|
220
|
-
//
|
|
219
|
+
// V.1.5d wrapper convention: groupBlocksByFolder returns ONE node
|
|
220
|
+
// (the wrapper), content sits one level deeper.
|
|
221
221
|
const result = groupBlocksByFolder([block({ slug: "imprint" })]);
|
|
222
222
|
expect(result).toHaveLength(1);
|
|
223
223
|
const wrapper = result[0];
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Client feature factory for the text-block content tree. Apps mount it via
|
|
3
|
+
// createKumikoApp({ clientFeatures: [textBlocksClient()] }); it loads the
|
|
4
|
+
// blocks from the by-tenant query, groups them by their `folder` field and
|
|
5
|
+
// emits them as TreeNode[], plus the editor behind the edit action.
|
|
6
|
+
//
|
|
7
|
+
// **Folder grouping**: block.folder !== null puts the node under a container
|
|
8
|
+
// node labelled with the folder; folder === null keeps it at the root.
|
|
9
|
+
//
|
|
10
|
+
// **State**: TreeNode.state = "filled" when content is set, otherwise "stub"
|
|
11
|
+
// (a hint that the slug exists but is empty).
|
|
12
|
+
|
|
13
|
+
import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
|
|
14
|
+
import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
15
|
+
import type {
|
|
16
|
+
TargetRef,
|
|
17
|
+
TreeChildrenSubscribe,
|
|
18
|
+
TreeNode,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
+
import { useDispatcher, usePrimitives, useQuery } from "@cosmicdrift/kumiko-renderer";
|
|
21
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
22
|
+
import { type FormEvent, type ReactNode, useEffect, useState } from "react";
|
|
23
|
+
import { TemplateResolverHandlers, TemplateResolverQueries } from "../qualified-names";
|
|
24
|
+
|
|
25
|
+
// Exported for the unit test — groupBlocksByFolder is a pure function.
|
|
26
|
+
export type BlockSummary = {
|
|
27
|
+
readonly slug: string;
|
|
28
|
+
readonly locale: string;
|
|
29
|
+
readonly title: string | null;
|
|
30
|
+
readonly content: string | null;
|
|
31
|
+
readonly folder: string | null;
|
|
32
|
+
readonly updatedAt: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type ByTenantResponse = {
|
|
36
|
+
readonly data: { readonly blocks: readonly BlockSummary[] };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Multi-level folder splitting: `folder` is a `/`-separated path, the tree
|
|
40
|
+
// splits per segment and creates nested folder nodes:
|
|
41
|
+
// block.folder = null → root leaf
|
|
42
|
+
// block.folder = "page" → folder page > leaf
|
|
43
|
+
// block.folder = "page/marketing" → folder page > folder marketing > leaf
|
|
44
|
+
//
|
|
45
|
+
// **Folder/leaf collision**: a block with `folder=null, slug="page"` and
|
|
46
|
+
// another with `folder="page"` both live on the same level — the folder with
|
|
47
|
+
// children plus a clickable leaf. Visually distinct (chevron + folder icon),
|
|
48
|
+
// no crash, no surprise.
|
|
49
|
+
|
|
50
|
+
type FolderNode = {
|
|
51
|
+
readonly leaves: TreeNode[];
|
|
52
|
+
readonly subFolders: Map<string, FolderNode>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function newFolderNode(): FolderNode {
|
|
56
|
+
return { leaves: [], subFolders: new Map() };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function attachBlock(root: FolderNode, block: BlockSummary, tenantIdOverride?: string): void {
|
|
60
|
+
const leaf: TreeNode = {
|
|
61
|
+
label: block.title || block.slug,
|
|
62
|
+
icon: "file",
|
|
63
|
+
target: {
|
|
64
|
+
featureId: "template-resolver",
|
|
65
|
+
action: "edit",
|
|
66
|
+
// tenantIdOverride travels with the node → the editor's by-slug read +
|
|
67
|
+
// set write target the same tenant the tree was loaded from (SystemAdmin
|
|
68
|
+
// editing SYSTEM-tenant content). Omitted → session tenant.
|
|
69
|
+
args: {
|
|
70
|
+
slug: block.slug,
|
|
71
|
+
locale: block.locale,
|
|
72
|
+
...(tenantIdOverride !== undefined && { tenantIdOverride }),
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
state: block.content ? "filled" : "stub",
|
|
76
|
+
};
|
|
77
|
+
if (block.folder === null) {
|
|
78
|
+
root.leaves.push(leaf);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
let cur = root;
|
|
82
|
+
for (const segment of block.folder.split("/")) {
|
|
83
|
+
let next = cur.subFolders.get(segment);
|
|
84
|
+
if (next === undefined) {
|
|
85
|
+
next = newFolderNode();
|
|
86
|
+
cur.subFolders.set(segment, next);
|
|
87
|
+
}
|
|
88
|
+
cur = next;
|
|
89
|
+
}
|
|
90
|
+
cur.leaves.push(leaf);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function renderFolderNode(node: FolderNode): TreeNode[] {
|
|
94
|
+
// Leaves first (root items), then folders alphabetically.
|
|
95
|
+
const result: TreeNode[] = [...node.leaves];
|
|
96
|
+
for (const folderName of [...node.subFolders.keys()].sort()) {
|
|
97
|
+
const subNode = node.subFolders.get(folderName);
|
|
98
|
+
if (subNode === undefined) continue;
|
|
99
|
+
result.push({
|
|
100
|
+
label: folderName,
|
|
101
|
+
icon: "folder",
|
|
102
|
+
state: "filled",
|
|
103
|
+
children: renderFolderNode(subNode),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function groupBlocksByFolder(
|
|
110
|
+
blocks: readonly BlockSummary[],
|
|
111
|
+
tenantIdOverride?: string,
|
|
112
|
+
): readonly TreeNode[] {
|
|
113
|
+
const root = newFolderNode();
|
|
114
|
+
for (const block of blocks) {
|
|
115
|
+
attachBlock(root, block, tenantIdOverride);
|
|
116
|
+
}
|
|
117
|
+
const rendered = renderFolderNode(root);
|
|
118
|
+
if (rendered.length === 0) return [];
|
|
119
|
+
return [
|
|
120
|
+
{
|
|
121
|
+
label: "Content",
|
|
122
|
+
icon: "folder",
|
|
123
|
+
state: "filled",
|
|
124
|
+
children: rendered,
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// tenantIdOverride (SystemAdmin-only) lets an app point the content tree at a
|
|
130
|
+
// tenant other than the session's — publicstatus seeds marketing/legal blocks
|
|
131
|
+
// on SYSTEM_TENANT_ID, so its SystemAdmin must read that tenant, not their own.
|
|
132
|
+
function makeTreeProvider(tenantIdOverride?: string): TreeChildrenSubscribe {
|
|
133
|
+
return () => (emit, emitError) => {
|
|
134
|
+
// CSRF header is mandatory on authenticated requests (auth-middleware
|
|
135
|
+
// double-submit pattern). Anonymous/pre-login has no token → header
|
|
136
|
+
// omitted → the server takes the anonymous path.
|
|
137
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
138
|
+
const csrf = readCsrfToken();
|
|
139
|
+
if (csrf !== undefined) headers[CSRF_HEADER_NAME] = csrf;
|
|
140
|
+
fetch("/api/query", {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers,
|
|
143
|
+
body: JSON.stringify({
|
|
144
|
+
type: TemplateResolverQueries.byTenant,
|
|
145
|
+
payload: tenantIdOverride !== undefined ? { tenantIdOverride } : {},
|
|
146
|
+
}),
|
|
147
|
+
})
|
|
148
|
+
.then(async (r) => {
|
|
149
|
+
if (!r.ok) {
|
|
150
|
+
// 403 (CSRF/auth) or 5xx — explicit error instead of silent empty.
|
|
151
|
+
const text = await r.text().catch(() => r.statusText);
|
|
152
|
+
throw new Error(`text-block load failed: ${r.status} ${text}`);
|
|
153
|
+
}
|
|
154
|
+
return r.json();
|
|
155
|
+
})
|
|
156
|
+
.then((data: ByTenantResponse) => {
|
|
157
|
+
// The app-side r.nav node IS the "Content" container → the provider's
|
|
158
|
+
// children are the folders/leaves below it, not the wrapper.
|
|
159
|
+
const content = groupBlocksByFolder(data.data.blocks, tenantIdOverride)[0];
|
|
160
|
+
emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
|
|
161
|
+
})
|
|
162
|
+
.catch((e) => {
|
|
163
|
+
// Explicit error signal via emitError; ProviderBranch shows a banner
|
|
164
|
+
// plus retry. Falls back to emit([]) for consumers without emitError.
|
|
165
|
+
if (emitError) {
|
|
166
|
+
emitError(e instanceof Error ? e : new Error(String(e)));
|
|
167
|
+
} else {
|
|
168
|
+
emit([]);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
return () => {};
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Edit form: loads the current values via by-slug, lets TenantAdmin and
|
|
176
|
+
// SystemAdmin edit title + content, dispatches set on submit. Other users see
|
|
177
|
+
// the form read-only with a hint banner — write permission stays opinionated
|
|
178
|
+
// TenantAdmin-only, apps widen it via dual-role mapping if they want to.
|
|
179
|
+
|
|
180
|
+
type TextBlock = {
|
|
181
|
+
readonly slug: string;
|
|
182
|
+
readonly locale: string;
|
|
183
|
+
readonly title: string | null;
|
|
184
|
+
readonly content: string | null;
|
|
185
|
+
readonly folder: string | null;
|
|
186
|
+
readonly updatedAt: string;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
type SetResponse = { readonly slug: string; readonly locale: string; readonly isNew: boolean };
|
|
190
|
+
|
|
191
|
+
function TextBlockEditor({
|
|
192
|
+
target,
|
|
193
|
+
}: {
|
|
194
|
+
readonly target: TargetRef;
|
|
195
|
+
// onClose stays part of the resolver contract (the visual panel passes it)
|
|
196
|
+
// but is unused: tree selection navigates, there is no close button.
|
|
197
|
+
readonly onClose: () => void;
|
|
198
|
+
}): ReactNode {
|
|
199
|
+
// @cast-boundary visual-tree-args — TargetRef.args is erased to
|
|
200
|
+
// Record<string, unknown>; the resolver knows the action shape (see the
|
|
201
|
+
// treeActions.edit definition in feature.ts) and de-erases per action, the
|
|
202
|
+
// same way event payloads do. Optional chaining absorbs missing fields so a
|
|
203
|
+
// hand-edited URL cannot crash the editor.
|
|
204
|
+
const args = target.args as
|
|
205
|
+
| { slug?: string; locale?: string; tenantIdOverride?: string }
|
|
206
|
+
| undefined;
|
|
207
|
+
const slug = args?.slug ?? "";
|
|
208
|
+
const locale = args?.locale ?? "";
|
|
209
|
+
const tenantIdOverride = args?.tenantIdOverride;
|
|
210
|
+
|
|
211
|
+
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
212
|
+
const dispatcher = useDispatcher();
|
|
213
|
+
const user = useShellUser();
|
|
214
|
+
const canWrite =
|
|
215
|
+
user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
|
|
216
|
+
|
|
217
|
+
const {
|
|
218
|
+
data: loaded,
|
|
219
|
+
loading,
|
|
220
|
+
error: loadError,
|
|
221
|
+
} = useQuery<TextBlock | null>(
|
|
222
|
+
TemplateResolverQueries.bySlug,
|
|
223
|
+
{ slug, locale, ...(tenantIdOverride !== undefined && { tenantIdOverride }) },
|
|
224
|
+
{ enabled: slug !== "" && locale !== "" },
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const [title, setTitle] = useState("");
|
|
228
|
+
const [content, setContent] = useState("");
|
|
229
|
+
const [submitting, setSubmitting] = useState(false);
|
|
230
|
+
const [saveError, setSaveError] = useState<string | null>(null);
|
|
231
|
+
const [savedMsg, setSavedMsg] = useState<string | null>(null);
|
|
232
|
+
|
|
233
|
+
// Sync loaded data into the form state. loaded === null means the block does
|
|
234
|
+
// not exist yet — empty form for the create flow.
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (loading) return;
|
|
237
|
+
setTitle(loaded?.title ?? "");
|
|
238
|
+
setContent(loaded?.content ?? "");
|
|
239
|
+
setSaveError(null);
|
|
240
|
+
setSavedMsg(null);
|
|
241
|
+
}, [loading, loaded]);
|
|
242
|
+
|
|
243
|
+
const handleSave = async (): Promise<void> => {
|
|
244
|
+
setSubmitting(true);
|
|
245
|
+
setSaveError(null);
|
|
246
|
+
setSavedMsg(null);
|
|
247
|
+
try {
|
|
248
|
+
const result = await dispatcher.write<SetResponse>(TemplateResolverHandlers.set, {
|
|
249
|
+
slug,
|
|
250
|
+
locale,
|
|
251
|
+
title,
|
|
252
|
+
content: content.length > 0 ? content : null,
|
|
253
|
+
folder: loaded?.folder ?? null,
|
|
254
|
+
...(tenantIdOverride !== undefined && { tenantIdOverride }),
|
|
255
|
+
});
|
|
256
|
+
if (result.isSuccess) {
|
|
257
|
+
setSavedMsg(result.data.isNew ? "Neu angelegt." : "Gespeichert.");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
setSaveError(result.error.message ?? result.error.code ?? "Speichern fehlgeschlagen.");
|
|
261
|
+
} catch (e) {
|
|
262
|
+
// Network blip / dispatcher throw — otherwise submitting stays true and
|
|
263
|
+
// the save button is locked forever with no feedback.
|
|
264
|
+
setSaveError(e instanceof Error ? e.message : "Netzwerkfehler beim Speichern.");
|
|
265
|
+
} finally {
|
|
266
|
+
setSubmitting(false);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const onSubmit = (e?: FormEvent): void => {
|
|
271
|
+
e?.preventDefault();
|
|
272
|
+
void handleSave();
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const disabled = submitting || loading || !canWrite;
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<Form
|
|
279
|
+
onSubmit={onSubmit}
|
|
280
|
+
testId="text-block-editor"
|
|
281
|
+
title={title || slug || "—"}
|
|
282
|
+
subtitle={locale !== "" ? `${slug} · (${locale})` : slug}
|
|
283
|
+
actions={
|
|
284
|
+
canWrite ? (
|
|
285
|
+
<Button type="submit" loading={submitting} disabled={disabled}>
|
|
286
|
+
{submitting ? "Speichern…" : "Speichern"}
|
|
287
|
+
</Button>
|
|
288
|
+
) : undefined
|
|
289
|
+
}
|
|
290
|
+
>
|
|
291
|
+
{loading && <Banner variant="loading">Lädt aktuellen Stand…</Banner>}
|
|
292
|
+
{loadError !== null && (
|
|
293
|
+
<Banner variant="error">Konnte Block nicht laden: {loadError.code}</Banner>
|
|
294
|
+
)}
|
|
295
|
+
{!canWrite && !loading && (
|
|
296
|
+
<Banner variant="info">
|
|
297
|
+
Read-only — TenantAdmin- oder SystemAdmin-Rolle für Änderungen erforderlich.
|
|
298
|
+
</Banner>
|
|
299
|
+
)}
|
|
300
|
+
<Field id="text-block-title" label="Titel" required>
|
|
301
|
+
<Input
|
|
302
|
+
kind="text"
|
|
303
|
+
id="text-block-title"
|
|
304
|
+
name="text-block-title"
|
|
305
|
+
value={title}
|
|
306
|
+
onChange={setTitle}
|
|
307
|
+
disabled={disabled}
|
|
308
|
+
required
|
|
309
|
+
/>
|
|
310
|
+
</Field>
|
|
311
|
+
<Field id="text-block-content" label="Inhalt">
|
|
312
|
+
<Input
|
|
313
|
+
kind="textarea"
|
|
314
|
+
id="text-block-content"
|
|
315
|
+
name="text-block-content"
|
|
316
|
+
value={content}
|
|
317
|
+
onChange={setContent}
|
|
318
|
+
disabled={disabled}
|
|
319
|
+
rows={14}
|
|
320
|
+
/>
|
|
321
|
+
</Field>
|
|
322
|
+
{saveError !== null && <Banner variant="error">{saveError}</Banner>}
|
|
323
|
+
{savedMsg !== null && <Banner variant="info">{savedMsg}</Banner>}
|
|
324
|
+
</Form>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// `navId` = the QN of the r.nav({ provider: true }) node the app registers for
|
|
329
|
+
// the content tree (e.g. "publicstatus:nav:content"). The app owns the node's
|
|
330
|
+
// label/icon/access, the feature only supplies children plus the editor.
|
|
331
|
+
// Without navId: resolver only, no sidebar node (server-only consumers such as
|
|
332
|
+
// money-horse leak nothing).
|
|
333
|
+
// `tenantId` (SystemAdmin-only): which tenant the tree and editor serve —
|
|
334
|
+
// SYSTEM_TENANT_ID for apps that seed marketing/legal there. Omit for the
|
|
335
|
+
// session tenant (default, no cross-tenant access).
|
|
336
|
+
export function textBlocksClient(opts?: {
|
|
337
|
+
readonly navId?: string;
|
|
338
|
+
readonly tenantId?: string;
|
|
339
|
+
}): ClientFeatureDefinition {
|
|
340
|
+
const navId = opts?.navId;
|
|
341
|
+
return {
|
|
342
|
+
name: "template-resolver",
|
|
343
|
+
...(navId !== undefined && {
|
|
344
|
+
navProviders: { [navId]: makeTreeProvider(opts?.tenantId) },
|
|
345
|
+
// SSE refresh: every template-resource event re-fires the provider.
|
|
346
|
+
navEntities: { [navId]: ["template-resource"] },
|
|
347
|
+
}),
|
|
348
|
+
resolvers: {
|
|
349
|
+
"template-resolver:edit": TextBlockEditor,
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Public exports for the browser side of template-resolver. Consumed through
|
|
3
|
+
// the sub-path export `@cosmicdrift/kumiko-bundled-features/template-resolver/web`
|
|
4
|
+
// — the server side (defineFeature) lives in
|
|
5
|
+
// `@cosmicdrift/kumiko-bundled-features/template-resolver` and has no React or
|
|
6
|
+
// DOM deps.
|
|
7
|
+
|
|
8
|
+
export { textBlocksClient } from "./client-plugin";
|