@emdash-cms/plugin-forms 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +40 -0
- package/src/admin.tsx +1288 -0
- package/src/astro/Form.astro +26 -0
- package/src/astro/FormEmbed.astro +301 -0
- package/src/astro/index.ts +11 -0
- package/src/client/index.ts +536 -0
- package/src/format.ts +160 -0
- package/src/handlers/cron.ts +151 -0
- package/src/handlers/forms.ts +269 -0
- package/src/handlers/submissions.ts +191 -0
- package/src/handlers/submit.ts +297 -0
- package/src/index.ts +230 -0
- package/src/schemas.ts +215 -0
- package/src/storage.ts +41 -0
- package/src/styles/forms.css +200 -0
- package/src/turnstile.ts +51 -0
- package/src/types.ts +164 -0
- package/src/validation.ts +205 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Standalone form component.
|
|
4
|
+
*
|
|
5
|
+
* Use this outside Portable Text content to embed a form directly.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```astro
|
|
9
|
+
* ---
|
|
10
|
+
* import { Form } from "@emdash-cms/plugin-forms/ui";
|
|
11
|
+
* ---
|
|
12
|
+
*
|
|
13
|
+
* <Form id="contact-form" />
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
import FormEmbed from "./FormEmbed.astro";
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
/** Form ID or slug */
|
|
20
|
+
id: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { id } = Astro.props;
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<FormEmbed node={{ formId: id }} />
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Form embed component for Portable Text blocks.
|
|
4
|
+
*
|
|
5
|
+
* Server-renders the full form with all pages as <fieldset> elements.
|
|
6
|
+
* Without JavaScript, all pages are visible as one long form.
|
|
7
|
+
* The client-side script enhances with multi-page navigation, AJAX, etc.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
node: { formId: string };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface FormField {
|
|
15
|
+
id: string;
|
|
16
|
+
type: string;
|
|
17
|
+
label: string;
|
|
18
|
+
name: string;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
helpText?: string;
|
|
21
|
+
required: boolean;
|
|
22
|
+
validation?: {
|
|
23
|
+
minLength?: number;
|
|
24
|
+
maxLength?: number;
|
|
25
|
+
min?: number;
|
|
26
|
+
max?: number;
|
|
27
|
+
pattern?: string;
|
|
28
|
+
patternMessage?: string;
|
|
29
|
+
accept?: string;
|
|
30
|
+
maxFileSize?: number;
|
|
31
|
+
};
|
|
32
|
+
options?: Array<{ label: string; value: string }>;
|
|
33
|
+
defaultValue?: string;
|
|
34
|
+
width: "full" | "half";
|
|
35
|
+
condition?: { field: string; op: string; value?: string };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface FormPage {
|
|
39
|
+
title?: string;
|
|
40
|
+
fields: FormField[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface FormDefinition {
|
|
44
|
+
name: string;
|
|
45
|
+
slug: string;
|
|
46
|
+
pages: FormPage[];
|
|
47
|
+
settings: {
|
|
48
|
+
spamProtection: string;
|
|
49
|
+
submitLabel: string;
|
|
50
|
+
nextLabel?: string;
|
|
51
|
+
prevLabel?: string;
|
|
52
|
+
};
|
|
53
|
+
status: string;
|
|
54
|
+
_turnstileSiteKey?: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { node } = Astro.props;
|
|
58
|
+
const formId = node.formId;
|
|
59
|
+
|
|
60
|
+
// Fetch form definition server-side
|
|
61
|
+
const response = await fetch(
|
|
62
|
+
new URL("/_emdash/api/plugins/emdash-forms/definition", Astro.url),
|
|
63
|
+
{
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { "Content-Type": "application/json" },
|
|
66
|
+
body: JSON.stringify({ id: formId }),
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (!response.ok) return;
|
|
71
|
+
|
|
72
|
+
const form = (await response.json()) as FormDefinition;
|
|
73
|
+
if (!form || form.status !== "active") return;
|
|
74
|
+
|
|
75
|
+
const submitUrl = `/_emdash/api/plugins/emdash-forms/submit`;
|
|
76
|
+
const isMultiPage = form.pages.length > 1;
|
|
77
|
+
const turnstileSiteKey = form._turnstileSiteKey;
|
|
78
|
+
const hasFiles = form.pages.some((p: FormPage) =>
|
|
79
|
+
p.fields.some((f: FormField) => f.type === "file")
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
/** Generate an element ID for a field */
|
|
83
|
+
function fieldId(name: string): string {
|
|
84
|
+
return `${formId}-${name}`;
|
|
85
|
+
}
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
<form
|
|
89
|
+
class="ec-form"
|
|
90
|
+
method="POST"
|
|
91
|
+
action={submitUrl}
|
|
92
|
+
enctype={hasFiles ? "multipart/form-data" : undefined}
|
|
93
|
+
data-form-id={formId}
|
|
94
|
+
data-ec-form
|
|
95
|
+
data-pages={isMultiPage ? form.pages.length : undefined}
|
|
96
|
+
>
|
|
97
|
+
{
|
|
98
|
+
form.pages.map((page: FormPage, pageIndex: number) => (
|
|
99
|
+
<fieldset
|
|
100
|
+
class="ec-form-page"
|
|
101
|
+
data-page={pageIndex}
|
|
102
|
+
aria-label={page.title || `Page ${pageIndex + 1}`}
|
|
103
|
+
>
|
|
104
|
+
{isMultiPage && page.title && (
|
|
105
|
+
<legend class="ec-form-page-title">{page.title}</legend>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{page.fields.map((field: FormField) => (
|
|
109
|
+
<div
|
|
110
|
+
class:list={[
|
|
111
|
+
"ec-form-field",
|
|
112
|
+
`ec-form-field--${field.type}`,
|
|
113
|
+
field.width === "half" && "ec-form-field--half",
|
|
114
|
+
]}
|
|
115
|
+
data-condition={
|
|
116
|
+
field.condition ? JSON.stringify(field.condition) : undefined
|
|
117
|
+
}
|
|
118
|
+
>
|
|
119
|
+
{field.type !== "hidden" && field.type !== "checkbox" && (
|
|
120
|
+
<label class="ec-form-label" for={fieldId(field.name)}>
|
|
121
|
+
{field.label}
|
|
122
|
+
{field.required && (
|
|
123
|
+
<span class="ec-form-required" aria-label="required">
|
|
124
|
+
*
|
|
125
|
+
</span>
|
|
126
|
+
)}
|
|
127
|
+
</label>
|
|
128
|
+
)}
|
|
129
|
+
{[
|
|
130
|
+
"text",
|
|
131
|
+
"email",
|
|
132
|
+
"tel",
|
|
133
|
+
"url",
|
|
134
|
+
"number",
|
|
135
|
+
"date",
|
|
136
|
+
"hidden",
|
|
137
|
+
].includes(field.type) && (
|
|
138
|
+
<input
|
|
139
|
+
type={field.type as astroHTML.JSX.HTMLInputTypeAttribute}
|
|
140
|
+
class={field.type !== "hidden" ? "ec-form-input" : undefined}
|
|
141
|
+
id={fieldId(field.name)}
|
|
142
|
+
name={field.name}
|
|
143
|
+
placeholder={field.placeholder}
|
|
144
|
+
required={field.required}
|
|
145
|
+
minlength={field.validation?.minLength}
|
|
146
|
+
maxlength={field.validation?.maxLength}
|
|
147
|
+
min={field.validation?.min}
|
|
148
|
+
max={field.validation?.max}
|
|
149
|
+
pattern={field.validation?.pattern}
|
|
150
|
+
value={field.defaultValue}
|
|
151
|
+
/>
|
|
152
|
+
)}
|
|
153
|
+
{field.type === "file" && (
|
|
154
|
+
<input
|
|
155
|
+
type="file"
|
|
156
|
+
class="ec-form-input"
|
|
157
|
+
id={fieldId(field.name)}
|
|
158
|
+
name={field.name}
|
|
159
|
+
required={field.required}
|
|
160
|
+
accept={field.validation?.accept}
|
|
161
|
+
/>
|
|
162
|
+
)}
|
|
163
|
+
{field.type === "textarea" && (
|
|
164
|
+
<textarea
|
|
165
|
+
class="ec-form-input"
|
|
166
|
+
id={fieldId(field.name)}
|
|
167
|
+
name={field.name}
|
|
168
|
+
placeholder={field.placeholder}
|
|
169
|
+
required={field.required}
|
|
170
|
+
minlength={field.validation?.minLength}
|
|
171
|
+
maxlength={field.validation?.maxLength}
|
|
172
|
+
>
|
|
173
|
+
{field.defaultValue || ""}
|
|
174
|
+
</textarea>
|
|
175
|
+
)}
|
|
176
|
+
{field.type === "select" && (
|
|
177
|
+
<select
|
|
178
|
+
class="ec-form-input"
|
|
179
|
+
id={fieldId(field.name)}
|
|
180
|
+
name={field.name}
|
|
181
|
+
required={field.required}
|
|
182
|
+
>
|
|
183
|
+
{(field.options || []).map((o) => (
|
|
184
|
+
<option
|
|
185
|
+
value={o.value}
|
|
186
|
+
selected={o.value === field.defaultValue}
|
|
187
|
+
>
|
|
188
|
+
{o.label}
|
|
189
|
+
</option>
|
|
190
|
+
))}
|
|
191
|
+
</select>
|
|
192
|
+
)}
|
|
193
|
+
{field.type === "radio" && (
|
|
194
|
+
<fieldset class="ec-form-radio-group" role="radiogroup">
|
|
195
|
+
{(field.options || []).map((o) => (
|
|
196
|
+
<label class="ec-form-radio-label">
|
|
197
|
+
<input
|
|
198
|
+
type="radio"
|
|
199
|
+
name={field.name}
|
|
200
|
+
value={o.value}
|
|
201
|
+
checked={o.value === field.defaultValue}
|
|
202
|
+
required={field.required}
|
|
203
|
+
/>{" "}
|
|
204
|
+
{o.label}
|
|
205
|
+
</label>
|
|
206
|
+
))}
|
|
207
|
+
</fieldset>
|
|
208
|
+
)}
|
|
209
|
+
{field.type === "checkbox" && (
|
|
210
|
+
<label class="ec-form-checkbox-label">
|
|
211
|
+
<input
|
|
212
|
+
type="checkbox"
|
|
213
|
+
class="ec-form-input"
|
|
214
|
+
id={fieldId(field.name)}
|
|
215
|
+
name={field.name}
|
|
216
|
+
value={field.defaultValue || "1"}
|
|
217
|
+
required={field.required}
|
|
218
|
+
/>{" "}
|
|
219
|
+
{field.label}
|
|
220
|
+
</label>
|
|
221
|
+
)}
|
|
222
|
+
{field.type === "checkbox-group" && (
|
|
223
|
+
<fieldset class="ec-form-checkbox-group">
|
|
224
|
+
{(field.options || []).map((o) => (
|
|
225
|
+
<label class="ec-form-checkbox-label">
|
|
226
|
+
<input type="checkbox" name={field.name} value={o.value} />{" "}
|
|
227
|
+
{o.label}
|
|
228
|
+
</label>
|
|
229
|
+
))}
|
|
230
|
+
</fieldset>
|
|
231
|
+
)}
|
|
232
|
+
{field.helpText && (
|
|
233
|
+
<span class="ec-form-help">{field.helpText}</span>
|
|
234
|
+
)}
|
|
235
|
+
<span
|
|
236
|
+
class="ec-form-error"
|
|
237
|
+
data-error-for={field.name}
|
|
238
|
+
aria-live="polite"
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
))}
|
|
242
|
+
</fieldset>
|
|
243
|
+
))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
{
|
|
247
|
+
form.settings.spamProtection === "honeypot" && (
|
|
248
|
+
<div
|
|
249
|
+
class="ec-form-field"
|
|
250
|
+
style="position:absolute;left:-9999px;"
|
|
251
|
+
aria-hidden="true"
|
|
252
|
+
>
|
|
253
|
+
<label for={`${formId}-_hp`}>Leave blank</label>
|
|
254
|
+
<input
|
|
255
|
+
type="text"
|
|
256
|
+
id={`${formId}-_hp`}
|
|
257
|
+
name="_hp"
|
|
258
|
+
tabindex="-1"
|
|
259
|
+
autocomplete="off"
|
|
260
|
+
/>
|
|
261
|
+
</div>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
{
|
|
266
|
+
form.settings.spamProtection === "turnstile" && turnstileSiteKey && (
|
|
267
|
+
<div
|
|
268
|
+
class="ec-form-turnstile"
|
|
269
|
+
data-ec-turnstile
|
|
270
|
+
data-sitekey={turnstileSiteKey}
|
|
271
|
+
/>
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
<input type="hidden" name="formId" value={formId} />
|
|
276
|
+
|
|
277
|
+
<div class="ec-form-nav">
|
|
278
|
+
<button type="button" class="ec-form-prev" data-ec-prev hidden>
|
|
279
|
+
{form.settings.prevLabel || "Previous"}
|
|
280
|
+
</button>
|
|
281
|
+
<button type="button" class="ec-form-next" data-ec-next hidden>
|
|
282
|
+
{form.settings.nextLabel || "Next"}
|
|
283
|
+
</button>
|
|
284
|
+
<button type="submit" class="ec-form-submit">
|
|
285
|
+
{form.settings.submitLabel || "Submit"}
|
|
286
|
+
</button>
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
{
|
|
290
|
+
isMultiPage && (
|
|
291
|
+
<div class="ec-form-progress" data-ec-progress aria-live="polite" />
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
<div class="ec-form-status" data-form-status aria-live="polite"></div>
|
|
296
|
+
</form>
|
|
297
|
+
|
|
298
|
+
<script>
|
|
299
|
+
import { initForms } from "@emdash-cms/plugin-forms/client";
|
|
300
|
+
initForms();
|
|
301
|
+
</script>
|