@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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +28 -0
  3. package/dist/index.js +24 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/next/index.d.ts +20 -0
  6. package/dist/next/index.js +19 -0
  7. package/dist/next/index.js.map +1 -0
  8. package/dist/next/routes.d.ts +145 -0
  9. package/dist/next/routes.js +214 -0
  10. package/dist/next/routes.js.map +1 -0
  11. package/dist/next/submit.d.ts +83 -0
  12. package/dist/next/submit.js +137 -0
  13. package/dist/next/submit.js.map +1 -0
  14. package/dist/shared/delivery.d.ts +154 -0
  15. package/dist/shared/delivery.js +218 -0
  16. package/dist/shared/delivery.js.map +1 -0
  17. package/dist/shared/kind.d.ts +120 -0
  18. package/dist/shared/kind.js +98 -0
  19. package/dist/shared/kind.js.map +1 -0
  20. package/dist/shared/mail.d.ts +35 -0
  21. package/dist/shared/mail.js +47 -0
  22. package/dist/shared/mail.js.map +1 -0
  23. package/dist/shared/source.d.ts +70 -0
  24. package/dist/shared/source.js +40 -0
  25. package/dist/shared/source.js.map +1 -0
  26. package/dist/shared/submission.d.ts +83 -0
  27. package/dist/shared/submission.js +146 -0
  28. package/dist/shared/submission.js.map +1 -0
  29. package/dist/studio/appendix.d.ts +11 -0
  30. package/dist/studio/appendix.js +50 -0
  31. package/dist/studio/appendix.js.map +1 -0
  32. package/dist/studio/inbox.d.ts +39 -0
  33. package/dist/studio/inbox.js +211 -0
  34. package/dist/studio/inbox.js.map +1 -0
  35. package/dist/studio/index.d.ts +15 -0
  36. package/dist/studio/index.js +13 -0
  37. package/dist/studio/index.js.map +1 -0
  38. package/dist/studio/lens.d.ts +68 -0
  39. package/dist/studio/lens.js +149 -0
  40. package/dist/studio/lens.js.map +1 -0
  41. package/package.json +75 -3
  42. package/src/convex/forms.convex.test.ts +552 -0
  43. package/src/convex/index.ts +340 -0
  44. package/src/shared/delivery.test.ts +122 -0
  45. package/src/shared/delivery.ts +263 -0
  46. package/src/shared/kind.ts +120 -0
  47. package/src/shared/mail.ts +67 -0
  48. package/src/shared/source.test.ts +83 -0
  49. package/src/shared/source.ts +107 -0
  50. package/src/shared/submission.test.ts +97 -0
  51. package/src/shared/submission.ts +163 -0
  52. 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
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "author": "finaldream <hi@finaldream.de>",
4
+ "version": "0.0.95",
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.95"
32
+ },
33
+ "peerDependencies": {
34
+ "convex": "^1.42.1",
35
+ "react": "^19.0.0",
36
+ "@elytracms/next": "^0.0.95",
37
+ "@elytracms/studio": "^0.0.95"
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/studio": "^0.0.95",
63
+ "@elytracms/next": "^0.0.95"
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
  }