@@ -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+
2839function 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
0 commit comments