feat(description): added version description for deployments table

This commit is contained in:
waleed
2026-01-28 11:11:18 -08:00
parent 304cf717a4
commit d4af644159
9 changed files with 10632 additions and 38 deletions

View File

@@ -9,13 +9,24 @@ import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/
const logger = createLogger('WorkflowDeploymentVersionAPI')
const patchBodySchema = z.object({
name: z
.string()
.trim()
.min(1, 'Name cannot be empty')
.max(100, 'Name must be 100 characters or less'),
})
const patchBodySchema = z
.object({
name: z
.string()
.trim()
.min(1, 'Name cannot be empty')
.max(100, 'Name must be 100 characters or less')
.optional(),
description: z
.string()
.trim()
.max(500, 'Description must be 500 characters or less')
.nullable()
.optional(),
})
.refine((data) => data.name !== undefined || data.description !== undefined, {
message: 'At least one of name or description must be provided',
})
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
@@ -88,33 +99,46 @@ export async function PATCH(
return createErrorResponse(validation.error.errors[0]?.message || 'Invalid request body', 400)
}
const { name } = validation.data
const { name, description } = validation.data
const updateData: { name?: string; description?: string | null } = {}
if (name !== undefined) {
updateData.name = name
}
if (description !== undefined) {
updateData.description = description
}
const [updated] = await db
.update(workflowDeploymentVersion)
.set({ name })
.set(updateData)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.returning({ id: workflowDeploymentVersion.id, name: workflowDeploymentVersion.name })
.returning({
id: workflowDeploymentVersion.id,
name: workflowDeploymentVersion.name,
description: workflowDeploymentVersion.description,
})
if (!updated) {
return createErrorResponse('Deployment version not found', 404)
}
logger.info(
`[${requestId}] Renamed deployment version ${version} for workflow ${id} to "${name}"`
)
logger.info(`[${requestId}] Updated deployment version ${version} for workflow ${id}`, {
name: updateData.name,
description: updateData.description,
})
return createSuccessResponse({ name: updated.name })
return createSuccessResponse({ name: updated.name, description: updated.description })
} catch (error: any) {
logger.error(
`[${requestId}] Error renaming deployment version ${version} for workflow ${id}`,
error
)
return createErrorResponse(error.message || 'Failed to rename deployment version', 500)
return createErrorResponse(error.message || 'Failed to update deployment version', 500)
}
}

View File

@@ -26,6 +26,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
id: workflowDeploymentVersion.id,
version: workflowDeploymentVersion.version,
name: workflowDeploymentVersion.name,
description: workflowDeploymentVersion.description,
isActive: workflowDeploymentVersion.isActive,
createdAt: workflowDeploymentVersion.createdAt,
createdBy: workflowDeploymentVersion.createdBy,

View File

@@ -0,0 +1,155 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
const logger = createLogger('VersionDescriptionModal')
interface VersionDescriptionModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workflowId: string
version: number
versionName: string
currentDescription: string | null | undefined
onSave: () => Promise<void>
}
export function VersionDescriptionModal({
open,
onOpenChange,
workflowId,
version,
versionName,
currentDescription,
onSave,
}: VersionDescriptionModalProps) {
const [description, setDescription] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const initialDescriptionRef = useRef('')
useEffect(() => {
if (open) {
const initialDescription = currentDescription || ''
setDescription(initialDescription)
initialDescriptionRef.current = initialDescription
setError(null)
}
}, [open, currentDescription])
const hasChanges = description.trim() !== initialDescriptionRef.current.trim()
const handleCloseAttempt = useCallback(() => {
if (hasChanges && !isSaving) {
setShowUnsavedChangesAlert(true)
} else {
onOpenChange(false)
}
}, [hasChanges, isSaving, onOpenChange])
const handleDiscardChanges = useCallback(() => {
setShowUnsavedChangesAlert(false)
setDescription(initialDescriptionRef.current)
onOpenChange(false)
}, [onOpenChange])
const handleSave = useCallback(async () => {
if (!workflowId) return
setIsSaving(true)
setError(null)
try {
const res = await fetch(`/api/workflows/${workflowId}/deployments/${version}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: description.trim() || null }),
})
if (res.ok) {
await onSave()
onOpenChange(false)
} else {
const data = await res.json().catch(() => ({}))
const message = data.error || 'Failed to save description'
setError(message)
logger.error('Failed to save description:', message)
}
} catch (err) {
const message = err instanceof Error ? err.message : 'An unexpected error occurred'
setError(message)
logger.error('Error saving description:', err)
} finally {
setIsSaving(false)
}
}, [workflowId, version, description, onSave, onOpenChange])
return (
<>
<Modal open={open} onOpenChange={(openState) => !openState && handleCloseAttempt()}>
<ModalContent className='max-w-[480px]'>
<ModalHeader>
<span>Version Description</span>
</ModalHeader>
<ModalBody className='space-y-[12px]'>
<p className='text-[12px] text-[var(--text-secondary)]'>
{currentDescription ? 'Edit' : 'Add'} a description for{' '}
<span className='font-medium text-[var(--text-primary)]'>{versionName}</span>
</p>
<Textarea
placeholder='Describe the changes in this deployment version...'
className='min-h-[120px] resize-none'
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={500}
/>
<div className='flex items-center justify-between'>
{error ? <p className='text-[12px] text-[var(--text-error)]'>{error}</p> : <div />}
<p className='text-[11px] text-[var(--text-tertiary)]'>{description.length}/500</p>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={handleCloseAttempt} disabled={isSaving}>
Cancel
</Button>
<Button variant='tertiary' onClick={handleSave} disabled={isSaving || !hasChanges}>
{isSaving ? 'Saving...' : 'Save'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
<ModalContent className='max-w-[400px]'>
<ModalHeader>
<span>Unsaved Changes</span>
</ModalHeader>
<ModalBody>
<p className='text-[14px] text-[var(--text-secondary)]'>
You have unsaved changes. Are you sure you want to discard them?
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowUnsavedChangesAlert(false)}>
Keep Editing
</Button>
<Button variant='destructive' onClick={handleDiscardChanges}>
Discard Changes
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -3,10 +3,19 @@
import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import clsx from 'clsx'
import { MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
import { FileText, MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
import {
Button,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { formatDateTime } from '@/lib/core/utils/formatting'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { VersionDescriptionModal } from './version-description-modal'
const logger = createLogger('Versions')
@@ -20,7 +29,7 @@ const COLUMN_WIDTHS = {
VERSION: 'w-[180px]',
DEPLOYED_BY: 'w-[140px]',
TIMESTAMP: 'flex-1',
ACTIONS: 'w-[32px]',
ACTIONS: 'w-[56px]',
} as const
interface VersionsProps {
@@ -37,28 +46,14 @@ interface VersionsProps {
/**
* Formats a timestamp into a readable string.
* @param value - The date string or Date object to format
* @returns Formatted string like "8:36 PM PT on Oct 11, 2025"
* @returns Formatted string like "Jan 28, 2026, 10:43 AM"
*/
const formatDate = (value: string | Date): string => {
const formatTimestamp = (value: string | Date): string => {
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
return '-'
}
const timePart = date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
timeZoneName: 'short',
})
const datePart = date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
return `${timePart} on ${datePart}`
return formatDateTime(date)
}
/**
@@ -79,6 +74,7 @@ export function Versions({
const [editValue, setEditValue] = useState('')
const [isRenaming, setIsRenaming] = useState(false)
const [openDropdown, setOpenDropdown] = useState<number | null>(null)
const [descriptionModalVersion, setDescriptionModalVersion] = useState<number | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
@@ -149,6 +145,16 @@ export function Versions({
onLoadDeployment(version)
}
const handleOpenDescriptionModal = (version: number) => {
setOpenDropdown(null)
setDescriptionModalVersion(version)
}
const descriptionModalVersionData =
descriptionModalVersion !== null
? versions.find((v) => v.version === descriptionModalVersion)
: null
if (versionsLoading && versions.length === 0) {
return (
<div className='overflow-hidden rounded-[4px] border border-[var(--border)]'>
@@ -179,7 +185,14 @@ export function Versions({
<div className={clsx(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}>
<Skeleton className='h-[12px] w-[160px]' />
</div>
<div className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS, 'flex justify-end')}>
<div
className={clsx(
COLUMN_WIDTHS.ACTIONS,
COLUMN_BASE_CLASS,
'flex justify-end gap-[2px]'
)}
>
<Skeleton className='h-[20px] w-[20px] rounded-[4px]' />
<Skeleton className='h-[20px] w-[20px] rounded-[4px]' />
</div>
</div>
@@ -289,14 +302,40 @@ export function Versions({
<span
className={clsx('block truncate text-[var(--text-tertiary)]', ROW_TEXT_CLASS)}
>
{formatDate(v.createdAt)}
{formatTimestamp(v.createdAt)}
</span>
</div>
<div
className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS, 'flex justify-end')}
className={clsx(
COLUMN_WIDTHS.ACTIONS,
COLUMN_BASE_CLASS,
'flex items-center justify-end gap-[2px]'
)}
onClick={(e) => e.stopPropagation()}
>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className={clsx(
'!p-1',
!v.description &&
'text-[var(--text-quaternary)] hover:text-[var(--text-tertiary)]'
)}
onClick={() => handleOpenDescriptionModal(v.version)}
>
<FileText className='h-3.5 w-3.5' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-[240px]'>
{v.description ? (
<p className='line-clamp-3 text-[12px]'>{v.description}</p>
) : (
<p className='text-[12px]'>Add description</p>
)}
</Tooltip.Content>
</Tooltip.Root>
<Popover
open={openDropdown === v.version}
onOpenChange={(open) => setOpenDropdown(open ? v.version : null)}
@@ -311,6 +350,10 @@ export function Versions({
<Pencil className='h-3 w-3' />
<span>Rename</span>
</PopoverItem>
<PopoverItem onClick={() => handleOpenDescriptionModal(v.version)}>
<FileText className='h-3 w-3' />
<span>{v.description ? 'Edit description' : 'Add description'}</span>
</PopoverItem>
{!v.isActive && (
<PopoverItem onClick={() => handlePromote(v.version)}>
<RotateCcw className='h-3 w-3' />
@@ -328,6 +371,20 @@ export function Versions({
)
})}
</div>
{workflowId && descriptionModalVersionData && (
<VersionDescriptionModal
open={descriptionModalVersion !== null}
onOpenChange={(open) => !open && setDescriptionModalVersion(null)}
workflowId={workflowId}
version={descriptionModalVersionData.version}
versionName={
descriptionModalVersionData.name || `v${descriptionModalVersionData.version}`
}
currentDescription={descriptionModalVersionData.description}
onSave={fetchVersions}
/>
)}
</div>
)
}

View File

@@ -27,6 +27,7 @@ export interface WorkflowDeploymentVersionResponse {
id: string
version: number
name?: string | null
description?: string | null
isActive: boolean
createdAt: string
createdBy?: string | null

View File

@@ -0,0 +1 @@
ALTER TABLE "workflow_deployment_version" ADD COLUMN "description" text;

File diff suppressed because it is too large Load Diff

View File

@@ -1030,6 +1030,13 @@
"when": 1769134350805,
"tag": "0147_rare_firebrand",
"breakpoints": true
},
{
"idx": 148,
"version": "7",
"when": 1769626313827,
"tag": "0148_aberrant_venom",
"breakpoints": true
}
]
}

View File

@@ -1634,6 +1634,7 @@ export const workflowDeploymentVersion = pgTable(
.references(() => workflow.id, { onDelete: 'cascade' }),
version: integer('version').notNull(),
name: text('name'),
description: text('description'),
state: json('state').notNull(),
isActive: boolean('is_active').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),