mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-28 16:27:55 -05:00
feat(description): added version description for deployments table
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface WorkflowDeploymentVersionResponse {
|
||||
id: string
|
||||
version: number
|
||||
name?: string | null
|
||||
description?: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
createdBy?: string | null
|
||||
|
||||
1
packages/db/migrations/0148_aberrant_venom.sql
Normal file
1
packages/db/migrations/0148_aberrant_venom.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "workflow_deployment_version" ADD COLUMN "description" text;
|
||||
10347
packages/db/migrations/meta/0148_snapshot.json
Normal file
10347
packages/db/migrations/meta/0148_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1030,6 +1030,13 @@
|
||||
"when": 1769134350805,
|
||||
"tag": "0147_rare_firebrand",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 148,
|
||||
"version": "7",
|
||||
"when": 1769626313827,
|
||||
"tag": "0148_aberrant_venom",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user