@cosmicdrift/kumiko-samples 0.377.0 → 0.378.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 +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/cap-counter/changes.json +12 -0
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +9 -0
- package/packages/bundled-features/src/config/web/i18n.ts +1 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +11 -4
- package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/i18n.ts +3 -2
- package/packages/bundled-features/src/mail-transport-smtp/i18n.ts +1 -1
- package/packages/bundled-features/src/template-resolver/changes.json +20 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -3
- package/packages/bundled-features/src/template-resolver/constants.ts +3 -0
- package/packages/bundled-features/src/template-resolver/document-blocks.ts +3 -1
- package/packages/bundled-features/src/template-resolver/document-images.ts +29 -0
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +35 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +5 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +77 -36
- package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +2 -6
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +0 -6
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +67 -58
- package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +36 -13
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +37 -28
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +66 -62
- package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +105 -96
- package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +62 -49
- package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +44 -29
- package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +52 -36
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +126 -106
- package/packages/bundled-features/src/template-resolver/handlers/send-test-shared.ts +38 -0
- package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +31 -0
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +105 -111
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +110 -103
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +56 -28
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +38 -29
- package/packages/bundled-features/src/template-resolver/index.ts +8 -1
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +2 -0
- package/packages/bundled-features/src/template-resolver/template-edit-policy.ts +91 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +8 -2
- package/packages/bundled-features/src/template-resolver/template-validation.ts +31 -10
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +29 -0
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +42 -4
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +2 -0
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
- package/samples/apps/ui-walkthrough/src/app/help-action.tsx +37 -0
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +6 -0
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +20 -0
- package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +65 -1
- package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +70 -0
- package/samples/apps/ui-walkthrough/src/features/rental/invoice-receive.ts +20 -0
- package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +15 -0
- package/samples/apps/ui-walkthrough/src/features/rental/qualified-names.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +79 -0
- package/samples/apps/ui-walkthrough/src/features/rental/web/index.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/rental/web/upload-standard-layout.tsx +61 -0
- package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
- package/samples/apps/ui-walkthrough/src/run-config.ts +32 -3
- package/samples/apps/use-all-bundled/feature-manifest.json +2 -2
- package/samples/recipes/delivery-notifications/README.md +1 -1
- package/samples/recipes/embedded/README.md +8 -0
- package/samples/recipes/embedded/src/feature.ts +8 -0
- package/samples/recipes/embedded/src/handlers/invoice-receive.write.ts +38 -0
- package/samples/recipes/embedded/src/screens/invoice-receive.screen.ts +59 -0
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
+
import {
|
|
4
|
+
refuseTemplateEdit,
|
|
5
|
+
type TemplateEditPolicy,
|
|
6
|
+
templateEditAccess,
|
|
7
|
+
} from "../template-edit-policy.ts";
|
|
3
8
|
import { resolveTemplateVariables } from "../template-validation.ts";
|
|
4
9
|
import { upsertPayloadSchema } from "./shared.ts";
|
|
5
10
|
import { writeTemplateRow } from "./template-write.ts";
|
|
@@ -9,32 +14,36 @@ import { writeTemplateRow } from "./template-write.ts";
|
|
|
9
14
|
// via publish-Handler. SystemAdmin kann via tenantIdOverride für
|
|
10
15
|
// einen anderen Tenant schreiben (typisch: Plattform-Admin-UI das
|
|
11
16
|
// Tenant-Templates kuratiert).
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
event.payload.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
17
|
+
export function makeUpsertTenantWrite(policy: TemplateEditPolicy) {
|
|
18
|
+
return defineWriteHandler({
|
|
19
|
+
name: "upsert-tenant",
|
|
20
|
+
schema: upsertPayloadSchema.extend({
|
|
21
|
+
tenantIdOverride: z.string().min(1).optional(),
|
|
22
|
+
status: z.enum(["draft", "active"]).default("draft"),
|
|
23
|
+
}),
|
|
24
|
+
access: templateEditAccess(policy.tenantEditable),
|
|
25
|
+
description:
|
|
26
|
+
"Creates or overwrites a tenant-scoped template override with its variable schema, landing as a draft that only publish makes live (over an active override the draft is stored beside it, the active version keeps being served until publish); it refuses a system-tenant target, which is what upsert-system is for; a TenantAdmin may write it only for templates the app lists in tenantEditable, otherwise SystemAdmin only.",
|
|
27
|
+
handler: async (event, ctx) => {
|
|
28
|
+
const refused = refuseTemplateEdit(policy, event.user, event.payload);
|
|
29
|
+
if (refused !== null) return refused;
|
|
30
|
+
// @cast-boundary engine-payload — override is a zod-validated string and
|
|
31
|
+
// event.user.tenantId is already TenantId-branded; forTenant rejects the
|
|
32
|
+
// override for non-SystemAdmins.
|
|
33
|
+
const tenantId = (event.payload.tenantIdOverride ?? event.user.tenantId) as TenantId;
|
|
34
|
+
return writeTemplateRow({
|
|
35
|
+
ctx,
|
|
36
|
+
user: event.user,
|
|
37
|
+
tenantId,
|
|
38
|
+
scope: "tenant",
|
|
39
|
+
status: event.payload.status,
|
|
40
|
+
variables: resolveTemplateVariables(
|
|
41
|
+
ctx.registry,
|
|
42
|
+
event.payload.slug,
|
|
43
|
+
event.payload.variableSchema,
|
|
44
|
+
),
|
|
45
|
+
payload: event.payload,
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { isResourceImageContentType } from "../shared/index.ts";
|
|
1
2
|
export {
|
|
2
3
|
createTemplateResolverApi,
|
|
3
4
|
type ResolveRequest,
|
|
@@ -8,6 +9,7 @@ export {
|
|
|
8
9
|
} from "./api.ts";
|
|
9
10
|
export {
|
|
10
11
|
type CompileDocumentBodyOptions,
|
|
12
|
+
collectDocumentImageVariables,
|
|
11
13
|
collectDocumentResourceKeys,
|
|
12
14
|
compileDocumentBodyToHandlebars,
|
|
13
15
|
toResourceDataUri,
|
|
@@ -75,7 +77,7 @@ export {
|
|
|
75
77
|
isDocumentReferenceName,
|
|
76
78
|
isDocumentResourceKey,
|
|
77
79
|
} from "./document-schema.ts";
|
|
78
|
-
export { createTemplateResolverFeature } from "./feature.ts";
|
|
80
|
+
export { createTemplateResolverFeature, type TemplateResolverOptions } from "./feature.ts";
|
|
79
81
|
export type { LayoutSourceResponse, LayoutSourceRow } from "./handlers/layout-source.query.ts";
|
|
80
82
|
export type {
|
|
81
83
|
OverviewKindGroup,
|
|
@@ -126,6 +128,11 @@ export {
|
|
|
126
128
|
templateResourcesTable,
|
|
127
129
|
} from "./table.ts";
|
|
128
130
|
export { type TemplateBundle, templateBundleSchema } from "./template-bundle.ts";
|
|
131
|
+
export {
|
|
132
|
+
type TemplateEditPolicy,
|
|
133
|
+
type TenantEditableTemplate,
|
|
134
|
+
templateEditAccess,
|
|
135
|
+
} from "./template-edit-policy.ts";
|
|
129
136
|
export {
|
|
130
137
|
isMailTemplateMeta,
|
|
131
138
|
type LayoutTemplateMeta,
|
|
@@ -35,6 +35,7 @@ export const TemplateResolverQueries = {
|
|
|
35
35
|
preview: "template-resolver:query:preview",
|
|
36
36
|
documentBlocks: "template-resolver:query:document-blocks",
|
|
37
37
|
exportBundle: "template-resolver:query:export-bundle",
|
|
38
|
+
sendTestStatus: "template-resolver:query:send-test-status",
|
|
38
39
|
// Public pair — anonymous-reachable, kind pinned to text-block.
|
|
39
40
|
bySlug: "template-resolver:query:by-slug",
|
|
40
41
|
byTenant: "template-resolver:query:by-tenant",
|
|
@@ -63,6 +64,7 @@ export const TemplateResolverErrors = {
|
|
|
63
64
|
targetExists: "target_exists",
|
|
64
65
|
systemResourcesNeedSystemTenant: "system_resources_need_system_tenant",
|
|
65
66
|
systemOnlyTemplate: "system_only_template",
|
|
67
|
+
templateNotTenantEditable: "template_not_tenant_editable",
|
|
66
68
|
bundleLocalesDiffer: "bundle_locales_differ",
|
|
67
69
|
resourceTooLarge: "template_resource_too_large",
|
|
68
70
|
resourceUnsupportedImage: "template_resource_unsupported_image",
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
type HandlerContext,
|
|
4
|
+
hasAccess,
|
|
5
|
+
type RoleAccessRule,
|
|
6
|
+
type SessionUser,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import {
|
|
9
|
+
AccessDeniedError,
|
|
10
|
+
type WriteFailure,
|
|
11
|
+
writeFailure,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
13
|
+
import type { TemplateKind } from "./constants.ts";
|
|
14
|
+
import { TemplateResolverErrors } from "./qualified-names.ts";
|
|
15
|
+
import { type TemplateResourceRow, templateResourcesTable } from "./table.ts";
|
|
16
|
+
import { isSystemAdmin } from "./template-draft.ts";
|
|
17
|
+
|
|
18
|
+
export type TenantEditableTemplate = {
|
|
19
|
+
readonly kind: TemplateKind;
|
|
20
|
+
readonly slug: string;
|
|
21
|
+
readonly access: RoleAccessRule;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type TemplateEditPolicy = {
|
|
25
|
+
readonly tenantEditable: readonly TenantEditableTemplate[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type EditTarget = { readonly slug: string; readonly kind: string };
|
|
29
|
+
type EditUser = Pick<SessionUser, "roles">;
|
|
30
|
+
|
|
31
|
+
const SYSTEM_ADMIN_ROLE = "SystemAdmin";
|
|
32
|
+
|
|
33
|
+
export function templateEditAccess(entries: readonly TenantEditableTemplate[]): RoleAccessRule {
|
|
34
|
+
const roles = new Set<string>([SYSTEM_ADMIN_ROLE]);
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
for (const role of entry.access.roles) roles.add(role);
|
|
37
|
+
}
|
|
38
|
+
return { roles: [...roles] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function mayEditTemplate(
|
|
42
|
+
policy: TemplateEditPolicy,
|
|
43
|
+
user: EditUser,
|
|
44
|
+
target: EditTarget,
|
|
45
|
+
): boolean {
|
|
46
|
+
if (isSystemAdmin(user)) return true;
|
|
47
|
+
const entry = policy.tenantEditable.find(
|
|
48
|
+
(candidate) => candidate.slug === target.slug && candidate.kind === target.kind,
|
|
49
|
+
);
|
|
50
|
+
return entry !== undefined && hasAccess(user, entry.access);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function refuseTemplateEdit(
|
|
54
|
+
policy: TemplateEditPolicy,
|
|
55
|
+
user: EditUser,
|
|
56
|
+
target: EditTarget,
|
|
57
|
+
): WriteFailure | null {
|
|
58
|
+
if (mayEditTemplate(policy, user, target)) return null;
|
|
59
|
+
return writeFailure(
|
|
60
|
+
new AccessDeniedError({
|
|
61
|
+
i18nKey: "templateResolver.errors.templateNotTenantEditable",
|
|
62
|
+
details: { reason: TemplateResolverErrors.templateNotTenantEditable },
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Fail closed: a missing row gives a non-SystemAdmin the same refusal as a locked one,
|
|
68
|
+
// so the response never reveals whether an id exists. SystemAdmin keeps the 404.
|
|
69
|
+
export function refuseMissingTemplate(
|
|
70
|
+
policy: TemplateEditPolicy,
|
|
71
|
+
user: EditUser,
|
|
72
|
+
): WriteFailure | null {
|
|
73
|
+
return isSystemAdmin(user) ? null : refuseTemplateEdit(policy, user, { slug: "", kind: "" });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function refuseTemplateEditById(
|
|
77
|
+
db: HandlerContext["db"],
|
|
78
|
+
policy: TemplateEditPolicy,
|
|
79
|
+
user: EditUser,
|
|
80
|
+
tenantId: string,
|
|
81
|
+
templateId: string,
|
|
82
|
+
): Promise<WriteFailure | null> {
|
|
83
|
+
if (isSystemAdmin(user)) return null;
|
|
84
|
+
const row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
|
|
85
|
+
tenantId,
|
|
86
|
+
id: templateId,
|
|
87
|
+
});
|
|
88
|
+
return row === undefined
|
|
89
|
+
? refuseMissingTemplate(policy, user)
|
|
90
|
+
: refuseTemplateEdit(policy, user, row);
|
|
91
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type SessionUser,
|
|
5
5
|
type TemplateListVariableDef,
|
|
6
6
|
type TemplateScalarVariableDef,
|
|
7
|
+
type TemplateVariableDef,
|
|
7
8
|
type TemplateVariables,
|
|
8
9
|
templateVariablesToZod,
|
|
9
10
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -120,6 +121,12 @@ function sampleList(def: TemplateListVariableDef, labelText: LabelText): Record<
|
|
|
120
121
|
return rows;
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
function sampleValueFor(name: string, def: TemplateVariableDef, labelText: LabelText): unknown {
|
|
125
|
+
// An images variable has no sample: file ids are record data, never preview content.
|
|
126
|
+
if (def.type === "images") return [];
|
|
127
|
+
return def.type === "list" ? sampleList(def, labelText) : sampleScalar(name, def, labelText);
|
|
128
|
+
}
|
|
129
|
+
|
|
123
130
|
// Declared examples win; the fallbacks are deterministic so a preview never differs between calls.
|
|
124
131
|
export function sampleValuesFor(
|
|
125
132
|
variables: TemplateVariables,
|
|
@@ -127,8 +134,7 @@ export function sampleValuesFor(
|
|
|
127
134
|
): Record<string, unknown> {
|
|
128
135
|
const values: Record<string, unknown> = {};
|
|
129
136
|
for (const [name, def] of Object.entries(variables)) {
|
|
130
|
-
values[name] =
|
|
131
|
-
def.type === "list" ? sampleList(def, labelText) : sampleScalar(name, def, labelText);
|
|
137
|
+
values[name] = sampleValueFor(name, def, labelText);
|
|
132
138
|
}
|
|
133
139
|
return values;
|
|
134
140
|
}
|
|
@@ -65,6 +65,7 @@ const scalarVariableSchema = z.discriminatedUnion("type", [
|
|
|
65
65
|
|
|
66
66
|
const variableDefSchema = z.union([
|
|
67
67
|
scalarVariableSchema,
|
|
68
|
+
z.object({ ...variableCommon, type: z.literal("images") }),
|
|
68
69
|
z.object({
|
|
69
70
|
...variableCommon,
|
|
70
71
|
type: z.literal("list"),
|
|
@@ -126,17 +127,24 @@ function listItemFields(
|
|
|
126
127
|
return def?.type === "list" ? def.item : {};
|
|
127
128
|
}
|
|
128
129
|
|
|
130
|
+
// An images variable holds file ids: as text it would leak them, so only an images block
|
|
131
|
+
// (and a condition on its presence) may name it.
|
|
129
132
|
function isKnownName(
|
|
130
133
|
name: string,
|
|
131
134
|
scope: PlaceholderScope,
|
|
132
135
|
itemFields: Readonly<Record<string, unknown>>,
|
|
136
|
+
allowImages = false,
|
|
133
137
|
): Resolution {
|
|
134
138
|
const { variables } = scope;
|
|
135
139
|
const root = rootName(name);
|
|
136
140
|
// Declared variables and item fields are scalars: a dotted path under one never renders a value.
|
|
137
141
|
if (Object.hasOwn(itemFields, name)) return { known: true, used: "" };
|
|
138
142
|
if (scope.systemPlaceholders.has(root)) return { known: true, used: "" };
|
|
139
|
-
if (Object.hasOwn(variables, name))
|
|
143
|
+
if (Object.hasOwn(variables, name)) {
|
|
144
|
+
return variables[name]?.type === "images" && !allowImages
|
|
145
|
+
? { known: false, used: "" }
|
|
146
|
+
: { known: true, used: name };
|
|
147
|
+
}
|
|
140
148
|
return { known: false, used: "" };
|
|
141
149
|
}
|
|
142
150
|
|
|
@@ -144,8 +152,9 @@ function recordName(
|
|
|
144
152
|
name: string,
|
|
145
153
|
itemFields: Readonly<Record<string, unknown>>,
|
|
146
154
|
scope: PlaceholderScope,
|
|
155
|
+
allowImages = false,
|
|
147
156
|
) {
|
|
148
|
-
const resolution = isKnownName(name, scope, itemFields);
|
|
157
|
+
const resolution = isKnownName(name, scope, itemFields, allowImages);
|
|
149
158
|
if (!resolution.known) scope.unknown.add(name);
|
|
150
159
|
else if (resolution.used !== "") scope.used.add(resolution.used);
|
|
151
160
|
}
|
|
@@ -154,10 +163,18 @@ function enclosingEachName(element: ScanElement): string | null {
|
|
|
154
163
|
return element.closest("tr[data-each]")?.getAttribute("data-each") ?? null;
|
|
155
164
|
}
|
|
156
165
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
166
|
+
function nameAttributeOf(element: ScanElement): string | undefined {
|
|
167
|
+
const tag = element.tagName.toLowerCase();
|
|
168
|
+
if (tag === "span") return "data-variable";
|
|
169
|
+
if (tag !== "div") return undefined;
|
|
170
|
+
return element.hasAttribute("data-images") ? "data-images" : "data-steps";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function recordImagesName(name: string, scope: PlaceholderScope): void {
|
|
174
|
+
const def = Object.hasOwn(scope.variables, name) ? scope.variables[name] : undefined;
|
|
175
|
+
if (def?.type === "images") scope.used.add(name);
|
|
176
|
+
else scope.unknown.add(name);
|
|
177
|
+
}
|
|
161
178
|
|
|
162
179
|
function scanRepeatRows(root: ScanElement, scope: PlaceholderScope): void {
|
|
163
180
|
for (const element of Array.from(root.querySelectorAll("tr[data-each]"))) {
|
|
@@ -174,18 +191,22 @@ function scanConditions(root: ScanElement, scope: PlaceholderScope): void {
|
|
|
174
191
|
scope.variables,
|
|
175
192
|
enclosingEachName(conditionScopeElement(element)),
|
|
176
193
|
);
|
|
177
|
-
recordName(element.getAttribute(DOCUMENT_CONDITION_ATTRIBUTE) ?? "", itemFields, scope);
|
|
194
|
+
recordName(element.getAttribute(DOCUMENT_CONDITION_ATTRIBUTE) ?? "", itemFields, scope, true);
|
|
178
195
|
}
|
|
179
196
|
}
|
|
180
197
|
|
|
181
198
|
function scanReferences(root: ScanElement, scope: PlaceholderScope): void {
|
|
182
199
|
for (const element of Array.from(
|
|
183
|
-
root.querySelectorAll(
|
|
200
|
+
root.querySelectorAll(
|
|
201
|
+
"span[data-variable], div[data-steps], div[data-images], a[href], img[data-resource]",
|
|
202
|
+
),
|
|
184
203
|
)) {
|
|
185
204
|
const itemFields = listItemFields(scope.variables, enclosingEachName(element));
|
|
186
205
|
const tag = element.tagName.toLowerCase();
|
|
187
|
-
const nameAttribute =
|
|
188
|
-
if (nameAttribute
|
|
206
|
+
const nameAttribute = nameAttributeOf(element);
|
|
207
|
+
if (nameAttribute === "data-images") {
|
|
208
|
+
recordImagesName(element.getAttribute(nameAttribute) ?? "", scope);
|
|
209
|
+
} else if (nameAttribute !== undefined) {
|
|
189
210
|
recordName(element.getAttribute(nameAttribute) ?? "", itemFields, scope);
|
|
190
211
|
} else if (tag === "a") {
|
|
191
212
|
const href = element.getAttribute("href") ?? "";
|
|
@@ -5,6 +5,7 @@ import { Dropcursor, Gapcursor, Placeholder, UndoRedo } from "@tiptap/extensions
|
|
|
5
5
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
|
6
6
|
import {
|
|
7
7
|
DocumentButton,
|
|
8
|
+
DocumentImages,
|
|
8
9
|
DocumentPageBreak,
|
|
9
10
|
DocumentResource,
|
|
10
11
|
DocumentSteps,
|
|
@@ -23,6 +24,7 @@ export type DocumentEditorLabels = {
|
|
|
23
24
|
readonly pageBreak: string;
|
|
24
25
|
readonly button?: string;
|
|
25
26
|
readonly steps?: string;
|
|
27
|
+
readonly images?: string;
|
|
26
28
|
/** Canvas block label of an image, e.g. "Image · inline". */
|
|
27
29
|
readonly imageBlock?: string;
|
|
28
30
|
readonly variableLabels?: Readonly<Record<string, string>>;
|
|
@@ -206,6 +208,31 @@ function createStepsView(labels: DocumentEditorLabels): NodeViewRenderer {
|
|
|
206
208
|
};
|
|
207
209
|
}
|
|
208
210
|
|
|
211
|
+
// A sketch only: the photos are record data, known at render time.
|
|
212
|
+
function createImagesView(labels: DocumentEditorLabels): NodeViewRenderer {
|
|
213
|
+
return ({ node }) => {
|
|
214
|
+
const dom = createElementWithClass(
|
|
215
|
+
"div",
|
|
216
|
+
"relative my-3 rounded border border-dashed border-input px-3 pb-3 pt-4",
|
|
217
|
+
);
|
|
218
|
+
dom.setAttribute(DOCUMENT_BLOCK_ATTRIBUTE, "");
|
|
219
|
+
dom.contentEditable = "false";
|
|
220
|
+
const variable = String(node.attrs["variable"]);
|
|
221
|
+
const variableLabels = labels.variableLabels ?? {};
|
|
222
|
+
const variableLabel = Object.hasOwn(variableLabels, variable)
|
|
223
|
+
? variableLabels[variable]
|
|
224
|
+
: undefined;
|
|
225
|
+
dom.append(createBlockLabel(`${labels.images ?? "Photos"} · ${variableLabel ?? variable}`));
|
|
226
|
+
const grid = createElementWithClass("div", "grid grid-cols-2 gap-1.5");
|
|
227
|
+
grid.append(
|
|
228
|
+
createElementWithClass("div", "h-12 rounded bg-muted"),
|
|
229
|
+
createElementWithClass("div", "h-12 rounded bg-muted"),
|
|
230
|
+
);
|
|
231
|
+
dom.append(grid);
|
|
232
|
+
return { dom };
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
209
236
|
// Replaced by name, not appended: the schema must keep exactly one node per
|
|
210
237
|
// name, and only the editor view differs from the shared document schema.
|
|
211
238
|
export function createDocumentEditorExtensions(labels: DocumentEditorLabels): Extensions {
|
|
@@ -224,6 +251,8 @@ export function createDocumentEditorExtensions(labels: DocumentEditorLabels): Ex
|
|
|
224
251
|
: extension;
|
|
225
252
|
case DocumentSteps.name:
|
|
226
253
|
return DocumentSteps.extend({ addNodeView: () => createStepsView(labels) });
|
|
254
|
+
case DocumentImages.name:
|
|
255
|
+
return DocumentImages.extend({ addNodeView: () => createImagesView(labels) });
|
|
227
256
|
case DocumentPageBreak.name:
|
|
228
257
|
return DocumentPageBreak.extend({
|
|
229
258
|
addNodeView: () => createPageBreakView(labels.pageBreak),
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
Bold as BoldIcon,
|
|
26
26
|
Braces,
|
|
27
27
|
ChevronDown,
|
|
28
|
+
GalleryThumbnails,
|
|
28
29
|
Image as ImageIcon,
|
|
29
30
|
Italic as ItalicIcon,
|
|
30
31
|
Link as LinkIcon,
|
|
@@ -72,7 +73,15 @@ function previewsKey(previews: Readonly<Record<string, DocumentResourcePreview>>
|
|
|
72
73
|
.join("\n");
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
type PanelKind =
|
|
76
|
+
type PanelKind =
|
|
77
|
+
| "link"
|
|
78
|
+
| "placeholder"
|
|
79
|
+
| "image"
|
|
80
|
+
| "button"
|
|
81
|
+
| "repeat"
|
|
82
|
+
| "align"
|
|
83
|
+
| "steps"
|
|
84
|
+
| "images";
|
|
76
85
|
|
|
77
86
|
// Blocks that stay in the canvas toolbar; the rest moves into the overflow menu.
|
|
78
87
|
const CANVAS_INLINE_BLOCKS: readonly DocumentToolbarBlock[] = ["image", "table"];
|
|
@@ -83,6 +92,7 @@ const BLOCK_PANEL: Partial<Record<DocumentToolbarBlock, PanelKind>> = {
|
|
|
83
92
|
repeat: "repeat",
|
|
84
93
|
align: "align",
|
|
85
94
|
steps: "steps",
|
|
95
|
+
images: "images",
|
|
86
96
|
};
|
|
87
97
|
|
|
88
98
|
const ALIGNMENT_ICONS: Record<DocumentTextAlignment, ReactNode> = {
|
|
@@ -392,7 +402,11 @@ function InsertPanel({
|
|
|
392
402
|
};
|
|
393
403
|
|
|
394
404
|
const needsVariables =
|
|
395
|
-
kind === "button" ||
|
|
405
|
+
kind === "button" ||
|
|
406
|
+
kind === "repeat" ||
|
|
407
|
+
kind === "placeholder" ||
|
|
408
|
+
kind === "steps" ||
|
|
409
|
+
kind === "images";
|
|
396
410
|
const noVariables = needsVariables && selectable.length === 0;
|
|
397
411
|
const idPrefix = `${editorId}-panel`;
|
|
398
412
|
|
|
@@ -498,6 +512,16 @@ function InsertPanel({
|
|
|
498
512
|
onChange={setChosenName}
|
|
499
513
|
/>
|
|
500
514
|
)}
|
|
515
|
+
{kind === "images" && !noVariables && (
|
|
516
|
+
<NameSelect
|
|
517
|
+
id={`${idPrefix}-images-variable`}
|
|
518
|
+
label={t("template-resolver.documentEditor.imagesVariable")}
|
|
519
|
+
names={selectable}
|
|
520
|
+
labels={variableLabels}
|
|
521
|
+
value={selectedName}
|
|
522
|
+
onChange={setChosenName}
|
|
523
|
+
/>
|
|
524
|
+
)}
|
|
501
525
|
{kind === "steps" && !noVariables && (
|
|
502
526
|
<>
|
|
503
527
|
<NameSelect
|
|
@@ -551,6 +575,10 @@ function InsertPanel({
|
|
|
551
575
|
{...(kind === "repeat" &&
|
|
552
576
|
!noVariables && { onInsert: () => insertAndClose(repeatTableContent(selectedName)) })}
|
|
553
577
|
{...(kind === "steps" && !noVariables && { onInsert: submitSteps })}
|
|
578
|
+
{...(kind === "images" &&
|
|
579
|
+
!noVariables && {
|
|
580
|
+
onInsert: () => insertAndClose({ type: "images", attrs: { variable: selectedName } }),
|
|
581
|
+
})}
|
|
554
582
|
/>
|
|
555
583
|
</div>
|
|
556
584
|
</div>
|
|
@@ -563,7 +591,8 @@ export default function DocumentTiptapEditor({
|
|
|
563
591
|
onChange,
|
|
564
592
|
variables,
|
|
565
593
|
readOnly,
|
|
566
|
-
blocks = MAIL_DOCUMENT_BLOCKS,
|
|
594
|
+
blocks: configuredBlocks = MAIL_DOCUMENT_BLOCKS,
|
|
595
|
+
imageVariables = NO_NAMES,
|
|
567
596
|
variableLabels = NO_LABELS,
|
|
568
597
|
systemVariables = NO_NAMES,
|
|
569
598
|
resourcePreviews = NO_PREVIEWS,
|
|
@@ -581,6 +610,11 @@ export default function DocumentTiptapEditor({
|
|
|
581
610
|
const canvas = chrome === "canvas";
|
|
582
611
|
const compact = toolbar === "compact" && !canvas;
|
|
583
612
|
const hosted = toolbarHost !== undefined;
|
|
613
|
+
// A photo block needs an images variable to bind to; without one the button would open an empty panel.
|
|
614
|
+
const blocks =
|
|
615
|
+
imageVariables.length === 0
|
|
616
|
+
? configuredBlocks.filter((block) => block !== "images")
|
|
617
|
+
: configuredBlocks;
|
|
584
618
|
|
|
585
619
|
const placeholderLabel = t("template-resolver.documentEditor.placeholder");
|
|
586
620
|
const imageLabel = t("template-resolver.documentEditor.image");
|
|
@@ -588,6 +622,7 @@ export default function DocumentTiptapEditor({
|
|
|
588
622
|
const pageBreakLabel = t("template-resolver.documentEditor.pageBreak");
|
|
589
623
|
const buttonLabel = t("template-resolver.documentEditor.button");
|
|
590
624
|
const stepsLabel = t("template-resolver.documentEditor.steps");
|
|
625
|
+
const imagesLabel = t("template-resolver.documentEditor.images");
|
|
591
626
|
const stableLabels = useStableByKey(variableLabels, JSON.stringify(variableLabels));
|
|
592
627
|
const stableSystem = useStableByKey(systemVariables, systemVariables.join("\n"));
|
|
593
628
|
const stablePreviews = useStableByKey(resourcePreviews, previewsKey(resourcePreviews));
|
|
@@ -599,6 +634,7 @@ export default function DocumentTiptapEditor({
|
|
|
599
634
|
pageBreak: pageBreakLabel,
|
|
600
635
|
button: buttonLabel,
|
|
601
636
|
steps: stepsLabel,
|
|
637
|
+
images: imagesLabel,
|
|
602
638
|
imageBlock: imageBlockLabel,
|
|
603
639
|
variableLabels: stableLabels,
|
|
604
640
|
systemVariables: stableSystem,
|
|
@@ -612,6 +648,7 @@ export default function DocumentTiptapEditor({
|
|
|
612
648
|
pageBreakLabel,
|
|
613
649
|
buttonLabel,
|
|
614
650
|
stepsLabel,
|
|
651
|
+
imagesLabel,
|
|
615
652
|
stableLabels,
|
|
616
653
|
stableSystem,
|
|
617
654
|
stablePreviews,
|
|
@@ -731,6 +768,7 @@ export default function DocumentTiptapEditor({
|
|
|
731
768
|
pageBreak: { label: pageBreakLabel, icon: <Scissors size={16} /> },
|
|
732
769
|
align: { label: t("template-resolver.documentEditor.align"), icon: <AlignLeft size={16} /> },
|
|
733
770
|
steps: { label: stepsLabel, icon: <ListChecks size={16} /> },
|
|
771
|
+
images: { label: imagesLabel, icon: <GalleryThumbnails size={16} /> },
|
|
734
772
|
};
|
|
735
773
|
|
|
736
774
|
const repeatTable = activeState?.repeatTable ?? null;
|
|
@@ -1058,7 +1096,7 @@ export default function DocumentTiptapEditor({
|
|
|
1058
1096
|
editor={editor}
|
|
1059
1097
|
editorId={id}
|
|
1060
1098
|
kind={openPanel}
|
|
1061
|
-
variables={variables}
|
|
1099
|
+
variables={openPanel === "images" ? imageVariables : variables}
|
|
1062
1100
|
variableLabels={stableLabels}
|
|
1063
1101
|
onClose={() => setOpenPanel(null)}
|
|
1064
1102
|
/>
|
|
@@ -22,6 +22,8 @@ export type DocumentEditorChrome = "default" | "canvas";
|
|
|
22
22
|
|
|
23
23
|
export type DocumentEditorProps = ContentEditorProps & {
|
|
24
24
|
readonly blocks?: readonly DocumentToolbarBlock[];
|
|
25
|
+
/** Names of the template's images variables; the photo block offers exactly these and is hidden without any. */
|
|
26
|
+
readonly imageVariables?: readonly string[];
|
|
25
27
|
/** Display label per variable name, as inserted (list item fields use the
|
|
26
28
|
* dotted form, e.g. "lines.description"). A chip shows label and name. */
|
|
27
29
|
readonly variableLabels?: Readonly<Record<string, string>>;
|
|
@@ -43,6 +43,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
43
43
|
"template-resolver.documentEditor.removeLink": "Remove link",
|
|
44
44
|
"template-resolver.documentEditor.repeat": "Repeat",
|
|
45
45
|
"template-resolver.documentEditor.repeatList": "List to repeat",
|
|
46
|
+
"template-resolver.documentEditor.images": "Photos",
|
|
47
|
+
"template-resolver.documentEditor.imagesVariable": "Photos from",
|
|
46
48
|
"template-resolver.documentEditor.steps": "Steps",
|
|
47
49
|
"template-resolver.documentEditor.stepsVariable": "Current step from",
|
|
48
50
|
"template-resolver.documentEditor.stepsList": "Steps, separated by commas (value=label)",
|
|
@@ -58,6 +60,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
58
60
|
"template-resolver.editor.saving": "Saving…",
|
|
59
61
|
"template-resolver.editor.created": "Created.",
|
|
60
62
|
"template-resolver.editor.saved": "Saved.",
|
|
63
|
+
"templateResolver.errors.document.imagesBlockPdfOnly":
|
|
64
|
+
"Photo blocks are only allowed in PDF documents.",
|
|
61
65
|
"templateResolver.errors.document.buttonHrefNotPlaceholder":
|
|
62
66
|
"The button target “{detail}” must be a placeholder.",
|
|
63
67
|
"templateResolver.errors.document.disallowedLinkHref":
|
|
@@ -122,6 +126,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
122
126
|
"templateResolver.errors.noDraft": "There is no draft to discard.",
|
|
123
127
|
"templateResolver.errors.systemOnlyTemplate":
|
|
124
128
|
"Only the platform maintains this template; it cannot be overridden per tenant.",
|
|
129
|
+
"templateResolver.errors.templateNotTenantEditable":
|
|
130
|
+
"Only the platform may edit this template.",
|
|
125
131
|
"templateResolver.errors.template.invalidMeta": "The template settings are invalid.",
|
|
126
132
|
"templateResolver.errors.template.meta.subject.required": "The subject is missing.",
|
|
127
133
|
"templateResolver.errors.template.meta.subject.invalid":
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import { Drawer } from "@cosmicdrift/kumiko-renderer/web";
|
|
3
|
+
import { CircleHelp } from "lucide-react";
|
|
4
|
+
import { type ReactNode, useState } from "react";
|
|
5
|
+
|
|
6
|
+
// A non-modal side panel: the screen behind stays usable and narrows instead of being covered.
|
|
7
|
+
export function HelpAction(): ReactNode {
|
|
8
|
+
const t = useTranslation();
|
|
9
|
+
const [open, setOpen] = useState(false);
|
|
10
|
+
return (
|
|
11
|
+
<>
|
|
12
|
+
<button
|
|
13
|
+
type="button"
|
|
14
|
+
data-testid="help-action"
|
|
15
|
+
aria-label={t("tasks.help.label")}
|
|
16
|
+
aria-pressed={open}
|
|
17
|
+
onClick={() => setOpen((current) => !current)}
|
|
18
|
+
className="inline-flex size-9 items-center justify-center rounded-md border border-border bg-card text-foreground hover:bg-accent"
|
|
19
|
+
>
|
|
20
|
+
<CircleHelp className="h-4 w-4" aria-hidden="true" />
|
|
21
|
+
</button>
|
|
22
|
+
<Drawer
|
|
23
|
+
open={open}
|
|
24
|
+
onOpenChange={setOpen}
|
|
25
|
+
side="right"
|
|
26
|
+
modal={false}
|
|
27
|
+
belowHeader
|
|
28
|
+
width={360}
|
|
29
|
+
title={t("tasks.help.label")}
|
|
30
|
+
description={t("tasks.help.description")}
|
|
31
|
+
testId="help-drawer"
|
|
32
|
+
>
|
|
33
|
+
<p className="py-2 text-sm text-muted-foreground">{t("tasks.help.body")}</p>
|
|
34
|
+
</Drawer>
|
|
35
|
+
</>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
SYSTEM_INVOICE_DOCUMENTS,
|
|
32
32
|
SYSTEM_INVOICE_ISSUED,
|
|
33
33
|
SYSTEM_PDF_LAYOUT,
|
|
34
|
+
SYSTEM_PROPERTY_EXPOSE,
|
|
34
35
|
SYSTEM_TENANCY_CERTIFICATE,
|
|
35
36
|
SYSTEM_TERMS_CHANGED,
|
|
36
37
|
TENANT_INVOICE_OVERRIDES,
|
|
@@ -168,6 +169,7 @@ export const seedTemplates: SeedFn = async (stack) => {
|
|
|
168
169
|
await upsertSystem(SYSTEM_INVOICE_ISSUED);
|
|
169
170
|
await upsertSystem(SYSTEM_TERMS_CHANGED);
|
|
170
171
|
for (const document of SYSTEM_INVOICE_DOCUMENTS) await upsertSystem(document);
|
|
172
|
+
await upsertSystem(SYSTEM_PROPERTY_EXPOSE);
|
|
171
173
|
const certificate = await upsertSystem(SYSTEM_TENANCY_CERTIFICATE);
|
|
172
174
|
await stack.http.writeOk(
|
|
173
175
|
TemplateResolverHandlers.archiveSystem,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
SidebarBrand,
|
|
15
15
|
ThemeMenuItem,
|
|
16
16
|
ThemeToggle,
|
|
17
|
+
useNav,
|
|
17
18
|
useTranslation,
|
|
18
19
|
} from "@cosmicdrift/kumiko-renderer/web";
|
|
19
20
|
import { MoonStar, Sun } from "lucide-react";
|
|
@@ -25,8 +26,10 @@ import {
|
|
|
25
26
|
NORDLICHT_TENANT_ID,
|
|
26
27
|
NORDLICHT_TENANT_NAME,
|
|
27
28
|
} from "./auth-constants";
|
|
29
|
+
import { HelpAction } from "./help-action";
|
|
28
30
|
|
|
29
31
|
const APP_NAME = "Kumiko Walkthrough";
|
|
32
|
+
const HELP_SCREEN_ID = "vehicle-edit";
|
|
30
33
|
|
|
31
34
|
const tenantName = (tenantId: string): string => {
|
|
32
35
|
if (tenantId === DEV_TENANT_ID) return "Dev Tenant";
|
|
@@ -49,6 +52,7 @@ export function AppShell({
|
|
|
49
52
|
}): ReactNode {
|
|
50
53
|
const t = useTranslation();
|
|
51
54
|
const session = useSession();
|
|
55
|
+
const nav = useNav();
|
|
52
56
|
// Without the user the shell hides role-gated navs and KumikoScreen denies role-gated screens.
|
|
53
57
|
const user = session.user !== null ? { id: session.user.id, roles: session.roles } : undefined;
|
|
54
58
|
return (
|
|
@@ -58,6 +62,8 @@ export function AppShell({
|
|
|
58
62
|
{...(user !== undefined && { user })}
|
|
59
63
|
headerActions={
|
|
60
64
|
<>
|
|
65
|
+
{/* Only here: the editor screens need the header width for their own actions. */}
|
|
66
|
+
{nav.route?.screenId === HELP_SCREEN_ID && <HelpAction />}
|
|
61
67
|
<AssistantAction />
|
|
62
68
|
<ThemeToggle />
|
|
63
69
|
</>
|