Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions admin/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,10 @@ export function useFormsApi() {
const { data } = await get(`${BASE}/submissions/${formId}/stats`);
return data;
},

async exportSubmissions(formId: number): Promise<string> {
const { data } = await get(`${BASE}/submissions/${formId}/export`);
return typeof data === 'string' ? data : String(data ?? '');
},
};
}
127 changes: 50 additions & 77 deletions admin/src/components/DropZone.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import React from 'react';
import { Box, Flex, Typography, IconButton } from '@strapi/design-system';
import {
DndContext,
closestCenter,
Expand All @@ -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 (
<span style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 3px)', gridAutoRows: '3px', gap: 3, cursor: 'grab', flex: 'none' }}>
{Array.from({ length: 6 }).map((_, i) => (
<i key={i} style={{ width: 3, height: 3, borderRadius: '50%', background: active ? C.p500 : C.n300 }} />
))}
</span>
);
}

/**
* 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,
Expand All @@ -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 (
<Box
<div
ref={setNodeRef}
padding={3}
background={selected ? 'primary100' : 'neutral0'}
marginBottom={2}
onClick={onSelect}
hasRadius
style={{
...style,
border: `2px solid ${selected ? 'var(--strapi-primary-600)' : 'transparent'}`,
outline: selected ? 'none' : '1px solid var(--strapi-neutral-200)',
outlineOffset: '-1px',
cursor: 'pointer',
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
display: 'flex', alignItems: 'center', gap: 16,
background: selected ? C.p100 : C.n0,
border: `1px solid ${selected ? C.p600 : C.n150}`,
borderRadius: 6, padding: '15px 18px', cursor: 'pointer',
boxShadow: selected ? `0 0 0 1px ${C.p600}` : '0 1px 4px rgba(33,33,52,.1)',
}}
>
<Flex justifyContent="space-between" alignItems="center">
<Flex gap={2} alignItems="center">
<Box
{...attributes}
{...listeners}
style={{ cursor: 'grab', color: 'var(--strapi-neutral-400)', padding: '0 4px' }}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Drag />
</Box>
<Box>
<Typography variant="pi" fontWeight="semiBold">
{field.label || '(no label)'}
</Typography>
<Typography variant="pi" textColor="neutral500">
{' '}— {field.type}
</Typography>
</Box>
</Flex>
<IconButton
label="Delete field"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onDelete();
}}
variant="ghost"
>
<Trash />
</IconButton>
</Flex>
</Box>
<span
{...attributes}
{...listeners}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<DragDots active={selected} />
</span>
<div style={{ flex: 1, font: `600 15px ${FF}`, color: C.n900, display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{field.label || '(no label)'}</span>
{field.required && <span style={{ color: C.dng600, fontWeight: 700 }}>*</span>}
<span style={{ fontWeight: 400, color: C.n500 }}>— {typeName(field.type)}</span>
</div>
{deco ? (
<span style={{ font: `600 10px ${FF}`, letterSpacing: '.3px', textTransform: 'uppercase', color: C.wrn700, background: C.wrn100, border: '1px solid #f2d9a6', borderRadius: 4, padding: '3px 7px', flex: 'none' }}>Deco</span>
) : (
<span style={{ font: `600 10px ${FF}`, letterSpacing: '.3px', textTransform: 'uppercase', color: C.n600, background: C.n100, border: `1px solid ${C.n200}`, borderRadius: 4, padding: '3px 7px', flex: 'none' }}>{field.width}</span>
)}
<button
type="button"
title="Delete field"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDelete(); }}
style={{ width: 30, height: 30, borderRadius: 5, display: 'flex', alignItems: 'center', justifyContent: 'center', color: C.n400, border: 'none', background: 'none', cursor: 'pointer', flex: 'none' }}
>
<Trash width="16px" height="16px" fill="currentColor" />
</button>
</div>
);
}

Expand All @@ -124,25 +112,10 @@ export function DropZone({ fields, selectedId, onSelect, onDelete, onReorder }:
}
};

if (fields.length === 0) {
return (
<Box
padding={10}
background="neutral100"
hasRadius
style={{ border: '2px dashed var(--strapi-neutral-300)', textAlign: 'center', flex: 1 }}
>
<Typography textColor="neutral500">
Click on a field in the left panel to add it to the form
</Typography>
</Box>
);
}

return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={fields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
<Box style={{ flex: 1 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{fields.map((field) => (
<SortableFieldRow
key={field.id}
Expand All @@ -152,7 +125,7 @@ export function DropZone({ fields, selectedId, onSelect, onDelete, onReorder }:
onDelete={() => onDelete(field.id)}
/>
))}
</Box>
</div>
</SortableContext>
</DndContext>
);
Expand Down
Loading
Loading