Skip to content

Commit e538a56

Browse files
committed
fix(skills): show the new skill after creating it
The create page navigated to the new skill's detail route, but the unsaved-changes guard immediately undid it: `isDirty` was derived from `createSkill.isSuccess`, so on success the guard's effect fired `history.back()` to pop its sentinel entry and cancelled the navigation that had just run. The header stayed on "New skill" with the fields intact, and nothing confirmed the save. The guard now exposes `release()` to retire itself for the rest of the mount, and the create page calls it before navigating with `replace` so the sentinel entry is consumed rather than stacked. Also from a cleanup pass over the same surfaces: - `useCreateSkill` resolves the created row, so the page reads `created.id` instead of re-deriving it from the response list - seed the list cache with the upsert's authoritative list verbatim; the id-merge kept the previous ordering and appended the new skill last - treat placeholder data as loading in the detail page, which could otherwise flash "Skill not found." on a workspace switch - seed credential-detail drafts on id change rather than in a value-keyed effect, so a background refetch can't clobber an in-progress edit - toast on save and on delete failure, which were both silent
1 parent f43b52c commit e538a56

5 files changed

Lines changed: 60 additions & 32 deletions

File tree

apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useCallback, useEffect, useState } from 'react'
3+
import { useCallback, useState } from 'react'
44
import { toast } from '@sim/emcn'
55
import { createLogger } from '@sim/logger'
66
import { getErrorMessage } from '@sim/utils/errors'
@@ -30,11 +30,16 @@ export function useCredentialDetailForm({
3030

3131
const [displayNameDraft, setDisplayNameDraft] = useState('')
3232
const [descriptionDraft, setDescriptionDraft] = useState('')
33+
const [prevCredentialId, setPrevCredentialId] = useState<string | null>(null)
3334

34-
useEffect(() => {
35-
setDisplayNameDraft(credential?.displayName ?? '')
36-
setDescriptionDraft(credential?.description ?? '')
37-
}, [credential?.id, credential?.displayName, credential?.description])
35+
// Seed drafts when the credential first resolves (or the route id changes); a
36+
// background refetch of the same credential must not clobber an in-progress
37+
// edit — nor silently reset isDirty, which pops the guard's history sentinel.
38+
if (credential && credential.id !== prevCredentialId) {
39+
setPrevCredentialId(credential.id)
40+
setDisplayNameDraft(credential.displayName ?? '')
41+
setDescriptionDraft(credential.description ?? '')
42+
}
3843

3944
const isDisplayNameDirty = credential ? displayNameDraft !== credential.displayName : false
4045
const isDescriptionDirty = credential

apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,13 @@ interface UseUnsavedChangesGuardParams {
2626
export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesGuardParams) {
2727
const router = useRouter()
2828
const [showUnsavedAlert, setShowUnsavedAlert] = useState(false)
29+
const [isReleased, setIsReleased] = useState(false)
2930
const hasSentinelRef = useRef(false)
3031

3132
useEffect(() => {
33+
// Released: the caller is navigating away, so leave the seeded entry alone —
34+
// popping it would cancel that navigation.
35+
if (isReleased) return
3236
if (!isDirty) {
3337
// Clean again while still mounted (saved/reverted): pop the seeded entry so
3438
// it can't pile up across edit/save cycles. This runs in the effect body,
@@ -58,7 +62,7 @@ export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesG
5862
window.removeEventListener('beforeunload', handleBeforeUnload)
5963
window.removeEventListener('popstate', handlePopState)
6064
}
61-
}, [isDirty])
65+
}, [isDirty, isReleased])
6266

6367
const handleBackClick = useCallback(
6468
(event: MouseEvent<HTMLAnchorElement>) => {
@@ -75,5 +79,13 @@ export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesG
7579
router.push(backHref)
7680
}, [router, backHref])
7781

78-
return { showUnsavedAlert, setShowUnsavedAlert, handleBackClick, confirmDiscard }
82+
/**
83+
* Retires the guard for the rest of this mount: no unload warning, no Back
84+
* trap, and no pop of the seeded entry when the form goes clean. Call it
85+
* immediately before navigating away on a successful save, and navigate with
86+
* `router.replace` so the seeded entry is the one consumed.
87+
*/
88+
const release = useCallback(() => setIsReleased(true), [])
89+
90+
return { showUnsavedAlert, setShowUnsavedAlert, handleBackClick, confirmDiscard, release }
7991
}

apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
4444
const router = useRouter()
4545
const skillsHref = `/workspace/${workspaceId}/skills`
4646

47-
const { data: skills = [], isPending: skillsLoading } = useSkills(workspaceId)
47+
const { data: skills = [], isPending, isPlaceholderData } = useSkills(workspaceId)
48+
// `keepPreviousData` can serve another workspace's list while this one loads
49+
// (the key is workspace-scoped), which reads as success with no match — treat
50+
// that as loading so the detail never flashes "Skill not found."
51+
const skillsLoading = isPending || isPlaceholderData
4852
const updateSkill = useUpdateSkill()
4953
const deleteSkill = useDeleteSkill()
5054
const skill = skills.find((s) => s.id === skillId) ?? null
@@ -112,6 +116,7 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
112116
},
113117
})
114118
setErrors({})
119+
toast.success(`Saved "${nameDraft}"`)
115120
} catch (error) {
116121
if (isSkillNameConflictError(error)) {
117122
setErrors({ name: getErrorMessage(error, 'This skill name is already taken.') })
@@ -129,8 +134,14 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
129134
setShowDeleteConfirm(false)
130135
try {
131136
await deleteSkill.mutateAsync({ workspaceId, skillId: skill.id })
132-
router.push(skillsHref)
137+
// The skill is gone from the list cache, so this surface is about to have no
138+
// entity to guard — retire it before navigating, as the create page does.
139+
guard.release()
140+
router.replace(skillsHref)
133141
} catch (error) {
142+
toast.error("Couldn't delete skill", {
143+
description: getErrorMessage(error, 'Please try again in a moment.'),
144+
})
134145
logger.error('Failed to delete skill', error)
135146
}
136147
}

apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,7 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) {
5050
const [contentSeed, setContentSeed] = useState(0)
5151
const [errors, setErrors] = useState<SkillFieldErrors>({})
5252

53-
// Drops on success so the guard pops its history sentinel before we navigate —
54-
// otherwise Back from the new skill lands on a stale, empty create form.
55-
const isDirty =
56-
!createSkill.isSuccess &&
57-
(!!nameDraft.trim() || !!descriptionDraft.trim() || !!contentDraft.trim())
53+
const isDirty = !!nameDraft.trim() || !!descriptionDraft.trim() || !!contentDraft.trim()
5854

5955
const guard = useUnsavedChangesGuard({ isDirty, backHref: skillsHref })
6056

@@ -72,16 +68,17 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) {
7268
}
7369

7470
try {
75-
const created = await createSkill.mutateAsync({
71+
const { created } = await createSkill.mutateAsync({
7672
workspaceId,
7773
skill: { name: nameDraft, description: descriptionDraft, content: contentDraft },
7874
})
7975
setErrors({})
80-
// The upsert responds with the caller's whole skill list (built-ins
81-
// included), not just the new row — match by name, which is unique per
82-
// workspace, rather than trusting the first element.
83-
const createdId = created.find((skill) => skill.name === nameDraft)?.id
84-
router.push(createdId ? `${skillsHref}/${createdId}` : skillsHref)
76+
toast.success(`Created "${nameDraft}"`)
77+
// Retire the guard before navigating — otherwise the form going clean pops
78+
// its seeded history entry and lands us back on this create form. `replace`
79+
// consumes that entry instead of stacking another.
80+
guard.release()
81+
router.replace(created ? `${skillsHref}/${created.id}` : skillsHref)
8582
} catch (error) {
8683
if (isSkillNameConflictError(error)) {
8784
setErrors({ name: getErrorMessage(error, 'This skill name is already taken.') })

apps/sim/hooks/queries/skills.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ export const skillsKeys = {
2626
all: ['skills'] as const,
2727
lists: () => [...skillsKeys.all, 'list'] as const,
2828
list: (workspaceId: string) => [...skillsKeys.lists(), workspaceId] as const,
29-
members: (skillId?: string) => [...skillsKeys.all, 'members', skillId ?? ''] as const,
29+
memberLists: () => [...skillsKeys.all, 'members'] as const,
30+
members: (skillId?: string) => [...skillsKeys.memberLists(), skillId ?? ''] as const,
3031
}
3132

3233
/**
@@ -88,17 +89,18 @@ export function useCreateSkill() {
8889
})
8990

9091
logger.info(`Created skill: ${s.name}`)
91-
return data
92+
// The upsert responds with the caller's whole skill list (built-ins
93+
// included), not just the new row. Resolve the created row here — by name,
94+
// which is unique per workspace and which a same-named built-in is filtered
95+
// out of — so consumers get an id instead of re-deriving one.
96+
return { skills: data, created: data.find((skill) => skill.name === s.name) ?? null }
9297
},
93-
onSuccess: (data, variables) => {
94-
queryClient.setQueryData<SkillDefinition[]>(
95-
skillsKeys.list(variables.workspaceId),
96-
(prev) => {
97-
const byId = new Map((prev ?? []).map((skill) => [skill.id, skill]))
98-
for (const skill of data) byId.set(skill.id, skill)
99-
return Array.from(byId.values())
100-
}
101-
)
98+
onSuccess: ({ skills }, variables) => {
99+
// The response is the same authoritative list GET /api/skills returns for
100+
// this caller, so seed the cache with it verbatim. Merging by id would keep
101+
// the previous ordering and append the new skill at the bottom until the
102+
// refetch lands.
103+
queryClient.setQueryData<SkillDefinition[]>(skillsKeys.list(variables.workspaceId), skills)
102104
},
103105
onSettled: (_data, _error, variables) => {
104106
queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) })
@@ -169,8 +171,9 @@ export function useUpdateSkill() {
169171
}
170172
},
171173
onSettled: (_data, _error, variables) => {
174+
// Only name/description/content go over the wire here, none of which can
175+
// change the editor roster — no need to invalidate it.
172176
queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) })
173-
queryClient.invalidateQueries({ queryKey: skillsKeys.members(variables.skillId) })
174177
},
175178
})
176179
}

0 commit comments

Comments
 (0)