Skip to content

Commit fea3391

Browse files
improvement(forking): minor ui improvements (#5533)
* improvement(forking): minor ui improvements * fix warning
1 parent 472457b commit fea3391

14 files changed

Lines changed: 248 additions & 133 deletions

File tree

apps/sim/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ interface SettingsUnsavedGuard {
1616

1717
/**
1818
* Wires a settings surface's local dirty state into the shared
19-
* `useSettingsDirtyStore`, so the sidebar's section-switch confirmation and the
20-
* centralized `beforeunload` both apply without per-page wiring. Also provides
21-
* an in-view back/close guard (`guardBack` + the shared `UnsavedChangesModal`)
22-
* for detail sub-views whose "back" is an in-component state change rather than
23-
* a route navigation.
19+
* `useSettingsDirtyStore`, so the sidebar's leave confirmation (section switch,
20+
* Back, workspace switch) and the centralized `beforeunload` both apply without
21+
* per-page wiring. Also provides an in-view back/close guard (`guardBack` + the
22+
* shared `UnsavedChangesModal`) for detail sub-views whose "back" is an
23+
* in-component state change rather than a route navigation.
2424
*/
2525
export function useSettingsUnsavedGuard({
2626
isDirty,

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,12 @@ export function SettingsSidebar({
5151

5252
const queryClient = useQueryClient()
5353

54-
const requestNavigation = useSettingsDirtyStore((s) => s.requestNavigation)
55-
const confirmNavigation = useSettingsDirtyStore((s) => s.confirmNavigation)
56-
const cancelNavigation = useSettingsDirtyStore((s) => s.cancelNavigation)
57-
const isDirty = useSettingsDirtyStore((s) => s.isDirty)
54+
const requestLeave = useSettingsDirtyStore((s) => s.requestLeave)
55+
const confirmLeave = useSettingsDirtyStore((s) => s.confirmLeave)
56+
const cancelLeave = useSettingsDirtyStore((s) => s.cancelLeave)
57+
const pendingLeave = useSettingsDirtyStore((s) => s.pendingLeave)
58+
const showDiscardDialog = pendingLeave !== null
5859

59-
const [showDiscardDialog, setShowDiscardDialog] = useState(false)
6060
const [hasOverflowTop, setHasOverflowTop] = useState(false)
6161

6262
const { data: session } = useSession()
@@ -217,27 +217,18 @@ export function SettingsSidebar({
217217
const { popSettingsReturnUrl, getSettingsHref } = useSettingsNavigation()
218218

219219
const handleBack = useCallback(() => {
220-
if (isDirty) {
221-
setShowDiscardDialog(true)
222-
return
223-
}
224-
router.push(popSettingsReturnUrl(`/workspace/${workspaceId}/home`))
225-
}, [router, popSettingsReturnUrl, workspaceId, isDirty])
220+
requestLeave(() => {
221+
router.push(popSettingsReturnUrl(`/workspace/${workspaceId}/home`))
222+
})
223+
}, [requestLeave, router, popSettingsReturnUrl, workspaceId])
226224

227225
const handleConfirmDiscard = useCallback(() => {
228-
const section = confirmNavigation()
229-
setShowDiscardDialog(false)
230-
if (section) {
231-
router.replace(getSettingsHref({ section }), { scroll: false })
232-
} else {
233-
router.push(popSettingsReturnUrl(`/workspace/${workspaceId}/home`))
234-
}
235-
}, [confirmNavigation, router, getSettingsHref, popSettingsReturnUrl, workspaceId])
226+
confirmLeave()
227+
}, [confirmLeave])
236228

237229
const handleCancelDiscard = useCallback(() => {
238-
cancelNavigation()
239-
setShowDiscardDialog(false)
240-
}, [cancelNavigation])
230+
cancelLeave()
231+
}, [cancelLeave])
241232

242233
useEffect(() => {
243234
const container = scrollContainerRef.current
@@ -350,11 +341,9 @@ export function SettingsSidebar({
350341
onClick={() => {
351342
const section = item.id as SettingsSection
352343
if (section === activeSection) return
353-
if (!requestNavigation(section)) {
354-
setShowDiscardDialog(true)
355-
return
356-
}
357-
router.replace(getSettingsHref({ section }), { scroll: false })
344+
requestLeave(() => {
345+
router.replace(getSettingsHref({ section }), { scroll: false })
346+
})
358347
}}
359348
>
360349
{content}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ import { SIDEBAR_WIDTH } from '@/stores/constants'
110110
import { useFolderStore } from '@/stores/folders/store'
111111
import { useSearchModalStore } from '@/stores/modals/search/store'
112112
import { useProvidersStore } from '@/stores/providers'
113+
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
113114
import { useSidebarStore } from '@/stores/sidebar/store'
114115

115116
const logger = createLogger('Sidebar')
@@ -1072,16 +1073,21 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) {
10721073
fileInputRef.current?.click()
10731074
}
10741075

1076+
const requestLeave = useSettingsDirtyStore((s) => s.requestLeave)
1077+
10751078
const handleWorkspaceSwitch = useCallback(
1076-
async (workspace: Workspace) => {
1079+
(workspace: Workspace) => {
10771080
if (workspace.id === workspaceId) {
10781081
setIsWorkspaceMenuOpen(false)
10791082
return
10801083
}
1081-
await switchWorkspace(workspace)
1084+
// Close the switcher first so the settings discard dialog (if any) is visible.
10821085
setIsWorkspaceMenuOpen(false)
1086+
requestLeave(() => {
1087+
void switchWorkspace(workspace)
1088+
})
10831089
},
1084-
[workspaceId, switchWorkspace]
1090+
[workspaceId, switchWorkspace, requestLeave]
10851091
)
10861092

10871093
const handleSidebarClick = (e: React.MouseEvent<HTMLElement>) => {

apps/sim/ee/workspace-forking/components/fork-resource-picker/fork-resource-picker.tsx

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ interface ResourceKindRowProps {
3030
* the user can copy a specific subset. Shared by the fork modal's "Copy resources" and the sync
3131
* modal's "Copy resources" so the two surfaces stay identical. Files nest in a folder tree
3232
* instead - use {@link FileKindRow}.
33+
*
34+
* Expanded body uses the settings MCP expand pattern (`border-t` + `--surface-2`) so items
35+
* read as contained in the kind rather than indented siblings of other kinds.
3336
*/
3437
export function ResourceKindRow({
3538
label,
@@ -47,7 +50,7 @@ export function ResourceKindRow({
4750
const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate'
4851

4952
return (
50-
<div className='flex flex-col gap-1'>
53+
<div className='flex flex-col'>
5154
<div className='flex items-center gap-2 text-[var(--text-body)] text-sm'>
5255
<Checkbox
5356
size='sm'
@@ -79,32 +82,34 @@ export function ResourceKindRow({
7982
</div>
8083

8184
{expanded ? (
82-
<div className='ml-6 flex max-h-44 flex-col gap-0.5 overflow-y-auto'>
83-
{items.map((item) => {
84-
const isChecked = selected.has(item.id)
85-
const itemId = `${fieldId}-${item.id}`
86-
return (
87-
<label
88-
key={item.id}
89-
htmlFor={itemId}
90-
className={cn(
91-
'flex min-w-0 items-center gap-2 rounded-md py-0.5 text-[var(--text-body)] text-sm',
92-
disabled
93-
? 'cursor-not-allowed opacity-60'
94-
: 'cursor-pointer hover:text-[var(--text-primary)]'
95-
)}
96-
>
97-
<Checkbox
98-
id={itemId}
99-
size='sm'
100-
checked={isChecked}
101-
onCheckedChange={(checked) => onToggleItem(item.id, checked === true)}
102-
disabled={disabled}
103-
/>
104-
<span className='truncate'>{item.label}</span>
105-
</label>
106-
)
107-
})}
85+
<div className='mt-1 max-h-44 overflow-y-auto border-[var(--border-1)] border-t bg-[var(--surface-2)] px-2.5 py-2'>
86+
<div className='flex flex-col gap-0.5'>
87+
{items.map((item) => {
88+
const isChecked = selected.has(item.id)
89+
const itemId = `${fieldId}-${item.id}`
90+
return (
91+
<label
92+
key={item.id}
93+
htmlFor={itemId}
94+
className={cn(
95+
'flex min-w-0 items-center gap-2 rounded-md py-0.5 text-[var(--text-body)] text-sm',
96+
disabled
97+
? 'cursor-not-allowed opacity-60'
98+
: 'cursor-pointer hover:text-[var(--text-primary)]'
99+
)}
100+
>
101+
<Checkbox
102+
id={itemId}
103+
size='sm'
104+
checked={isChecked}
105+
onCheckedChange={(checked) => onToggleItem(item.id, checked === true)}
106+
disabled={disabled}
107+
/>
108+
<span className='truncate'>{item.label}</span>
109+
</label>
110+
)
111+
})}
112+
</div>
108113
</div>
109114
) : null}
110115
</div>
@@ -145,7 +150,7 @@ export function FileKindRow({
145150
const { folders, rootFiles } = useMemo(() => groupForkFilesIntoFolders(files), [files])
146151

147152
return (
148-
<div className='flex flex-col gap-1'>
153+
<div className='flex flex-col'>
149154
<div className='flex items-center gap-2 text-[var(--text-body)] text-sm'>
150155
<Checkbox
151156
size='sm'
@@ -172,7 +177,7 @@ export function FileKindRow({
172177
</div>
173178

174179
{expanded ? (
175-
<div className='ml-6'>
180+
<div className='mt-1 border-[var(--border-1)] border-t bg-[var(--surface-2)] px-2.5 py-2'>
176181
<ForkFileTree
177182
folders={folders}
178183
rootFiles={rootFiles}

apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx

Lines changed: 65 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,8 @@ interface DependentWorkflowCardProps {
228228
/**
229229
* One workflow's dependent fields as a collapsible card (the same `CollapsibleCard` the table
230230
* workflow sidebar's input mapping and the enrichment config use): the header names the
231-
* workflow, the body groups fields under their block's label. Cards holding a required field
232-
* start expanded - a required field is what gates Sync.
231+
* workflow; the body groups fields under block → optional tool → plain field label.
232+
* Cards holding a required field start expanded - a required field is what gates Sync.
233233
*/
234234
function DependentWorkflowCard({
235235
workflow,
@@ -250,30 +250,70 @@ function DependentWorkflowCard({
250250
collapsed={collapsed}
251251
onToggleCollapse={() => setCollapsed((value) => !value)}
252252
>
253-
{workflow.blocks.map((block) => (
254-
<div key={block.targetBlockId} className='flex flex-col gap-1.5'>
255-
<Label className='text-small'>{block.blockName}</Label>
256-
{block.fields.map((field) => (
257-
<div key={dependentKey(field)} className='flex flex-col gap-1'>
258-
<span className='text-[var(--text-muted)] text-caption'>
259-
{field.title}
260-
{field.required ? <span className='text-[var(--text-error)]'> *</span> : null}
261-
</span>
262-
<DependentSelector
263-
field={field}
264-
block={block}
265-
target={target}
266-
parentChanged={parentChanged}
267-
copying={copying}
268-
workspaceId={workspaceId}
269-
sourceWorkspaceId={sourceWorkspaceId}
270-
reconfig={reconfig}
271-
setReconfig={setReconfig}
272-
/>
253+
<div className='flex flex-col gap-3'>
254+
{workflow.blocks.map((block) => {
255+
const topLevel = block.fields.filter((field) => !field.toolName)
256+
const byTool = new Map<string, ForkDependentReconfig[]>()
257+
for (const field of block.fields) {
258+
if (!field.toolName) continue
259+
const list = byTool.get(field.toolName)
260+
if (list) list.push(field)
261+
else byTool.set(field.toolName, [field])
262+
}
263+
const toolGroups = Array.from(byTool.entries()).sort(([a], [b]) => a.localeCompare(b))
264+
265+
return (
266+
<div key={block.targetBlockId} className='flex flex-col gap-2'>
267+
<Label className='text-small'>{block.blockName}</Label>
268+
{topLevel.map((field) => (
269+
<div key={dependentKey(field)} className='flex flex-col gap-1'>
270+
<Label className='text-[var(--text-muted)] text-caption'>
271+
{field.title}
272+
{field.required ? <span className='text-[var(--text-error)]'> *</span> : null}
273+
</Label>
274+
<DependentSelector
275+
field={field}
276+
block={block}
277+
target={target}
278+
parentChanged={parentChanged}
279+
copying={copying}
280+
workspaceId={workspaceId}
281+
sourceWorkspaceId={sourceWorkspaceId}
282+
reconfig={reconfig}
283+
setReconfig={setReconfig}
284+
/>
285+
</div>
286+
))}
287+
{toolGroups.map(([toolName, fields]) => (
288+
<div key={toolName} className='flex flex-col gap-1.5 pl-2'>
289+
<span className='text-[var(--text-muted)] text-small'>{toolName}</span>
290+
{fields.map((field) => (
291+
<div key={dependentKey(field)} className='flex flex-col gap-1'>
292+
<Label className='text-[var(--text-muted)] text-caption'>
293+
{field.title}
294+
{field.required ? (
295+
<span className='text-[var(--text-error)]'> *</span>
296+
) : null}
297+
</Label>
298+
<DependentSelector
299+
field={field}
300+
block={block}
301+
target={target}
302+
parentChanged={parentChanged}
303+
copying={copying}
304+
workspaceId={workspaceId}
305+
sourceWorkspaceId={sourceWorkspaceId}
306+
reconfig={reconfig}
307+
setReconfig={setReconfig}
308+
/>
309+
</div>
310+
))}
311+
</div>
312+
))}
273313
</div>
274-
))}
275-
</div>
276-
))}
314+
)
315+
})}
316+
</div>
277317
</CollapsibleCard>
278318
)
279319
}

apps/sim/ee/workspace-forking/components/forks.tsx

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ interface ForkSyncDetailViewProps {
8787
otherWorkspaceId: string
8888
otherWorkspaceName: string
8989
onBack: () => void
90-
/** Header chips rendered left of Sync (Rollback / Open workspace) — the caller owns those. */
90+
/** Header chips rendered left of Sync (e.g. Open workspace) — the caller owns those. */
9191
actions: SettingsAction[]
9292
}
9393

@@ -129,8 +129,8 @@ function ForkSyncDetailView({
129129
const [confirmSyncOpen, setConfirmSyncOpen] = useState(false)
130130

131131
// Sync is the edge's primary action, so it's the rightmost/black chip; the caller's
132-
// Rollback/Open workspace chips sit left of it. Dirty mapping edits swap the whole
133-
// cluster for Discard/Save until they're saved or discarded.
132+
// Open workspace chip sits left of it. Dirty mapping edits swap the whole cluster
133+
// for Discard/Save until they're saved or discarded.
134134
const panelActions: SettingsAction[] = controller.dirty
135135
? saveDiscardActions({
136136
dirty: controller.dirty,
@@ -263,8 +263,9 @@ function ForkActivityDetailView({
263263
* "Parent" section, above the "Forks" list of child forks. The parent row's `...` menu
264264
* has Edit mappings (the child owns its edge's re-picks), Open workspace, and
265265
* Disconnect; fork rows offer Open workspace and Disconnect only. Activity is
266-
* workspace-scoped and lives behind the header's "See activity" action; sync/rollback
267-
* live on the parent's sync detail page.
266+
* workspace-scoped and lives behind the header's "See activity" action (including
267+
* Rollback when the last sync into this workspace is undoable). Sync lives on the
268+
* parent's sync detail page.
268269
* Forking and sync rewrite workflow state and deployments en masse, so the page is
269270
* workspace-admin only and gated on the workspace's fork entitlement - every fork route
270271
* re-checks both; the server remains the boundary.
@@ -374,21 +375,10 @@ export function Forks() {
374375
// deep link falls back to the list). Fork rows offer Open workspace / Disconnect only.
375376
const showParentDetail = Boolean(selectedForkId && parent && parent.id === selectedForkId)
376377

377-
// Rollback (destructive) and Open workspace sit left of the detail view's primary Sync
378-
// chip, which the sync page owns (it carries the gating).
378+
// Open workspace sits left of the detail view's primary Sync chip, which the sync
379+
// page owns (it carries the gating). Rollback lives on the Activity view only.
379380
const parentHeaderActions: SettingsAction[] = parent
380381
? [
381-
...(undoableRun
382-
? [
383-
{
384-
text: 'Rollback',
385-
variant: 'destructive' as const,
386-
onSelect: () => setConfirmRollbackOpen(true),
387-
disabled: rollback.isPending,
388-
tooltip: `The last sync into this workspace (from ${undoableRun.otherName}) can be undone — it restores each workflow's prior deployed version.`,
389-
},
390-
]
391-
: []),
392382
{
393383
text: 'Open workspace',
394384
onSelect: () => openForkWorkspace(parent.id),

0 commit comments

Comments
 (0)