@cosmicdrift/kumiko-bundled-features 0.3.0 → 0.4.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/CHANGELOG.md +49 -0
- package/package.json +7 -5
- package/src/delivery/__tests__/delivery.integration.ts +6 -0
- package/src/delivery/delivery-service.ts +4 -12
- package/src/delivery/feature.ts +6 -4
- package/src/delivery/index.ts +0 -1
- package/src/legal-pages/web/client-plugin.ts +50 -10
- package/src/renderer-foundation/README.md +86 -0
- package/src/renderer-foundation/__tests__/api.test.ts +188 -0
- package/src/renderer-foundation/__tests__/collect-plugins.integration.ts +101 -0
- package/src/renderer-foundation/api.ts +106 -0
- package/src/renderer-foundation/constants.ts +21 -0
- package/src/renderer-foundation/feature.ts +47 -0
- package/src/renderer-foundation/index.ts +25 -0
- package/src/renderer-foundation/types.ts +109 -0
- package/src/renderer-simple/__tests__/adapter.test.ts +50 -0
- package/src/renderer-simple/feature.ts +28 -3
- package/src/template-resolver/README.md +89 -0
- package/src/template-resolver/__tests__/handlers.integration.ts +403 -0
- package/src/template-resolver/__tests__/template-resolver.integration.ts +570 -0
- package/src/template-resolver/api.ts +189 -0
- package/src/template-resolver/constants.ts +28 -0
- package/src/template-resolver/feature.ts +36 -0
- package/src/template-resolver/handlers/archive.write.ts +42 -0
- package/src/template-resolver/handlers/find-by-id.query.ts +45 -0
- package/src/template-resolver/handlers/list.query.ts +69 -0
- package/src/template-resolver/handlers/publish.write.ts +45 -0
- package/src/template-resolver/handlers/shared.ts +41 -0
- package/src/template-resolver/handlers/upsert-system.write.ts +75 -0
- package/src/template-resolver/handlers/upsert-tenant.write.ts +98 -0
- package/src/template-resolver/index.ts +28 -0
- package/src/template-resolver/qualified-names.ts +24 -0
- package/src/template-resolver/table.ts +67 -0
- package/src/text-content/__tests__/text-content.integration.ts +54 -0
- package/src/text-content/handlers/by-slug.query.ts +1 -0
- package/src/text-content/handlers/by-tenant.query.ts +2 -0
- package/src/text-content/handlers/set.write.ts +23 -0
- package/src/text-content/seeding.ts +9 -1
- package/src/text-content/table.ts +6 -0
- package/src/text-content/web/__tests__/editor-read-only.test.tsx +125 -0
- package/src/text-content/web/__tests__/group-blocks.test.ts +221 -0
- package/src/text-content/web/client-plugin.tsx +378 -0
- package/src/text-content/web/client-plugin.ts +0 -113
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createTemplateResolverApi,
|
|
3
|
+
type ResolveRequest,
|
|
4
|
+
requireTemplateResolver,
|
|
5
|
+
TemplateNotFoundError,
|
|
6
|
+
type TemplateResolverApi,
|
|
7
|
+
type TemplateResource,
|
|
8
|
+
} from "./api";
|
|
9
|
+
export {
|
|
10
|
+
CONTENT_FORMATS,
|
|
11
|
+
type ContentFormat,
|
|
12
|
+
FALLBACK_LOCALE,
|
|
13
|
+
RENDER_KINDS,
|
|
14
|
+
type RenderKind,
|
|
15
|
+
SYSTEM_TENANT_ID,
|
|
16
|
+
TEMPLATE_SCOPES,
|
|
17
|
+
TEMPLATE_STATUSES,
|
|
18
|
+
type TemplateScope,
|
|
19
|
+
type TemplateStatus,
|
|
20
|
+
} from "./constants";
|
|
21
|
+
export { createTemplateResolverFeature } from "./feature";
|
|
22
|
+
export {
|
|
23
|
+
TEMPLATE_RESOLVER_FEATURE,
|
|
24
|
+
TemplateResolverErrors,
|
|
25
|
+
TemplateResolverHandlers,
|
|
26
|
+
TemplateResolverQueries,
|
|
27
|
+
} from "./qualified-names";
|
|
28
|
+
export { type TemplateResourceRow, templateResourceEntity, templateResourcesTable } from "./table";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Feature name + qualified handler/query names (QN: scope:type:name).
|
|
3
|
+
export const TEMPLATE_RESOLVER_FEATURE = "template-resolver" as const;
|
|
4
|
+
|
|
5
|
+
export const TemplateResolverHandlers = {
|
|
6
|
+
upsertSystem: "template-resolver:write:upsert-system",
|
|
7
|
+
upsertTenant: "template-resolver:write:upsert-tenant",
|
|
8
|
+
publish: "template-resolver:write:publish",
|
|
9
|
+
archive: "template-resolver:write:archive",
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
export const TemplateResolverQueries = {
|
|
13
|
+
findById: "template-resolver:query:find-by-id",
|
|
14
|
+
list: "template-resolver:query:list",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export const TemplateResolverErrors = {
|
|
18
|
+
notFound: "template_resource_not_found",
|
|
19
|
+
invalidSlug: "invalid_slug",
|
|
20
|
+
invalidLocale: "invalid_locale",
|
|
21
|
+
systemAdminRequired: "system_admin_required",
|
|
22
|
+
alreadyActive: "template_already_active",
|
|
23
|
+
alreadyArchived: "template_already_archived",
|
|
24
|
+
} as const;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createEntity,
|
|
4
|
+
createLongTextField,
|
|
5
|
+
createSelectField,
|
|
6
|
+
createTextField,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { CONTENT_FORMATS, RENDER_KINDS, TEMPLATE_SCOPES, TEMPLATE_STATUSES } from "./constants";
|
|
9
|
+
|
|
10
|
+
// TemplateResource — strukturierte Template-Definition mit Tenant-
|
|
11
|
+
// Override-Hierarchie, Locale-Fallback und Resource-Linking via
|
|
12
|
+
// file-foundation. Pro (tenantId, slug, kind, locale) genau eine Row;
|
|
13
|
+
// scope/status/version differenzieren Lifecycle.
|
|
14
|
+
//
|
|
15
|
+
// `variableSchema` + `linkedResources` sind JSON-Strings in longText
|
|
16
|
+
// (Pattern aus compliance-profiles — kein dedizierter jsonbField im
|
|
17
|
+
// createEntity-DSL). App-Layer parsed JSON, persistiert wieder als String.
|
|
18
|
+
export const templateResourceEntity = createEntity({
|
|
19
|
+
table: "read_template_resources",
|
|
20
|
+
fields: {
|
|
21
|
+
slug: createTextField({ required: true }),
|
|
22
|
+
kind: createSelectField({ required: true, options: [...RENDER_KINDS] }),
|
|
23
|
+
locale: createTextField({ required: true }),
|
|
24
|
+
content: createLongTextField({}),
|
|
25
|
+
contentFormat: createSelectField({ required: true, options: [...CONTENT_FORMATS] }),
|
|
26
|
+
variableSchema: createLongTextField({}),
|
|
27
|
+
linkedResources: createLongTextField({}),
|
|
28
|
+
scope: createSelectField({ required: true, options: [...TEMPLATE_SCOPES] }),
|
|
29
|
+
parentTemplateId: createTextField({}),
|
|
30
|
+
status: createSelectField({ required: true, options: [...TEMPLATE_STATUSES] }),
|
|
31
|
+
},
|
|
32
|
+
indexes: [
|
|
33
|
+
{
|
|
34
|
+
unique: true,
|
|
35
|
+
columns: ["tenantId", "slug", "kind", "locale"],
|
|
36
|
+
name: "read_template_resources_unique",
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const templateResourcesTable = buildDrizzleTable(
|
|
42
|
+
"template-resource",
|
|
43
|
+
templateResourceEntity,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Concrete Row-Type — single-source dafür dass die unknown-Werte die
|
|
47
|
+
// Drizzle aus `Record<string, unknown>` liefert genau einmal benannt
|
|
48
|
+
// werden (statt 12× `row["x"] as Y` Casts in Handlern + Resolver).
|
|
49
|
+
export type TemplateResourceRow = {
|
|
50
|
+
readonly id: string | number;
|
|
51
|
+
readonly version: number;
|
|
52
|
+
readonly tenantId: string;
|
|
53
|
+
readonly slug: string;
|
|
54
|
+
readonly kind: string;
|
|
55
|
+
readonly locale: string;
|
|
56
|
+
readonly content: string | null;
|
|
57
|
+
readonly contentFormat: string;
|
|
58
|
+
readonly variableSchema: string | null;
|
|
59
|
+
readonly linkedResources: string | null;
|
|
60
|
+
readonly scope: string;
|
|
61
|
+
readonly parentTemplateId: string | null;
|
|
62
|
+
readonly status: string;
|
|
63
|
+
readonly createdAt: Date;
|
|
64
|
+
readonly updatedAt: Date;
|
|
65
|
+
readonly createdBy: string;
|
|
66
|
+
readonly updatedBy: string;
|
|
67
|
+
};
|
|
@@ -412,4 +412,58 @@ describe("text-content :: seedTextBlock", () => {
|
|
|
412
412
|
});
|
|
413
413
|
expect(a.id).toBe(b.id);
|
|
414
414
|
});
|
|
415
|
+
|
|
416
|
+
// Drift-Documentation: seedTextBlock geht direkt durch den Executor
|
|
417
|
+
// OHNE slugSchema-Validation, set.write läuft DURCH die Validation.
|
|
418
|
+
// Folge: seedTextBlock akzeptiert Slugs mit ":" oder "/" (legal-pages
|
|
419
|
+
// Plattform-Seeds nutzen das für "page:index:hero.title" etc.), aber
|
|
420
|
+
// ein User-Edit derselben Block über set.write würde mit
|
|
421
|
+
// validation_error fail (regex `^[a-z0-9][a-z0-9-]*$`). Drift ist
|
|
422
|
+
// **bewusst** in V.1.3 — seedTextBlock ist system-trusted (boot-fixture,
|
|
423
|
+
// kein User-Input). V.1.4 plant ein echtes `folder`-Field statt
|
|
424
|
+
// `:`-Separator-im-Slug, dann fällt die Drift weg.
|
|
425
|
+
//
|
|
426
|
+
// Dieser Test pinnt den Status quo: Editor-Form via set.write rejected
|
|
427
|
+
// ":"-Slugs auch wenn seedTextBlock sie angelegt hat. Plus-Test
|
|
428
|
+
// verhindert dass jemand silent seedTextBlock-Validation hinzufügt
|
|
429
|
+
// ohne app-side seed-Slugs (z.B. legal-pages-Plattform-Seeds) zu
|
|
430
|
+
// konvertieren.
|
|
431
|
+
test("seedTextBlock + set.write drift: `:`-slugs sind seed-only, set.write rejected sie", async () => {
|
|
432
|
+
// Seed mit `:`-Slug funktioniert (legal-pages-Pattern)
|
|
433
|
+
const seeded = await seedTextBlock(db, {
|
|
434
|
+
tenantId: tenantAdmin.tenantId,
|
|
435
|
+
slug: "page:hero",
|
|
436
|
+
lang: "de",
|
|
437
|
+
title: "Seeded",
|
|
438
|
+
body: "from-seed",
|
|
439
|
+
});
|
|
440
|
+
expect(seeded.id).toBeDefined();
|
|
441
|
+
|
|
442
|
+
// Set.write auf demselben Slug → validation_error (kebab-only regex)
|
|
443
|
+
const error = await stack.http.writeErr(
|
|
444
|
+
TextContentHandlers.set,
|
|
445
|
+
{ slug: "page:hero", lang: "de", title: "User-edit", body: "from-write" },
|
|
446
|
+
tenantAdmin,
|
|
447
|
+
);
|
|
448
|
+
expectErrorIncludes(error, "validation_error");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("seedTextBlock + set.write parity: kebab-only Slugs durchlaufen beide Pfade", async () => {
|
|
452
|
+
// Inverse-Test: für kebab-only Slugs (`page-hero`) klappen beide
|
|
453
|
+
// Pfade. App-Builder die Edit-Form-fähige Seeds wollen, müssen
|
|
454
|
+
// kebab-only verwenden (siehe publicstatus/bin/seed-demo.ts).
|
|
455
|
+
await seedTextBlock(db, {
|
|
456
|
+
tenantId: tenantAdmin.tenantId,
|
|
457
|
+
slug: "page-hero",
|
|
458
|
+
lang: "de",
|
|
459
|
+
title: "Seeded",
|
|
460
|
+
body: "from-seed",
|
|
461
|
+
});
|
|
462
|
+
const result = await stack.http.writeOk<{ slug: string; lang: string }>(
|
|
463
|
+
TextContentHandlers.set,
|
|
464
|
+
{ slug: "page-hero", lang: "de", title: "User-edit", body: "from-write" },
|
|
465
|
+
tenantAdmin,
|
|
466
|
+
);
|
|
467
|
+
expect(result.slug).toBe("page-hero");
|
|
468
|
+
});
|
|
415
469
|
});
|
|
@@ -20,6 +20,7 @@ export type TextBlockSummary = {
|
|
|
20
20
|
readonly lang: string;
|
|
21
21
|
readonly title: string;
|
|
22
22
|
readonly body: string | null;
|
|
23
|
+
readonly folder: string | null;
|
|
23
24
|
readonly updatedAt: Date;
|
|
24
25
|
};
|
|
25
26
|
|
|
@@ -49,6 +50,7 @@ export const byTenantQuery = defineQueryHandler({
|
|
|
49
50
|
lang: row.lang,
|
|
50
51
|
title: row.title,
|
|
51
52
|
body: row.body,
|
|
53
|
+
folder: row.folder,
|
|
52
54
|
updatedAt: row.updatedAt,
|
|
53
55
|
})),
|
|
54
56
|
};
|
|
@@ -11,6 +11,17 @@ const slugSchema = z
|
|
|
11
11
|
.max(64)
|
|
12
12
|
.regex(/^[a-z0-9][a-z0-9-]*$/, "slug must be kebab-case (lowercase, digits, dashes)");
|
|
13
13
|
|
|
14
|
+
// Folder-Convention V.1.4: gleiches kebab-Pattern wie slug, optional
|
|
15
|
+
// `/`-Separator für nested folders ("legal/imprint", "page/marketing").
|
|
16
|
+
// Multi-level wird vom Visual-Tree-Grouping flat-rendered bis V.1.5
|
|
17
|
+
// rekursive Hierarchie braucht — dann erweitert sich nur der UI-Render,
|
|
18
|
+
// nicht das Schema.
|
|
19
|
+
const folderSchema = z
|
|
20
|
+
.string()
|
|
21
|
+
.min(1)
|
|
22
|
+
.max(128)
|
|
23
|
+
.regex(/^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)*$/, "folder must be kebab-case path");
|
|
24
|
+
|
|
14
25
|
const langSchema = z
|
|
15
26
|
.string()
|
|
16
27
|
.min(2)
|
|
@@ -36,6 +47,11 @@ export const setWrite = defineWriteHandler({
|
|
|
36
47
|
lang: langSchema,
|
|
37
48
|
title: z.string().min(1).max(200),
|
|
38
49
|
body: z.string().max(100_000).nullable(),
|
|
50
|
+
/** V.1.4: Folder-Pfad für Visual-Tree-Gruppierung. Optional + null
|
|
51
|
+
* → root-node (kein Folder). Tree groupt nach diesem Field, slug
|
|
52
|
+
* bleibt flach + kebab-validiert. Beispiele: "page", "legal",
|
|
53
|
+
* "page/marketing". */
|
|
54
|
+
folder: folderSchema.nullable().optional(),
|
|
39
55
|
/** Optional cross-tenant write — nur für SystemAdmin. Typischer
|
|
40
56
|
* use-case: legal-pages-Edit-UI lässt SystemAdmin auf
|
|
41
57
|
* SYSTEM_TENANT_ID schreiben (sonst landet der text auf seinem
|
|
@@ -78,6 +94,11 @@ export const setWrite = defineWriteHandler({
|
|
|
78
94
|
eq(textBlocksTable["lang"], event.payload.lang),
|
|
79
95
|
);
|
|
80
96
|
|
|
97
|
+
// V.1.4 folder: optional + null erlaubt (root-node). Optional-Chain
|
|
98
|
+
// mapped undefined → null damit drizzle nullable-column konsistent
|
|
99
|
+
// schreibt (sonst SQL-default kicked-in vs. explicit-null Unterschied).
|
|
100
|
+
const folder = event.payload.folder ?? null;
|
|
101
|
+
|
|
81
102
|
if (existing) {
|
|
82
103
|
const result = await executor.update(
|
|
83
104
|
{
|
|
@@ -86,6 +107,7 @@ export const setWrite = defineWriteHandler({
|
|
|
86
107
|
changes: {
|
|
87
108
|
title: event.payload.title,
|
|
88
109
|
body: event.payload.body,
|
|
110
|
+
folder,
|
|
89
111
|
},
|
|
90
112
|
},
|
|
91
113
|
executorUser,
|
|
@@ -104,6 +126,7 @@ export const setWrite = defineWriteHandler({
|
|
|
104
126
|
lang: event.payload.lang,
|
|
105
127
|
title: event.payload.title,
|
|
106
128
|
body: event.payload.body,
|
|
129
|
+
folder,
|
|
107
130
|
tenantId,
|
|
108
131
|
},
|
|
109
132
|
executorUser,
|
|
@@ -24,6 +24,11 @@ export type SeedTextBlockOptions = {
|
|
|
24
24
|
readonly lang: string;
|
|
25
25
|
readonly title: string;
|
|
26
26
|
readonly body?: string | null;
|
|
27
|
+
/** V.1.4: Folder-Pfad für Visual-Tree-Gruppierung. Optional + null =
|
|
28
|
+
* root-node. Seed-Pfad bypasst slugSchema/folderSchema-Validation
|
|
29
|
+
* (system-trusted), aber App-Builder sollten kebab-only nutzen damit
|
|
30
|
+
* set.write die geseedete Row später überschreiben kann. */
|
|
31
|
+
readonly folder?: string | null;
|
|
27
32
|
readonly by?: SessionUser;
|
|
28
33
|
};
|
|
29
34
|
|
|
@@ -50,12 +55,14 @@ export async function seedTextBlock(
|
|
|
50
55
|
eq(textBlocksTable["lang"], opts.lang),
|
|
51
56
|
);
|
|
52
57
|
|
|
58
|
+
const folder = opts.folder ?? null;
|
|
59
|
+
|
|
53
60
|
if (existing) {
|
|
54
61
|
const result = await executor.update(
|
|
55
62
|
{
|
|
56
63
|
id: existing.id,
|
|
57
64
|
version: existing.version,
|
|
58
|
-
changes: { title: opts.title, body: opts.body ?? null },
|
|
65
|
+
changes: { title: opts.title, body: opts.body ?? null, folder },
|
|
59
66
|
},
|
|
60
67
|
by,
|
|
61
68
|
tdb,
|
|
@@ -72,6 +79,7 @@ export async function seedTextBlock(
|
|
|
72
79
|
lang: opts.lang,
|
|
73
80
|
title: opts.title,
|
|
74
81
|
body: opts.body ?? null,
|
|
82
|
+
folder,
|
|
75
83
|
tenantId: opts.tenantId,
|
|
76
84
|
},
|
|
77
85
|
by,
|
|
@@ -16,6 +16,11 @@ export const textBlockEntity = createEntity({
|
|
|
16
16
|
lang: createTextField({ required: true }),
|
|
17
17
|
title: createTextField({ required: true }),
|
|
18
18
|
body: createTextField({}),
|
|
19
|
+
// V.1.4: explicit folder-Hierarchie statt `:`-Encoding im Slug.
|
|
20
|
+
// Visual-Tree gruppiert nach diesem Field; null/undefined → root.
|
|
21
|
+
// Pflicht-Constraint im set.write-Schema (kebab-only wie slug,
|
|
22
|
+
// damit App-Builder konsistente Naming-Conventions haben).
|
|
23
|
+
folder: createTextField({}),
|
|
19
24
|
},
|
|
20
25
|
indexes: [
|
|
21
26
|
{ unique: true, columns: ["tenantId", "slug", "lang"], name: "read_text_blocks_unique" },
|
|
@@ -38,6 +43,7 @@ export type TextBlockRow = {
|
|
|
38
43
|
readonly lang: string;
|
|
39
44
|
readonly title: string;
|
|
40
45
|
readonly body: string | null;
|
|
46
|
+
readonly folder: string | null;
|
|
41
47
|
readonly createdAt: Date;
|
|
42
48
|
readonly updatedAt: Date;
|
|
43
49
|
readonly createdBy: string;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createStaticLocaleResolver,
|
|
5
|
+
LocaleProvider,
|
|
6
|
+
PrimitivesProvider,
|
|
7
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
8
|
+
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
9
|
+
import { render, screen } from "@testing-library/react";
|
|
10
|
+
import type { ReactNode } from "react";
|
|
11
|
+
import { describe, expect, test, vi } from "vitest";
|
|
12
|
+
import { textContentClient } from "../client-plugin";
|
|
13
|
+
|
|
14
|
+
// Mock-Setup für die drei externen Hooks die TextContentEditor benutzt.
|
|
15
|
+
// vi.mock + vi.fn() erlaubt pro-Test verschiedene Roles + Dispatcher-
|
|
16
|
+
// Antworten. Memory `[Keine Fake-Tests]`: wir testen echtes Rendering,
|
|
17
|
+
// nicht nur die canWrite-Bedingung.
|
|
18
|
+
vi.mock("@cosmicdrift/kumiko-bundled-features/auth-email-password/web", () => ({
|
|
19
|
+
useShellUser: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("@cosmicdrift/kumiko-renderer", async () => {
|
|
23
|
+
const actual = await vi.importActual<typeof import("@cosmicdrift/kumiko-renderer")>(
|
|
24
|
+
"@cosmicdrift/kumiko-renderer",
|
|
25
|
+
);
|
|
26
|
+
return {
|
|
27
|
+
...actual,
|
|
28
|
+
useDispatcher: vi.fn(() => ({
|
|
29
|
+
write: vi.fn(),
|
|
30
|
+
query: vi.fn(),
|
|
31
|
+
})),
|
|
32
|
+
useQuery: vi.fn(() => ({
|
|
33
|
+
data: { slug: "imprint", lang: "de", title: "Impressum", body: "Inhalt" },
|
|
34
|
+
loading: false,
|
|
35
|
+
error: null,
|
|
36
|
+
refetch: vi.fn(),
|
|
37
|
+
})),
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
|
|
42
|
+
|
|
43
|
+
const TARGET = {
|
|
44
|
+
featureId: "text-content",
|
|
45
|
+
action: "edit",
|
|
46
|
+
args: { slug: "imprint", lang: "de" },
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
function getEditor() {
|
|
50
|
+
const def = textContentClient();
|
|
51
|
+
const Editor = def.resolvers?.["text-content:edit"];
|
|
52
|
+
if (!Editor) throw new Error("Editor not registered");
|
|
53
|
+
return Editor;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const localeResolver = createStaticLocaleResolver();
|
|
57
|
+
|
|
58
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
59
|
+
return (
|
|
60
|
+
<LocaleProvider resolver={localeResolver}>
|
|
61
|
+
<PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
|
|
62
|
+
</LocaleProvider>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("TextContentEditor — role-based write-access", () => {
|
|
67
|
+
test("TenantAdmin sieht Save-Button + editable inputs", () => {
|
|
68
|
+
vi.mocked(useShellUser).mockReturnValue({ id: "u1", roles: ["TenantAdmin"] });
|
|
69
|
+
const Editor = getEditor();
|
|
70
|
+
render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
|
|
71
|
+
|
|
72
|
+
// Save-Button gerendert (canWrite=true → Button.type=submit)
|
|
73
|
+
const saveButton = screen.getByRole("button", { name: /speichern/i });
|
|
74
|
+
expect(saveButton).toBeTruthy();
|
|
75
|
+
expect(saveButton.hasAttribute("disabled")).toBe(false);
|
|
76
|
+
|
|
77
|
+
// Read-only-Banner darf NICHT erscheinen
|
|
78
|
+
expect(screen.queryByText(/Read-only/)).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("SystemAdmin sieht Save-Button (alternative write-role)", () => {
|
|
82
|
+
vi.mocked(useShellUser).mockReturnValue({ id: "u1", roles: ["SystemAdmin"] });
|
|
83
|
+
const Editor = getEditor();
|
|
84
|
+
render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
|
|
85
|
+
|
|
86
|
+
expect(screen.getByRole("button", { name: /speichern/i })).toBeTruthy();
|
|
87
|
+
expect(screen.queryByText(/Read-only/)).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("Editor-Role sieht Read-only-Banner + KEIN Save-Button", () => {
|
|
91
|
+
// Das ist der advisor-flagged Pfad — bisher unverifiziert. Editor-
|
|
92
|
+
// Role hat in publicstatus's Schema Zugriff auf visual-Workspace +
|
|
93
|
+
// by-slug-query (read), aber NICHT auf set.write. UI muss das
|
|
94
|
+
// explizit kommunizieren statt 403 erst beim save zu zeigen.
|
|
95
|
+
vi.mocked(useShellUser).mockReturnValue({ id: "u1", roles: ["Editor"] });
|
|
96
|
+
const Editor = getEditor();
|
|
97
|
+
render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
|
|
98
|
+
|
|
99
|
+
expect(screen.getByText(/Read-only/)).toBeTruthy();
|
|
100
|
+
expect(screen.queryByRole("button", { name: /^speichern/i })).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("Admin-Role (publicstatus-Convention, ohne TenantAdmin-dual-Tag) sieht Read-only-Banner", () => {
|
|
104
|
+
// Apps die NUR `Admin` (ohne `TenantAdmin`) im JWT haben, kriegen
|
|
105
|
+
// read-only. Dokumentiert das Dual-Role-Pattern aus publicstatus:
|
|
106
|
+
// wer "Admin" allein hat (Memory `[Role-Naming-Drift]`), muss
|
|
107
|
+
// explizit auch "TenantAdmin" im JWT tragen damit der Editor
|
|
108
|
+
// schreiben darf.
|
|
109
|
+
vi.mocked(useShellUser).mockReturnValue({ id: "u1", roles: ["Admin"] });
|
|
110
|
+
const Editor = getEditor();
|
|
111
|
+
render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
|
|
112
|
+
|
|
113
|
+
expect(screen.getByText(/Read-only/)).toBeTruthy();
|
|
114
|
+
expect(screen.queryByRole("button", { name: /^speichern/i })).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("Logged-out (useShellUser=undefined) sieht Read-only-Banner", () => {
|
|
118
|
+
vi.mocked(useShellUser).mockReturnValue(undefined);
|
|
119
|
+
const Editor = getEditor();
|
|
120
|
+
render(<Editor target={TARGET} onClose={() => {}} />, { wrapper: Wrapper });
|
|
121
|
+
|
|
122
|
+
expect(screen.getByText(/Read-only/)).toBeTruthy();
|
|
123
|
+
expect(screen.queryByRole("button", { name: /^speichern/i })).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import type { TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { describe, expect, test } from "vitest";
|
|
5
|
+
import { type BlockSummary, groupBlocksByFolder } from "../client-plugin";
|
|
6
|
+
|
|
7
|
+
// TreeNode.children ist `readonly TreeNode[] | TreeChildrenSubscribe` —
|
|
8
|
+
// im Provider-Output ist die Subscribe-Form nur für deferred-children
|
|
9
|
+
// gedacht, groupBlocksByFolder produziert ausschließlich statische
|
|
10
|
+
// Array-Children. TypeGuard statt as-Cast (Memory `[Type Assertions]`).
|
|
11
|
+
function childrenArray(children: TreeNode["children"] | undefined): readonly TreeNode[] {
|
|
12
|
+
if (!Array.isArray(children)) throw new Error("expected static children-array");
|
|
13
|
+
return children;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// V.1.5d-Helper: groupBlocksByFolder gibt jetzt einen "Content"-Wrapper-
|
|
17
|
+
// Folder zurück. Tests wollen den Inhalt UNTER dem Wrapper prüfen.
|
|
18
|
+
function inside(result: readonly TreeNode[]): readonly TreeNode[] {
|
|
19
|
+
expect(result).toHaveLength(1);
|
|
20
|
+
const wrapper = result[0];
|
|
21
|
+
expect(wrapper?.label).toBe("Content");
|
|
22
|
+
expect(wrapper?.icon).toBe("folder");
|
|
23
|
+
return childrenArray(wrapper?.children);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Helper: BlockSummary mit defaults für die nicht-test-relevanten Felder.
|
|
27
|
+
function block(opts: {
|
|
28
|
+
slug: string;
|
|
29
|
+
folder?: string | null;
|
|
30
|
+
body?: string | null;
|
|
31
|
+
title?: string;
|
|
32
|
+
}): BlockSummary {
|
|
33
|
+
return {
|
|
34
|
+
slug: opts.slug,
|
|
35
|
+
lang: "de",
|
|
36
|
+
title: opts.title ?? opts.slug,
|
|
37
|
+
// Nicht ?? — null soll durchgereicht werden (state="stub"-Test).
|
|
38
|
+
body: opts.body === undefined ? "irgendwas" : opts.body,
|
|
39
|
+
folder: opts.folder === undefined ? null : opts.folder,
|
|
40
|
+
updatedAt: "2026-05-19T00:00:00Z",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("groupBlocksByFolder", () => {
|
|
45
|
+
test("leeres Array → leeres Array", () => {
|
|
46
|
+
expect(groupBlocksByFolder([])).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("folder=null → Root-Node ohne Folder (innerhalb Content-Wrapper)", () => {
|
|
50
|
+
const nodes = inside(groupBlocksByFolder([block({ slug: "imprint", folder: null })]));
|
|
51
|
+
expect(nodes).toHaveLength(1);
|
|
52
|
+
const root = nodes[0];
|
|
53
|
+
expect(root).toBeDefined();
|
|
54
|
+
if (!root) return;
|
|
55
|
+
expect(root.label).toBe("imprint");
|
|
56
|
+
expect(root.target).toEqual({
|
|
57
|
+
featureId: "text-content",
|
|
58
|
+
action: "edit",
|
|
59
|
+
args: { slug: "imprint", lang: "de" },
|
|
60
|
+
});
|
|
61
|
+
expect(root.children).toBeUndefined();
|
|
62
|
+
expect(root.icon).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('folder="page" → Folder-Container mit child', () => {
|
|
66
|
+
const nodes = inside(
|
|
67
|
+
groupBlocksByFolder([block({ slug: "hero", folder: "page", title: "Hero" })]),
|
|
68
|
+
);
|
|
69
|
+
expect(nodes).toHaveLength(1);
|
|
70
|
+
const folder = nodes[0];
|
|
71
|
+
expect(folder).toBeDefined();
|
|
72
|
+
if (!folder) return;
|
|
73
|
+
expect(folder.label).toBe("page");
|
|
74
|
+
expect(folder.icon).toBe("folder");
|
|
75
|
+
const children = childrenArray(folder.children);
|
|
76
|
+
expect(children).toHaveLength(1);
|
|
77
|
+
const child = children[0];
|
|
78
|
+
expect(child).toBeDefined();
|
|
79
|
+
if (!child) return;
|
|
80
|
+
expect(child.label).toBe("Hero");
|
|
81
|
+
expect(child.target?.args).toEqual({ slug: "hero", lang: "de" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("mehrere Slugs gleicher Folder → ein Folder mit mehreren children", () => {
|
|
85
|
+
const nodes = inside(
|
|
86
|
+
groupBlocksByFolder([
|
|
87
|
+
block({ slug: "hero", folder: "page", title: "Hero" }),
|
|
88
|
+
block({ slug: "cta", folder: "page", title: "CTA" }),
|
|
89
|
+
block({ slug: "footer", folder: "page", title: "Footer" }),
|
|
90
|
+
]),
|
|
91
|
+
);
|
|
92
|
+
expect(nodes).toHaveLength(1);
|
|
93
|
+
const folder = nodes[0];
|
|
94
|
+
expect(folder).toBeDefined();
|
|
95
|
+
if (!folder) return;
|
|
96
|
+
expect(folder.label).toBe("page");
|
|
97
|
+
const children = childrenArray(folder.children);
|
|
98
|
+
expect(children).toHaveLength(3);
|
|
99
|
+
const labels = children.map((c) => c.label);
|
|
100
|
+
expect(labels).toEqual(["Hero", "CTA", "Footer"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("mixed root + folder → root-nodes zuerst, dann Folders", () => {
|
|
104
|
+
const nodes = inside(
|
|
105
|
+
groupBlocksByFolder([
|
|
106
|
+
block({ slug: "imprint", folder: null }),
|
|
107
|
+
block({ slug: "hero", folder: "page" }),
|
|
108
|
+
block({ slug: "cta", folder: "page" }),
|
|
109
|
+
]),
|
|
110
|
+
);
|
|
111
|
+
expect(nodes).toHaveLength(2);
|
|
112
|
+
expect(nodes[0]?.label).toBe("imprint");
|
|
113
|
+
expect(nodes[0]?.icon).toBeUndefined();
|
|
114
|
+
expect(nodes[1]?.label).toBe("page");
|
|
115
|
+
expect(nodes[1]?.icon).toBe("folder");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("Folders alphabetisch sortiert (deterministisch gegen Map-order)", () => {
|
|
119
|
+
const nodes = inside(
|
|
120
|
+
groupBlocksByFolder([
|
|
121
|
+
block({ slug: "x", folder: "zebra" }),
|
|
122
|
+
block({ slug: "y", folder: "apple" }),
|
|
123
|
+
block({ slug: "z", folder: "mango" }),
|
|
124
|
+
]),
|
|
125
|
+
);
|
|
126
|
+
const folderLabels = nodes.map((n) => n.label);
|
|
127
|
+
expect(folderLabels).toEqual(["apple", "mango", "zebra"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('body=null → state="stub" (Designer-Hinweis dass Slug existiert aber leer)', () => {
|
|
131
|
+
const nodes = inside(groupBlocksByFolder([block({ slug: "draft", body: null })]));
|
|
132
|
+
expect(nodes[0]?.state).toBe("stub");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('body=string → state="filled"', () => {
|
|
136
|
+
const nodes = inside(groupBlocksByFolder([block({ slug: "imprint", body: "content" })]));
|
|
137
|
+
expect(nodes[0]?.state).toBe("filled");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("title leer → fallback auf slug als label", () => {
|
|
141
|
+
const nodes = inside(groupBlocksByFolder([block({ slug: "untitled-block", title: "" })]));
|
|
142
|
+
expect(nodes[0]?.label).toBe("untitled-block");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('V.1.6a multi-level folder ("page/marketing") wird genested gerendert', () => {
|
|
146
|
+
const nodes = inside(
|
|
147
|
+
groupBlocksByFolder([block({ slug: "hero", folder: "page/marketing", title: "Hero" })]),
|
|
148
|
+
);
|
|
149
|
+
expect(nodes).toHaveLength(1);
|
|
150
|
+
const pageFolder = nodes[0];
|
|
151
|
+
expect(pageFolder?.label).toBe("page");
|
|
152
|
+
expect(pageFolder?.icon).toBe("folder");
|
|
153
|
+
const pageChildren = childrenArray(pageFolder?.children);
|
|
154
|
+
expect(pageChildren).toHaveLength(1);
|
|
155
|
+
const marketingFolder = pageChildren[0];
|
|
156
|
+
expect(marketingFolder?.label).toBe("marketing");
|
|
157
|
+
expect(marketingFolder?.icon).toBe("folder");
|
|
158
|
+
const marketingChildren = childrenArray(marketingFolder?.children);
|
|
159
|
+
expect(marketingChildren).toHaveLength(1);
|
|
160
|
+
expect(marketingChildren[0]?.label).toBe("Hero");
|
|
161
|
+
expect(marketingChildren[0]?.target?.args).toEqual({ slug: "hero", lang: "de" });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("V.1.6a shared folder-prefix → ein gemeinsamer parent", () => {
|
|
165
|
+
// Zwei blocks mit verschachteltem Pfad teilen die ersten Segmente.
|
|
166
|
+
// page/hero + page/cta + page/marketing/banner → 1× page-folder mit
|
|
167
|
+
// 3 children (2 leaves + 1 sub-folder).
|
|
168
|
+
const nodes = inside(
|
|
169
|
+
groupBlocksByFolder([
|
|
170
|
+
block({ slug: "hero", folder: "page", title: "Hero" }),
|
|
171
|
+
block({ slug: "cta", folder: "page", title: "CTA" }),
|
|
172
|
+
block({ slug: "banner", folder: "page/marketing", title: "Banner" }),
|
|
173
|
+
]),
|
|
174
|
+
);
|
|
175
|
+
expect(nodes).toHaveLength(1);
|
|
176
|
+
const pageFolder = nodes[0];
|
|
177
|
+
expect(pageFolder?.label).toBe("page");
|
|
178
|
+
const pageChildren = childrenArray(pageFolder?.children);
|
|
179
|
+
// Leaves first (Hero, CTA), dann sub-folder (marketing alphabetisch).
|
|
180
|
+
expect(pageChildren.map((c) => c.label)).toEqual(["Hero", "CTA", "marketing"]);
|
|
181
|
+
expect(pageChildren[2]?.icon).toBe("folder");
|
|
182
|
+
const marketingChildren = childrenArray(pageChildren[2]?.children);
|
|
183
|
+
expect(marketingChildren).toHaveLength(1);
|
|
184
|
+
expect(marketingChildren[0]?.label).toBe("Banner");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("V.1.6a folder/leaf-collision: gleicher Name auf gleicher Ebene", () => {
|
|
188
|
+
// Edge-Case (advisor-flagged): block mit folder=null, slug="page"
|
|
189
|
+
// + block mit folder="page" → "page" existiert als Leaf-Root UND
|
|
190
|
+
// als Folder. Beide bleiben sichtbar; Folder hat Chevron + Folder-
|
|
191
|
+
// Icon, Leaf hat target + ist klickbar. Renderer-Pattern macht
|
|
192
|
+
// visuell klar dass es zwei verschiedene Dinge sind.
|
|
193
|
+
const nodes = inside(
|
|
194
|
+
groupBlocksByFolder([
|
|
195
|
+
block({ slug: "page", folder: null, title: "Page-Root" }),
|
|
196
|
+
block({ slug: "hero", folder: "page", title: "Hero" }),
|
|
197
|
+
]),
|
|
198
|
+
);
|
|
199
|
+
expect(nodes).toHaveLength(2);
|
|
200
|
+
const leaf = nodes[0];
|
|
201
|
+
const folder = nodes[1];
|
|
202
|
+
expect(leaf?.label).toBe("Page-Root");
|
|
203
|
+
expect(leaf?.icon).toBeUndefined();
|
|
204
|
+
expect(leaf?.target).toBeDefined();
|
|
205
|
+
expect(folder?.label).toBe("page");
|
|
206
|
+
expect(folder?.icon).toBe("folder");
|
|
207
|
+
expect(folder?.target).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("Wrapper-Folder 'Content' umschließt alle blocks", () => {
|
|
211
|
+
// V.1.5d Wrapper-Convention: groupBlocksByFolder gibt EINEN Knoten
|
|
212
|
+
// zurück (den Wrapper), Inhalt liegt eine Ebene tiefer.
|
|
213
|
+
const result = groupBlocksByFolder([block({ slug: "imprint" })]);
|
|
214
|
+
expect(result).toHaveLength(1);
|
|
215
|
+
const wrapper = result[0];
|
|
216
|
+
expect(wrapper?.label).toBe("Content");
|
|
217
|
+
expect(wrapper?.icon).toBe("folder");
|
|
218
|
+
expect(wrapper?.target).toBeUndefined();
|
|
219
|
+
expect(childrenArray(wrapper?.children)).toHaveLength(1);
|
|
220
|
+
});
|
|
221
|
+
});
|