Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
302 lines
7.3 KiB
Plaintext
302 lines
7.3 KiB
Plaintext
---
|
|
/**
|
|
* Form embed component for Portable Text blocks.
|
|
*
|
|
* Server-renders the full form with all pages as <fieldset> elements.
|
|
* Without JavaScript, all pages are visible as one long form.
|
|
* The client-side script enhances with multi-page navigation, AJAX, etc.
|
|
*/
|
|
|
|
interface Props {
|
|
node: { formId: string };
|
|
}
|
|
|
|
interface FormField {
|
|
id: string;
|
|
type: string;
|
|
label: string;
|
|
name: string;
|
|
placeholder?: string;
|
|
helpText?: string;
|
|
required: boolean;
|
|
validation?: {
|
|
minLength?: number;
|
|
maxLength?: number;
|
|
min?: number;
|
|
max?: number;
|
|
pattern?: string;
|
|
patternMessage?: string;
|
|
accept?: string;
|
|
maxFileSize?: number;
|
|
};
|
|
options?: Array<{ label: string; value: string }>;
|
|
defaultValue?: string;
|
|
width: "full" | "half";
|
|
condition?: { field: string; op: string; value?: string };
|
|
}
|
|
|
|
interface FormPage {
|
|
title?: string;
|
|
fields: FormField[];
|
|
}
|
|
|
|
interface FormDefinition {
|
|
name: string;
|
|
slug: string;
|
|
pages: FormPage[];
|
|
settings: {
|
|
spamProtection: string;
|
|
submitLabel: string;
|
|
nextLabel?: string;
|
|
prevLabel?: string;
|
|
};
|
|
status: string;
|
|
_turnstileSiteKey?: string | null;
|
|
}
|
|
|
|
const { node } = Astro.props;
|
|
const formId = node.formId;
|
|
|
|
// Fetch form definition server-side
|
|
const response = await fetch(
|
|
new URL("/_emdash/api/plugins/emdash-forms/definition", Astro.url),
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ id: formId }),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) return;
|
|
|
|
const form = (await response.json()) as FormDefinition;
|
|
if (!form || form.status !== "active") return;
|
|
|
|
const submitUrl = `/_emdash/api/plugins/emdash-forms/submit`;
|
|
const isMultiPage = form.pages.length > 1;
|
|
const turnstileSiteKey = form._turnstileSiteKey;
|
|
const hasFiles = form.pages.some((p: FormPage) =>
|
|
p.fields.some((f: FormField) => f.type === "file")
|
|
);
|
|
|
|
/** Generate an element ID for a field */
|
|
function fieldId(name: string): string {
|
|
return `${formId}-${name}`;
|
|
}
|
|
---
|
|
|
|
<form
|
|
class="ec-form"
|
|
method="POST"
|
|
action={submitUrl}
|
|
enctype={hasFiles ? "multipart/form-data" : undefined}
|
|
data-form-id={formId}
|
|
data-ec-form
|
|
data-pages={isMultiPage ? form.pages.length : undefined}
|
|
>
|
|
{
|
|
form.pages.map((page: FormPage, pageIndex: number) => (
|
|
<fieldset
|
|
class="ec-form-page"
|
|
data-page={pageIndex}
|
|
aria-label={page.title || `Page ${pageIndex + 1}`}
|
|
>
|
|
{isMultiPage && page.title && (
|
|
<legend class="ec-form-page-title">{page.title}</legend>
|
|
)}
|
|
|
|
{page.fields.map((field: FormField) => (
|
|
<div
|
|
class:list={[
|
|
"ec-form-field",
|
|
`ec-form-field--${field.type}`,
|
|
field.width === "half" && "ec-form-field--half",
|
|
]}
|
|
data-condition={
|
|
field.condition ? JSON.stringify(field.condition) : undefined
|
|
}
|
|
>
|
|
{field.type !== "hidden" && field.type !== "checkbox" && (
|
|
<label class="ec-form-label" for={fieldId(field.name)}>
|
|
{field.label}
|
|
{field.required && (
|
|
<span class="ec-form-required" aria-label="required">
|
|
*
|
|
</span>
|
|
)}
|
|
</label>
|
|
)}
|
|
{[
|
|
"text",
|
|
"email",
|
|
"tel",
|
|
"url",
|
|
"number",
|
|
"date",
|
|
"hidden",
|
|
].includes(field.type) && (
|
|
<input
|
|
type={field.type as astroHTML.JSX.HTMLInputTypeAttribute}
|
|
class={field.type !== "hidden" ? "ec-form-input" : undefined}
|
|
id={fieldId(field.name)}
|
|
name={field.name}
|
|
placeholder={field.placeholder}
|
|
required={field.required}
|
|
minlength={field.validation?.minLength}
|
|
maxlength={field.validation?.maxLength}
|
|
min={field.validation?.min}
|
|
max={field.validation?.max}
|
|
pattern={field.validation?.pattern}
|
|
value={field.defaultValue}
|
|
/>
|
|
)}
|
|
{field.type === "file" && (
|
|
<input
|
|
type="file"
|
|
class="ec-form-input"
|
|
id={fieldId(field.name)}
|
|
name={field.name}
|
|
required={field.required}
|
|
accept={field.validation?.accept}
|
|
/>
|
|
)}
|
|
{field.type === "textarea" && (
|
|
<textarea
|
|
class="ec-form-input"
|
|
id={fieldId(field.name)}
|
|
name={field.name}
|
|
placeholder={field.placeholder}
|
|
required={field.required}
|
|
minlength={field.validation?.minLength}
|
|
maxlength={field.validation?.maxLength}
|
|
>
|
|
{field.defaultValue || ""}
|
|
</textarea>
|
|
)}
|
|
{field.type === "select" && (
|
|
<select
|
|
class="ec-form-input"
|
|
id={fieldId(field.name)}
|
|
name={field.name}
|
|
required={field.required}
|
|
>
|
|
{(field.options || []).map((o) => (
|
|
<option
|
|
value={o.value}
|
|
selected={o.value === field.defaultValue}
|
|
>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
{field.type === "radio" && (
|
|
<fieldset class="ec-form-radio-group" role="radiogroup">
|
|
{(field.options || []).map((o) => (
|
|
<label class="ec-form-radio-label">
|
|
<input
|
|
type="radio"
|
|
name={field.name}
|
|
value={o.value}
|
|
checked={o.value === field.defaultValue}
|
|
required={field.required}
|
|
/>{" "}
|
|
{o.label}
|
|
</label>
|
|
))}
|
|
</fieldset>
|
|
)}
|
|
{field.type === "checkbox" && (
|
|
<label class="ec-form-checkbox-label">
|
|
<input
|
|
type="checkbox"
|
|
class="ec-form-input"
|
|
id={fieldId(field.name)}
|
|
name={field.name}
|
|
value={field.defaultValue || "1"}
|
|
required={field.required}
|
|
/>{" "}
|
|
{field.label}
|
|
</label>
|
|
)}
|
|
{field.type === "checkbox-group" && (
|
|
<fieldset class="ec-form-checkbox-group">
|
|
{(field.options || []).map((o) => (
|
|
<label class="ec-form-checkbox-label">
|
|
<input type="checkbox" name={field.name} value={o.value} />{" "}
|
|
{o.label}
|
|
</label>
|
|
))}
|
|
</fieldset>
|
|
)}
|
|
{field.helpText && (
|
|
<span class="ec-form-help">{field.helpText}</span>
|
|
)}
|
|
<span
|
|
class="ec-form-error"
|
|
data-error-for={field.name}
|
|
aria-live="polite"
|
|
/>
|
|
</div>
|
|
))}
|
|
</fieldset>
|
|
))
|
|
}
|
|
|
|
{
|
|
form.settings.spamProtection === "honeypot" && (
|
|
<div
|
|
class="ec-form-field"
|
|
style="position:absolute;left:-9999px;"
|
|
aria-hidden="true"
|
|
>
|
|
<label for={`${formId}-_hp`}>Leave blank</label>
|
|
<input
|
|
type="text"
|
|
id={`${formId}-_hp`}
|
|
name="_hp"
|
|
tabindex="-1"
|
|
autocomplete="off"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
{
|
|
form.settings.spamProtection === "turnstile" && turnstileSiteKey && (
|
|
<div
|
|
class="ec-form-turnstile"
|
|
data-ec-turnstile
|
|
data-sitekey={turnstileSiteKey}
|
|
/>
|
|
)
|
|
}
|
|
|
|
<input type="hidden" name="formId" value={formId} />
|
|
|
|
<div class="ec-form-nav">
|
|
<button type="button" class="ec-form-prev" data-ec-prev hidden>
|
|
{form.settings.prevLabel || "Previous"}
|
|
</button>
|
|
<button type="button" class="ec-form-next" data-ec-next hidden>
|
|
{form.settings.nextLabel || "Next"}
|
|
</button>
|
|
<button type="submit" class="ec-form-submit">
|
|
{form.settings.submitLabel || "Submit"}
|
|
</button>
|
|
</div>
|
|
|
|
{
|
|
isMultiPage && (
|
|
<div class="ec-form-progress" data-ec-progress aria-live="polite" />
|
|
)
|
|
}
|
|
|
|
<div class="ec-form-status" data-form-status aria-live="polite"></div>
|
|
</form>
|
|
|
|
<script>
|
|
import { initForms } from "@emdash-cms/plugin-forms/client";
|
|
initForms();
|
|
</script>
|