Skip to content

Commit ffbaaef

Browse files
committed
feat(admin): builder status/settings polish, submission detail & column picker
- Builder header: single status pill (Draft/Published/Unpublished changes), plain right-aligned View live link, settings drawer edits a buffer applied on Save; unpublished-changes indicator compares saved copy vs published - Forms list: remove Status column; submissions=eye, live=external-link icons - Submissions: detail renders from stored data (never hides answers) using the submit-time field snapshot for labels; table columns are the union of current fields + stored keys; per-form column picker (localStorage); baseline-aligned data rows; Status tile removed from detail
1 parent 1e6e375 commit ffbaaef

3 files changed

Lines changed: 163 additions & 64 deletions

File tree

admin/src/pages/FormBuilderPage.tsx

Lines changed: 61 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ const DEFAULT_SETTINGS: FormSettings = {
2525
publicPage: false,
2626
};
2727

28+
// Order-insensitive stringify so key ordering in stored JSON doesn't create false diffs.
29+
function stableStringify(v: any): string {
30+
if (Array.isArray(v)) return '[' + v.map(stableStringify).join(',') + ']';
31+
if (v && typeof v === 'object') {
32+
return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + stableStringify(v[k])).join(',') + '}';
33+
}
34+
return JSON.stringify(v ?? null);
35+
}
36+
const liveSnap = (o: { title: string; description: string; fields: any; settings: any }) =>
37+
stableStringify({ title: o.title, description: o.description ?? '', fields: o.fields ?? [], settings: o.settings ?? {} });
38+
2839
function HeaderBtn({ variant, onClick, disabled, children }: {
2940
variant: 'ghost' | 'sec' | 'pri'; onClick: () => void; disabled?: boolean; children: React.ReactNode;
3041
}) {
@@ -73,17 +84,19 @@ function ToggleRow({ label, hint, on, onClick }: { label: string; hint: string;
7384
);
7485
}
7586

76-
function SettingsDrawer({ description, setDescription, settings, setSettings, slug, publishedAt, onCancel, onSave }: {
77-
description: string;
78-
setDescription: (v: string) => void;
79-
settings: FormSettings;
80-
setSettings: React.Dispatch<React.SetStateAction<FormSettings>>;
87+
function SettingsDrawer({ initialDescription, initialSettings, slug, publishedAt, onClose, onSave }: {
88+
initialDescription: string;
89+
initialSettings: FormSettings;
8190
slug: string | null;
8291
publishedAt: string | null;
83-
onCancel: () => void;
84-
onSave: () => void;
92+
onClose: () => void;
93+
onSave: (description: string, settings: FormSettings) => void;
8594
}) {
95+
// edits stay in a local buffer — they only reach the form on "Save settings"
96+
const [description, setDescription] = useState(initialDescription);
97+
const [settings, setSettings] = useState<FormSettings>(initialSettings);
8698
const patch = (p: Partial<FormSettings>) => setSettings((s) => ({ ...s, ...p }));
99+
const onCancel = onClose;
87100
const url = slug ? `${window.location.origin}/api/${PLUGIN_ID}/page/${slug}` : '';
88101
const live = !!publishedAt;
89102

@@ -157,7 +170,7 @@ function SettingsDrawer({ description, setDescription, settings, setSettings, sl
157170

158171
<div style={{ padding: '16px 24px', borderTop: `1px solid ${C.n150}`, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
159172
<HeaderBtn variant="ghost" onClick={onCancel}>Cancel</HeaderBtn>
160-
<HeaderBtn variant="pri" onClick={onSave}>Save settings</HeaderBtn>
173+
<HeaderBtn variant="pri" onClick={() => onSave(description, settings)}>Save settings</HeaderBtn>
161174
</div>
162175
</div>
163176
</>
@@ -205,10 +218,9 @@ export function FormBuilderPage() {
205218
const [showPreview, setShowPreview] = useState(false);
206219
const [showEmbed, setShowEmbed] = useState(false);
207220
const [publishedAt, setPublishedAt] = useState<string | null>(null);
221+
const [publishedData, setPublishedData] = useState<any>(null);
208222
const [slug, setSlug] = useState<string | null>(null);
209223
const [editingTitle, setEditingTitle] = useState(isNew);
210-
// snapshot of settings when the drawer opens, so Cancel can revert unsaved edits
211-
const settingsSnapshot = useRef<{ description: string; settings: FormSettings }>({ description: '', settings: DEFAULT_SETTINGS });
212224

213225
// dirty tracking: compare live state against the last saved/loaded snapshot
214226
const savedRef = useRef<string>('');
@@ -223,6 +235,7 @@ export function FormBuilderPage() {
223235
setFields(form.fields || []);
224236
setSettings({ ...DEFAULT_SETTINGS, ...(form.settings || {}) });
225237
setPublishedAt(form.publishedAt ?? null);
238+
setPublishedData((form as any).publishedData ?? null);
226239
setSlug(form.slug ?? null);
227240
setLoading(false);
228241
savedRef.current = JSON.stringify({
@@ -238,6 +251,26 @@ export function FormBuilderPage() {
238251
}, [id]);
239252

240253
const dirty = snapshot() !== savedRef.current;
254+
// published but the working copy no longer matches what the public is served
255+
// Compare the last SAVED copy (not in-memory edits) against the published snapshot,
256+
// so the status flips on Save draft — not merely while typing.
257+
const hasUnpublishedChanges = (() => {
258+
if (!publishedAt || !publishedData) return false;
259+
try {
260+
return liveSnap(JSON.parse(savedRef.current || '{}')) !== liveSnap(publishedData);
261+
} catch {
262+
return false;
263+
}
264+
})();
265+
266+
// single status pill: unpublished-changes replaces the published/draft label
267+
const status = hasUnpublishedChanges
268+
? { label: 'Unpublished changes', bg: C.wrn100, fg: C.wrn700, dot: C.wrn600 }
269+
: publishedAt
270+
? { label: 'Published', bg: '#eafbe7', fg: C.suc700, dot: C.suc600 }
271+
: { label: 'Draft', bg: C.n150, fg: C.n700, dot: C.n500 };
272+
const liveUrl = slug ? `${window.location.origin}/api/${PLUGIN_ID}/page/${slug}` : '';
273+
const showLiveLink = !!publishedAt && settings.publicPage && !!slug;
241274

242275
useEffect(() => {
243276
const handler = (e: BeforeUnloadEvent) => {
@@ -296,15 +329,19 @@ export function FormBuilderPage() {
296329
try {
297330
const now = new Date().toISOString();
298331
const payload = { title, description, fields, settings, conditionalLogic: [], publishedAt: now };
332+
// working copy is now the live snapshot → clears the "unpublished changes" indicator
333+
const snap = { title, description, fields, settings, conditionalLogic: [] };
299334
if (isNew) {
300335
const created = await api.createForm(payload);
301336
setPublishedAt(created.publishedAt ?? now);
337+
setPublishedData(created.publishedData ?? snap);
302338
setSlug(created.slug ?? null);
303339
markSaved();
304340
navigate(`/plugins/${PLUGIN_ID}/builder/${created.id}`, { replace: true });
305341
} else {
306342
const updated = await api.updateForm(Number(id), payload);
307343
setPublishedAt(updated.publishedAt ?? now);
344+
setPublishedData(updated.publishedData ?? snap);
308345
markSaved();
309346
}
310347
} finally {
@@ -351,22 +388,21 @@ export function FormBuilderPage() {
351388
)}
352389
</div>
353390
<div style={{ font: `400 11px ${FF}`, color: C.n500 }}>
354-
{fields.length} {fields.length === 1 ? 'field' : 'fields'} · {publishedAt ? 'published' : 'draft'}
391+
{fields.length} {fields.length === 1 ? 'field' : 'fields'}
355392
</div>
356393
</div>
357-
<span style={{ height: 24, padding: '0 9px', borderRadius: 4, display: 'inline-flex', alignItems: 'center', gap: 6, font: `700 11px ${FF}`, letterSpacing: '.3px', textTransform: 'uppercase', background: publishedAt ? '#eafbe7' : C.n150, color: publishedAt ? C.suc700 : C.n700 }}>
358-
<span style={{ width: 6, height: 6, borderRadius: '50%', background: publishedAt ? C.suc600 : C.n500 }} />
359-
{publishedAt ? 'Published' : 'Draft'}
360-
</span>
361-
{/* ponytail: version chip is display-only until version history (backend) lands */}
362-
<span title="Version history — coming soon" style={{ display: 'inline-flex', alignItems: 'center', gap: 7, height: 30, padding: '0 11px', border: `1px solid ${C.n200}`, borderRadius: 6, background: C.n0, font: `600 12px ${FF}`, color: C.n700 }}>
363-
<span style={{ width: 6, height: 6, borderRadius: '50%', background: C.n400 }} />
364-
{publishedAt ? 'Published' : 'v1 · editing'}
365-
<span style={{ color: C.n500, fontSize: 10 }}></span>
394+
<span title={hasUnpublishedChanges ? "Your edits aren't live yet — Publish to update the public form" : undefined} style={{ height: 24, padding: '0 9px', borderRadius: 4, display: 'inline-flex', alignItems: 'center', gap: 6, font: `700 11px ${FF}`, letterSpacing: '.3px', textTransform: 'uppercase', background: status.bg, color: status.fg }}>
395+
<span style={{ width: 6, height: 6, borderRadius: '50%', background: status.dot }} />
396+
{status.label}
366397
</span>
367398
</div>
368399
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
369-
<HeaderBtn variant="ghost" onClick={() => { if (!showSettings) settingsSnapshot.current = { description, settings }; setShowSettings((v) => !v); }}>Settings</HeaderBtn>
400+
{showLiveLink && (
401+
<a href={liveUrl} target="_blank" rel="noopener noreferrer" title="Open the live public form" style={{ font: `600 12px ${FF}`, color: C.p600, textDecoration: 'none', marginRight: 4 }}>
402+
View live ↗
403+
</a>
404+
)}
405+
<HeaderBtn variant="ghost" onClick={() => setShowSettings((v) => !v)}>Settings</HeaderBtn>
370406
<HeaderBtn variant="ghost" onClick={() => setShowPreview(true)}>Preview</HeaderBtn>
371407
{!isNew && <HeaderBtn variant="ghost" onClick={() => setShowEmbed(true)}>Embed</HeaderBtn>}
372408
<HeaderBtn variant="sec" onClick={saveDraft} disabled={saving}>Save draft</HeaderBtn>
@@ -377,14 +413,12 @@ export function FormBuilderPage() {
377413
{/* Settings drawer (right slide-over) */}
378414
{showSettings && (
379415
<SettingsDrawer
380-
description={description}
381-
setDescription={setDescription}
382-
settings={settings}
383-
setSettings={setSettings}
416+
initialDescription={description}
417+
initialSettings={settings}
384418
slug={slug}
385419
publishedAt={publishedAt}
386-
onCancel={() => { const s = settingsSnapshot.current; setDescription(s.description); setSettings(s.settings); setShowSettings(false); }}
387-
onSave={async () => { await saveDraft(); setShowSettings(false); }}
420+
onClose={() => setShowSettings(false)}
421+
onSave={(desc, s) => { setDescription(desc); setSettings(s); setShowSettings(false); }}
388422
/>
389423
)}
390424

admin/src/pages/FormListPage.tsx

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,13 @@ import {
1111
Tr,
1212
Th,
1313
Td,
14-
Badge,
1514
IconButton,
1615
Dialog,
1716
Checkbox,
1817
Searchbar,
1918
} from '@strapi/design-system';
2019
import {
21-
Pencil, Trash, Plus, Duplicate, Eye, File,
20+
Pencil, Trash, Plus, Duplicate, Eye, ExternalLink, File,
2221
Mail, BulletList, Message, Calendar, Feather,
2322
} from '@strapi/icons';
2423
import { useFormsApi } from '../api';
@@ -236,7 +235,7 @@ export function FormListPage() {
236235
</Typography>
237236
</Box>
238237
) : (
239-
<Table colCount={7} rowCount={visibleForms.length}>
238+
<Table colCount={6} rowCount={visibleForms.length}>
240239
<Thead>
241240
<Tr>
242241
<Th>
@@ -248,7 +247,6 @@ export function FormListPage() {
248247
</Th>
249248
<Th><Typography variant="sigma">Title</Typography></Th>
250249
<Th><Typography variant="sigma">Slug</Typography></Th>
251-
<Th><Typography variant="sigma">Status</Typography></Th>
252250
<Th><Typography variant="sigma">Submissions</Typography></Th>
253251
<Th><Typography variant="sigma">Created</Typography></Th>
254252
<Th><Typography variant="sigma">Actions</Typography></Th>
@@ -272,11 +270,6 @@ export function FormListPage() {
272270
{form.slug}
273271
</Typography>
274272
</Td>
275-
<Td>
276-
<Badge active={!!form.publishedAt}>
277-
{form.publishedAt ? 'Published' : 'Draft'}
278-
</Badge>
279-
</Td>
280273
<Td>
281274
{count > 0 ? (
282275
<Typography
@@ -308,11 +301,11 @@ export function FormListPage() {
308301
label="View submissions"
309302
onClick={() => navigate(`/plugins/${PLUGIN_ID}/submissions/${form.id}`)}
310303
>
311-
<File />
304+
<Eye />
312305
</IconButton>
313306
{form.publishedAt && form.settings?.publicPage && (
314307
<IconButton label="Open live form" onClick={() => openLive(form)}>
315-
<Eye />
308+
<ExternalLink />
316309
</IconButton>
317310
)}
318311
<IconButton label="Duplicate" onClick={() => handleDuplicate(form.id)}>

0 commit comments

Comments
 (0)