@elytracms/forms 0.0.0-stage → 0.0.94
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,68 @@
|
|
|
1
|
+
import type { CmsDocument, CollectionDef, LocalizedString } from '@elytracms/core/cms-core';
|
|
2
|
+
import type { SubmissionRecord, SubmissionStatus } from '../shared/kind.js';
|
|
3
|
+
import type { FormDocument, FormSource } from '../shared/source.js';
|
|
4
|
+
/**
|
|
5
|
+
* EC-613 — the inbox's LENS over the submission kind (pure — no React, so what a save writes
|
|
6
|
+
* is unit-tested on its own). The portable list/edit surfaces (AD-19) edit a lens document
|
|
7
|
+
* `{ collection, id, values }` over a `CollectionDef`; this module is the one place both
|
|
8
|
+
* directions are decided:
|
|
9
|
+
*
|
|
10
|
+
* - **read** ({@link submissionToLensDocument}): the row's columns for the list and the
|
|
11
|
+
* edit form — the form's TITLE (from the form document) instead of its key;
|
|
12
|
+
* - **write** ({@link submissionEditorWrite}): ONLY the editor's columns (`status`,
|
|
13
|
+
* `assignee`, `notes`) and only those the editor CHANGED — measured against the record AS
|
|
14
|
+
* THE EDITOR OPENED IT (the edit shell's `save(doc, base)`, EC-616), never against the cached
|
|
15
|
+
* record at save time, which may already hold another editor's change. Never the columns the
|
|
16
|
+
* lens merely displays: the delivery writes its own columns at any time, and a save carrying
|
|
17
|
+
* the editor's stale copy would put an old state back (and, moving the row into `pending`,
|
|
18
|
+
* schedule a second delivery — measured in `convex/forms.convex.test.ts`). Two editors
|
|
19
|
+
* changing different columns therefore merge per column (the record store keeps what a write
|
|
20
|
+
* leaves out, EC-577); the same column is the last writer's.
|
|
21
|
+
*
|
|
22
|
+
* Why not the generic `lensDocumentToRecordWrite(kind, doc, base)`: the inbox's lens shows
|
|
23
|
+
* DISPLAY values under kind column names (`form` holds the form's title, not its key), so a
|
|
24
|
+
* generic per-column diff could send a display value into a stored column; the whitelist
|
|
25
|
+
* makes that unrepresentable, and a cleared note stays `null` (EC-577's clear), not `''`.
|
|
26
|
+
*/
|
|
27
|
+
/** The lens collection id (the kind's name). */
|
|
28
|
+
export declare const SUBMISSION_LENS_ID: string;
|
|
29
|
+
/** The inbox's lens: the list columns, then (in the edit form) the editor's notes and the page. */
|
|
30
|
+
export declare function submissionLens(options_?: {
|
|
31
|
+
label?: LocalizedString;
|
|
32
|
+
detail?: boolean;
|
|
33
|
+
}): CollectionDef;
|
|
34
|
+
/** The form documents the inbox labels with, read once (the studio's stored values). */
|
|
35
|
+
export interface InboxForms<V> {
|
|
36
|
+
source: FormSource<V>;
|
|
37
|
+
documents: readonly FormDocument<V>[];
|
|
38
|
+
}
|
|
39
|
+
/** The form a submission was sent to (by its key), if the project still has it. */
|
|
40
|
+
export declare function formOf<V>(forms: InboxForms<V> | undefined, record: Pick<SubmissionRecord, 'form'>): FormDocument<V> | undefined;
|
|
41
|
+
/** The form's display title, else its key. */
|
|
42
|
+
export declare function formTitle<V>(forms: InboxForms<V> | undefined, record: Pick<SubmissionRecord, 'form'>): string;
|
|
43
|
+
/** Who sent it: the reply-to address, else the first answer. */
|
|
44
|
+
export declare function submissionSender<V>(forms: InboxForms<V> | undefined, record: Pick<SubmissionRecord, 'payload'>): string;
|
|
45
|
+
/** A submission as the lens document the portable surfaces edit. */
|
|
46
|
+
export declare function submissionToLensDocument<V>(record: SubmissionRecord, forms?: InboxForms<V>): CmsDocument;
|
|
47
|
+
/** What an editor's save writes: the id, and only the editor's columns that changed. */
|
|
48
|
+
export type SubmissionEditorWrite = {
|
|
49
|
+
id: string;
|
|
50
|
+
status?: SubmissionStatus;
|
|
51
|
+
assignee?: string | null;
|
|
52
|
+
notes?: string | null;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* The write an edited lens document makes: the editor's columns that DIFFER from `base` — the
|
|
56
|
+
* lens document as the editor OPENED it — nothing else (see the module header). A cleared
|
|
57
|
+
* optional text is `null` (the record store clears it); an unchanged document writes nothing
|
|
58
|
+
* but its id.
|
|
59
|
+
*/
|
|
60
|
+
export declare function submissionEditorWrite(base: CmsDocument, doc: CmsDocument): SubmissionEditorWrite;
|
|
61
|
+
/**
|
|
62
|
+
* The inbox's save: the write for an edited document, against `base` — the record as the
|
|
63
|
+
* editor opened it, which the edit shell passes (EC-616). Without one (a caller outside the
|
|
64
|
+
* shell) the cached record stands in, which is the record as of the last refetch.
|
|
65
|
+
*/
|
|
66
|
+
export declare function inboxSaveWrite<V>(doc: CmsDocument, base: CmsDocument | undefined, cached: SubmissionRecord, forms?: InboxForms<V>): ReturnType<typeof submissionEditorWrite>;
|
|
67
|
+
/** The inbox's findings for the rail foot: every submission whose mail did not (fully) go out. */
|
|
68
|
+
export declare function inboxIssueCount(records: readonly Pick<SubmissionRecord, 'deliveryState'>[]): number;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { collectionDefSchema } from '@elytracms/core/cms-core';
|
|
2
|
+
import { DELIVERY_STATES, SPAM_VERDICTS, SUBMISSION_EDITOR_COLUMNS, SUBMISSION_STATUSES, submissionRecordKind } from '../shared/kind.js';
|
|
3
|
+
import { deliveryNeedsAttention } from '../shared/delivery.js';
|
|
4
|
+
import { defaultReplyTo } from '../shared/source.js';
|
|
5
|
+
import { formatFormValue } from '../shared/submission.js';
|
|
6
|
+
/**
|
|
7
|
+
* EC-613 — the inbox's LENS over the submission kind (pure — no React, so what a save writes
|
|
8
|
+
* is unit-tested on its own). The portable list/edit surfaces (AD-19) edit a lens document
|
|
9
|
+
* `{ collection, id, values }` over a `CollectionDef`; this module is the one place both
|
|
10
|
+
* directions are decided:
|
|
11
|
+
*
|
|
12
|
+
* - **read** ({@link submissionToLensDocument}): the row's columns for the list and the
|
|
13
|
+
* edit form — the form's TITLE (from the form document) instead of its key;
|
|
14
|
+
* - **write** ({@link submissionEditorWrite}): ONLY the editor's columns (`status`,
|
|
15
|
+
* `assignee`, `notes`) and only those the editor CHANGED — measured against the record AS
|
|
16
|
+
* THE EDITOR OPENED IT (the edit shell's `save(doc, base)`, EC-616), never against the cached
|
|
17
|
+
* record at save time, which may already hold another editor's change. Never the columns the
|
|
18
|
+
* lens merely displays: the delivery writes its own columns at any time, and a save carrying
|
|
19
|
+
* the editor's stale copy would put an old state back (and, moving the row into `pending`,
|
|
20
|
+
* schedule a second delivery — measured in `convex/forms.convex.test.ts`). Two editors
|
|
21
|
+
* changing different columns therefore merge per column (the record store keeps what a write
|
|
22
|
+
* leaves out, EC-577); the same column is the last writer's.
|
|
23
|
+
*
|
|
24
|
+
* Why not the generic `lensDocumentToRecordWrite(kind, doc, base)`: the inbox's lens shows
|
|
25
|
+
* DISPLAY values under kind column names (`form` holds the form's title, not its key), so a
|
|
26
|
+
* generic per-column diff could send a display value into a stored column; the whitelist
|
|
27
|
+
* makes that unrepresentable, and a cleared note stays `null` (EC-577's clear), not `''`.
|
|
28
|
+
*/
|
|
29
|
+
/** The lens collection id (the kind's name). */
|
|
30
|
+
export const SUBMISSION_LENS_ID = submissionRecordKind.kind;
|
|
31
|
+
const L = (en, de) => ({ en, de });
|
|
32
|
+
const STATUS_LABELS = {
|
|
33
|
+
new: L('New', 'Neu'),
|
|
34
|
+
open: L('In progress', 'In Bearbeitung'),
|
|
35
|
+
done: L('Done', 'Erledigt'),
|
|
36
|
+
spam: L('Spam', 'Spam'),
|
|
37
|
+
};
|
|
38
|
+
const DELIVERY_LABELS = {
|
|
39
|
+
pending: L('Sending', 'Wird gesendet'),
|
|
40
|
+
sent: L('Sent', 'Gesendet'),
|
|
41
|
+
partial: L('Partly failed', 'Teilweise fehlgeschlagen'),
|
|
42
|
+
failed: L('Failed', 'Fehlgeschlagen'),
|
|
43
|
+
skipped: L('Not sent', 'Nicht gesendet'),
|
|
44
|
+
};
|
|
45
|
+
const SPAM_LABELS = {
|
|
46
|
+
clean: L('No', 'Nein'),
|
|
47
|
+
honeypot: L('Honeypot filled', 'Honeypot ausgefüllt'),
|
|
48
|
+
};
|
|
49
|
+
const options = (labels) => Object.entries(labels).map(([value, label]) => ({ value, label: label }));
|
|
50
|
+
/** The inbox's lens: the list columns, then (in the edit form) the editor's notes and the page. */
|
|
51
|
+
export function submissionLens(options_ = {}) {
|
|
52
|
+
const readOnly = (label, extra = {}) => ({ label, readOnly: true, ...extra });
|
|
53
|
+
// `from` leads: the list's link column and the edit shell's title (`titleField`).
|
|
54
|
+
const fields = [
|
|
55
|
+
{ name: 'from', type: 'text', form: readOnly(L('From', 'Von')) },
|
|
56
|
+
{ name: 'form', type: 'text', form: readOnly(L('Form', 'Formular'), { width: '50%' }) },
|
|
57
|
+
{ name: 'submittedAt', type: 'date', form: readOnly(L('Received', 'Eingegangen'), { width: '50%' }) },
|
|
58
|
+
{ name: 'status', type: 'select', options: options(STATUS_LABELS), form: { label: L('Status', 'Status'), width: '50%' } },
|
|
59
|
+
{ name: 'assignee', type: 'text', form: { label: L('Assignee', 'Zuständig'), width: '50%' } },
|
|
60
|
+
{ name: 'deliveryState', type: 'select', options: options(DELIVERY_LABELS), form: readOnly(L('Mail', 'E-Mail'), { width: '50%' }) },
|
|
61
|
+
{ name: 'spam', type: 'select', options: options(SPAM_LABELS), form: readOnly(L('Spam check', 'Spam-Prüfung'), { width: '50%' }) },
|
|
62
|
+
];
|
|
63
|
+
if (options_.detail) {
|
|
64
|
+
fields.push({ name: 'notes', type: 'text', form: { label: L('Notes', 'Notizen') } }, { name: 'sourceUrl', type: 'text', form: readOnly(L('Page', 'Seite')) });
|
|
65
|
+
}
|
|
66
|
+
return collectionDefSchema.parse({
|
|
67
|
+
id: SUBMISSION_LENS_ID,
|
|
68
|
+
kind: 'document',
|
|
69
|
+
titleField: 'from',
|
|
70
|
+
form: {
|
|
71
|
+
label: options_.label ?? L('Submissions', 'Einsendungen'),
|
|
72
|
+
labelSingular: L('Submission', 'Einsendung'),
|
|
73
|
+
},
|
|
74
|
+
fields,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** The form a submission was sent to (by its key), if the project still has it. */
|
|
78
|
+
export function formOf(forms, record) {
|
|
79
|
+
return forms ? forms.source.find(forms.documents, record.form) : undefined;
|
|
80
|
+
}
|
|
81
|
+
/** The form's display title, else its key. */
|
|
82
|
+
export function formTitle(forms, record) {
|
|
83
|
+
const form = formOf(forms, record);
|
|
84
|
+
return (form && forms?.source.title?.(form.values)) || record.form;
|
|
85
|
+
}
|
|
86
|
+
/** Who sent it: the reply-to address, else the first answer. */
|
|
87
|
+
export function submissionSender(forms, record) {
|
|
88
|
+
const replyTo = (forms?.source.replyTo ?? defaultReplyTo)(record.payload);
|
|
89
|
+
if (replyTo)
|
|
90
|
+
return replyTo;
|
|
91
|
+
const first = record.payload[0];
|
|
92
|
+
return first ? formatFormValue(first.value).slice(0, 80) : '';
|
|
93
|
+
}
|
|
94
|
+
/** A submission as the lens document the portable surfaces edit. */
|
|
95
|
+
export function submissionToLensDocument(record, forms) {
|
|
96
|
+
const values = {
|
|
97
|
+
submittedAt: record.submittedAt,
|
|
98
|
+
form: formTitle(forms, record),
|
|
99
|
+
from: submissionSender(forms, record),
|
|
100
|
+
status: record.status,
|
|
101
|
+
deliveryState: record.deliveryState,
|
|
102
|
+
spam: record.spam,
|
|
103
|
+
};
|
|
104
|
+
if (record.assignee !== undefined)
|
|
105
|
+
values['assignee'] = record.assignee;
|
|
106
|
+
if (record.notes !== undefined)
|
|
107
|
+
values['notes'] = record.notes;
|
|
108
|
+
if (record.sourceUrl !== undefined)
|
|
109
|
+
values['sourceUrl'] = record.sourceUrl;
|
|
110
|
+
return { collection: SUBMISSION_LENS_ID, id: record.id, values };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The write an edited lens document makes: the editor's columns that DIFFER from `base` — the
|
|
114
|
+
* lens document as the editor OPENED it — nothing else (see the module header). A cleared
|
|
115
|
+
* optional text is `null` (the record store clears it); an unchanged document writes nothing
|
|
116
|
+
* but its id.
|
|
117
|
+
*/
|
|
118
|
+
export function submissionEditorWrite(base, doc) {
|
|
119
|
+
const values = doc.values ?? {};
|
|
120
|
+
const opened = base.values ?? {};
|
|
121
|
+
const write = { id: doc.id };
|
|
122
|
+
for (const column of SUBMISSION_EDITOR_COLUMNS) {
|
|
123
|
+
const edited = values[column];
|
|
124
|
+
const before = opened[column];
|
|
125
|
+
if (column === 'status') {
|
|
126
|
+
if (typeof edited === 'string' && edited !== before)
|
|
127
|
+
write[column] = edited;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const text = typeof edited === 'string' ? edited : '';
|
|
131
|
+
if (text === (before ?? ''))
|
|
132
|
+
continue;
|
|
133
|
+
write[column] = text.length > 0 ? text : null;
|
|
134
|
+
}
|
|
135
|
+
return write;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The inbox's save: the write for an edited document, against `base` — the record as the
|
|
139
|
+
* editor opened it, which the edit shell passes (EC-616). Without one (a caller outside the
|
|
140
|
+
* shell) the cached record stands in, which is the record as of the last refetch.
|
|
141
|
+
*/
|
|
142
|
+
export function inboxSaveWrite(doc, base, cached, forms) {
|
|
143
|
+
return submissionEditorWrite(base ?? submissionToLensDocument(cached, forms), doc);
|
|
144
|
+
}
|
|
145
|
+
/** The inbox's findings for the rail foot: every submission whose mail did not (fully) go out. */
|
|
146
|
+
export function inboxIssueCount(records) {
|
|
147
|
+
return records.filter((record) => deliveryNeedsAttention(record.deliveryState)).length;
|
|
148
|
+
}
|
|
149
|
+
//# sourceMappingURL=lens.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lens.js","sourceRoot":"","sources":["../../src/studio/lens.ts"],"names":[],"mappings":"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","sourcesContent":["import { collectionDefSchema } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CollectionDef, LocalizedString } from '@elytracms/core/cms-core'\nimport { DELIVERY_STATES, SPAM_VERDICTS, SUBMISSION_EDITOR_COLUMNS, SUBMISSION_STATUSES, submissionRecordKind } from '../shared/kind'\nimport type { SubmissionRecord, SubmissionStatus } from '../shared/kind'\nimport { deliveryNeedsAttention } from '../shared/delivery'\nimport type { FormDocument, FormSource } from '../shared/source'\nimport { defaultReplyTo } from '../shared/source'\nimport { formatFormValue } from '../shared/submission'\n\n/**\n * EC-613 — the inbox's LENS over the submission kind (pure — no React, so what a save writes\n * is unit-tested on its own). The portable list/edit surfaces (AD-19) edit a lens document\n * `{ collection, id, values }` over a `CollectionDef`; this module is the one place both\n * directions are decided:\n *\n * - **read** ({@link submissionToLensDocument}): the row's columns for the list and the\n * edit form — the form's TITLE (from the form document) instead of its key;\n * - **write** ({@link submissionEditorWrite}): ONLY the editor's columns (`status`,\n * `assignee`, `notes`) and only those the editor CHANGED — measured against the record AS\n * THE EDITOR OPENED IT (the edit shell's `save(doc, base)`, EC-616), never against the cached\n * record at save time, which may already hold another editor's change. Never the columns the\n * lens merely displays: the delivery writes its own columns at any time, and a save carrying\n * the editor's stale copy would put an old state back (and, moving the row into `pending`,\n * schedule a second delivery — measured in `convex/forms.convex.test.ts`). Two editors\n * changing different columns therefore merge per column (the record store keeps what a write\n * leaves out, EC-577); the same column is the last writer's.\n *\n * Why not the generic `lensDocumentToRecordWrite(kind, doc, base)`: the inbox's lens shows\n * DISPLAY values under kind column names (`form` holds the form's title, not its key), so a\n * generic per-column diff could send a display value into a stored column; the whitelist\n * makes that unrepresentable, and a cleared note stays `null` (EC-577's clear), not `''`.\n */\n\n/** The lens collection id (the kind's name). */\nexport const SUBMISSION_LENS_ID = submissionRecordKind.kind\n\nconst L = (en: string, de: string): LocalizedString => ({ en, de })\n\nconst STATUS_LABELS: Record<(typeof SUBMISSION_STATUSES)[number], LocalizedString> = {\n new: L('New', 'Neu'),\n open: L('In progress', 'In Bearbeitung'),\n done: L('Done', 'Erledigt'),\n spam: L('Spam', 'Spam'),\n}\nconst DELIVERY_LABELS: Record<(typeof DELIVERY_STATES)[number], LocalizedString> = {\n pending: L('Sending', 'Wird gesendet'),\n sent: L('Sent', 'Gesendet'),\n partial: L('Partly failed', 'Teilweise fehlgeschlagen'),\n failed: L('Failed', 'Fehlgeschlagen'),\n skipped: L('Not sent', 'Nicht gesendet'),\n}\nconst SPAM_LABELS: Record<(typeof SPAM_VERDICTS)[number], LocalizedString> = {\n clean: L('No', 'Nein'),\n honeypot: L('Honeypot filled', 'Honeypot ausgefüllt'),\n}\n\nconst options = <T extends string>(labels: Record<T, LocalizedString>) =>\n Object.entries(labels).map(([value, label]) => ({ value, label: label as LocalizedString }))\n\n/** The inbox's lens: the list columns, then (in the edit form) the editor's notes and the page. */\nexport function submissionLens(options_: { label?: LocalizedString; detail?: boolean } = {}): CollectionDef {\n const readOnly = (label: LocalizedString, extra: Record<string, unknown> = {}) => ({ label, readOnly: true, ...extra })\n // `from` leads: the list's link column and the edit shell's title (`titleField`).\n const fields: Record<string, unknown>[] = [\n { name: 'from', type: 'text', form: readOnly(L('From', 'Von')) },\n { name: 'form', type: 'text', form: readOnly(L('Form', 'Formular'), { width: '50%' }) },\n { name: 'submittedAt', type: 'date', form: readOnly(L('Received', 'Eingegangen'), { width: '50%' }) },\n { name: 'status', type: 'select', options: options(STATUS_LABELS), form: { label: L('Status', 'Status'), width: '50%' } },\n { name: 'assignee', type: 'text', form: { label: L('Assignee', 'Zuständig'), width: '50%' } },\n { name: 'deliveryState', type: 'select', options: options(DELIVERY_LABELS), form: readOnly(L('Mail', 'E-Mail'), { width: '50%' }) },\n { name: 'spam', type: 'select', options: options(SPAM_LABELS), form: readOnly(L('Spam check', 'Spam-Prüfung'), { width: '50%' }) },\n ]\n if (options_.detail) {\n fields.push(\n { name: 'notes', type: 'text', form: { label: L('Notes', 'Notizen') } },\n { name: 'sourceUrl', type: 'text', form: readOnly(L('Page', 'Seite')) },\n )\n }\n return collectionDefSchema.parse({\n id: SUBMISSION_LENS_ID,\n kind: 'document',\n titleField: 'from',\n form: {\n label: options_.label ?? L('Submissions', 'Einsendungen'),\n labelSingular: L('Submission', 'Einsendung'),\n },\n fields,\n })\n}\n\n/** The form documents the inbox labels with, read once (the studio's stored values). */\nexport interface InboxForms<V> {\n source: FormSource<V>\n documents: readonly FormDocument<V>[]\n}\n\n/** The form a submission was sent to (by its key), if the project still has it. */\nexport function formOf<V>(forms: InboxForms<V> | undefined, record: Pick<SubmissionRecord, 'form'>): FormDocument<V> | undefined {\n return forms ? forms.source.find(forms.documents, record.form) : undefined\n}\n\n/** The form's display title, else its key. */\nexport function formTitle<V>(forms: InboxForms<V> | undefined, record: Pick<SubmissionRecord, 'form'>): string {\n const form = formOf(forms, record)\n return (form && forms?.source.title?.(form.values)) || record.form\n}\n\n/** Who sent it: the reply-to address, else the first answer. */\nexport function submissionSender<V>(forms: InboxForms<V> | undefined, record: Pick<SubmissionRecord, 'payload'>): string {\n const replyTo = (forms?.source.replyTo ?? defaultReplyTo)(record.payload)\n if (replyTo) return replyTo\n const first = record.payload[0]\n return first ? formatFormValue(first.value).slice(0, 80) : ''\n}\n\n/** A submission as the lens document the portable surfaces edit. */\nexport function submissionToLensDocument<V>(record: SubmissionRecord, forms?: InboxForms<V>): CmsDocument {\n const values: Record<string, unknown> = {\n submittedAt: record.submittedAt,\n form: formTitle(forms, record),\n from: submissionSender(forms, record),\n status: record.status,\n deliveryState: record.deliveryState,\n spam: record.spam,\n }\n if (record.assignee !== undefined) values['assignee'] = record.assignee\n if (record.notes !== undefined) values['notes'] = record.notes\n if (record.sourceUrl !== undefined) values['sourceUrl'] = record.sourceUrl\n return { collection: SUBMISSION_LENS_ID, id: record.id, values }\n}\n\n/** What an editor's save writes: the id, and only the editor's columns that changed. */\nexport type SubmissionEditorWrite = {\n id: string\n status?: SubmissionStatus\n assignee?: string | null\n notes?: string | null\n}\n\n/**\n * The write an edited lens document makes: the editor's columns that DIFFER from `base` — the\n * lens document as the editor OPENED it — nothing else (see the module header). A cleared\n * optional text is `null` (the record store clears it); an unchanged document writes nothing\n * but its id.\n */\nexport function submissionEditorWrite(\n base: CmsDocument,\n doc: CmsDocument,\n): SubmissionEditorWrite {\n const values = doc.values ?? {}\n const opened = base.values ?? {}\n const write: Record<string, string | null> & { id: string } = { id: doc.id }\n for (const column of SUBMISSION_EDITOR_COLUMNS) {\n const edited = values[column]\n const before = opened[column] as string | undefined\n if (column === 'status') {\n if (typeof edited === 'string' && edited !== before) write[column] = edited\n continue\n }\n const text = typeof edited === 'string' ? edited : ''\n if (text === (before ?? '')) continue\n write[column] = text.length > 0 ? text : null\n }\n return write as SubmissionEditorWrite\n}\n\n/**\n * The inbox's save: the write for an edited document, against `base` — the record as the\n * editor opened it, which the edit shell passes (EC-616). Without one (a caller outside the\n * shell) the cached record stands in, which is the record as of the last refetch.\n */\nexport function inboxSaveWrite<V>(\n doc: CmsDocument,\n base: CmsDocument | undefined,\n cached: SubmissionRecord,\n forms?: InboxForms<V>,\n): ReturnType<typeof submissionEditorWrite> {\n return submissionEditorWrite(base ?? submissionToLensDocument(cached, forms), doc)\n}\n\n/** The inbox's findings for the rail foot: every submission whose mail did not (fully) go out. */\nexport function inboxIssueCount(records: readonly Pick<SubmissionRecord, 'deliveryState'>[]): number {\n return records.filter((record) => deliveryNeedsAttention(record.deliveryState)).length\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,78 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elytracms/forms",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"author": "finaldream <hi@finaldream.de>",
|
|
4
|
+
"version": "0.0.94",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=22.18.0"
|
|
7
|
+
},
|
|
8
|
+
"description": "Forms for Elytra — the first addon: every submission a record in the studio's inbox, delivery as a scheduled, logged action per row, uploads into storage through the public ingest seam.",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"private": false,
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./studio": {
|
|
19
|
+
"types": "./dist/studio/index.d.ts",
|
|
20
|
+
"default": "./dist/studio/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./next": {
|
|
23
|
+
"types": "./dist/next/index.d.ts",
|
|
24
|
+
"default": "./dist/next/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./convex": "./src/convex/index.ts"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"lucide-react": "^1.23.0",
|
|
30
|
+
"zod": "^4.4.3",
|
|
31
|
+
"@elytracms/core": "^0.0.94"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"convex": "^1.42.1",
|
|
35
|
+
"react": "^19.0.0",
|
|
36
|
+
"@elytracms/next": "^0.0.94",
|
|
37
|
+
"@elytracms/studio": "^0.0.94"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"@elytracms/next": {
|
|
41
|
+
"optional": true
|
|
42
|
+
},
|
|
43
|
+
"@elytracms/studio": {
|
|
44
|
+
"optional": true
|
|
45
|
+
},
|
|
46
|
+
"convex": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"react": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^26.1.0",
|
|
55
|
+
"@types/react": "^19.2.17",
|
|
56
|
+
"@types/react-dom": "^19.2.3",
|
|
57
|
+
"convex": "^1.42.1",
|
|
58
|
+
"convex-test": "^0.0.54",
|
|
59
|
+
"react": "^19.2.7",
|
|
60
|
+
"react-dom": "^19.2.7",
|
|
61
|
+
"typescript": "^6.0.3",
|
|
62
|
+
"@elytracms/next": "^0.0.94",
|
|
63
|
+
"@elytracms/studio": "^0.0.94"
|
|
64
|
+
},
|
|
65
|
+
"files": [
|
|
66
|
+
"dist",
|
|
67
|
+
"src/convex",
|
|
68
|
+
"src/shared"
|
|
69
|
+
],
|
|
70
|
+
"publishConfig": {
|
|
71
|
+
"access": "public"
|
|
72
|
+
},
|
|
73
|
+
"scripts": {
|
|
74
|
+
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && node ../../scripts/fix-esm-extensions.mjs dist && node ../../scripts/check-esm-extensions.mjs dist",
|
|
75
|
+
"typecheck": "tsc --noEmit",
|
|
76
|
+
"lint": "eslint src"
|
|
77
|
+
}
|
|
6
78
|
}
|