11'use client'
22
3- import { useCallback , useState } from 'react'
3+ import { type MouseEvent , useCallback , useState } from 'react'
44import { toast } from '@sim/emcn'
55import { createLogger } from '@sim/logger'
66import { getErrorMessage } from '@sim/utils/errors'
7- import { useUnsavedChangesGuard } from '@/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard'
7+ import { useRouter } from 'next/navigation'
8+ import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
89import { useUpdateWorkspaceCredential , type WorkspaceCredential } from '@/hooks/queries/credentials'
10+ import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
911
1012const logger = createLogger ( 'CredentialDetailForm' )
1113
@@ -25,6 +27,20 @@ export interface CredentialDetailFormSection {
2527 discard : ( ) => void
2628}
2729
30+ interface CredentialMetadata {
31+ displayName : string
32+ description : string
33+ unredacted : boolean
34+ }
35+
36+ function sameMetadata ( left : CredentialMetadata , right : CredentialMetadata ) {
37+ return (
38+ left . displayName === right . displayName &&
39+ left . description === right . description &&
40+ left . unredacted === right . unredacted
41+ )
42+ }
43+
2844interface UseCredentialDetailFormParams {
2945 workspaceId ?: string
3046 credential : WorkspaceCredential | null
@@ -33,9 +49,7 @@ interface UseCredentialDetailFormParams {
3349 backHref : string
3450 /**
3551 * An additional editable section on the page, folded into one dirty state, one
36- * save, and one unsaved-changes guard. Two independent guards on a page cannot
37- * coexist: each seeds its own same-URL history entry while dirty, so Back would
38- * pop only one of them and leave the other stranded.
52+ * save, and one unsaved-changes guard.
3953 */
4054 section ?: CredentialDetailFormSection
4155}
@@ -55,43 +69,101 @@ export function useCredentialDetailForm({
5569 section,
5670} : UseCredentialDetailFormParams ) {
5771 const updateCredential = useUpdateWorkspaceCredential ( workspaceId )
72+ const isSaving = updateCredential . isPending || ( section ?. isSaving ?? false )
5873
59- const [ displayNameDraft , setDisplayNameDraft ] = useState ( '' )
60- const [ descriptionDraft , setDescriptionDraft ] = useState ( '' )
61- const [ unredactedDraft , setUnredactedDraft ] = useState ( false )
62- const [ seededCredentialId , setSeededCredentialId ] = useState < string | null > ( null )
63-
64- // Seed drafts when the credential first resolves (or the route id changes); a
65- // background refetch of the same credential must not clobber an in-progress
66- // edit — Discard is the one way to reset.
67- /** Applies a credential to every draft — the one definition of "reset to server state". */
68- const seedDrafts = useCallback ( ( source : WorkspaceCredential ) => {
69- setDisplayNameDraft ( source . displayName )
70- setDescriptionDraft ( source . description ?? '' )
71- setUnredactedDraft ( source . unredacted )
72- } , [ ] )
73-
74- if ( credential && credential . id !== seededCredentialId ) {
75- setSeededCredentialId ( credential . id )
76- seedDrafts ( credential )
74+ const savedValues = {
75+ displayName : credential ?. displayName ?? '' ,
76+ description : credential ?. description ?? '' ,
77+ unredacted : credential ?. unredacted ?? false ,
7778 }
79+ const [ draft , setDraft ] = useState < {
80+ credentialId : string
81+ baseline : typeof savedValues
82+ values : typeof savedValues
83+ } | null > ( null )
84+
85+ if ( draft && credential && draft . credentialId !== credential . id ) setDraft ( null )
7886
79- const isDisplayNameDirty = credential ? displayNameDraft !== credential . displayName : false
80- const isDescriptionDirty = credential
81- ? descriptionDraft !== ( credential . description || '' )
82- : false
83- const isUnredactedDirty = credential ? unredactedDraft !== credential . unredacted : false
87+ const values = draft ?. values ?? savedValues
88+ const baseline = draft ?. baseline ?? savedValues
89+ const displayNameDraft = values . displayName
90+ const descriptionDraft = values . description
91+ const unredactedDraft = values . unredacted
92+ const isDisplayNameDirty = values . displayName . trim ( ) !== baseline . displayName . trim ( )
93+ const isDescriptionDirty = values . description . trim ( ) !== baseline . description . trim ( )
94+ const isUnredactedDirty = values . unredacted !== baseline . unredacted
8495 const isMetadataDirty = isDisplayNameDirty || isDescriptionDirty || isUnredactedDirty
96+
97+ const updateDraft = useCallback (
98+ ( change : Partial < typeof savedValues > ) => {
99+ if ( ! credential ) return
100+ setDraft ( ( current ) => {
101+ const baseline = current ?. baseline ?? {
102+ displayName : credential . displayName ,
103+ description : credential . description ?? '' ,
104+ unredacted : credential . unredacted ,
105+ }
106+ const values = { ...( current ?. values ?? baseline ) , ...change }
107+ if ( ! isSaving && sameMetadata ( values , baseline ) ) return null
108+ return { credentialId : credential . id , baseline, values }
109+ } )
110+ } ,
111+ [ credential , isSaving ]
112+ )
113+ const setDisplayNameDraft = useCallback (
114+ ( displayName : string ) => updateDraft ( { displayName } ) ,
115+ [ updateDraft ]
116+ )
117+ const setDescriptionDraft = useCallback (
118+ ( description : string ) => updateDraft ( { description } ) ,
119+ [ updateDraft ]
120+ )
121+ const setUnredactedDraft = useCallback (
122+ ( unredacted : boolean ) => updateDraft ( { unredacted } ) ,
123+ [ updateDraft ]
124+ )
125+
85126 const isSectionDirty = section ?. isDirty ?? false
86127 const isDirty = isMetadataDirty || isSectionDirty
87- const isSaving = updateCredential . isPending || ( section ?. isSaving ?? false )
128+ const router = useRouter ( )
129+ const discard = useCallback ( ( ) => {
130+ if ( isSaving ) return
131+ setDraft ( null )
132+ section ?. discard ( )
133+ } , [ isSaving , section ] )
134+
135+ const guard = useSettingsUnsavedGuard ( {
136+ isDirty,
137+ navigationBlocked : isSaving ,
138+ onDiscard : discard ,
139+ } )
140+ const handleBackClick = useCallback (
141+ ( event : MouseEvent < HTMLAnchorElement > ) => {
142+ const { isDirty, navigationBlocked } = useSettingsDirtyStore . getState ( )
143+ if ( ! isDirty && ! navigationBlocked ) return
144+ event . preventDefault ( )
145+ guard . guardBack ( ( ) => router . push ( backHref ) )
146+ } ,
147+ [ guard . guardBack , router , backHref ]
148+ )
88149
89- const guard = useUnsavedChangesGuard ( { isDirty, backHref } )
150+ const releaseUnchangedDraft = useCallback ( ( ) => {
151+ setDraft ( ( current ) =>
152+ current && sameMetadata ( current . values , current . baseline ) ? null : current
153+ )
154+ } , [ ] )
90155
91156 const save = useCallback ( async ( ) => {
92157 if ( ! credential || isSaving ) return
93- if ( isSectionDirty && ! ( await section ?. save ( ) ) ) return
94- if ( ! isAdmin || ! isMetadataDirty ) return
158+ const submitted = draft
159+ if ( isSectionDirty && ! ( await section ?. save ( ) ) ) {
160+ releaseUnchangedDraft ( )
161+ return
162+ }
163+ if ( ! isAdmin || ! isMetadataDirty ) {
164+ releaseUnchangedDraft ( )
165+ return
166+ }
95167
96168 try {
97169 await updateCredential . mutateAsync ( {
@@ -100,16 +172,29 @@ export function useCredentialDetailForm({
100172 ...( isDescriptionDirty ? { description : descriptionDraft . trim ( ) || null } : { } ) ,
101173 ...( isUnredactedDirty ? { unredacted : unredactedDraft } : { } ) ,
102174 } )
103- if ( isDisplayNameDirty ) setDisplayNameDraft ( ( value ) => value . trim ( ) )
104- if ( isDescriptionDirty ) setDescriptionDraft ( ( value ) => value . trim ( ) )
175+ setDraft ( ( current ) => {
176+ if ( current === submitted ) return null
177+ if ( ! current || ! submitted || current . credentialId !== submitted . credentialId )
178+ return current
179+ return {
180+ ...current ,
181+ baseline : {
182+ displayName : submitted . values . displayName . trim ( ) ,
183+ description : submitted . values . description . trim ( ) ,
184+ unredacted : submitted . values . unredacted ,
185+ } ,
186+ }
187+ } )
105188 } catch ( error ) {
189+ releaseUnchangedDraft ( )
106190 toast . error ( "Couldn't save changes" , {
107191 description : getErrorMessage ( error , 'Please try again in a moment.' ) ,
108192 } )
109193 logger . error ( 'Failed to save credential details' , error )
110194 }
111195 } , [
112196 credential ,
197+ draft ,
113198 isAdmin ,
114199 isMetadataDirty ,
115200 isSectionDirty ,
@@ -122,13 +207,9 @@ export function useCredentialDetailForm({
122207 descriptionDraft ,
123208 unredactedDraft ,
124209 updateCredential . mutateAsync ,
210+ releaseUnchangedDraft ,
125211 ] )
126212
127- const discard = useCallback ( ( ) => {
128- if ( credential ) seedDrafts ( credential )
129- section ?. discard ( )
130- } , [ credential , section , seedDrafts ] )
131-
132213 return {
133214 displayNameDraft,
134215 setDisplayNameDraft,
@@ -140,9 +221,6 @@ export function useCredentialDetailForm({
140221 save,
141222 discard,
142223 isSaving,
143- handleBackClick : guard . handleBackClick ,
144- showUnsavedAlert : guard . showUnsavedAlert ,
145- setShowUnsavedAlert : guard . setShowUnsavedAlert ,
146- confirmDiscard : guard . confirmDiscard ,
224+ handleBackClick,
147225 }
148226}
0 commit comments