diff --git a/CHANGELOG.md b/CHANGELOG.md index 3968fdb..70f7440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.0.0-alpha.4] - 2026-07-02 + +### ⚠️ Breaking +- Public serving now requires the form to be **published**. Previously the public page, embed schema and submit endpoint served the working record regardless of draft/publish state; now an unpublished form returns 404 and rejects submissions. Forms published before this release keep working via a fallback — **re-publish them once after upgrading** for the cleanest behavior. + +### Added +- **Draft / Publish separation** — publishing freezes an immutable `publishedData` snapshot; the public form always serves that snapshot, so *Save draft* no longer affects the live form. An "Unpublished changes" indicator appears when the saved draft differs from what's published. +- **Per-submission field snapshot** — each submission stores the field schema used at submit time, so the detail view stays accurate even after fields are added, removed or renamed. +- **Form templates** — first-run onboarding and a "Create form" picker with 6 starters (Contact, Newsletter, Feedback, Job application, Event RSVP, Blank). +- **Forms list**: submissions-count column and search over title/slug. +- **Submissions**: bulk *mark as read / archive / delete*, text search, right-side detail drawer with prev/next navigation, and a per-form column picker (union of current fields + stored keys). +- Right-aligned "View live" link and a Settings slide-over drawer in the builder. + +### Changed +- Full visual redesign of the forms list, builder and submissions screens to match the design system. +- Field settings panel split into General / Validation tabs; settings drawer changes apply on Save. +- Builder header shows a single status pill (Draft / Published / Unpublished changes). +- Submission detail always renders the stored data (never hides an answer); CSV export unchanged. +- Releases are now published from version tags (`v*`) on a single `main` branch. + +### Fixed +- Coerce non-string form `description` to a string (fixed `[object Object]` in the settings drawer). +- Submission detail rows are baseline-aligned. + +--- + ## [1.0.0-alpha.3] - 2026-04-04 ### Changed diff --git a/README.md b/README.md index fb30ece..bf58289 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The **Form Builder** entry will appear in the Strapi admin sidebar. 1. Open **Form Builder** in the sidebar. 2. Click **New form**, give it a title. -3. Drag field types from the left palette onto the canvas. +3. Click field types in the left palette to add them to the canvas, then drag to reorder. 4. Click any field to open its settings panel (label, name, placeholder, required, width, validation rules). 5. Click **Save draft** to persist without publishing, or **Publish** to make it live. diff --git a/admin/src/api.ts b/admin/src/api.ts index 1cdc6cf..2e8e0bb 100644 --- a/admin/src/api.ts +++ b/admin/src/api.ts @@ -59,5 +59,10 @@ export function useFormsApi() { const { data } = await get(`${BASE}/submissions/${formId}/stats`); return data; }, + + async exportSubmissions(formId: number): Promise { + const { data } = await get(`${BASE}/submissions/${formId}/export`); + return typeof data === 'string' ? data : String(data ?? ''); + }, }; } diff --git a/admin/src/components/DropZone.tsx b/admin/src/components/DropZone.tsx index 979a711..7b68634 100644 --- a/admin/src/components/DropZone.tsx +++ b/admin/src/components/DropZone.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { Box, Flex, Typography, IconButton } from '@strapi/design-system'; import { DndContext, closestCenter, @@ -17,20 +16,20 @@ import { arrayMove, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { Trash, Drag } from '@strapi/icons'; +import { Trash } from '@strapi/icons'; import { FormField } from '../types'; +import { C, FF, isDecorative, typeName } from '../ui'; + +function DragDots({ active }: { active: boolean }) { + return ( + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + ); +} -/** - * Renders a draggable, selectable row that represents a FormField with a delete action. - * - * The row highlights when `selected`. Clicking the row calls `onSelect`; interacting with the drag handle does not toggle selection; clicking the delete button calls `onDelete`. - * - * @param field - The form field to render (label and type are displayed). - * @param selected - Whether this row is currently selected; controls visual highlight. - * @param onSelect - Callback invoked when the row (outside the drag handle and delete button) is clicked. - * @param onDelete - Callback invoked when the delete button is clicked. - * @returns The rendered sortable field row element. - */ function SortableFieldRow({ field, selected, @@ -44,60 +43,49 @@ function SortableFieldRow({ }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: field.id }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; + const deco = isDecorative(field.type); return ( - - - - e.stopPropagation()} - > - - - - - {field.label || '(no label)'} - - - {' '}— {field.type} - - - - { - e.stopPropagation(); - onDelete(); - }} - variant="ghost" - > - - - - + e.stopPropagation()} + > + + +
+ {field.label || '(no label)'} + {field.required && *} + — {typeName(field.type)} +
+ {deco ? ( + Deco + ) : ( + {field.width} + )} + + ); } @@ -124,25 +112,10 @@ export function DropZone({ fields, selectedId, onSelect, onDelete, onReorder }: } }; - if (fields.length === 0) { - return ( - - - Click on a field in the left panel to add it to the form - - - ); - } - return ( f.id)} strategy={verticalListSortingStrategy}> - +
{fields.map((field) => ( onDelete(field.id)} /> ))} - +
); diff --git a/admin/src/components/EmbedModal.tsx b/admin/src/components/EmbedModal.tsx index 4f28b43..e09cf23 100644 --- a/admin/src/components/EmbedModal.tsx +++ b/admin/src/components/EmbedModal.tsx @@ -1,90 +1,115 @@ -import React, { useState } from 'react'; -import { Modal, Box, Typography, Button, Flex } from '@strapi/design-system'; -import { Check, Duplicate } from '@strapi/icons'; +import React, { useEffect, useState } from 'react'; +import { Lock } from '@strapi/icons'; +import { C, FF } from '../ui'; +import { PLUGIN_ID } from '../pluginId'; interface Props { formId: number | string; + title: string; + slug?: string | null; + publishedAt?: string | null; + publicPage?: boolean; open: boolean; onClose: () => void; } -/** - * Renders a modal that displays an embeddable HTML snippet for a given form and provides a one-click copy action. - * - * The component returns `null` when `open` is `false`. When visible, it shows a preformatted snippet containing a container div - * with `id="sfb-form-"` and a script tag that loads the embed script from the current origin with `data-form-id` set. - * - * @param formId - The form identifier inserted into the snippet's container id and `data-form-id` attribute - * @param open - Controls whether the modal is visible - * @param onClose - Callback invoked when the modal is closed - * @returns The modal element when `open` is `true`, otherwise `null` - */ -export function EmbedModal({ formId, open, onClose }: Props) { +export function EmbedModal({ formId, title, slug, publishedAt, publicPage, open, onClose }: Props) { + const [tab, setTab] = useState<'script' | 'link'>('script'); const [copied, setCopied] = useState(false); + // Share link only works when the public page is enabled; drop back to script otherwise. + const canShare = !!publicPage && !!slug; + useEffect(() => { if (!canShare && tab === 'link') setTab('script'); }, [canShare, tab]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + if (!open) return null; const origin = window.location.origin; - const snippet = `
\n<\/script>`; + const embedSrc = `${origin}/api/${PLUGIN_ID}/embed.js`; + const url = slug ? `${origin}/api/${PLUGIN_ID}/page/${slug}` : ''; + const snippet = `\n
\n`; + const published = !!publishedAt; const copy = () => { - navigator.clipboard.writeText(snippet).then(() => { + navigator.clipboard.writeText(tab === 'script' ? snippet : url).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }; + const tabBtn = (id: 'script' | 'link', label: string) => ( + + ); + return ( - !v && onClose()}> - - - Embed this form - + <> +
+
+
+

Embed “{title}”

+ +
+ +
+ {published ? ( + <> +
+ {tabBtn('script', 'Script embed')} + {canShare && tabBtn('link', 'Share link')} +
- - - - Paste this snippet into your website where you want the form to appear. - -
 {
-                const sel = window.getSelection();
-                const range = document.createRange();
-                range.selectNodeContents(e.currentTarget);
-                sel?.removeAllRanges();
-                sel?.addRange(range);
-              }}
-            >
-              {snippet}
-            
-
-
+ {tab === 'script' ? ( +
+
Paste this snippet where the form should appear
+
+ <!-- {title} -->
+ <div id="sfb-form-{formId}"></div>
+ <script
+   src="{embedSrc}"
+   data-form-id="{formId}" async
+ ></script> +
+
+ ) : ( +
+
Shareable link
+
+ + {url} +
+ Open live form ↗ +
+ )} - - - - - - +
+ + + Live — this form has a published version. + + +
+ + ) : ( +
+
+ +
+
Publish to get an embed
+

+ Embedding and the shareable link become available once you publish this form. +

+
+ )} +
+
+ ); } diff --git a/admin/src/components/FieldPalette.tsx b/admin/src/components/FieldPalette.tsx index 042f87e..19c2d9b 100644 --- a/admin/src/components/FieldPalette.tsx +++ b/admin/src/components/FieldPalette.tsx @@ -1,86 +1,66 @@ -import React, { useState } from 'react'; -import { Box, Flex, Typography } from '@strapi/design-system'; +import React, { useMemo, useState } from 'react'; +import { Search } from '@strapi/icons'; import { FieldType } from '../types'; - -interface PaletteItem { - type: FieldType; - label: string; - icon: string; - group: string; -} - -const PALETTE_ITEMS: PaletteItem[] = [ - // Basic - { type: 'text', label: 'Text', icon: '📝', group: 'Basic' }, - { type: 'email', label: 'Email', icon: '✉️', group: 'Basic' }, - { type: 'number', label: 'Number', icon: '#', group: 'Basic' }, - { type: 'phone', label: 'Phone', icon: '📱', group: 'Basic' }, - { type: 'textarea', label: 'Long text', icon: '📄', group: 'Basic' }, - { type: 'password', label: 'Password', icon: '🔒', group: 'Basic' }, - // Selection - { type: 'select', label: 'Select', icon: '▼', group: 'Selection' }, - { type: 'radio', label: 'Radio', icon: '◉', group: 'Selection' }, - { type: 'checkbox', label: 'Checkbox', icon: '☑', group: 'Selection' }, - { type: 'checkbox-group', label: 'Checkbox group', icon: '☑☑', group: 'Selection' }, - // Advanced - { type: 'date', label: 'Date', icon: '📅', group: 'Advanced' }, - { type: 'time', label: 'Time', icon: '🕐', group: 'Advanced' }, - { type: 'url', label: 'URL', icon: '🔗', group: 'Advanced' }, - { type: 'hidden', label: 'Hidden', icon: '👁', group: 'Advanced' }, - // Layout - { type: 'heading', label: 'Heading', icon: 'H', group: 'Layout' }, - { type: 'paragraph', label: 'Paragraph', icon: '¶', group: 'Layout' }, - { type: 'divider', label: 'Divider', icon: '—', group: 'Layout' }, -]; - -const GROUPS = ['Basic', 'Selection', 'Advanced', 'Layout']; +import { C, FF, FIELD_CATEGORIES, FieldIcon } from '../ui'; interface Props { onAdd: (type: FieldType) => void; } export function FieldPalette({ onAdd }: Props) { - const [hovered, setHovered] = useState(null); + const [query, setQuery] = useState(''); + const [hover, setHover] = useState(null); + + const groups = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return FIELD_CATEGORIES; + return FIELD_CATEGORIES + .map((g) => ({ ...g, items: g.items.filter((i) => i.name.toLowerCase().includes(q)) })) + .filter((g) => g.items.length > 0); + }, [query]); return ( - - - FIELDS - - {GROUPS.map((group) => ( - - - {group} - - - {PALETTE_ITEMS.filter((i) => i.group === group).map((item) => ( - onAdd(item.type)} - onMouseEnter={() => setHovered(item.type)} - onMouseLeave={() => setHovered(null)} - > - - {item.icon} - {item.label} - - - ))} - - +
+
+ + setQuery(e.target.value)} + placeholder="Search fields" + style={{ border: 'none', outline: 'none', flex: 1, font: `400 12px ${FF}`, color: C.n800, background: 'none', width: '100%' }} + /> +
+ + {groups.map((grp) => ( +
+
{grp.cat}
+
+ {grp.items.map((item) => { + const on = hover === item.type; + const full = item.type === 'divider'; + return ( + + ); + })} +
+
))} - +
); } diff --git a/admin/src/components/FieldSettingsPanel.tsx b/admin/src/components/FieldSettingsPanel.tsx index 7400ecc..64bebdf 100644 --- a/admin/src/components/FieldSettingsPanel.tsx +++ b/admin/src/components/FieldSettingsPanel.tsx @@ -1,319 +1,226 @@ -import React from 'react'; -import { - Box, - Flex, - Typography, - TextInput, - Textarea, - Button, - IconButton, - Field, - SingleSelect, - SingleSelectOption, -} from '@strapi/design-system'; -import { Plus, Trash } from '@strapi/icons'; +import React, { useState } from 'react'; +import { Trash } from '@strapi/icons'; import { FormField, FieldOption, ValidationRule } from '../types'; +import { C, FF, isDecorative, typeName } from '../ui'; interface Props { field: FormField; onChange: (updated: FormField) => void; } -function LabeledInput({ - label, - value, - onChange, - placeholder, - size, -}: { - label: string; - value: string; - onChange: (v: string) => void; - placeholder?: string; - size?: 'S' | 'M'; +// One-time style block for input focus + placeholder (inline styles can't do :focus). +const STYLE_ID = 'sfb-settings-style'; +function ensureStyle() { + if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return; + const el = document.createElement('style'); + el.id = STYLE_ID; + el.textContent = ` + .sfb-inp:focus{border-color:${C.p600} !important;box-shadow:0 0 0 2px ${C.p200} !important} + `; + document.head.appendChild(el); +} + +const inpStyle: React.CSSProperties = { + height: 36, border: `1px solid ${C.n200}`, borderRadius: 4, background: C.n0, + padding: '0 11px', font: `400 13px ${FF}`, color: C.n800, width: '100%', outline: 'none', +}; + +function Labeled({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function TextField({ label, value, onChange, hint, mono, placeholder }: { + label: string; value: string; onChange: (v: string) => void; hint?: string; mono?: boolean; placeholder?: string; }) { return ( - - {label} - + ) => onChange(e.target.value)} placeholder={placeholder} - size={size} + onChange={(e) => onChange(e.target.value)} + style={{ ...inpStyle, ...(mono ? { fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 12, color: C.n700 } : {}) }} /> - + {hint && {hint}} + + ); +} + +function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) { + return ( + + ); +} + +function Seg({ value, onChange }: { value: 'full' | 'half'; onChange: (v: 'full' | 'half') => void }) { + const cell = (active: boolean): React.CSSProperties => ({ + flex: 1, textAlign: 'center', padding: '8px 0', font: `600 12px ${FF}`, cursor: 'pointer', + color: active ? '#fff' : C.n600, background: active ? C.p600 : 'transparent', border: 'none', + }); + return ( +
+ + +
); } -/** - * Render the settings panel UI for editing a single form field. - * - * Displays controls for label, name, placeholder, help text, required/width toggles, - * type-specific inputs (heading, paragraph), option management (add/update/remove), - * validation rule management (add/update/remove) with value/message editing, and CSS class. - * - * @param field - The current FormField to edit. - * @param onChange - Callback invoked with the updated FormField whenever a change is made. - * @returns A React element containing the field settings panel UI. - */ export function FieldSettingsPanel({ field, onChange }: Props) { + ensureStyle(); + const deco = isDecorative(field.type); + const [tab, setTab] = useState<'general' | 'validation'>('general'); const update = (patch: Partial) => onChange({ ...field, ...patch }); - const addOption = () => { - const options = [...(field.options || []), { label: '', value: '' }]; - update({ options }); - }; + const hasOptions = ['select', 'radio', 'checkbox-group'].includes(field.type); - const updateOption = (index: number, patch: Partial) => { - const options = (field.options || []).map((o, i) => - i === index ? { ...o, ...patch } : o - ); - update({ options }); - }; - - const removeOption = (index: number) => { - update({ options: (field.options || []).filter((_, i) => i !== index) }); - }; + const addOption = () => update({ options: [...(field.options || []), { label: '', value: '' }] }); + const updateOption = (i: number, patch: Partial) => + update({ options: (field.options || []).map((o, j) => (j === i ? { ...o, ...patch } : o)) }); + const removeOption = (i: number) => update({ options: (field.options || []).filter((_, j) => j !== i) }); const addValidation = () => { - const usedTypes = field.validation.map((r) => r.type); - const defaultType = - field.type === 'number' ? (usedTypes.includes('min') ? (usedTypes.includes('max') ? 'pattern' : 'max') : 'min') : - field.type === 'email' ? (usedTypes.includes('email') ? 'minLength' : 'email') : - field.type === 'url' ? (usedTypes.includes('url') ? 'minLength' : 'url') : - usedTypes.includes('minLength') ? (usedTypes.includes('maxLength') ? 'pattern' : 'maxLength') : 'minLength'; - update({ validation: [...field.validation, { type: defaultType }] }); - }; - - const updateValidation = (index: number, patch: Partial) => { - const validation = field.validation.map((v, i) => - i === index ? { ...v, ...patch } : v - ); - update({ validation }); - }; - - const removeValidation = (index: number) => { - update({ validation: field.validation.filter((_, i) => i !== index) }); + const used = field.validation.map((r) => r.type); + const def = + field.type === 'number' ? (used.includes('min') ? (used.includes('max') ? 'pattern' : 'max') : 'min') : + field.type === 'email' ? (used.includes('email') ? 'minLength' : 'email') : + field.type === 'url' ? (used.includes('url') ? 'minLength' : 'url') : + used.includes('minLength') ? (used.includes('maxLength') ? 'pattern' : 'maxLength') : 'minLength'; + update({ validation: [...field.validation, { type: def }] }); }; + const updateValidation = (i: number, patch: Partial) => + update({ validation: field.validation.map((v, j) => (j === i ? { ...v, ...patch } : v)) }); + const removeValidation = (i: number) => update({ validation: field.validation.filter((_, j) => j !== i) }); - const hasOptions = ['select', 'radio', 'checkbox-group'].includes(field.type); - const isDecorative = ['heading', 'paragraph', 'divider'].includes(field.type); + const kicker = `${typeName(field.type)} field`.toUpperCase(); - return ( - ( + - - - - - - - Width - - - - - - - - )} - - {field.type === 'heading' && ( - update({ label: v })} - /> - )} - - {field.type === 'paragraph' && ( - - Content -