Skip to content

Commit 609c81b

Browse files
committed
feat(deploy): compare deployment versions and the draft
The deploy modal offers "View changes" (live against the draft) when a redeploy is pending and a "Compare" action on every version row, opening a full-width comparison whose two sides are pickable from the header. The draft state hook is shared with change detection and only subscribes while something to compare against exists.
1 parent d5dc867 commit 609c81b

9 files changed

Lines changed: 311 additions & 52 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { resolveComparePair } from './compare-pair'
6+
7+
describe('resolveComparePair', () => {
8+
it('puts the older version on the base side whichever was clicked', () => {
9+
const expected = {
10+
base: { kind: 'version', version: 3 },
11+
target: { kind: 'version', version: 5 },
12+
}
13+
expect(resolveComparePair(5, 3)).toEqual(expected)
14+
expect(resolveComparePair(3, 5)).toEqual(expected)
15+
})
16+
17+
it('compares the live version, or any version with nothing live, against the draft', () => {
18+
expect(resolveComparePair(3, 3)).toEqual({
19+
base: { kind: 'version', version: 3 },
20+
target: { kind: 'draft' },
21+
})
22+
expect(resolveComparePair(2, null)).toEqual({
23+
base: { kind: 'version', version: 2 },
24+
target: { kind: 'draft' },
25+
})
26+
})
27+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { CompareSide } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/compare-versions-modal'
2+
3+
export interface ComparePair {
4+
base: CompareSide
5+
target: CompareSide
6+
}
7+
8+
/**
9+
* Which two sides a "Compare" on a version opens: the older version on the
10+
* left and the newer on the right, except that the live version (or any
11+
* version when nothing is live) compares against the draft, since that is
12+
* what a redeploy would ship.
13+
*/
14+
export function resolveComparePair(version: number, activeVersion: number | null): ComparePair {
15+
if (activeVersion === null || activeVersion === version) {
16+
return { base: { kind: 'version', version }, target: { kind: 'draft' } }
17+
}
18+
const [low, high] = version < activeVersion ? [version, activeVersion] : [activeVersion, version]
19+
return { base: { kind: 'version', version: low }, target: { kind: 'version', version: high } }
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
'use client'
2+
3+
import { useId, useMemo, useState } from 'react'
4+
import {
5+
ChipDropdown,
6+
type ChipDropdownOption,
7+
ChipModal,
8+
ChipModalBody,
9+
ChipModalHeader,
10+
Skeleton,
11+
} from '@sim/emcn'
12+
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
13+
import { formatVersionLabel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/format-version-label'
14+
import { useDraftWorkflowState } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-draft-workflow-state'
15+
import {
16+
CHANGE_LIST_WIDTH_CLASS,
17+
WorkflowDiffView,
18+
} from '@/app/workspace/[workspaceId]/w/components/workflow-diff'
19+
import { useDeploymentVersionState } from '@/hooks/queries/workflows'
20+
21+
/** One side of a comparison: the editor draft or a numbered deployment version. */
22+
export type CompareSide = { kind: 'draft' } | { kind: 'version'; version: number }
23+
24+
const DRAFT_OPTION_VALUE = 'draft'
25+
26+
function sideToValue(side: CompareSide): string {
27+
return side.kind === 'draft' ? DRAFT_OPTION_VALUE : String(side.version)
28+
}
29+
30+
function valueToSide(value: string): CompareSide {
31+
return value === DRAFT_OPTION_VALUE
32+
? { kind: 'draft' }
33+
: { kind: 'version', version: Number(value) }
34+
}
35+
36+
interface CompareVersionsModalProps {
37+
open: boolean
38+
onOpenChange: (open: boolean) => void
39+
workflowId: string
40+
versions: WorkflowDeploymentVersionResponse[]
41+
initialBase: CompareSide
42+
initialTarget: CompareSide
43+
}
44+
45+
/**
46+
* Full-width comparison of any two versions of this workflow, including the
47+
* unsaved draft. Both sides are pickable from the header so a reviewer can
48+
* move between pairs without leaving the view.
49+
*/
50+
export function CompareVersionsModal({
51+
open,
52+
onOpenChange,
53+
workflowId,
54+
versions,
55+
initialBase,
56+
initialTarget,
57+
}: CompareVersionsModalProps) {
58+
const descriptionId = useId()
59+
const [base, setBase] = useState<CompareSide>(initialBase)
60+
const [target, setTarget] = useState<CompareSide>(initialTarget)
61+
/* Subscribed only while the modal is open, and only if a side is the draft. */
62+
const draftState = useDraftWorkflowState(
63+
workflowId,
64+
base.kind === 'draft' || target.kind === 'draft'
65+
)
66+
67+
const options = useMemo((): ChipDropdownOption[] => {
68+
const sorted = [...versions].sort((a, b) => b.version - a.version)
69+
return [
70+
{ value: DRAFT_OPTION_VALUE, label: 'Draft' },
71+
...sorted.map((version) => {
72+
/* A name that merely repeats the number would read as "v2 · v2". */
73+
const name = version.name === `v${version.version}` ? null : version.name
74+
const label = formatVersionLabel(version.version, name)
75+
return {
76+
value: String(version.version),
77+
label: version.isActive ? `${label} (live)` : label,
78+
}
79+
}),
80+
]
81+
}, [versions])
82+
83+
const baseQuery = useDeploymentVersionState(
84+
workflowId,
85+
base.kind === 'version' ? base.version : null
86+
)
87+
const targetQuery = useDeploymentVersionState(
88+
workflowId,
89+
target.kind === 'version' ? target.version : null
90+
)
91+
92+
const baseState = base.kind === 'draft' ? draftState : (baseQuery.data ?? null)
93+
const targetState = target.kind === 'draft' ? draftState : (targetQuery.data ?? null)
94+
const isLoading =
95+
(base.kind === 'version' && baseQuery.isLoading) ||
96+
(target.kind === 'version' && targetQuery.isLoading)
97+
const loadError = baseQuery.error ?? targetQuery.error
98+
99+
return (
100+
<ChipModal
101+
open={open}
102+
onOpenChange={onOpenChange}
103+
srTitle='Compare versions'
104+
aria-describedby={descriptionId}
105+
size='full'
106+
className='h-[92vh] [&>div]:h-full'
107+
>
108+
<ChipModalHeader onClose={() => onOpenChange(false)}>
109+
<div className='flex items-center gap-2'>
110+
<span>Compare</span>
111+
<ChipDropdown
112+
options={options}
113+
value={sideToValue(base)}
114+
onChange={(value) => setBase(valueToSide(value))}
115+
align='start'
116+
/>
117+
<span className='text-[var(--text-muted)]'>→</span>
118+
<ChipDropdown
119+
options={options}
120+
value={sideToValue(target)}
121+
onChange={(value) => setTarget(valueToSide(value))}
122+
align='start'
123+
/>
124+
</div>
125+
</ChipModalHeader>
126+
<ChipModalBody fullBleed>
127+
<p id={descriptionId} className='sr-only'>
128+
Blocks, fields and connections that differ between the two selected versions.
129+
</p>
130+
{loadError ? (
131+
<div className='flex h-full items-center justify-center text-[var(--text-error)] text-small'>
132+
{loadError.message || 'Could not load one of the versions.'}
133+
</div>
134+
) : isLoading || !baseState || !targetState ? (
135+
<div className='flex h-full gap-0'>
136+
<Skeleton className='h-full flex-1 rounded-none' />
137+
<Skeleton
138+
className={`h-full ${CHANGE_LIST_WIDTH_CLASS} rounded-none border-[var(--border)] border-l`}
139+
/>
140+
</div>
141+
) : (
142+
<WorkflowDiffView baseState={baseState} targetState={targetState} />
143+
)}
144+
</ChipModalBody>
145+
</ChipModal>
146+
)
147+
}
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export { type CompareSide, CompareVersionsModal } from './compare-versions-modal'
12
export { Versions } from './versions'

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/versions.tsx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
Skeleton,
1414
Tooltip,
1515
} from '@sim/emcn'
16-
import { FileText, MoreVertical, Pencil, RefreshCw, SendToBack } from '@sim/emcn/icons'
16+
import { Columns2, FileText, MoreVertical, Pencil, RefreshCw, SendToBack } from '@sim/emcn/icons'
1717
import { formatDateTime } from '@sim/utils/formatting'
1818
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
1919
import { formatVersionLabel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/format-version-label'
@@ -40,6 +40,7 @@ interface VersionsProps {
4040
onSelectVersion: (version: number | null) => void
4141
onPromoteToLive: (version: number) => void
4242
onLoadDeployment: (version: number) => void
43+
onCompare: (version: number) => void
4344
}
4445

4546
/**
@@ -55,6 +56,7 @@ export function Versions({
5556
onSelectVersion,
5657
onPromoteToLive,
5758
onLoadDeployment,
59+
onCompare,
5860
}: VersionsProps) {
5961
const [editingVersion, setEditingVersion] = useState<number | null>(null)
6062
const [editValue, setEditValue] = useState('')
@@ -129,6 +131,11 @@ export function Versions({
129131
onLoadDeployment(version)
130132
}
131133

134+
const handleCompare = (version: number) => {
135+
setOpenDropdown(null)
136+
onCompare(version)
137+
}
138+
132139
const handleOpenDescriptionModal = (version: number) => {
133140
setOpenDropdown(null)
134141
setDescriptionModalVersion(version)
@@ -375,6 +382,10 @@ export function Versions({
375382
<FileText className='size-3' />
376383
<span>{v.description ? 'Edit description' : 'Add description'}</span>
377384
</PopoverItem>
385+
<PopoverItem onClick={() => handleCompare(v.version)}>
386+
<Columns2 className='size-3' />
387+
<span>Compare</span>
388+
</PopoverItem>
378389
{!v.isActive && (
379390
<PopoverItem onClick={() => handlePromote(v.version)}>
380391
<RefreshCw className='size-3' />

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx‎

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import { Preview, PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/compon
2222
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
2323
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
2424
import type { WorkflowState } from '@/stores/workflows/workflow/types'
25-
import { Versions } from './components'
25+
import { resolveComparePair } from './compare-pair'
26+
import { type CompareSide, CompareVersionsModal, Versions } from './components'
2627
import { formatVersionLabel } from './format-version-label'
2728

2829
const logger = createLogger('GeneralDeploy')
@@ -37,6 +38,8 @@ interface GeneralDeployProps {
3738
versionsLoading: boolean
3839
isPromotingVersion: boolean
3940
deployReadiness: DeployReadiness
41+
/** The draft differs from the live version, so a "View changes" affordance is offered */
42+
needsRedeployment: boolean
4043
onPromoteToLive: (version: number) => Promise<void>
4144
onLoadDeploymentComplete: () => void
4245
onLoadDeploymentBlocked: (message: string) => void
@@ -56,13 +59,27 @@ export function GeneralDeploy({
5659
versionsLoading,
5760
isPromotingVersion,
5861
deployReadiness,
62+
needsRedeployment,
5963
onPromoteToLive,
6064
onLoadDeploymentComplete,
6165
onLoadDeploymentBlocked,
6266
}: GeneralDeployProps) {
6367
const expandedPreviewDescriptionId = useId()
68+
const [comparePair, setComparePair] = useState<{ base: CompareSide; target: CompareSide } | null>(
69+
null
70+
)
6471
const [selectedVersion, setSelectedVersion] = useState<number | null>(null)
6572
const [showActiveDespiteSelection, setShowActiveDespiteSelection] = useState(false)
73+
const activeVersion = versions.find((v) => v.isActive)?.version ?? null
74+
75+
/**
76+
* Opens a comparison with the older version on the left. Comparing the live
77+
* version (or any version when nothing is live) shows it against the draft,
78+
* which is what a redeploy would ship.
79+
*/
80+
const handleCompareVersion = (version: number) => {
81+
setComparePair(resolveComparePair(version, activeVersion))
82+
}
6683
const previewMode: PreviewMode =
6784
selectedVersion !== null && !showActiveDespiteSelection ? 'selected' : 'active'
6885
const [showLoadDialog, setShowLoadDialog] = useState(false)
@@ -198,11 +215,22 @@ export function GeneralDeploy({
198215
<div className='space-y-3'>
199216
<div>
200217
<div className='relative mb-[6.5px]'>
201-
<Label className='block truncate pl-0.5 text-small'>
202-
{previewMode === 'selected' && selectedVersionInfo
203-
? formatVersionLabel(selectedVersionInfo.version, selectedVersionInfo.name)
204-
: 'Live Workflow'}
205-
</Label>
218+
<div className='flex items-center gap-2'>
219+
<Label className='block truncate pl-0.5 text-small'>
220+
{previewMode === 'selected' && selectedVersionInfo
221+
? formatVersionLabel(selectedVersionInfo.version, selectedVersionInfo.name)
222+
: 'Live Workflow'}
223+
</Label>
224+
{needsRedeployment && activeVersion !== null && !showToggle && (
225+
<button
226+
type='button'
227+
onClick={() => handleCompareVersion(activeVersion)}
228+
className='text-[var(--warning)] text-caption underline-offset-2 hover-hover:underline focus-visible:underline focus-visible:outline-none'
229+
>
230+
View changes
231+
</button>
232+
)}
233+
</div>
206234
<div className={cn('absolute top-[-5px] right-0', !showToggle && 'invisible')}>
207235
<ChipButtonGroup
208236
value={previewMode}
@@ -273,6 +301,7 @@ export function GeneralDeploy({
273301
onSelectVersion={handleSelectVersion}
274302
onPromoteToLive={handlePromoteToLive}
275303
onLoadDeployment={handleLoadDeployment}
304+
onCompare={handleCompareVersion}
276305
/>
277306
</div>
278307
</div>
@@ -326,6 +355,20 @@ export function GeneralDeploy({
326355
}}
327356
/>
328357

358+
{workflowId && comparePair && (
359+
<CompareVersionsModal
360+
key={JSON.stringify(comparePair)}
361+
open
362+
onOpenChange={(open) => {
363+
if (!open) setComparePair(null)
364+
}}
365+
workflowId={workflowId}
366+
versions={versions}
367+
initialBase={comparePair.base}
368+
initialTarget={comparePair.target}
369+
/>
370+
)}
371+
329372
{workflowToShow && (
330373
<ChipModal
331374
open={showExpandedPreview}

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,7 @@ export function DeployModal({
585585
versionsLoading={versionsLoading}
586586
isPromotingVersion={isActivatingVersion || activateVersionMutation.isPending}
587587
deployReadiness={deployReadiness}
588+
needsRedeployment={needsRedeployment}
588589
onPromoteToLive={handlePromoteToLive}
589590
onLoadDeploymentComplete={handleCloseModal}
590591
onLoadDeploymentBlocked={setDeployError}

0 commit comments

Comments
 (0)