@elytracms/forms 0.0.0-stage → 0.0.95
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/LICENSE +21 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/next/index.d.ts +20 -0
- package/dist/next/index.js +19 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/routes.d.ts +145 -0
- package/dist/next/routes.js +214 -0
- package/dist/next/routes.js.map +1 -0
- package/dist/next/submit.d.ts +83 -0
- package/dist/next/submit.js +137 -0
- package/dist/next/submit.js.map +1 -0
- package/dist/shared/delivery.d.ts +154 -0
- package/dist/shared/delivery.js +218 -0
- package/dist/shared/delivery.js.map +1 -0
- package/dist/shared/kind.d.ts +120 -0
- package/dist/shared/kind.js +98 -0
- package/dist/shared/kind.js.map +1 -0
- package/dist/shared/mail.d.ts +35 -0
- package/dist/shared/mail.js +47 -0
- package/dist/shared/mail.js.map +1 -0
- package/dist/shared/source.d.ts +70 -0
- package/dist/shared/source.js +40 -0
- package/dist/shared/source.js.map +1 -0
- package/dist/shared/submission.d.ts +83 -0
- package/dist/shared/submission.js +146 -0
- package/dist/shared/submission.js.map +1 -0
- package/dist/studio/appendix.d.ts +11 -0
- package/dist/studio/appendix.js +50 -0
- package/dist/studio/appendix.js.map +1 -0
- package/dist/studio/inbox.d.ts +39 -0
- package/dist/studio/inbox.js +211 -0
- package/dist/studio/inbox.js.map +1 -0
- package/dist/studio/index.d.ts +15 -0
- package/dist/studio/index.js +13 -0
- package/dist/studio/index.js.map +1 -0
- package/dist/studio/lens.d.ts +68 -0
- package/dist/studio/lens.js +149 -0
- package/dist/studio/lens.js.map +1 -0
- package/package.json +75 -3
- package/src/convex/forms.convex.test.ts +552 -0
- package/src/convex/index.ts +340 -0
- package/src/shared/delivery.test.ts +122 -0
- package/src/shared/delivery.ts +263 -0
- package/src/shared/kind.ts +120 -0
- package/src/shared/mail.ts +67 -0
- package/src/shared/source.test.ts +83 -0
- package/src/shared/source.ts +107 -0
- package/src/shared/submission.test.ts +97 -0
- package/src/shared/submission.ts +163 -0
- package/README.md +0 -3
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { convexFieldNameProblem } from '@elytracms/core/ingest';
|
|
2
|
+
import { formValueSchema } from './kind.js';
|
|
3
|
+
/**
|
|
4
|
+
* EC-613 — the FORMS WIRE inside the ingest envelope (EC-617): what a site's `submission`
|
|
5
|
+
* payload carries for this addon, and the ONE reader the receiver's handler turns it into a
|
|
6
|
+
* row with. Pure (no Convex), so the rules are unit-tested on their own.
|
|
7
|
+
*
|
|
8
|
+
* `data` holds the form's fields by name, plus RESERVED keys the addon's site helpers fill,
|
|
9
|
+
* in the `elytra:` namespace:
|
|
10
|
+
*
|
|
11
|
+
* - `elytra:source` — the page the form was submitted from (the row's `sourceUrl`);
|
|
12
|
+
* - `elytra:honeypot` — the value of the form's honeypot field (a real visitor leaves it empty).
|
|
13
|
+
*
|
|
14
|
+
* Any other `elytra:` key is refused — never silently dropped. Empty values (`''`, `[]`) are
|
|
15
|
+
* not answers and are not stored; a submission with no answer and no file is refused.
|
|
16
|
+
*
|
|
17
|
+
* A field NAME follows ONE rule ({@link formFieldNameProblem}): the ingest envelope's — a name
|
|
18
|
+
* Convex can store (`convexFieldNameProblem`, `@elytracms/core/ingest`: printable ASCII, no
|
|
19
|
+
* leading `$`, at most 1024 characters; the route and the receiver refuse anything else with
|
|
20
|
+
* 400) — plus the forms addon's own cap of 200 characters (the stored payload's `name`). The
|
|
21
|
+
* site helpers refuse a name before sending, with the same reason the envelope would give.
|
|
22
|
+
*/
|
|
23
|
+
/** The reserved namespace of the forms wire. */
|
|
24
|
+
export const FORMS_RESERVED_PREFIX = 'elytra:';
|
|
25
|
+
/** The page a submission came from. */
|
|
26
|
+
export const FORMS_SOURCE_KEY = 'elytra:source';
|
|
27
|
+
/** The honeypot's value. */
|
|
28
|
+
export const FORMS_HONEYPOT_KEY = 'elytra:honeypot';
|
|
29
|
+
/**
|
|
30
|
+
* The hidden input a native (no-JS) form post names its form with — read by the `./next`
|
|
31
|
+
* form-post route, never sent on: it becomes the ingest payload's `target`.
|
|
32
|
+
*/
|
|
33
|
+
export const FORMS_TARGET_FIELD = 'elytra:form';
|
|
34
|
+
/** The forms addon's own cap on a field name (the stored payload's `name`) — below Convex's 1024. */
|
|
35
|
+
export const FORM_FIELD_NAME_MAX = 200;
|
|
36
|
+
/**
|
|
37
|
+
* Why a form field may not be named `name`, or `undefined` when it may: the ingest envelope's
|
|
38
|
+
* rule first (`convexFieldNameProblem` — the ONE rule, read, never restated), then the forms
|
|
39
|
+
* addon's own limits (not empty, at most {@link FORM_FIELD_NAME_MAX} characters). Phrased to
|
|
40
|
+
* follow `The field name “<name>” …`, exactly as the envelope's 400 issue reads.
|
|
41
|
+
*/
|
|
42
|
+
export function formFieldNameProblem(name) {
|
|
43
|
+
const core = convexFieldNameProblem(name);
|
|
44
|
+
if (core)
|
|
45
|
+
return core;
|
|
46
|
+
if (name.length === 0)
|
|
47
|
+
return 'is empty';
|
|
48
|
+
if (name.length > FORM_FIELD_NAME_MAX)
|
|
49
|
+
return `is longer than ${FORM_FIELD_NAME_MAX} characters (the forms addon's own limit)`;
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
/** The issue message for a refused field name — the envelope's wording (`@elytracms/core/ingest`). */
|
|
53
|
+
export function formFieldNameMessage(name, problem) {
|
|
54
|
+
return `The field name “${name}” ${problem}.`;
|
|
55
|
+
}
|
|
56
|
+
/** The honeypot field both consumers' forms already render (overridable in the site helpers). */
|
|
57
|
+
export const DEFAULT_HONEYPOT_FIELD = 'website';
|
|
58
|
+
/**
|
|
59
|
+
* The file types a form upload may have — documents and raster images. Anything that a
|
|
60
|
+
* browser would run when opened from the deployment's storage URL (HTML, SVG, scripts) is
|
|
61
|
+
* refused by the receiver's handler (415), and so is every type not listed.
|
|
62
|
+
*/
|
|
63
|
+
export const FORMS_UPLOAD_CONTENT_TYPES = [
|
|
64
|
+
'application/pdf',
|
|
65
|
+
'application/msword',
|
|
66
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
67
|
+
'application/vnd.oasis.opendocument.text',
|
|
68
|
+
'application/rtf',
|
|
69
|
+
'text/plain',
|
|
70
|
+
'text/csv',
|
|
71
|
+
'image/jpeg',
|
|
72
|
+
'image/png',
|
|
73
|
+
'image/webp',
|
|
74
|
+
'image/gif',
|
|
75
|
+
'image/heic',
|
|
76
|
+
'image/heif',
|
|
77
|
+
];
|
|
78
|
+
/** The longest source URL a row keeps. */
|
|
79
|
+
const SOURCE_URL_MAX = 2000;
|
|
80
|
+
function isEmptyValue(value) {
|
|
81
|
+
return (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Read a `submission` payload into the row's visitor columns (fields sorted by name). Refuses (422, with the issues)
|
|
85
|
+
* a value that is not a form value, an unknown reserved key, an over-long source URL, and a
|
|
86
|
+
* submission with no answer — unless it carries files (`hasFiles`).
|
|
87
|
+
*/
|
|
88
|
+
export function readSubmissionPayload(payload, options = {}) {
|
|
89
|
+
const issues = [];
|
|
90
|
+
const fields = [];
|
|
91
|
+
let sourceUrl;
|
|
92
|
+
let spam = 'clean';
|
|
93
|
+
// Sorted by name: an object's key order does not survive a Convex function boundary
|
|
94
|
+
// (measured — the receiver's handler receives the keys sorted), so the stored order is the
|
|
95
|
+
// one order every runtime agrees on. The FORM's order is its declaration's (`labelPayload`).
|
|
96
|
+
for (const [name, raw] of Object.entries(payload.data).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
97
|
+
if (name === FORMS_SOURCE_KEY) {
|
|
98
|
+
if (typeof raw !== 'string' || raw.length > SOURCE_URL_MAX) {
|
|
99
|
+
issues.push({ path: ['data', name], message: `“${name}” must be a URL of at most ${SOURCE_URL_MAX} characters.` });
|
|
100
|
+
}
|
|
101
|
+
else if (raw.length > 0)
|
|
102
|
+
sourceUrl = raw;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (name === FORMS_HONEYPOT_KEY) {
|
|
106
|
+
if (typeof raw === 'string' && raw.trim().length > 0)
|
|
107
|
+
spam = 'honeypot';
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (name.startsWith(FORMS_RESERVED_PREFIX)) {
|
|
111
|
+
issues.push({ path: ['data', name], message: `“${name}” is not a reserved forms key (${FORMS_SOURCE_KEY}, ${FORMS_HONEYPOT_KEY}).` });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const value = formValueSchema.safeParse(raw);
|
|
115
|
+
if (!value.success) {
|
|
116
|
+
issues.push({ path: ['data', name], message: `“${name}” is not a form value (text, a number, yes/no, or a list of texts).` });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const nameProblem = formFieldNameProblem(name);
|
|
120
|
+
if (nameProblem) {
|
|
121
|
+
issues.push({ path: ['data', name], message: formFieldNameMessage(name, nameProblem) });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (!isEmptyValue(value.data))
|
|
125
|
+
fields.push({ name, value: value.data });
|
|
126
|
+
}
|
|
127
|
+
if (issues.length === 0 && fields.length === 0 && !options.hasFiles) {
|
|
128
|
+
issues.push({ path: ['data'], message: 'The submission carries no answer.' });
|
|
129
|
+
}
|
|
130
|
+
if (issues.length > 0) {
|
|
131
|
+
return { ok: false, status: 422, message: 'The submission is not valid.', issues };
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
ok: true,
|
|
135
|
+
columns: { form: payload.target, payload: fields, ...(sourceUrl ? { sourceUrl } : {}), spam },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** The display text of one value (a list joined by commas, a boolean as yes/no). */
|
|
139
|
+
export function formatFormValue(value) {
|
|
140
|
+
if (Array.isArray(value))
|
|
141
|
+
return value.join(', ');
|
|
142
|
+
if (typeof value === 'boolean')
|
|
143
|
+
return value ? 'yes' : 'no';
|
|
144
|
+
return String(value);
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=submission.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"submission.js","sourceRoot":"","sources":["../../src/shared/submission.ts"],"names":[],"mappings":"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","sourcesContent":["import { convexFieldNameProblem } from '@elytracms/core/ingest'\nimport type { IngestPayload } from '@elytracms/core/ingest'\nimport { formValueSchema } from './kind'\nimport type { FormValue, SpamVerdict, SubmissionField } from './kind'\n\n/**\n * EC-613 — the FORMS WIRE inside the ingest envelope (EC-617): what a site's `submission`\n * payload carries for this addon, and the ONE reader the receiver's handler turns it into a\n * row with. Pure (no Convex), so the rules are unit-tested on their own.\n *\n * `data` holds the form's fields by name, plus RESERVED keys the addon's site helpers fill,\n * in the `elytra:` namespace:\n *\n * - `elytra:source` — the page the form was submitted from (the row's `sourceUrl`);\n * - `elytra:honeypot` — the value of the form's honeypot field (a real visitor leaves it empty).\n *\n * Any other `elytra:` key is refused — never silently dropped. Empty values (`''`, `[]`) are\n * not answers and are not stored; a submission with no answer and no file is refused.\n *\n * A field NAME follows ONE rule ({@link formFieldNameProblem}): the ingest envelope's — a name\n * Convex can store (`convexFieldNameProblem`, `@elytracms/core/ingest`: printable ASCII, no\n * leading `$`, at most 1024 characters; the route and the receiver refuse anything else with\n * 400) — plus the forms addon's own cap of 200 characters (the stored payload's `name`). The\n * site helpers refuse a name before sending, with the same reason the envelope would give.\n */\n\n/** The reserved namespace of the forms wire. */\nexport const FORMS_RESERVED_PREFIX = 'elytra:'\n/** The page a submission came from. */\nexport const FORMS_SOURCE_KEY = 'elytra:source'\n/** The honeypot's value. */\nexport const FORMS_HONEYPOT_KEY = 'elytra:honeypot'\n/**\n * The hidden input a native (no-JS) form post names its form with — read by the `./next`\n * form-post route, never sent on: it becomes the ingest payload's `target`.\n */\nexport const FORMS_TARGET_FIELD = 'elytra:form'\n\n/** The forms addon's own cap on a field name (the stored payload's `name`) — below Convex's 1024. */\nexport const FORM_FIELD_NAME_MAX = 200\n\n/**\n * Why a form field may not be named `name`, or `undefined` when it may: the ingest envelope's\n * rule first (`convexFieldNameProblem` — the ONE rule, read, never restated), then the forms\n * addon's own limits (not empty, at most {@link FORM_FIELD_NAME_MAX} characters). Phrased to\n * follow `The field name “<name>” …`, exactly as the envelope's 400 issue reads.\n */\nexport function formFieldNameProblem(name: string): string | undefined {\n const core = convexFieldNameProblem(name)\n if (core) return core\n if (name.length === 0) return 'is empty'\n if (name.length > FORM_FIELD_NAME_MAX) return `is longer than ${FORM_FIELD_NAME_MAX} characters (the forms addon's own limit)`\n return undefined\n}\n\n/** The issue message for a refused field name — the envelope's wording (`@elytracms/core/ingest`). */\nexport function formFieldNameMessage(name: string, problem: string): string {\n return `The field name “${name}” ${problem}.`\n}\n/** The honeypot field both consumers' forms already render (overridable in the site helpers). */\nexport const DEFAULT_HONEYPOT_FIELD = 'website'\n\n/**\n * The file types a form upload may have — documents and raster images. Anything that a\n * browser would run when opened from the deployment's storage URL (HTML, SVG, scripts) is\n * refused by the receiver's handler (415), and so is every type not listed.\n */\nexport const FORMS_UPLOAD_CONTENT_TYPES: readonly string[] = [\n 'application/pdf',\n 'application/msword',\n 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'application/vnd.oasis.opendocument.text',\n 'application/rtf',\n 'text/plain',\n 'text/csv',\n 'image/jpeg',\n 'image/png',\n 'image/webp',\n 'image/gif',\n 'image/heic',\n 'image/heif',\n]\n\n/** The longest source URL a row keeps. */\nconst SOURCE_URL_MAX = 2000\n\n/** What a site's submission becomes in the row (the visitor's part — see the kind's header). */\nexport interface SubmissionColumns {\n form: string\n payload: SubmissionField[]\n sourceUrl?: string\n spam: SpamVerdict\n}\n\nexport type SubmissionRead =\n | { ok: true; columns: SubmissionColumns }\n | { ok: false; status: 422; message: string; issues: { path: (string | number)[]; message: string }[] }\n\nfunction isEmptyValue(value: FormValue): boolean {\n return (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0)\n}\n\n/**\n * Read a `submission` payload into the row's visitor columns (fields sorted by name). Refuses (422, with the issues)\n * a value that is not a form value, an unknown reserved key, an over-long source URL, and a\n * submission with no answer — unless it carries files (`hasFiles`).\n */\nexport function readSubmissionPayload(\n payload: Extract<IngestPayload, { kind: 'submission' }>,\n options: { hasFiles?: boolean } = {},\n): SubmissionRead {\n const issues: { path: (string | number)[]; message: string }[] = []\n const fields: SubmissionField[] = []\n let sourceUrl: string | undefined\n let spam: SpamVerdict = 'clean'\n // Sorted by name: an object's key order does not survive a Convex function boundary\n // (measured — the receiver's handler receives the keys sorted), so the stored order is the\n // one order every runtime agrees on. The FORM's order is its declaration's (`labelPayload`).\n for (const [name, raw] of Object.entries(payload.data).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {\n if (name === FORMS_SOURCE_KEY) {\n if (typeof raw !== 'string' || raw.length > SOURCE_URL_MAX) {\n issues.push({ path: ['data', name], message: `“${name}” must be a URL of at most ${SOURCE_URL_MAX} characters.` })\n } else if (raw.length > 0) sourceUrl = raw\n continue\n }\n if (name === FORMS_HONEYPOT_KEY) {\n if (typeof raw === 'string' && raw.trim().length > 0) spam = 'honeypot'\n continue\n }\n if (name.startsWith(FORMS_RESERVED_PREFIX)) {\n issues.push({ path: ['data', name], message: `“${name}” is not a reserved forms key (${FORMS_SOURCE_KEY}, ${FORMS_HONEYPOT_KEY}).` })\n continue\n }\n const value = formValueSchema.safeParse(raw)\n if (!value.success) {\n issues.push({ path: ['data', name], message: `“${name}” is not a form value (text, a number, yes/no, or a list of texts).` })\n continue\n }\n const nameProblem = formFieldNameProblem(name)\n if (nameProblem) {\n issues.push({ path: ['data', name], message: formFieldNameMessage(name, nameProblem) })\n continue\n }\n if (!isEmptyValue(value.data)) fields.push({ name, value: value.data })\n }\n if (issues.length === 0 && fields.length === 0 && !options.hasFiles) {\n issues.push({ path: ['data'], message: 'The submission carries no answer.' })\n }\n if (issues.length > 0) {\n return { ok: false, status: 422, message: 'The submission is not valid.', issues }\n }\n return {\n ok: true,\n columns: { form: payload.target, payload: fields, ...(sourceUrl ? { sourceUrl } : {}), spam },\n }\n}\n\n/** The display text of one value (a list joined by commas, a boolean as yes/no). */\nexport function formatFormValue(value: FormValue): string {\n if (Array.isArray(value)) return value.join(', ')\n if (typeof value === 'boolean') return value ? 'yes' : 'no'\n return String(value)\n}\n"]}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { LocalizedString } from '@elytracms/core/cms-core';
|
|
2
|
+
import type { SubmissionRecord } from '../shared/kind.js';
|
|
3
|
+
import type { LabelledField } from '../shared/mail.js';
|
|
4
|
+
export interface SubmissionAppendixProps {
|
|
5
|
+
record: SubmissionRecord;
|
|
6
|
+
fields: readonly LabelledField[];
|
|
7
|
+
stateLabel: LocalizedString;
|
|
8
|
+
onRetry: () => void;
|
|
9
|
+
retrying: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function SubmissionAppendix({ record, fields, stateLabel, onRetry, retrying }: SubmissionAppendixProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useLabel } from '@elytracms/studio/addon';
|
|
3
|
+
import { Badge, Button, CollapsibleSection, Row, Stack, Text } from '@elytracms/studio/ui';
|
|
4
|
+
import { deliveryNeedsAttention } from '../shared/delivery.js';
|
|
5
|
+
import { formatFormValue } from '../shared/submission.js';
|
|
6
|
+
/**
|
|
7
|
+
* EC-613 — what the edit shell shows BELOW the editable fields (its form-column appendix,
|
|
8
|
+
* EC-396): what the visitor sent (labelled by the form), the files (downloadable), and the
|
|
9
|
+
* delivery log per recipient with a retry for a failed mail. Read-only by construction — the
|
|
10
|
+
* appendix never writes the row; only its Retry asks the kind's `save` to move the row into
|
|
11
|
+
* `pending` (the deployment then schedules the delivery).
|
|
12
|
+
*/
|
|
13
|
+
const L = (en, de) => ({ en, de });
|
|
14
|
+
const TEXT = {
|
|
15
|
+
answers: L('Answers', 'Angaben'),
|
|
16
|
+
noAnswers: L('No answers — files only.', 'Keine Angaben — nur Dateien.'),
|
|
17
|
+
files: L('Files', 'Dateien'),
|
|
18
|
+
download: L('Download', 'Herunterladen'),
|
|
19
|
+
noUrl: L('not available here', 'hier nicht verfügbar'),
|
|
20
|
+
delivery: L('Mail delivery', 'E-Mail-Zustellung'),
|
|
21
|
+
noLog: L('Nothing sent yet.', 'Noch nichts gesendet.'),
|
|
22
|
+
retry: L('Retry delivery', 'Zustellung wiederholen'),
|
|
23
|
+
retrying: L('Retrying…', 'Wird wiederholt…'),
|
|
24
|
+
nextAttempt: L('next attempt', 'nächster Versuch'),
|
|
25
|
+
request: L('the site', 'die Website'),
|
|
26
|
+
};
|
|
27
|
+
const STATE_VARIANT = {
|
|
28
|
+
pending: 'info',
|
|
29
|
+
sent: 'success',
|
|
30
|
+
partial: 'warning',
|
|
31
|
+
failed: 'error',
|
|
32
|
+
skipped: 'outline',
|
|
33
|
+
};
|
|
34
|
+
/** A section title as the edit shell writes its own fieldset titles. */
|
|
35
|
+
function SectionTitle({ children }) {
|
|
36
|
+
return (_jsx(Text, { size: "sm", weight: "semibold", children: children }));
|
|
37
|
+
}
|
|
38
|
+
function kilobytes(size) {
|
|
39
|
+
return `${Math.max(1, Math.round(size / 1024))} KB`;
|
|
40
|
+
}
|
|
41
|
+
function LogLine({ entry }) {
|
|
42
|
+
const label = useLabel();
|
|
43
|
+
return (_jsxs(Row, { gap: "2", align: "start", "data-testid": "forms-delivery-line", children: [_jsx(Badge, { variant: entry.ok ? 'success' : 'error', children: entry.ok ? '✓' : '✕' }), _jsxs(Stack, { gap: "none", children: [_jsxs(Text, { size: "sm", children: [entry.to ?? (entry.note ? entry.note : label(TEXT.request)), " \u00B7 #", entry.attempt, " \u00B7 ", new Date(entry.at).toLocaleString()] }), entry.error ? (_jsx(Text, { size: "xs", tone: "muted", children: entry.error })) : null, entry.retryAt ? (_jsxs(Text, { size: "xs", tone: "subtle", children: [label(TEXT.nextAttempt), ": ", new Date(entry.retryAt).toLocaleString()] })) : null] })] }));
|
|
44
|
+
}
|
|
45
|
+
export function SubmissionAppendix({ record, fields, stateLabel, onRetry, retrying }) {
|
|
46
|
+
const label = useLabel();
|
|
47
|
+
const log = record.deliveryLog ?? [];
|
|
48
|
+
return (_jsxs(Stack, { gap: "6", "data-testid": "forms-submission-appendix", children: [_jsx(CollapsibleSection, { title: _jsx(SectionTitle, { children: label(TEXT.answers) }), "data-testid": "forms-answers", children: fields.length === 0 ? (_jsx(Text, { tone: "muted", className: "p-4", children: label(TEXT.noAnswers) })) : (_jsx(Stack, { gap: "3", className: "p-4", children: fields.map((field) => (_jsxs(Stack, { gap: "none", children: [_jsx(Text, { size: "xs", tone: "muted", weight: "medium", children: label(field.label, field.name) }), _jsx(Text, { className: "whitespace-pre-wrap", children: formatFormValue(field.value) })] }, field.name))) })) }), record.files && record.files.length > 0 ? (_jsx(CollapsibleSection, { title: _jsx(SectionTitle, { children: label(TEXT.files) }), "data-testid": "forms-files", children: _jsx(Stack, { gap: "2", className: "p-4", children: record.files.map((file) => (_jsxs(Row, { gap: "2", justify: "between", children: [_jsxs(Text, { children: [file.name ?? file.field, " ", _jsxs(Text, { as: "span", tone: "muted", children: ["(", file.contentType, ", ", kilobytes(file.size), ")"] })] }), file.url ? (_jsx(Button, { asChild: true, variant: "outline", size: "sm", children: _jsx("a", { href: file.url, target: "_blank", rel: "noreferrer", download: file.name ?? true, children: label(TEXT.download) }) })) : (_jsx(Text, { size: "xs", tone: "subtle", children: label(TEXT.noUrl) }))] }, file.storageId))) }) })) : null, _jsx(CollapsibleSection, { title: _jsxs(Row, { gap: "2", children: [_jsx(SectionTitle, { children: label(TEXT.delivery) }), _jsx(Badge, { variant: STATE_VARIANT[record.deliveryState], children: label(stateLabel) })] }), "data-testid": "forms-delivery", children: _jsxs(Stack, { gap: "3", className: "p-4", children: [log.length === 0 ? _jsx(Text, { tone: "muted", children: label(TEXT.noLog) }) : log.map((entry, index) => _jsx(LogLine, { entry: entry }, index)), deliveryNeedsAttention(record.deliveryState) ? (_jsx(Row, { children: _jsx(Button, { variant: "secondary", size: "sm", disabled: retrying, onClick: onRetry, "data-testid": "forms-retry-delivery", children: label(retrying ? TEXT.retrying : TEXT.retry) }) })) : null] }) })] }));
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=appendix.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"appendix.js","sourceRoot":"","sources":["../../src/studio/appendix.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useLabel } from '@elytracms/studio/addon'\nimport type { ReactNode } from 'react'\nimport { Badge, Button, CollapsibleSection, Row, Stack, Text } from '@elytracms/studio/ui'\nimport type { LocalizedString } from '@elytracms/core/cms-core'\nimport type { DeliveryEntry, DeliveryState, SubmissionRecord } from '../shared/kind'\nimport { deliveryNeedsAttention } from '../shared/delivery'\nimport type { LabelledField } from '../shared/mail'\nimport { formatFormValue } from '../shared/submission'\n\n/**\n * EC-613 — what the edit shell shows BELOW the editable fields (its form-column appendix,\n * EC-396): what the visitor sent (labelled by the form), the files (downloadable), and the\n * delivery log per recipient with a retry for a failed mail. Read-only by construction — the\n * appendix never writes the row; only its Retry asks the kind's `save` to move the row into\n * `pending` (the deployment then schedules the delivery).\n */\n\nconst L = (en: string, de: string): LocalizedString => ({ en, de })\n\nconst TEXT = {\n answers: L('Answers', 'Angaben'),\n noAnswers: L('No answers — files only.', 'Keine Angaben — nur Dateien.'),\n files: L('Files', 'Dateien'),\n download: L('Download', 'Herunterladen'),\n noUrl: L('not available here', 'hier nicht verfügbar'),\n delivery: L('Mail delivery', 'E-Mail-Zustellung'),\n noLog: L('Nothing sent yet.', 'Noch nichts gesendet.'),\n retry: L('Retry delivery', 'Zustellung wiederholen'),\n retrying: L('Retrying…', 'Wird wiederholt…'),\n nextAttempt: L('next attempt', 'nächster Versuch'),\n request: L('the site', 'die Website'),\n} as const\n\nconst STATE_VARIANT: Record<DeliveryState, 'success' | 'warning' | 'error' | 'info' | 'outline'> = {\n pending: 'info',\n sent: 'success',\n partial: 'warning',\n failed: 'error',\n skipped: 'outline',\n}\n\n/** A section title as the edit shell writes its own fieldset titles. */\nfunction SectionTitle({ children }: { children: ReactNode }) {\n return (\n <Text size=\"sm\" weight=\"semibold\">\n {children}\n </Text>\n )\n}\n\nfunction kilobytes(size: number): string {\n return `${Math.max(1, Math.round(size / 1024))} KB`\n}\n\nfunction LogLine({ entry }: { entry: DeliveryEntry }) {\n const label = useLabel()\n return (\n <Row gap=\"2\" align=\"start\" data-testid=\"forms-delivery-line\">\n <Badge variant={entry.ok ? 'success' : 'error'}>{entry.ok ? '✓' : '✕'}</Badge>\n <Stack gap=\"none\">\n <Text size=\"sm\">\n {entry.to ?? (entry.note ? entry.note : label(TEXT.request))} · #{entry.attempt} · {new Date(entry.at).toLocaleString()}\n </Text>\n {entry.error ? (\n <Text size=\"xs\" tone=\"muted\">\n {entry.error}\n </Text>\n ) : null}\n {entry.retryAt ? (\n <Text size=\"xs\" tone=\"subtle\">\n {label(TEXT.nextAttempt)}: {new Date(entry.retryAt).toLocaleString()}\n </Text>\n ) : null}\n </Stack>\n </Row>\n )\n}\n\nexport interface SubmissionAppendixProps {\n record: SubmissionRecord\n fields: readonly LabelledField[]\n stateLabel: LocalizedString\n onRetry: () => void\n retrying: boolean\n}\n\nexport function SubmissionAppendix({ record, fields, stateLabel, onRetry, retrying }: SubmissionAppendixProps) {\n const label = useLabel()\n const log = record.deliveryLog ?? []\n return (\n <Stack gap=\"6\" data-testid=\"forms-submission-appendix\">\n <CollapsibleSection title={<SectionTitle>{label(TEXT.answers)}</SectionTitle>} data-testid=\"forms-answers\">\n {fields.length === 0 ? (\n <Text tone=\"muted\" className=\"p-4\">\n {label(TEXT.noAnswers)}\n </Text>\n ) : (\n <Stack gap=\"3\" className=\"p-4\">\n {fields.map((field) => (\n <Stack key={field.name} gap=\"none\">\n <Text size=\"xs\" tone=\"muted\" weight=\"medium\">\n {label(field.label, field.name)}\n </Text>\n <Text className=\"whitespace-pre-wrap\">{formatFormValue(field.value)}</Text>\n </Stack>\n ))}\n </Stack>\n )}\n </CollapsibleSection>\n\n {record.files && record.files.length > 0 ? (\n <CollapsibleSection title={<SectionTitle>{label(TEXT.files)}</SectionTitle>} data-testid=\"forms-files\">\n <Stack gap=\"2\" className=\"p-4\">\n {record.files.map((file) => (\n <Row key={file.storageId} gap=\"2\" justify=\"between\">\n <Text>\n {file.name ?? file.field} <Text as=\"span\" tone=\"muted\">({file.contentType}, {kilobytes(file.size)})</Text>\n </Text>\n {file.url ? (\n <Button asChild variant=\"outline\" size=\"sm\">\n <a href={file.url} target=\"_blank\" rel=\"noreferrer\" download={file.name ?? true}>\n {label(TEXT.download)}\n </a>\n </Button>\n ) : (\n <Text size=\"xs\" tone=\"subtle\">\n {label(TEXT.noUrl)}\n </Text>\n )}\n </Row>\n ))}\n </Stack>\n </CollapsibleSection>\n ) : null}\n\n <CollapsibleSection\n title={\n <Row gap=\"2\">\n <SectionTitle>{label(TEXT.delivery)}</SectionTitle>\n <Badge variant={STATE_VARIANT[record.deliveryState]}>{label(stateLabel)}</Badge>\n </Row>\n }\n data-testid=\"forms-delivery\"\n >\n <Stack gap=\"3\" className=\"p-4\">\n {log.length === 0 ? <Text tone=\"muted\">{label(TEXT.noLog)}</Text> : log.map((entry, index) => <LogLine key={index} entry={entry} />)}\n {deliveryNeedsAttention(record.deliveryState) ? (\n <Row>\n <Button variant=\"secondary\" size=\"sm\" disabled={retrying} onClick={onRetry} data-testid=\"forms-retry-delivery\">\n {label(retrying ? TEXT.retrying : TEXT.retry)}\n </Button>\n </Row>\n ) : null}\n </Stack>\n </CollapsibleSection>\n </Stack>\n )\n}\n"]}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { CollectionViewCapabilities, NavIssue, NavPermission } from '@elytracms/studio/addon';
|
|
2
|
+
import type { FormSource } from '../shared/source.js';
|
|
3
|
+
/**
|
|
4
|
+
* EC-613 — the forms INBOX: the submission kind on the studio's portable list/edit surfaces
|
|
5
|
+
* (AD-19, EC-616's scaffold for a source of one's own), one entry in the project's rail:
|
|
6
|
+
*
|
|
7
|
+
* ```tsx
|
|
8
|
+
* // studio/studio.config.tsx
|
|
9
|
+
* import { formsInbox } from '@elytracms/forms/studio'
|
|
10
|
+
* import { contactForms } from '../cms/forms'
|
|
11
|
+
* export default defineStudio({ sidebar: [ …, formsInbox({ source: contactForms }) ] })
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* The list shows every submission, newest first; one opens in the portable edit shell, where
|
|
15
|
+
* the editor sets the status, the assignee and notes (the inbox lifecycle) and reads — below
|
|
16
|
+
* the fields — what was sent, the files and the mail delivery per recipient, with a retry for
|
|
17
|
+
* a failed mail. A failed or partly failed delivery is an issue on the row AND at the rail
|
|
18
|
+
* foot, from any surface. Reads and writes go through the operations client
|
|
19
|
+
* (`operations.records(submissionRecordKind)`) and the one query cache (AD-17).
|
|
20
|
+
*/
|
|
21
|
+
export interface FormsInboxConfig<V> {
|
|
22
|
+
/** How the inbox reads your form documents (labels, titles) — the same declaration the site's delivery route reads. */
|
|
23
|
+
source: FormSource<V>;
|
|
24
|
+
/** The route segment (default `submissions`). */
|
|
25
|
+
id?: string;
|
|
26
|
+
/** The rail label (default `Submissions`). */
|
|
27
|
+
label?: string;
|
|
28
|
+
/** OPTIONAL, default-open nav permission (AD-18). */
|
|
29
|
+
visible?: NavPermission;
|
|
30
|
+
}
|
|
31
|
+
/** The inbox's capabilities: no "New" (submissions come from the site), delete and bulk delete. */
|
|
32
|
+
export declare const INBOX_CAPABILITIES: CollectionViewCapabilities;
|
|
33
|
+
/** The rail-foot finding: how many submissions' mail did not (fully) go out. */
|
|
34
|
+
declare function failedDeliveryIssues(count: number): NavIssue[];
|
|
35
|
+
/** The inbox's deep link: `[]` the list, `[submissionId]` one submission. */
|
|
36
|
+
declare function parseInboxPath(segments: string[]): Record<string, string | null> | null;
|
|
37
|
+
/** The forms inbox as a rail entry (see the module header). */
|
|
38
|
+
export declare function formsInbox<V>(config: FormsInboxConfig<V>): import("@elytracms/studio/addon").SidebarViewNode;
|
|
39
|
+
export { failedDeliveryIssues, parseInboxPath };
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
3
|
+
import { Inbox } from 'lucide-react';
|
|
4
|
+
import { PortableDetailEditor, CollectionViewSurface, nav, normalizeOkResult, recordListKey, useRecordList, useRailViewSource, } from '@elytracms/studio/addon';
|
|
5
|
+
import { createCollectionRegistry, parseLocaleConfig, validateDocument } from '@elytracms/core/cms-core';
|
|
6
|
+
import { Text } from '@elytracms/studio/ui';
|
|
7
|
+
import { DELIVERY_STATES, submissionRecordKind } from '../shared/kind.js';
|
|
8
|
+
import { deliveryNeedsAttention } from '../shared/delivery.js';
|
|
9
|
+
import { labelPayload } from '../shared/mail.js';
|
|
10
|
+
import { formOf, inboxIssueCount, inboxSaveWrite, submissionLens, submissionSender, submissionToLensDocument, } from './lens.js';
|
|
11
|
+
import { SubmissionAppendix } from './appendix.js';
|
|
12
|
+
/** The inbox's capabilities: no "New" (submissions come from the site), delete and bulk delete. */
|
|
13
|
+
export const INBOX_CAPABILITIES = {
|
|
14
|
+
publish: false,
|
|
15
|
+
versions: false,
|
|
16
|
+
locales: false,
|
|
17
|
+
inlineEdit: false,
|
|
18
|
+
create: false,
|
|
19
|
+
duplicate: false,
|
|
20
|
+
remove: true,
|
|
21
|
+
bulk: true,
|
|
22
|
+
liveUrl: false,
|
|
23
|
+
hierarchy: false,
|
|
24
|
+
importExport: false,
|
|
25
|
+
adminOnly: false,
|
|
26
|
+
};
|
|
27
|
+
const LENS_LOCALE = 'en';
|
|
28
|
+
const LENS_LOCALE_CONFIG = parseLocaleConfig({ default: LENS_LOCALE, locales: [LENS_LOCALE] });
|
|
29
|
+
const DELIVERY_STATE_LABELS = {
|
|
30
|
+
pending: { en: 'Sending', de: 'Wird gesendet' },
|
|
31
|
+
sent: { en: 'Sent', de: 'Gesendet' },
|
|
32
|
+
partial: { en: 'Partly failed', de: 'Teilweise fehlgeschlagen' },
|
|
33
|
+
failed: { en: 'Failed', de: 'Fehlgeschlagen' },
|
|
34
|
+
skipped: { en: 'Not sent', de: 'Nicht gesendet' },
|
|
35
|
+
};
|
|
36
|
+
/** The rail-foot finding: how many submissions' mail did not (fully) go out. */
|
|
37
|
+
function failedDeliveryIssues(count) {
|
|
38
|
+
return count === 0
|
|
39
|
+
? []
|
|
40
|
+
: [
|
|
41
|
+
{
|
|
42
|
+
key: 'forms-delivery-failed',
|
|
43
|
+
message: `${count} form submission${count === 1 ? '' : 's'}: the mail was not delivered — open the inbox to retry.`,
|
|
44
|
+
severity: 'error',
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
/** The always-on rail-foot hook (it reads the inbox's own cached list). */
|
|
49
|
+
function useInboxIssues({ projectId }) {
|
|
50
|
+
const list = useRecordList(submissionRecordKind, projectId);
|
|
51
|
+
const count = inboxIssueCount(list.data ?? []);
|
|
52
|
+
return useMemo(() => failedDeliveryIssues(count), [count]);
|
|
53
|
+
}
|
|
54
|
+
/** The form documents of the source's collection, through the operations client. */
|
|
55
|
+
function useInboxForms(ctx, source) {
|
|
56
|
+
const forms = ctx.query.useOperationQuery({
|
|
57
|
+
queryKey: ['forms-inbox', 'forms', ctx.projectId, source.collection],
|
|
58
|
+
loader: async (operations) => {
|
|
59
|
+
const result = await operations.documents.list({ projectId: ctx.projectId, collection: source.collection });
|
|
60
|
+
if (!result.ok)
|
|
61
|
+
throw new Error(result.error.message);
|
|
62
|
+
return result.value.map((record) => ({ id: record.document.id, values: record.document.values }));
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
return useMemo(() => (forms.data ? { source, documents: forms.data } : undefined), [forms.data, source]);
|
|
66
|
+
}
|
|
67
|
+
/** A failed delivery as a warning on the row's own Issues pane (never blocks a save). */
|
|
68
|
+
function deliveryIssues(record) {
|
|
69
|
+
if (!record || !deliveryNeedsAttention(record.deliveryState))
|
|
70
|
+
return [];
|
|
71
|
+
const failed = (record.deliveryLog ?? []).filter((entry) => !entry.ok);
|
|
72
|
+
const last = failed.at(-1);
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
code: 'custom-validation',
|
|
76
|
+
severity: 'warning',
|
|
77
|
+
message: `The mail was not delivered${last?.to ? ` to ${last.to}` : ''}${last?.error ? `: ${last.error}` : ''}.`,
|
|
78
|
+
path: ['deliveryState'],
|
|
79
|
+
collectionId: submissionRecordKind.kind,
|
|
80
|
+
documentId: record.id,
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
function FormsInbox({ ctx, config, id }) {
|
|
85
|
+
const { projectId } = ctx;
|
|
86
|
+
const recordId = ctx.deepLink['submissionId'] ?? null;
|
|
87
|
+
const list = useRecordList(submissionRecordKind, projectId);
|
|
88
|
+
const cache = ctx.query.useOperationCache();
|
|
89
|
+
const forms = useInboxForms(ctx, config.source);
|
|
90
|
+
const [retrying, setRetrying] = useState(false);
|
|
91
|
+
const [retryError, setRetryError] = useState(null);
|
|
92
|
+
// Newest first — the inbox order.
|
|
93
|
+
const records = useMemo(() => [...(list.data ?? [])].sort((a, b) => (a.submittedAt < b.submittedAt ? 1 : a.submittedAt > b.submittedAt ? -1 : 0)), [list.data]);
|
|
94
|
+
const byId = useMemo(() => new Map(records.map((record) => [record.id, record])), [records]);
|
|
95
|
+
const schema = useMemo(() => submissionLens({ detail: recordId !== null }), [recordId]);
|
|
96
|
+
const registry = useMemo(() => createCollectionRegistry([schema]), [schema]);
|
|
97
|
+
const documents = useCallback(() => records.map((record) => submissionToLensDocument(record, forms)), [records, forms]);
|
|
98
|
+
const get = useCallback((ref) => {
|
|
99
|
+
const record = byId.get(ref.id);
|
|
100
|
+
return record ? submissionToLensDocument(record, forms) : undefined;
|
|
101
|
+
}, [byId, forms]);
|
|
102
|
+
const titleOf = useCallback((ref) => {
|
|
103
|
+
const record = byId.get(ref.id);
|
|
104
|
+
return record ? submissionSender(forms, record) || record.form : ref.id;
|
|
105
|
+
}, [byId, forms]);
|
|
106
|
+
const validate = useCallback((doc) => [
|
|
107
|
+
...validateDocument(registry, doc, { localeConfig: LENS_LOCALE_CONFIG }),
|
|
108
|
+
...deliveryIssues(byId.get(doc.id)),
|
|
109
|
+
], [registry, byId]);
|
|
110
|
+
/** Write through the operations client and place the answer in the one cached list. */
|
|
111
|
+
const write = useCallback(async (input) => {
|
|
112
|
+
const operations = await ctx.operations();
|
|
113
|
+
const result = await operations.records(submissionRecordKind).save({ projectId, ...input });
|
|
114
|
+
if (!result.ok)
|
|
115
|
+
return { ok: false, message: result.error.message };
|
|
116
|
+
const saved = result.value;
|
|
117
|
+
cache.patch(recordListKey(submissionRecordKind, projectId), (prev) => [
|
|
118
|
+
...(prev ?? []).filter((record) => record.id !== saved.id),
|
|
119
|
+
saved,
|
|
120
|
+
]);
|
|
121
|
+
return { ok: true };
|
|
122
|
+
}, [ctx, projectId, cache]);
|
|
123
|
+
const save = useCallback(async (doc, base) => {
|
|
124
|
+
const stored = byId.get(doc.id);
|
|
125
|
+
if (!stored)
|
|
126
|
+
return { ok: false, message: `Submission “${doc.id}” is not loaded.` };
|
|
127
|
+
// Against the record as the editor OPENED it (`base`, EC-616) — never the cache, which may
|
|
128
|
+
// already hold another editor's change to a column this editor did not touch.
|
|
129
|
+
const change = inboxSaveWrite(doc, base, stored, forms);
|
|
130
|
+
// Nothing the editor owns changed: nothing to write (the displayed columns are never sent).
|
|
131
|
+
if (Object.keys(change).length === 1)
|
|
132
|
+
return { ok: true };
|
|
133
|
+
return write(change);
|
|
134
|
+
}, [byId, write, forms]);
|
|
135
|
+
const remove = useCallback(async (ref) => {
|
|
136
|
+
const operations = await ctx.operations();
|
|
137
|
+
const result = await operations.records(submissionRecordKind).remove({ projectId, id: ref.id });
|
|
138
|
+
if (!result.ok)
|
|
139
|
+
return { ok: false, message: result.error.message };
|
|
140
|
+
cache.patch(recordListKey(submissionRecordKind, projectId), (prev) => (prev ?? []).filter((record) => record.id !== ref.id));
|
|
141
|
+
return { ok: true };
|
|
142
|
+
}, [ctx, projectId, cache]);
|
|
143
|
+
const create = useCallback(() => {
|
|
144
|
+
throw new Error('Submissions come from the site — the inbox creates none.');
|
|
145
|
+
}, []);
|
|
146
|
+
const source = useRailViewSource({
|
|
147
|
+
projectId,
|
|
148
|
+
schema,
|
|
149
|
+
capabilities: INBOX_CAPABILITIES,
|
|
150
|
+
localeConfig: LENS_LOCALE_CONFIG,
|
|
151
|
+
documents,
|
|
152
|
+
get,
|
|
153
|
+
titleOf,
|
|
154
|
+
detailPath: (submissionId) => `${id}/${submissionId}`,
|
|
155
|
+
listPath: id,
|
|
156
|
+
validate,
|
|
157
|
+
create,
|
|
158
|
+
save,
|
|
159
|
+
remove,
|
|
160
|
+
normalizeSaveResult: normalizeOkResult,
|
|
161
|
+
normalizeRemoveResult: normalizeOkResult,
|
|
162
|
+
});
|
|
163
|
+
const retry = useCallback(async () => {
|
|
164
|
+
if (!recordId)
|
|
165
|
+
return;
|
|
166
|
+
setRetrying(true);
|
|
167
|
+
setRetryError(null);
|
|
168
|
+
// Moving the row INTO `pending` is the retry: the deployment schedules the delivery.
|
|
169
|
+
const result = await write({ id: recordId, deliveryState: 'pending' });
|
|
170
|
+
if (!result.ok)
|
|
171
|
+
setRetryError(result.message);
|
|
172
|
+
setRetrying(false);
|
|
173
|
+
}, [recordId, write]);
|
|
174
|
+
if (list.status === 'pending')
|
|
175
|
+
return _jsx(InboxMessage, { text: "Loading submissions\u2026" });
|
|
176
|
+
if (list.status === 'error')
|
|
177
|
+
return _jsx(InboxMessage, { text: `Submissions could not be loaded: ${list.error?.message ?? 'unknown error'}` });
|
|
178
|
+
if (recordId === null)
|
|
179
|
+
return _jsx(CollectionViewSurface, { source: source, projectId: projectId });
|
|
180
|
+
const record = byId.get(recordId);
|
|
181
|
+
const form = record ? formOf(forms, record) : undefined;
|
|
182
|
+
const fields = record ? labelPayload(record.payload, form ? (config.source.fields?.(form.values) ?? []) : []) : [];
|
|
183
|
+
return (_jsx(PortableDetailEditor, { source: source, projectId: projectId, documentId: recordId, formAppendix: record ? (_jsxs(_Fragment, { children: [_jsx(SubmissionAppendix, { record: record, fields: fields, stateLabel: DELIVERY_STATE_LABELS[record.deliveryState], onRetry: () => void retry(), retrying: retrying }), retryError ? _jsx(InboxMessage, { text: retryError }) : null] })) : null }));
|
|
184
|
+
}
|
|
185
|
+
function InboxMessage({ text }) {
|
|
186
|
+
return (_jsx(Text, { tone: "muted", className: "p-6", "data-testid": "forms-inbox-message", children: text }));
|
|
187
|
+
}
|
|
188
|
+
/** The inbox's deep link: `[]` the list, `[submissionId]` one submission. */
|
|
189
|
+
function parseInboxPath(segments) {
|
|
190
|
+
if (segments.length === 0)
|
|
191
|
+
return {};
|
|
192
|
+
if (segments.length === 1)
|
|
193
|
+
return { submissionId: decodeURIComponent(segments[0]) };
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
/** The forms inbox as a rail entry (see the module header). */
|
|
197
|
+
export function formsInbox(config) {
|
|
198
|
+
const id = config.id ?? 'submissions';
|
|
199
|
+
return nav.view({
|
|
200
|
+
id,
|
|
201
|
+
label: config.label ?? 'Submissions',
|
|
202
|
+
description: 'Form submissions from the site, with their mail delivery.',
|
|
203
|
+
icon: Inbox,
|
|
204
|
+
parsePath: parseInboxPath,
|
|
205
|
+
useIssues: useInboxIssues,
|
|
206
|
+
...(config.visible ? { visible: config.visible } : {}),
|
|
207
|
+
render: (ctx) => _jsx(FormsInbox, { ctx: ctx, config: config, id: id }),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
export { failedDeliveryIssues, parseInboxPath };
|
|
211
|
+
//# sourceMappingURL=inbox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inbox.js","sourceRoot":"","sources":["../../src/studio/inbox.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useCallback, useMemo, useState } from 'react'\nimport { Inbox } from 'lucide-react'\nimport {\n PortableDetailEditor,\n CollectionViewSurface,\n nav,\n normalizeOkResult,\n recordListKey,\n useRecordList,\n useRailViewSource,\n} from '@elytracms/studio/addon'\nimport type { AppViewContext, CollectionViewCapabilities, NavIssue, NavPermission, OkResult } from '@elytracms/studio/addon'\nimport { createCollectionRegistry, parseLocaleConfig, validateDocument } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CmsValidationIssue, DocumentRef, Locale, LocalizedString } from '@elytracms/core/cms-core'\nimport { Text } from '@elytracms/studio/ui'\nimport { DELIVERY_STATES, submissionRecordKind } from '../shared/kind'\nimport type { SubmissionRecord } from '../shared/kind'\nimport { deliveryNeedsAttention } from '../shared/delivery'\nimport { labelPayload } from '../shared/mail'\nimport type { FormDocument, FormSource } from '../shared/source'\nimport {\n formOf,\n inboxIssueCount,\n inboxSaveWrite,\n submissionLens,\n submissionSender,\n submissionToLensDocument,\n} from './lens'\nimport type { InboxForms } from './lens'\nimport { SubmissionAppendix } from './appendix'\n\n/**\n * EC-613 — the forms INBOX: the submission kind on the studio's portable list/edit surfaces\n * (AD-19, EC-616's scaffold for a source of one's own), one entry in the project's rail:\n *\n * ```tsx\n * // studio/studio.config.tsx\n * import { formsInbox } from '@elytracms/forms/studio'\n * import { contactForms } from '../cms/forms'\n * export default defineStudio({ sidebar: [ …, formsInbox({ source: contactForms }) ] })\n * ```\n *\n * The list shows every submission, newest first; one opens in the portable edit shell, where\n * the editor sets the status, the assignee and notes (the inbox lifecycle) and reads — below\n * the fields — what was sent, the files and the mail delivery per recipient, with a retry for\n * a failed mail. A failed or partly failed delivery is an issue on the row AND at the rail\n * foot, from any surface. Reads and writes go through the operations client\n * (`operations.records(submissionRecordKind)`) and the one query cache (AD-17).\n */\n\nexport interface FormsInboxConfig<V> {\n /** How the inbox reads your form documents (labels, titles) — the same declaration the site's delivery route reads. */\n source: FormSource<V>\n /** The route segment (default `submissions`). */\n id?: string\n /** The rail label (default `Submissions`). */\n label?: string\n /** OPTIONAL, default-open nav permission (AD-18). */\n visible?: NavPermission\n}\n\n/** The inbox's capabilities: no \"New\" (submissions come from the site), delete and bulk delete. */\nexport const INBOX_CAPABILITIES: CollectionViewCapabilities = {\n publish: false,\n versions: false,\n locales: false,\n inlineEdit: false,\n create: false,\n duplicate: false,\n remove: true,\n bulk: true,\n liveUrl: false,\n hierarchy: false,\n importExport: false,\n adminOnly: false,\n}\n\nconst LENS_LOCALE: Locale = 'en'\nconst LENS_LOCALE_CONFIG = parseLocaleConfig({ default: LENS_LOCALE, locales: [LENS_LOCALE] })\n\nconst DELIVERY_STATE_LABELS: Record<(typeof DELIVERY_STATES)[number], LocalizedString> = {\n pending: { en: 'Sending', de: 'Wird gesendet' },\n sent: { en: 'Sent', de: 'Gesendet' },\n partial: { en: 'Partly failed', de: 'Teilweise fehlgeschlagen' },\n failed: { en: 'Failed', de: 'Fehlgeschlagen' },\n skipped: { en: 'Not sent', de: 'Nicht gesendet' },\n}\n\n/** The rail-foot finding: how many submissions' mail did not (fully) go out. */\nfunction failedDeliveryIssues(count: number): NavIssue[] {\n return count === 0\n ? []\n : [\n {\n key: 'forms-delivery-failed',\n message: `${count} form submission${count === 1 ? '' : 's'}: the mail was not delivered — open the inbox to retry.`,\n severity: 'error',\n },\n ]\n}\n\n/** The always-on rail-foot hook (it reads the inbox's own cached list). */\nfunction useInboxIssues({ projectId }: { projectId: string }): readonly NavIssue[] {\n const list = useRecordList(submissionRecordKind, projectId)\n const count = inboxIssueCount(list.data ?? [])\n return useMemo(() => failedDeliveryIssues(count), [count])\n}\n\n/** The form documents of the source's collection, through the operations client. */\nfunction useInboxForms<V>(ctx: AppViewContext, source: FormSource<V>): InboxForms<V> | undefined {\n const forms = ctx.query.useOperationQuery<FormDocument<V>[]>({\n queryKey: ['forms-inbox', 'forms', ctx.projectId, source.collection],\n loader: async (operations) => {\n const result = await operations.documents.list({ projectId: ctx.projectId, collection: source.collection })\n if (!result.ok) throw new Error(result.error.message)\n return result.value.map((record) => ({ id: record.document.id, values: record.document.values as V }))\n },\n })\n return useMemo(() => (forms.data ? { source, documents: forms.data } : undefined), [forms.data, source])\n}\n\n/** A failed delivery as a warning on the row's own Issues pane (never blocks a save). */\nfunction deliveryIssues(record: SubmissionRecord | undefined): CmsValidationIssue[] {\n if (!record || !deliveryNeedsAttention(record.deliveryState)) return []\n const failed = (record.deliveryLog ?? []).filter((entry) => !entry.ok)\n const last = failed.at(-1)\n return [\n {\n code: 'custom-validation',\n severity: 'warning',\n message: `The mail was not delivered${last?.to ? ` to ${last.to}` : ''}${last?.error ? `: ${last.error}` : ''}.`,\n path: ['deliveryState'],\n collectionId: submissionRecordKind.kind,\n documentId: record.id,\n },\n ]\n}\n\nfunction FormsInbox<V>({ ctx, config, id }: { ctx: AppViewContext; config: FormsInboxConfig<V>; id: string }) {\n const { projectId } = ctx\n const recordId = ctx.deepLink['submissionId'] ?? null\n const list = useRecordList(submissionRecordKind, projectId)\n const cache = ctx.query.useOperationCache()\n const forms = useInboxForms(ctx, config.source)\n const [retrying, setRetrying] = useState(false)\n const [retryError, setRetryError] = useState<string | null>(null)\n\n // Newest first — the inbox order.\n const records = useMemo(\n () => [...(list.data ?? [])].sort((a, b) => (a.submittedAt < b.submittedAt ? 1 : a.submittedAt > b.submittedAt ? -1 : 0)),\n [list.data],\n )\n const byId = useMemo(() => new Map(records.map((record) => [record.id, record])), [records])\n\n const schema = useMemo(() => submissionLens({ detail: recordId !== null }), [recordId])\n const registry = useMemo(() => createCollectionRegistry([schema]), [schema])\n\n const documents = useCallback(() => records.map((record) => submissionToLensDocument(record, forms)), [records, forms])\n const get = useCallback(\n (ref: DocumentRef) => {\n const record = byId.get(ref.id)\n return record ? submissionToLensDocument(record, forms) : undefined\n },\n [byId, forms],\n )\n const titleOf = useCallback(\n (ref: DocumentRef) => {\n const record = byId.get(ref.id)\n return record ? submissionSender(forms, record) || record.form : ref.id\n },\n [byId, forms],\n )\n const validate = useCallback(\n (doc: CmsDocument) => [\n ...validateDocument(registry, doc, { localeConfig: LENS_LOCALE_CONFIG }),\n ...deliveryIssues(byId.get(doc.id)),\n ],\n [registry, byId],\n )\n\n /** Write through the operations client and place the answer in the one cached list. */\n const write = useCallback(\n async (input: { id: string } & Record<string, unknown>): Promise<OkResult> => {\n const operations = await ctx.operations()\n const result = await operations.records(submissionRecordKind).save({ projectId, ...input } as never)\n if (!result.ok) return { ok: false, message: result.error.message }\n const saved = result.value as SubmissionRecord\n cache.patch<SubmissionRecord[]>(recordListKey(submissionRecordKind, projectId), (prev) => [\n ...(prev ?? []).filter((record) => record.id !== saved.id),\n saved,\n ])\n return { ok: true }\n },\n [ctx, projectId, cache],\n )\n\n const save = useCallback(\n async (doc: CmsDocument, base?: CmsDocument): Promise<OkResult> => {\n const stored = byId.get(doc.id)\n if (!stored) return { ok: false, message: `Submission “${doc.id}” is not loaded.` }\n // Against the record as the editor OPENED it (`base`, EC-616) — never the cache, which may\n // already hold another editor's change to a column this editor did not touch.\n const change = inboxSaveWrite(doc, base, stored, forms)\n // Nothing the editor owns changed: nothing to write (the displayed columns are never sent).\n if (Object.keys(change).length === 1) return { ok: true }\n return write(change)\n },\n [byId, write, forms],\n )\n const remove = useCallback(\n async (ref: DocumentRef): Promise<OkResult> => {\n const operations = await ctx.operations()\n const result = await operations.records(submissionRecordKind).remove({ projectId, id: ref.id })\n if (!result.ok) return { ok: false, message: result.error.message }\n cache.patch<SubmissionRecord[]>(recordListKey(submissionRecordKind, projectId), (prev) =>\n (prev ?? []).filter((record) => record.id !== ref.id),\n )\n return { ok: true }\n },\n [ctx, projectId, cache],\n )\n const create = useCallback((): DocumentRef => {\n throw new Error('Submissions come from the site — the inbox creates none.')\n }, [])\n\n const source = useRailViewSource({\n projectId,\n schema,\n capabilities: INBOX_CAPABILITIES,\n localeConfig: LENS_LOCALE_CONFIG,\n documents,\n get,\n titleOf,\n detailPath: (submissionId: string) => `${id}/${submissionId}`,\n listPath: id,\n validate,\n create,\n save,\n remove,\n normalizeSaveResult: normalizeOkResult,\n normalizeRemoveResult: normalizeOkResult,\n })\n\n const retry = useCallback(async () => {\n if (!recordId) return\n setRetrying(true)\n setRetryError(null)\n // Moving the row INTO `pending` is the retry: the deployment schedules the delivery.\n const result = await write({ id: recordId, deliveryState: 'pending' })\n if (!result.ok) setRetryError(result.message)\n setRetrying(false)\n }, [recordId, write])\n\n if (list.status === 'pending') return <InboxMessage text=\"Loading submissions…\" />\n if (list.status === 'error') return <InboxMessage text={`Submissions could not be loaded: ${list.error?.message ?? 'unknown error'}`} />\n if (recordId === null) return <CollectionViewSurface source={source} projectId={projectId} />\n\n const record = byId.get(recordId)\n const form = record ? formOf(forms, record) : undefined\n const fields = record ? labelPayload(record.payload, form ? (config.source.fields?.(form.values) ?? []) : []) : []\n return (\n <PortableDetailEditor\n source={source}\n projectId={projectId}\n documentId={recordId}\n formAppendix={\n record ? (\n <>\n <SubmissionAppendix\n record={record}\n fields={fields}\n stateLabel={DELIVERY_STATE_LABELS[record.deliveryState]}\n onRetry={() => void retry()}\n retrying={retrying}\n />\n {retryError ? <InboxMessage text={retryError} /> : null}\n </>\n ) : null\n }\n />\n )\n}\n\nfunction InboxMessage({ text }: { text: string }) {\n return (\n <Text tone=\"muted\" className=\"p-6\" data-testid=\"forms-inbox-message\">\n {text}\n </Text>\n )\n}\n\n/** The inbox's deep link: `[]` the list, `[submissionId]` one submission. */\nfunction parseInboxPath(segments: string[]): Record<string, string | null> | null {\n if (segments.length === 0) return {}\n if (segments.length === 1) return { submissionId: decodeURIComponent(segments[0]!) }\n return null\n}\n\n/** The forms inbox as a rail entry (see the module header). */\nexport function formsInbox<V>(config: FormsInboxConfig<V>) {\n const id = config.id ?? 'submissions'\n return nav.view({\n id,\n label: config.label ?? 'Submissions',\n description: 'Form submissions from the site, with their mail delivery.',\n icon: Inbox,\n parsePath: parseInboxPath,\n useIssues: useInboxIssues,\n ...(config.visible ? { visible: config.visible } : {}),\n render: (ctx) => <FormsInbox ctx={ctx} config={config} id={id} />,\n })\n}\n\nexport { failedDeliveryIssues, parseInboxPath }\n"]}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@elytracms/forms/studio` — the forms addon's STUDIO half (EC-613): the inbox, one rail
|
|
3
|
+
* entry on the addon contract (`@elytracms/studio/addon`, EC-616).
|
|
4
|
+
*
|
|
5
|
+
* ```tsx
|
|
6
|
+
* import { formsInbox } from '@elytracms/forms/studio'
|
|
7
|
+
* export default defineStudio({ sidebar: [ …, formsInbox({ source: contactForms }) ] })
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export { INBOX_CAPABILITIES, failedDeliveryIssues, formsInbox, parseInboxPath } from './inbox.js';
|
|
11
|
+
export type { FormsInboxConfig } from './inbox.js';
|
|
12
|
+
export { SUBMISSION_LENS_ID, formOf, formTitle, inboxIssueCount, inboxSaveWrite, submissionEditorWrite, submissionLens, submissionSender, submissionToLensDocument, } from './lens.js';
|
|
13
|
+
export type { InboxForms, SubmissionEditorWrite } from './lens.js';
|
|
14
|
+
export { SubmissionAppendix } from './appendix.js';
|
|
15
|
+
export type { SubmissionAppendixProps } from './appendix.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@elytracms/forms/studio` — the forms addon's STUDIO half (EC-613): the inbox, one rail
|
|
3
|
+
* entry on the addon contract (`@elytracms/studio/addon`, EC-616).
|
|
4
|
+
*
|
|
5
|
+
* ```tsx
|
|
6
|
+
* import { formsInbox } from '@elytracms/forms/studio'
|
|
7
|
+
* export default defineStudio({ sidebar: [ …, formsInbox({ source: contactForms }) ] })
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export { INBOX_CAPABILITIES, failedDeliveryIssues, formsInbox, parseInboxPath } from './inbox.js';
|
|
11
|
+
export { SUBMISSION_LENS_ID, formOf, formTitle, inboxIssueCount, inboxSaveWrite, submissionEditorWrite, submissionLens, submissionSender, submissionToLensDocument, } from './lens.js';
|
|
12
|
+
export { SubmissionAppendix } from './appendix.js';
|
|
13
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/studio/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE9F,OAAO,EACL,kBAAkB,EAClB,MAAM,EACN,SAAS,EACT,eAAe,EACf,cAAc,EACd,qBAAqB,EACrB,cAAc,EACd,gBAAgB,EAChB,wBAAwB,GACzB,MAAM,QAAQ,CAAA;AAEf,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA","sourcesContent":["/**\n * `@elytracms/forms/studio` — the forms addon's STUDIO half (EC-613): the inbox, one rail\n * entry on the addon contract (`@elytracms/studio/addon`, EC-616).\n *\n * ```tsx\n * import { formsInbox } from '@elytracms/forms/studio'\n * export default defineStudio({ sidebar: [ …, formsInbox({ source: contactForms }) ] })\n * ```\n */\nexport { INBOX_CAPABILITIES, failedDeliveryIssues, formsInbox, parseInboxPath } from './inbox'\nexport type { FormsInboxConfig } from './inbox'\nexport {\n SUBMISSION_LENS_ID,\n formOf,\n formTitle,\n inboxIssueCount,\n inboxSaveWrite,\n submissionEditorWrite,\n submissionLens,\n submissionSender,\n submissionToLensDocument,\n} from './lens'\nexport type { InboxForms, SubmissionEditorWrite } from './lens'\nexport { SubmissionAppendix } from './appendix'\nexport type { SubmissionAppendixProps } from './appendix'\n"]}
|