diff --git a/apps/realtime/src/handlers/index.ts b/apps/realtime/src/handlers/index.ts index 8977eea550a..6ded2e54741 100644 --- a/apps/realtime/src/handlers/index.ts +++ b/apps/realtime/src/handlers/index.ts @@ -2,7 +2,6 @@ import { setupConnectionHandlers } from '@/handlers/connection' import { setupOperationsHandlers } from '@/handlers/operations' import { setupPresenceHandlers } from '@/handlers/presence' import { setupSubblocksHandlers } from '@/handlers/subblocks' -import { setupTableHandlers } from '@/handlers/tables' import { setupVariablesHandlers } from '@/handlers/variables' import { setupWorkflowHandlers } from '@/handlers/workflow' import type { AuthenticatedSocket } from '@/middleware/auth' @@ -14,6 +13,5 @@ export function setupAllHandlers(socket: AuthenticatedSocket, roomManager: IRoom setupSubblocksHandlers(socket, roomManager) setupVariablesHandlers(socket, roomManager) setupPresenceHandlers(socket, roomManager) - setupTableHandlers(socket, roomManager) setupConnectionHandlers(socket, roomManager) } diff --git a/apps/realtime/src/handlers/tables.ts b/apps/realtime/src/handlers/tables.ts deleted file mode 100644 index ae9a7c6f003..00000000000 --- a/apps/realtime/src/handlers/tables.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { createLogger } from '@sim/logger' -import type { AuthenticatedSocket } from '@/middleware/auth' -import { verifyTableAccess } from '@/middleware/permissions' -import { type IRoomManager, tableRoomName } from '@/rooms/types' - -const logger = createLogger('TableHandlers') - -/** - * Wires `join-table` / `leave-table` socket events. Tables don't track presence - * or last-modified state — joining is a thin wrapper around `socket.join` so the - * Sim API → Realtime HTTP bridge can broadcast row updates back to subscribed clients. - */ -export function setupTableHandlers(socket: AuthenticatedSocket, _roomManager: IRoomManager) { - socket.on('join-table', async ({ tableId }: { tableId?: string }) => { - try { - if (!tableId || typeof tableId !== 'string') { - socket.emit('join-table-error', { - tableId: tableId ?? null, - error: 'tableId required', - code: 'INVALID_TABLE_ID', - retryable: false, - }) - return - } - - const userId = socket.userId - if (!userId) { - socket.emit('join-table-error', { - tableId, - error: 'Authentication required', - code: 'AUTHENTICATION_REQUIRED', - retryable: false, - }) - return - } - - const { hasAccess } = await verifyTableAccess(userId, tableId) - if (!hasAccess) { - socket.emit('join-table-error', { - tableId, - error: 'Access denied to table', - code: 'ACCESS_DENIED', - retryable: false, - }) - return - } - - const room = tableRoomName(tableId) - socket.join(room) - socket.emit('join-table-success', { tableId, socketId: socket.id }) - logger.debug(`Socket ${socket.id} (user ${userId}) joined ${room}`) - } catch (error) { - logger.error(`Error joining table room:`, error) - socket.emit('join-table-error', { - tableId: null, - error: 'Failed to join table', - code: 'JOIN_TABLE_FAILED', - retryable: true, - }) - } - }) - - socket.on('leave-table', async ({ tableId }: { tableId?: string }) => { - try { - if (!tableId || typeof tableId !== 'string') return - const room = tableRoomName(tableId) - socket.leave(room) - logger.debug(`Socket ${socket.id} left ${room}`) - } catch (error) { - logger.error(`Error leaving table room:`, error) - } - }) -} diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index db97b16f8a2..dcc893b1478 100644 --- a/apps/realtime/src/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -131,51 +131,3 @@ export async function verifyWorkflowAccess( return { hasAccess: false } } } - -/** - * Verify a user has read access to a table by virtue of workspace permission. - * Mirrors `verifyWorkflowAccess` for the table-room socket join check. - */ -export async function verifyTableAccess( - userId: string, - tableId: string -): Promise<{ hasAccess: boolean; workspaceId?: string }> { - try { - const { userTableDefinitions, permissions } = await import('@sim/db') - const tableData = await db - .select({ workspaceId: userTableDefinitions.workspaceId }) - .from(userTableDefinitions) - .where(and(eq(userTableDefinitions.id, tableId), isNull(userTableDefinitions.archivedAt))) - .limit(1) - - if (!tableData.length) { - logger.warn(`Table ${tableId} not found`) - return { hasAccess: false } - } - const { workspaceId } = tableData[0] - if (!workspaceId) return { hasAccess: false } - - const [permissionRow] = await db - .select({ permissionType: permissions.permissionType }) - .from(permissions) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId) - ) - ) - .limit(1) - - if (!permissionRow?.permissionType) { - logger.warn( - `User ${userId} has no permission for workspace ${workspaceId} (table ${tableId})` - ) - return { hasAccess: false } - } - return { hasAccess: true, workspaceId } - } catch (error) { - logger.error(`Error verifying table access for user ${userId}, table ${tableId}:`, error) - return { hasAccess: false } - } -} diff --git a/apps/realtime/src/rooms/memory-manager.ts b/apps/realtime/src/rooms/memory-manager.ts index 0cd37daf493..a032e785bb5 100644 --- a/apps/realtime/src/rooms/memory-manager.ts +++ b/apps/realtime/src/rooms/memory-manager.ts @@ -1,13 +1,6 @@ import { createLogger } from '@sim/logger' import type { Server } from 'socket.io' -import { - type IRoomManager, - type TableRowUpdatedPayload, - tableRoomName, - type UserPresence, - type UserSession, - type WorkflowRoom, -} from '@/rooms/types' +import type { IRoomManager, UserPresence, UserSession, WorkflowRoom } from '@/rooms/types' const logger = createLogger('MemoryRoomManager') @@ -262,23 +255,4 @@ export class MemoryRoomManager implements IRoomManager { logger.info(`Notified ${room.users.size} users about workflow deployment change: ${workflowId}`) } - - emitToTable(tableId: string, event: string, payload: T): void { - this._io.to(tableRoomName(tableId)).emit(event, payload) - } - - async handleTableRowUpdated(tableId: string, payload: TableRowUpdatedPayload): Promise { - this.emitToTable(tableId, 'table-row-updated', { tableId, ...payload }) - } - - async handleTableRowDeleted(tableId: string, rowId: string): Promise { - this.emitToTable(tableId, 'table-row-deleted', { tableId, rowId }) - } - - async handleTableDeleted(tableId: string): Promise { - logger.info(`Handling table deletion notification for ${tableId}`) - this.emitToTable(tableId, 'table-deleted', { tableId, timestamp: Date.now() }) - // Eject sockets so they don't hold a stale room. Cross-pod safe via socket.io. - await this._io.in(tableRoomName(tableId)).socketsLeave(tableRoomName(tableId)) - } } diff --git a/apps/realtime/src/rooms/redis-manager.ts b/apps/realtime/src/rooms/redis-manager.ts index 0fb41417906..0e6b3eadf2b 100644 --- a/apps/realtime/src/rooms/redis-manager.ts +++ b/apps/realtime/src/rooms/redis-manager.ts @@ -1,13 +1,7 @@ import { createLogger } from '@sim/logger' import { createClient, type RedisClientType } from 'redis' import type { Server } from 'socket.io' -import { - type IRoomManager, - type TableRowUpdatedPayload, - tableRoomName, - type UserPresence, - type UserSession, -} from '@/rooms/types' +import type { IRoomManager, UserPresence, UserSession } from '@/rooms/types' const logger = createLogger('RedisRoomManager') @@ -463,23 +457,4 @@ export class RedisRoomManager implements IRoomManager { const userCount = await this.getUniqueUserCount(workflowId) logger.info(`Notified ${userCount} users about workflow deployment change: ${workflowId}`) } - - emitToTable(tableId: string, event: string, payload: T): void { - this._io.to(tableRoomName(tableId)).emit(event, payload) - } - - async handleTableRowUpdated(tableId: string, payload: TableRowUpdatedPayload): Promise { - this.emitToTable(tableId, 'table-row-updated', { tableId, ...payload }) - } - - async handleTableRowDeleted(tableId: string, rowId: string): Promise { - this.emitToTable(tableId, 'table-row-deleted', { tableId, rowId }) - } - - async handleTableDeleted(tableId: string): Promise { - logger.info(`Handling table deletion notification for ${tableId}`) - this.emitToTable(tableId, 'table-deleted', { tableId, timestamp: Date.now() }) - // Eject sockets across all pods via socket.io's Redis adapter. - await this._io.in(tableRoomName(tableId)).socketsLeave(tableRoomName(tableId)) - } } diff --git a/apps/realtime/src/rooms/types.ts b/apps/realtime/src/rooms/types.ts index 9c15c967d54..9553a427e1e 100644 --- a/apps/realtime/src/rooms/types.ts +++ b/apps/realtime/src/rooms/types.ts @@ -143,45 +143,4 @@ export interface IRoomManager { * Handle workflow deployment change - notify users to refresh deployment state */ handleWorkflowDeployed(workflowId: string): Promise - - /** - * Emit an event to all clients in a table room (`table:${tableId}`). - * Tables don't track presence/last-modified state — just pub/sub. - */ - emitToTable(tableId: string, event: string, payload: T): void - - /** - * Notify all clients in a table room of a row write (insert/update/cell-state-change). - * Sim API calls this via the `/api/table-row-updated` HTTP bridge after every successful - * row commit; the client merges the delta into its React Query cache. - */ - handleTableRowUpdated(tableId: string, payload: TableRowUpdatedPayload): Promise - - /** - * Notify all clients in a table room that a row has been deleted. - */ - handleTableRowDeleted(tableId: string, rowId: string): Promise - - /** - * Notify all clients in a table room that the table has been deleted; eject sockets. - */ - handleTableDeleted(tableId: string): Promise -} - -/** - * Payload broadcast on `table-row-updated`. Mirrors the shape of `TableRow.data` so - * the client can merge directly into its React Query rows cache. `position` and - * `updatedAt` are included for cache reconciliation; `data` is the full row data - * (not a per-cell delta) — see plan Notes. - */ -export interface TableRowUpdatedPayload { - rowId: string - data: Record - /** Per-workflow-group execution state. Keyed by `WorkflowGroup.id`. */ - executions?: Record - position: number - updatedAt: string | number } - -/** Socket.IO room name for a table. Namespaced from workflow rooms. */ -export const tableRoomName = (tableId: string): string => `table:${tableId}` diff --git a/apps/realtime/src/routes/http.ts b/apps/realtime/src/routes/http.ts index 78cd89e63d9..0f8ed73cc52 100644 --- a/apps/realtime/src/routes/http.ts +++ b/apps/realtime/src/routes/http.ts @@ -150,52 +150,6 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { return } - // Handle table row write notifications from the Sim API - if (req.method === 'POST' && req.url === '/api/table-row-updated') { - try { - const body = await readRequestBody(req) - const { tableId, rowId, data, executions, position, updatedAt } = JSON.parse(body) - await roomManager.handleTableRowUpdated(tableId, { - rowId, - data, - executions, - position, - updatedAt, - }) - sendSuccess(res) - } catch (error) { - logger.error('Error handling table row update notification:', error) - sendError(res, 'Failed to process table row update') - } - return - } - - if (req.method === 'POST' && req.url === '/api/table-row-deleted') { - try { - const body = await readRequestBody(req) - const { tableId, rowId } = JSON.parse(body) - await roomManager.handleTableRowDeleted(tableId, rowId) - sendSuccess(res) - } catch (error) { - logger.error('Error handling table row deletion notification:', error) - sendError(res, 'Failed to process table row deletion') - } - return - } - - if (req.method === 'POST' && req.url === '/api/table-deleted') { - try { - const body = await readRequestBody(req) - const { tableId } = JSON.parse(body) - await roomManager.handleTableDeleted(tableId) - sendSuccess(res) - } catch (error) { - logger.error('Error handling table deletion notification:', error) - sendError(res, 'Failed to process table deletion') - } - return - } - res.writeHead(404, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Not found' })) } diff --git a/apps/sim/app/api/table/[tableId]/columns/run/route.ts b/apps/sim/app/api/table/[tableId]/columns/run/route.ts new file mode 100644 index 00000000000..7b997c9c232 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/columns/run/route.ts @@ -0,0 +1,48 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { runColumnContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { runWorkflowColumn } from '@/lib/table/workflow-columns' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableRunColumnAPI') + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** POST /api/table/[tableId]/columns/run */ +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(runColumnContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { workspaceId, groupIds, runMode, rowIds } = parsed.data.body + const access = await checkAccess(tableId, auth.userId, 'write') + if (!access.ok) return accessError(access, requestId, tableId) + + const { triggered } = await runWorkflowColumn({ + tableId, + workspaceId, + groupIds, + mode: runMode, + rowIds, + requestId, + }) + return NextResponse.json({ success: true, data: { triggered } }) + } catch (error) { + if (error instanceof Error && error.message === 'Invalid workspace ID') { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + logger.error(`run-column failed:`, error) + return NextResponse.json({ error: 'Failed to run columns' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/table/[tableId]/groups/[groupId]/run/route.ts b/apps/sim/app/api/table/[tableId]/groups/[groupId]/run/route.ts deleted file mode 100644 index 80f80bb7945..00000000000 --- a/apps/sim/app/api/table/[tableId]/groups/[groupId]/run/route.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { runWorkflowGroupContract } from '@/lib/api/contracts/tables' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { triggerWorkflowGroupRun } from '@/lib/table/workflow-columns' -import { accessError, checkAccess } from '@/app/api/table/utils' - -const logger = createLogger('TableRunGroupAPI') - -interface RouteParams { - params: Promise<{ tableId: string; groupId: string }> -} - -/** - * POST /api/table/[tableId]/groups/[groupId]/run - * - * Manually triggers the workflow group for every eligible row in the table. - * Each eligible row's `executions[groupId]` is reset to `pending` so the - * scheduler picks it up and enqueues a per-cell trigger.dev job. Rows whose - * deps aren't satisfied or whose group is already running are skipped. - */ -export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - const parsed = await parseRequest(runWorkflowGroupContract, request, { params }) - if (!parsed.success) return parsed.response - const { tableId, groupId } = parsed.data.params - const { workspaceId, runMode, rowIds } = parsed.data.body - - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) - const { table } = result - - if (table.workspaceId !== workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - - const { triggered } = await triggerWorkflowGroupRun({ - tableId, - groupId, - workspaceId, - mode: runMode, - requestId, - rowIds, - }) - - return NextResponse.json({ success: true, data: { triggered } }) - } catch (error) { - if (error instanceof Error && error.message === 'Workflow group not found') { - return NextResponse.json({ error: 'Workflow group not found' }, { status: 404 }) - } - if (error instanceof Error && error.message === 'Invalid workspace ID') { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - logger.error(`run-group failed:`, error) - return NextResponse.json({ error: 'Failed to run group' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts index 847647fc397..bf74653212a 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.ts @@ -110,6 +110,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R ...(validated.newOutputColumns !== undefined ? { newOutputColumns: validated.newOutputColumns } : {}), + ...(validated.mappingUpdates !== undefined + ? { mappingUpdates: validated.mappingUpdates } + : {}), + ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}), }, requestId ) diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/run-workflow-group/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/run-workflow-group/route.ts deleted file mode 100644 index aee786d226d..00000000000 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/run-workflow-group/route.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { type NextRequest, NextResponse } from 'next/server' -import { runRowWorkflowGroupContract } from '@/lib/api/contracts/tables' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { RowExecutionMetadata } from '@/lib/table' -import { updateRow } from '@/lib/table' -import { accessError, checkAccess } from '@/app/api/table/utils' - -const logger = createLogger('TableRunWorkflowGroupAPI') - -interface RouteParams { - params: Promise<{ tableId: string; rowId: string }> -} - -/** - * POST /api/table/[tableId]/rows/[rowId]/run-workflow-group - * - * Manually (re-)runs a workflow group for a single row by force-resetting - * `executions[groupId]` to `pending`. The `updateRow` call fires the - * scheduler which enqueues the cell job. - */ -export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - const parsed = await parseRequest(runRowWorkflowGroupContract, request, { params }) - if (!parsed.success) return parsed.response - const { tableId, rowId } = parsed.data.params - const { workspaceId, groupId } = parsed.data.body - - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) - const { table } = result - - if (table.workspaceId !== workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - - const group = (table.schema.workflowGroups ?? []).find((g) => g.id === groupId) - if (!group) { - return NextResponse.json({ error: 'Workflow group not found' }, { status: 404 }) - } - - const executionId = generateId() - const pendingExec: RowExecutionMetadata = { - status: 'pending', - executionId, - jobId: null, - workflowId: group.workflowId, - error: null, - } - /** - * Clear the group's output cells so the rerun starts visually fresh — - * otherwise stale values from the previous run linger in the UI until the - * new run writes new ones (or doesn't, on error/router-skip). - */ - const clearedData = Object.fromEntries(group.outputs.map((o) => [o.columnName, null])) - const updated = await updateRow( - { - tableId, - rowId, - data: clearedData, - workspaceId, - executionsPatch: { [groupId]: pendingExec }, - }, - table, - requestId - ) - if (updated === null) { - // The cell-task cancellation guard rejected the write — typically a - // racing stop click that already wrote `cancelled` for this run. - // Surface 409 so the caller doesn't poll indefinitely for a run that - // was never enqueued. - return NextResponse.json( - { error: 'Run was cancelled before it could be scheduled' }, - { status: 409 } - ) - } - - return NextResponse.json({ success: true, data: { executionId } }) - } catch (error) { - if (error instanceof Error && error.message === 'Row not found') { - return NextResponse.json({ error: 'Row not found' }, { status: 404 }) - } - logger.error(`run-workflow-group failed:`, error) - return NextResponse.json({ error: 'Failed to run workflow group' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 22686115782..9b4392d0110 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -55,6 +55,8 @@ interface ResourceHeaderProps { breadcrumbs?: BreadcrumbItem[] create?: CreateAction actions?: HeaderAction[] + /** Arbitrary content rendered in the right-aligned actions row, before `actions`. */ + leadingActions?: React.ReactNode /** Arbitrary content rendered in the right-aligned actions row, before the Create button. */ trailingActions?: React.ReactNode /** @@ -71,6 +73,7 @@ export const ResourceHeader = memo(function ResourceHeader({ breadcrumbs, create, actions, + leadingActions, trailingActions, createTrigger, }: ResourceHeaderProps) { @@ -106,6 +109,7 @@ export const ResourceHeader = memo(function ResourceHeader({ )}
+ {leadingActions} {actions?.map((action) => { const ActionIcon = action.icon return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 1adfb0f3445..e93fe37cd6a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -2,7 +2,6 @@ import { lazy, memo, Suspense, useEffect, useMemo, useRef } from 'react' import { createLogger } from '@sim/logger' -import { Square } from 'lucide-react' import { useRouter } from 'next/navigation' import { Button, PlayOutline, Skeleton, Tooltip } from '@/components/emcn' import { @@ -10,6 +9,7 @@ import { FileX, Folder as FolderIcon, Library, + Square, SquareArrowUpRight, WorkflowX, } from '@/components/emcn/icons' @@ -43,7 +43,7 @@ import { useUserPermissionsContext, useWorkspacePermissionsContext, } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components' +import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/table' import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks' import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' import { useFolders } from '@/hooks/queries/folders' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx new file mode 100644 index 00000000000..9792b758376 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx @@ -0,0 +1,243 @@ +'use client' + +import type React from 'react' +import { useState } from 'react' +import { toError } from '@sim/utils/errors' +import { X } from 'lucide-react' +import { Button, Combobox, FieldDivider, Input, Label, Switch, toast } from '@/components/emcn' +import { findValidationIssue, isValidationError } from '@/lib/api/client/errors' +import { cn } from '@/lib/core/utils/cn' +import type { ColumnDefinition } from '@/lib/table' +import { useAddTableColumn, useUpdateColumn } from '@/hooks/queries/tables' +import { PLAIN_COLUMN_TYPE_OPTIONS } from './column-types' + +/** + * Discriminates the two flows the column-config sidebar handles. Workflow + * configuration is a separate component (``) so this surface + * never has to branch on `isWorkflow`. + */ +export type ColumnConfig = + | { mode: 'create'; proposedName: string; type: ColumnDefinition['type'] } + | { mode: 'edit'; columnName: string } + +interface ColumnConfigSidebarProps { + /** When non-null the sidebar is open. */ + config: ColumnConfig | null + onClose: () => void + /** Existing column record for `mode: 'edit'`; ignored otherwise. */ + existingColumn: ColumnDefinition | null + workspaceId: string + tableId: string + /** Notify parent of a rename so it can rewrite local `columnOrder` / + * `columnWidths` keys that reference the old name. */ + onColumnRename?: (oldName: string, newName: string) => void +} + +/** + * Right-edge sidebar for plain (non-workflow) column configuration. Handles + * create (with type pre-chosen by the parent's "+ New column" dropdown) and + * edit. No `isWorkflow` branches — workflow-output columns route through + * `` instead. + * + * Form state seeds from props via lazy `useState` initializers; the parent + * uses `key={config?.columnName ?? 'closed'}` to remount when switching + * columns, eliminating the prop-mirroring `useEffect` the previous combined + * sidebar relied on. + */ +export function ColumnConfigSidebar(props: ColumnConfigSidebarProps) { + // Mount the form body with `key` keyed on the config identity so opening a + // different column / mode remounts and re-seeds state from props. + const open = props.config !== null + return ( + + ) +} + +function configKey(config: ColumnConfig): string { + return config.mode === 'edit' ? `edit:${config.columnName}` : `create:${config.proposedName}` +} + +interface ColumnConfigBodyProps extends Omit { + config: ColumnConfig +} + +function ColumnConfigBody({ + config, + onClose, + existingColumn, + workspaceId, + tableId, + onColumnRename, +}: ColumnConfigBodyProps) { + const updateColumn = useUpdateColumn({ workspaceId, tableId }) + const addColumn = useAddTableColumn({ workspaceId, tableId }) + + const [nameInput, setNameInput] = useState(() => + config.mode === 'edit' ? (existingColumn?.name ?? config.columnName) : config.proposedName + ) + const [typeInput, setTypeInput] = useState(() => + config.mode === 'edit' ? (existingColumn?.type ?? 'string') : config.type + ) + const [uniqueInput, setUniqueInput] = useState(() => + config.mode === 'edit' ? !!existingColumn?.unique : false + ) + const [showValidation, setShowValidation] = useState(false) + const [nameError, setNameError] = useState(null) + + const saveDisabled = updateColumn.isPending || addColumn.isPending + const trimmedName = nameInput.trim() + + async function handleSave() { + if (!trimmedName) { + setShowValidation(true) + return + } + + try { + if (config.mode === 'create') { + await addColumn.mutateAsync({ + name: trimmedName, + type: typeInput, + ...(uniqueInput ? { unique: true } : {}), + }) + toast.success(`Added "${trimmedName}"`) + onClose() + return + } + + const renamed = trimmedName !== config.columnName + const typeChanged = !!existingColumn && existingColumn.type !== typeInput + const uniqueChanged = !!existingColumn && !!existingColumn.unique !== uniqueInput + + const updates: { name?: string; type?: ColumnDefinition['type']; unique?: boolean } = { + ...(renamed ? { name: trimmedName } : {}), + ...(typeChanged ? { type: typeInput } : {}), + ...(uniqueChanged ? { unique: uniqueInput } : {}), + } + if (Object.keys(updates).length === 0) { + onClose() + return + } + + await updateColumn.mutateAsync({ columnName: config.columnName, updates }) + if (renamed) onColumnRename?.(config.columnName, trimmedName) + toast.success(`Saved "${trimmedName}"`) + onClose() + } catch (err) { + // Server validation errors carry a Zod issue array on the body; surface + // them inline next to the offending field instead of as a raw toast. + if (isValidationError(err)) { + const nameIssue = + findValidationIssue(err, ['updates', 'name']) ?? + findValidationIssue(err, ['name']) ?? + findValidationIssue(err, ['columnName']) + if (nameIssue) { + setNameError(nameIssue.message) + return + } + } + toast.error(toError(err).message) + } + } + + return ( +
+
+

Configure column

+ +
+ +
+
+ Column name + { + setNameInput(e.target.value) + if (nameError) setNameError(null) + }} + spellCheck={false} + autoComplete='off' + aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined} + /> + {showValidation && !trimmedName && } + {nameError && !(showValidation && !trimmedName) && } +
+ + {config.mode === 'edit' && ( + <> + +
+ Type + ({ + label: o.label, + value: o.type, + icon: o.icon, + }))} + value={typeInput} + onChange={(v) => setTypeInput(v as ColumnDefinition['type'])} + placeholder='Select type' + maxHeight={260} + /> +
+ + )} + + +
+
+ + setUniqueInput(!!v)} + /> +
+
+
+ +
+ + +
+
+ ) +} + +function RequiredLabel({ htmlFor, children }: { htmlFor?: string; children: React.ReactNode }) { + return ( + + ) +} + +function FieldError({ message }: { message: string }) { + return

{message}

+} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts similarity index 66% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-types.ts rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts index 10e392e82a1..6c9f31ade67 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-types.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts @@ -10,9 +10,9 @@ import { import type { ColumnDefinition } from '@/lib/table' /** - * UI-only column type. `'workflow'` is a virtual selection that lets the user - * configure a workflow group from the sidebar; on save, it expands into N real - * scalar columns + one workflow group, none of which carry a `'workflow'` type. + * UI-only column type. `'workflow'` is the virtual entry users pick from the + * "+ New column" dropdown to spawn a workflow group; the resulting columns are + * stored as scalar types under the hood (none carry `'workflow'`). */ export type SidebarColumnType = ColumnDefinition['type'] | 'workflow' @@ -30,3 +30,6 @@ export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [ { type: 'json', label: 'JSON', icon: TypeJson }, { type: 'workflow', label: 'Workflow', icon: PlayOutline }, ] + +/** Plain column types (no workflow). Used by ``'s type combobox in edit mode. */ +export const PLAIN_COLUMN_TYPE_OPTIONS = COLUMN_TYPE_OPTIONS.filter((o) => o.type !== 'workflow') diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts new file mode 100644 index 00000000000..e458001136d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts @@ -0,0 +1,8 @@ +export type { ColumnConfig } from './column-config-sidebar' +export { ColumnConfigSidebar } from './column-config-sidebar' +export { + COLUMN_TYPE_OPTIONS, + type ColumnTypeOption, + PLAIN_COLUMN_TYPE_OPTIONS, + type SidebarColumnType, +} from './column-types' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx deleted file mode 100644 index 73017fc25ec..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx +++ /dev/null @@ -1,1314 +0,0 @@ -'use client' - -import type React from 'react' -import { useEffect, useMemo, useRef, useState } from 'react' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { - ChevronDown, - ChevronRight, - ExternalLink, - Loader2, - Plus, - RepeatIcon, - SplitIcon, - X, -} from 'lucide-react' -import { - Button, - Checkbox, - Combobox, - Expandable, - ExpandableContent, - Input, - Label, - Switch, - Tooltip, - toast, -} from '@/components/emcn' -import { requestJson } from '@/lib/api/client/request' -import type { - AddWorkflowGroupBodyInput, - UpdateWorkflowGroupBodyInput, -} from '@/lib/api/contracts/tables' -import { - putWorkflowNormalizedStateContract, - type WorkflowStateContractInput, -} from '@/lib/api/contracts/workflows' -import { cn } from '@/lib/core/utils/cn' -import type { - ColumnDefinition, - WorkflowGroup, - WorkflowGroupDependencies, - WorkflowGroupOutput, -} from '@/lib/table' -import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' -import { - type FlattenOutputsBlockInput, - type FlattenOutputsEdgeInput, - flattenWorkflowOutputs, - getBlockExecutionOrder, -} from '@/lib/workflows/blocks/flatten-outputs' -import { normalizeInputFormatValue } from '@/lib/workflows/input-format' -import { TriggerUtils } from '@/lib/workflows/triggers/triggers' -import type { InputFormatField } from '@/lib/workflows/types' -import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' -import { getBlock } from '@/blocks' -import { - useAddTableColumn, - useAddWorkflowGroup, - useUpdateColumn, - useUpdateWorkflowGroup, -} from '@/hooks/queries/tables' -import { useWorkflowState, workflowKeys } from '@/hooks/queries/workflows' -import type { WorkflowMetadata } from '@/stores/workflows/registry/types' -import { COLUMN_SIDEBAR_WIDTH_CSS } from '../table/constants' -import { COLUMN_TYPE_OPTIONS, type SidebarColumnType } from './column-types' - -export type ColumnConfigState = - | { mode: 'edit'; columnName: string } - | { mode: 'new'; columnName: string; workflowId: string; proposedName: string } - | { - mode: 'create' - columnName: string - proposedName: string - /** When present, the sidebar opens with the workflow type pre-selected. */ - workflowId?: string - } - | null - -interface ColumnSidebarProps { - configState: ColumnConfigState - onClose: () => void - /** The current column record for edit mode. Null for new mode or closed. */ - existingColumn: ColumnDefinition | null - allColumns: ColumnDefinition[] - workflowGroups: WorkflowGroup[] - workflows: WorkflowMetadata[] | undefined - workspaceId: string - tableId: string -} - -const OUTPUT_VALUE_SEPARATOR = '::' - -/** Shared dashed-divider style — mirrors the workflow editor's subblock divider. */ -const DASHED_DIVIDER_STYLE = { - backgroundImage: - 'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)', -} as const - -/** Encodes blockId + path so duplicate field names across blocks stay distinct in the picker UI. */ -const encodeOutputValue = (blockId: string, path: string) => - `${blockId}${OUTPUT_VALUE_SEPARATOR}${path}` - -/** Splits an encoded `${blockId}::${path}` into its components for persistence. */ -const decodeOutputValue = (value: string): { blockId: string; path: string } => { - const idx = value.indexOf(OUTPUT_VALUE_SEPARATOR) - if (idx === -1) return { blockId: '', path: value } - return { blockId: value.slice(0, idx), path: value.slice(idx + OUTPUT_VALUE_SEPARATOR.length) } -} - -interface BlockOutputGroup { - blockId: string - blockName: string - blockType: string - blockIcon: string | React.ComponentType<{ className?: string }> - blockColor: string - paths: string[] -} - -/** - * Loose shape of `useWorkflowState` data — we only need the fields we round-trip - * through PUT /state. Typed locally to avoid pulling the heavy `WorkflowState` - * generic from `@/stores/workflows/workflow/types`. - */ -interface WorkflowStatePayload { - blocks: Record< - string, - { - type: string - subBlocks?: Record - } & Record - > - edges: unknown[] - loops: unknown - parallels: unknown - lastSaved?: number - isDeployed?: boolean -} - -function tableColumnTypeToInputType(colType: ColumnDefinition['type'] | undefined): string { - switch (colType) { - case 'number': - return 'number' - case 'boolean': - return 'boolean' - case 'json': - return 'object' - default: - return 'string' - } -} - -const TagIcon: React.FC<{ - icon: string | React.ComponentType<{ className?: string }> - color: string -}> = ({ icon, color }) => ( -
- {typeof icon === 'string' ? ( - {icon} - ) : ( - (() => { - const IconComponent = icon - return - })() - )} -
-) - -function FieldDivider() { - return ( -
-
-
- ) -} - -/** Mirrors the workflow editor's required-field label: title + asterisk. */ -function FieldLabel({ - htmlFor, - required, - children, -}: { - htmlFor?: string - required?: boolean - children: React.ReactNode -}) { - return ( - - ) -} - -/** Inline validation message styled like the workflow editor's destructive text. */ -function FieldError({ message }: { message: string }) { - return

{message}

-} - -/** - * Tinted inline warning row with a message on the left and an action button - * on the right. Stacks naturally — render multiple in sequence and they line - * up. Color mirrors the group-header deploy badge: `red` for blocking states, - * `amber` for soft warnings. - */ -function WarningRow({ - tone, - message, - action, -}: { - tone: 'red' | 'amber' - message: string - action: React.ReactNode -}) { - return ( -
- - {message} - -
{action}
-
- ) -} - -/** - * Collapsible "Run settings" section. Collapsed by default since outputs are - * the primary focus of the workflow flow — most users never need to touch - * the trigger conditions. The header shows a one-line summary of when the - * group will fire so the current state is visible without expanding. - */ -function RunSettingsSection({ - open, - onOpenChange, - summary, - scalarDepColumns, - groupDepOptions, - deps, - groupDeps, - workflows, - onToggleDep, - onToggleGroupDep, -}: { - open: boolean - onOpenChange: (open: boolean) => void - summary: string - scalarDepColumns: ColumnDefinition[] - groupDepOptions: WorkflowGroup[] - deps: string[] - groupDeps: string[] - workflows: WorkflowMetadata[] | undefined - onToggleDep: (name: string) => void - onToggleGroupDep: (groupId: string) => void -}) { - return ( -
- - - -
- {scalarDepColumns.length === 0 && groupDepOptions.length === 0 ? ( -
- No upstream columns or groups. -
- ) : ( - <> - {scalarDepColumns.map((c, idx) => { - const checked = deps.includes(c.name) - const isLast = idx === scalarDepColumns.length - 1 && groupDepOptions.length === 0 - return ( -
onToggleDep(c.name)} - onKeyDown={(e) => { - if (e.key === ' ' || e.key === 'Enter') { - e.preventDefault() - onToggleDep(c.name) - } - }} - className={cn( - 'flex h-[36px] flex-shrink-0 cursor-pointer items-center gap-2.5 px-2.5 hover:bg-[var(--surface-2)]', - !isLast && 'border-[var(--border)] border-b' - )} - > - - - {c.name} - - - {c.type} - -
- ) - })} - {groupDepOptions.map((g, idx) => { - const checked = groupDeps.includes(g.id) - const isLast = idx === groupDepOptions.length - 1 - const wf = workflows?.find((w) => w.id === g.workflowId) - const color = wf?.color ?? 'var(--text-muted)' - const label = g.name ?? wf?.name ?? 'Workflow' - return ( -
onToggleGroupDep(g.id)} - onKeyDown={(e) => { - if (e.key === ' ' || e.key === 'Enter') { - e.preventDefault() - onToggleGroupDep(g.id) - } - }} - className={cn( - 'flex h-[36px] flex-shrink-0 cursor-pointer items-center gap-2.5 px-2.5 hover:bg-[var(--surface-2)]', - !isLast && 'border-[var(--border)] border-b' - )} - > - -
- ) - })} - - )} -
-
-
-
- ) -} - -/** - * Right-edge configuration panel for any column. - * - * Shows name / type / unique for every column, plus workflow-specific fields - * (workflow picker, output field, dependencies, run concurrency) when the - * selected type is `'workflow'`. - * - * Three modes: - * - 'edit': modify an existing column. PATCH sends a unified updates payload. - * - 'new': user picked a workflow via Change type → Workflow → [pick]. Nothing - * is persisted yet. Save writes type + workflowConfig + renames in one PATCH. - * - 'create': user picked a workflow from "Add column"; the column doesn't exist yet - * and Save creates it. - * - * Visual styling mirrors the workflow editor's subblock panel (label above - * control, dashed dividers between fields). - */ -export function ColumnSidebar({ - configState, - onClose, - existingColumn, - allColumns, - workflowGroups, - workflows, - workspaceId, - tableId, -}: ColumnSidebarProps) { - const updateColumn = useUpdateColumn({ workspaceId, tableId }) - const addColumn = useAddTableColumn({ workspaceId, tableId }) - const addWorkflowGroup = useAddWorkflowGroup({ workspaceId, tableId }) - const updateWorkflowGroup = useUpdateWorkflowGroup({ workspaceId, tableId }) - const open = configState !== null - - const columnName = configState ? configState.columnName : '' - - /** - * If the column being edited is a workflow output, resolve its parent group - * so we can populate workflow / outputs / dependencies state from it. - */ - const existingGroup = useMemo(() => { - if (!existingColumn?.workflowGroupId) return undefined - return workflowGroups.find((g) => g.id === existingColumn.workflowGroupId) - }, [existingColumn, workflowGroups]) - - const [nameInput, setNameInput] = useState('') - const [typeInput, setTypeInput] = useState('string') - - const isWorkflow = !!existingGroup || configState?.mode === 'new' || typeInput === 'workflow' - - /** - * Show the Column name field whenever a *specific* column is open: scalar - * columns (create or edit) and per-output workflow columns (edit only). Hide - * it when the surface is the workflow-group as a whole — i.e. creating a - * brand-new workflow column where individual output names are auto-derived. - */ - const showColumnNameField = - !isWorkflow || configState?.mode === 'edit' || configState?.mode === 'new' - - /** - * Columns to the left of the current column — these are the only valid trigger - * dependencies, since a workflow column can't depend on values that haven't been - * filled yet. For 'create' mode the column doesn't exist yet, so every existing - * column counts as left of it. - */ - const otherColumns = useMemo(() => { - if (!configState) return [] - if (configState.mode === 'create') return allColumns - const idx = allColumns.findIndex((c) => c.name === configState.columnName) - if (idx === -1) return allColumns.filter((c) => c.name !== configState.columnName) - return allColumns.slice(0, idx) - }, [configState, allColumns]) - - /** - * Split `otherColumns` into the two dep buckets: - * - `scalarDepColumns` — plain columns; tickable into `dependencies.columns`. - * - `groupDepOptions` — producing workflow groups whose outputs land left of the - * current column; tickable into `dependencies.workflowGroups`. A group only - * shows up here when at least one of its output columns is left-of-current. - * The current group itself is excluded so we never depend on ourselves. - */ - const scalarDepColumns = useMemo( - () => otherColumns.filter((c) => !c.workflowGroupId), - [otherColumns] - ) - const groupDepOptions = useMemo(() => { - const seen = new Set() - const result: WorkflowGroup[] = [] - for (const c of otherColumns) { - if (!c.workflowGroupId) continue - if (seen.has(c.workflowGroupId)) continue - if (existingGroup && c.workflowGroupId === existingGroup.id) continue - const g = workflowGroups.find((gg) => gg.id === c.workflowGroupId) - if (!g) continue - seen.add(c.workflowGroupId) - result.push(g) - } - return result - }, [otherColumns, workflowGroups, existingGroup]) - - const [uniqueInput, setUniqueInput] = useState(false) - const [selectedWorkflowId, setSelectedWorkflowId] = useState('') - /** Plain (non-workflow-output) column names this group waits on. */ - const [deps, setDeps] = useState([]) - /** Producing workflow group ids this group waits on. Workflow-output columns are - * represented by their parent group, since the schema validator forbids depending - * on a workflow-output column directly (`workflow-columns.ts` enforces this). */ - const [groupDeps, setGroupDeps] = useState([]) - /** Encoded `${blockId}::${path}` values — disambiguates duplicate paths in the picker. */ - const [selectedOutputs, setSelectedOutputs] = useState([]) - /** Surfaces required-field errors only after a save attempt, matching the workflow editor's deploy flow. */ - const [showValidation, setShowValidation] = useState(false) - /** Save-time error (network/validation thrown by the mutation). Rendered inline next to the footer - * buttons so it isn't covered by the toaster, which sits over the bottom-right of the panel. */ - const [saveError, setSaveError] = useState(null) - /** Run settings (the trigger-deps picker) starts collapsed — outputs are the - * primary task; configuring run timing is rare. */ - const [runSettingsOpen, setRunSettingsOpen] = useState(false) - - const existingColumnRef = useRef(existingColumn) - existingColumnRef.current = existingColumn - const allColumnsRef = useRef(allColumns) - allColumnsRef.current = allColumns - - useEffect(() => { - if (!open || !configState) return - setShowValidation(false) - setSaveError(null) - setRunSettingsOpen(false) - const existing = existingColumnRef.current - const cols = allColumnsRef.current - const leftOfCurrent = (() => { - if (configState.mode === 'create') return cols - const idx = cols.findIndex((c) => c.name === configState.columnName) - if (idx === -1) return cols.filter((c) => c.name !== configState.columnName) - return cols.slice(0, idx) - })() - // Default deps when there's no persisted group yet: tick every left-of-current - // scalar column + every left-of-current producing group. - const defaultScalarDeps = leftOfCurrent.filter((c) => !c.workflowGroupId).map((c) => c.name) - const defaultGroupDeps = (() => { - const seen = new Set() - for (const c of leftOfCurrent) { - if (c.workflowGroupId) seen.add(c.workflowGroupId) - } - return Array.from(seen) - })() - if (configState.mode === 'edit') { - const group = existing?.workflowGroupId - ? workflowGroups.find((g) => g.id === existing.workflowGroupId) - : undefined - // Surface workflow-typed columns as `'workflow'` in the combobox even - // though they're stored as scalar columns under the hood. - setTypeInput(group ? 'workflow' : (existing?.type ?? 'string')) - setUniqueInput(!!existing?.unique) - setNameInput(existing?.name ?? configState.columnName) - if (group) { - setSelectedWorkflowId(group.workflowId) - // Sanitize legacy persisted deps: any workflow-output column names that - // sneaked into `dependencies.columns` (writes from before the schema - // validator forbade them) are lifted into `workflowGroups` here so the - // sidebar surfaces a re-saveable state. - const persistedCols = group.dependencies?.columns - const persistedGroups = group.dependencies?.workflowGroups - if (persistedCols !== undefined || persistedGroups !== undefined) { - const liftedGroupIds = new Set(persistedGroups ?? []) - const cleanCols: string[] = [] - for (const colName of persistedCols ?? []) { - const c = cols.find((cc) => cc.name === colName) - if (c?.workflowGroupId) liftedGroupIds.add(c.workflowGroupId) - else cleanCols.push(colName) - } - setDeps(cleanCols) - setGroupDeps(Array.from(liftedGroupIds)) - } else { - setDeps(defaultScalarDeps) - setGroupDeps(defaultGroupDeps) - } - setSelectedOutputs([]) // re-encoded against current workflow blocks below - } else { - setSelectedWorkflowId('') - setDeps([]) - setGroupDeps([]) - setSelectedOutputs([]) - } - } else { - const workflowId = - 'workflowId' in configState && configState.workflowId ? configState.workflowId : '' - setTypeInput(workflowId ? 'workflow' : 'string') - setUniqueInput(false) - setNameInput(configState.proposedName) - setSelectedWorkflowId(workflowId) - setDeps(defaultScalarDeps) - setGroupDeps(defaultGroupDeps) - setSelectedOutputs([]) - } - }, [open, configState, workflowGroups]) - - const workflowState = useWorkflowState( - open && isWorkflow && selectedWorkflowId ? selectedWorkflowId : undefined - ) - - /** - * Resolves the unified Start block id and its current `inputFormat` field - * names. The "Add inputs" mutation only adds rows for table columns that - * aren't already represented in the start block — clicking the button when - * everything's covered does nothing, so we hide it in that case. - */ - const startBlockInputs = useMemo<{ - blockId: string | null - existingNames: Set - existing: InputFormatField[] - }>(() => { - const blocks = (workflowState.data as { blocks?: Record } | null) - ?.blocks - if (!blocks) return { blockId: null, existingNames: new Set(), existing: [] } - const candidate = TriggerUtils.findStartBlock(blocks, 'manual') - if (!candidate) return { blockId: null, existingNames: new Set(), existing: [] } - const block = blocks[candidate.blockId] as - | { subBlocks?: Record } - | undefined - const existing = normalizeInputFormatValue(block?.subBlocks?.inputFormat?.value) - return { - blockId: candidate.blockId, - existingNames: new Set(existing.map((f) => f.name).filter((n): n is string => !!n)), - existing, - } - }, [workflowState.data]) - - const missingInputColumnNames = useMemo(() => { - if (!startBlockInputs.blockId) return [] - return allColumns - .filter( - (c) => - c.name !== columnName && !c.workflowGroupId && !startBlockInputs.existingNames.has(c.name) - ) - .map((c) => c.name) - }, [allColumns, columnName, startBlockInputs]) - - const queryClient = useQueryClient() - const addInputsMutation = useMutation({ - mutationFn: async () => { - const wfId = selectedWorkflowId - const startBlockId = startBlockInputs.blockId - const state = workflowState.data as WorkflowStatePayload | null | undefined - if (!wfId || !startBlockId || !state || missingInputColumnNames.length === 0) { - throw new Error('Nothing to add') - } - const startBlock = state.blocks[startBlockId] - if (!startBlock) throw new Error('Start block missing from workflow') - - const newFields: InputFormatField[] = missingInputColumnNames.map((name) => { - const col = allColumns.find((c) => c.name === name) - return { - id: generateId(), - name, - type: tableColumnTypeToInputType(col?.type), - value: '', - collapsed: false, - } as InputFormatField & { id: string; collapsed: boolean } - }) - - const updatedSubBlock = { - ...(startBlock.subBlocks?.inputFormat ?? { id: 'inputFormat', type: 'input-format' }), - value: [...startBlockInputs.existing, ...newFields], - } - const updatedBlocks = { - ...state.blocks, - [startBlockId]: { - ...startBlock, - subBlocks: { ...startBlock.subBlocks, inputFormat: updatedSubBlock }, - }, - } - - const rawBody = { - blocks: updatedBlocks, - edges: state.edges, - loops: state.loops, - parallels: state.parallels, - lastSaved: state.lastSaved ?? Date.now(), - isDeployed: state.isDeployed ?? false, - } - // double-cast-allowed: WorkflowStatePayload is the loose local view of - // useWorkflowState; we round-trip it back to the strict PUT body shape. - const body = rawBody as unknown as WorkflowStateContractInput - await requestJson(putWorkflowNormalizedStateContract, { - params: { id: wfId }, - body, - }) - return missingInputColumnNames.length - }, - onSuccess: (added) => { - queryClient.invalidateQueries({ queryKey: workflowKeys.state(selectedWorkflowId) }) - toast.success(`Added ${added} input${added === 1 ? '' : 's'} to start block`) - }, - onError: (err) => { - toast.error(toError(err).message) - }, - }) - - const blockOutputGroups = useMemo(() => { - const state = workflowState.data as - | { - blocks?: Record - edges?: FlattenOutputsEdgeInput[] - } - | null - | undefined - if (!state?.blocks) return [] - - const blocks = Object.values(state.blocks) - const edges = state.edges ?? [] - const flat = flattenWorkflowOutputs(blocks, edges) - if (flat.length === 0) return [] - - const groupsByBlockId = new Map() - for (const f of flat) { - let group = groupsByBlockId.get(f.blockId) - if (!group) { - const blockConfig = getBlock(f.blockType) - const blockColor = blockConfig?.bgColor || '#2F55FF' - let blockIcon: string | React.ComponentType<{ className?: string }> = f.blockName - .charAt(0) - .toUpperCase() - if (blockConfig?.icon) blockIcon = blockConfig.icon - else if (f.blockType === 'loop') blockIcon = RepeatIcon - else if (f.blockType === 'parallel') blockIcon = SplitIcon - group = { - blockId: f.blockId, - blockName: f.blockName, - blockType: f.blockType, - blockIcon, - blockColor, - paths: [], - } - groupsByBlockId.set(f.blockId, group) - } - group.paths.push(f.path) - } - // Sort the picker by execution order (start block first) so it matches the - // saved-column ordering. Unreachable blocks sink to the end. - const distances = getBlockExecutionOrder(blocks, edges) - return Array.from(groupsByBlockId.values()).sort((a, b) => { - const da = distances[a.blockId] - const db = distances[b.blockId] - const sa = da === undefined || da < 0 ? Number.POSITIVE_INFINITY : da - const sb = db === undefined || db < 0 ? Number.POSITIVE_INFINITY : db - return sa - sb - }) - }, [workflowState.data]) - - /** - * Re-encode persisted `{blockId, path}` entries into the picker's encoded form - * once the workflow's blocks are loaded. Stale entries (block deleted or path - * removed) are dropped silently — the user can re-pick on save. - */ - useEffect(() => { - if (!existingGroup?.outputs.length) return - if (selectedOutputs.length > 0) return - if (blockOutputGroups.length === 0) return - const encoded: string[] = [] - for (const entry of existingGroup.outputs) { - const match = blockOutputGroups.find( - (g) => g.blockId === entry.blockId && g.paths.includes(entry.path) - ) - if (match) encoded.push(encodeOutputValue(entry.blockId, entry.path)) - } - if (encoded.length > 0) setSelectedOutputs(encoded) - }, [blockOutputGroups, selectedOutputs.length, existingGroup]) - - const toggleDep = (name: string) => { - setDeps((prev) => (prev.includes(name) ? prev.filter((d) => d !== name) : [...prev, name])) - } - - const toggleGroupDep = (groupId: string) => { - setGroupDeps((prev) => - prev.includes(groupId) ? prev.filter((d) => d !== groupId) : [...prev, groupId] - ) - } - - const toggleOutput = (encoded: string) => { - setSelectedOutputs((prev) => - prev.includes(encoded) ? prev.filter((v) => v !== encoded) : [...prev, encoded] - ) - } - - const typeOptions = useMemo( - () => - COLUMN_TYPE_OPTIONS.filter((o) => o.type !== 'workflow' || !!existingGroup).map((o) => ({ - label: o.label, - value: o.type, - icon: o.icon, - })), - [existingGroup] - ) - - /** - * One-line summary of the trigger picker shown when Run settings is collapsed. - * Lists the dep names ("Run when X, Y, are filled") so the user can see at a - * glance whether anything's gating the group without expanding the section. - */ - const runSettingsSummary = useMemo(() => { - const names: string[] = [...deps] - for (const gid of groupDeps) { - const g = workflowGroups.find((gg) => gg.id === gid) - const wf = workflows?.find((w) => w.id === g?.workflowId) - const label = g?.name ?? wf?.name ?? 'workflow' - names.push(label) - } - if (names.length === 0) return 'Runs as soon as the group is added' - return `Runs when ${names.join(', ')} ${names.length === 1 ? 'is' : 'are'} filled` - }, [deps, groupDeps, workflowGroups, workflows]) - - /** - * Builds the ordered, deduplicated `(blockId, path)` list from the picker - * state, sorted by execution order. Empty array if the user hasn't picked - * anything. - */ - const buildOrderedPickedOutputs = (): Array<{ - blockId: string - path: string - leafType?: string - }> => { - const seen = new Set() - const outputs: Array<{ blockId: string; path: string; leafType?: string }> = [] - for (const encoded of selectedOutputs) { - if (seen.has(encoded)) continue - seen.add(encoded) - outputs.push(decodeOutputValue(encoded)) - } - const wfState = workflowState.data as - | { - blocks?: Record - edges?: FlattenOutputsEdgeInput[] - } - | null - | undefined - if (wfState?.blocks) { - const blocks = Object.values(wfState.blocks) - const edges = wfState.edges ?? [] - const distances = getBlockExecutionOrder(blocks, edges) - const flat = flattenWorkflowOutputs(blocks, edges) - const indexInFlat = new Map( - flat.map((f, i) => [`${f.blockId}${OUTPUT_VALUE_SEPARATOR}${f.path}`, i]) - ) - const leafTypeByKey = new Map( - flat.map((f) => [`${f.blockId}${OUTPUT_VALUE_SEPARATOR}${f.path}`, f.leafType]) - ) - for (const o of outputs) { - o.leafType = leafTypeByKey.get(`${o.blockId}${OUTPUT_VALUE_SEPARATOR}${o.path}`) - } - outputs.sort((a, b) => { - const da = distances[a.blockId] - const db = distances[b.blockId] - const sa = da === undefined || da < 0 ? Number.POSITIVE_INFINITY : da - const sb = db === undefined || db < 0 ? Number.POSITIVE_INFINITY : db - if (sa !== sb) return sa - sb - const ia = - indexInFlat.get(`${a.blockId}${OUTPUT_VALUE_SEPARATOR}${a.path}`) ?? - Number.POSITIVE_INFINITY - const ib = - indexInFlat.get(`${b.blockId}${OUTPUT_VALUE_SEPARATOR}${b.path}`) ?? - Number.POSITIVE_INFINITY - return ia - ib - }) - } - return outputs - } - - const handleSave = async () => { - if (!configState) return - setSaveError(null) - const trimmedName = nameInput.trim() - // Name is required iff the field is shown — when configuring a whole - // workflow group at creation time, per-output column names are auto-derived - // and the field is hidden, so don't gate save on it. - const missing: string[] = [] - if (showColumnNameField && !trimmedName) missing.push('a column name') - if (isWorkflow && !selectedWorkflowId) missing.push('a workflow') - if (isWorkflow && selectedWorkflowId && selectedOutputs.length === 0) { - missing.push('at least one output column') - } - if (missing.length > 0) { - setShowValidation(true) - // Surface a short summary near the Save button too — the inline FieldError - // can be scrolled out of view when the panel content is tall. - setSaveError(`Add ${missing.join(' and ')} before saving.`) - return - } - - try { - if (isWorkflow) { - const orderedOutputs = buildOrderedPickedOutputs() - const dependencies: WorkflowGroupDependencies = { - columns: deps, - ...(groupDeps.length > 0 ? { workflowGroups: groupDeps } : {}), - } - - if (existingGroup) { - // Update path: diff outputs, derive new column names for added entries, - // call updateWorkflowGroup so service handles add/remove transactionally. - // If the sidebar was opened on a *specific* workflow-output column and - // the user renamed it, propagate that into the group's `outputs` ref - // (the column rename itself goes through `updateColumn` below, which - // server-side cascades into outputs/deps — but our outgoing payload - // also has to use the new name so the group update doesn't undo it). - const editedColumnName = configState.mode === 'edit' ? configState.columnName : null - const renamedColumn = - editedColumnName && trimmedName && trimmedName !== editedColumnName - ? { from: editedColumnName, to: trimmedName } - : null - const oldKeys = new Set(existingGroup.outputs.map((o) => `${o.blockId}::${o.path}`)) - const taken = new Set( - allColumns.map((c) => - renamedColumn && c.name === renamedColumn.from ? renamedColumn.to : c.name - ) - ) - const fullOutputs: WorkflowGroupOutput[] = [] - const newOutputColumns: NonNullable = [] - for (const o of orderedOutputs) { - const key = `${o.blockId}::${o.path}` - const existing = existingGroup.outputs.find( - (e) => e.blockId === o.blockId && e.path === o.path - ) - if (existing) { - fullOutputs.push( - renamedColumn && existing.columnName === renamedColumn.from - ? { ...existing, columnName: renamedColumn.to } - : existing - ) - } else { - const colName = deriveOutputColumnName(o.path, taken) - taken.add(colName) - fullOutputs.push({ blockId: o.blockId, path: o.path, columnName: colName }) - newOutputColumns.push({ - name: colName, - type: columnTypeForLeaf(o.leafType), - required: false, - unique: false, - workflowGroupId: existingGroup.id, - }) - } - oldKeys.delete(key) - } - if (renamedColumn) { - await updateColumn.mutateAsync({ - columnName: renamedColumn.from, - updates: { name: renamedColumn.to }, - }) - } - await updateWorkflowGroup.mutateAsync({ - groupId: existingGroup.id, - workflowId: selectedWorkflowId, - name: existingGroup.name, - dependencies, - outputs: fullOutputs, - ...(newOutputColumns.length > 0 ? { newOutputColumns } : {}), - }) - toast.success(`Saved "${existingGroup.name ?? 'Workflow'}"`) - } else { - // Create path: build a fresh group with auto-derived column names. - const groupId = generateId() - const taken = new Set(allColumns.map((c) => c.name)) - const newOutputColumns: AddWorkflowGroupBodyInput['outputColumns'] = [] - const groupOutputs: WorkflowGroupOutput[] = [] - for (const o of orderedOutputs) { - const colName = deriveOutputColumnName(o.path, taken) - taken.add(colName) - newOutputColumns.push({ - name: colName, - type: columnTypeForLeaf(o.leafType), - required: false, - unique: false, - workflowGroupId: groupId, - }) - groupOutputs.push({ blockId: o.blockId, path: o.path, columnName: colName }) - } - const workflowName = - workflows?.find((w) => w.id === selectedWorkflowId)?.name ?? 'Workflow' - const group: WorkflowGroup = { - id: groupId, - workflowId: selectedWorkflowId, - name: workflowName, - dependencies, - outputs: groupOutputs, - } - await addWorkflowGroup.mutateAsync({ group, outputColumns: newOutputColumns }) - toast.success(`Added "${workflowName}"`) - } - } else if (configState.mode === 'create') { - // `isWorkflow` is false here, so `typeInput` is a real ColumnDefinition type. - const scalarType = typeInput as ColumnDefinition['type'] - await addColumn.mutateAsync({ - name: trimmedName, - type: scalarType, - }) - toast.success(`Added "${trimmedName}"`) - } else { - const existing = existingColumnRef.current - const scalarType = typeInput as ColumnDefinition['type'] - const renamed = trimmedName !== configState.columnName - const typeChanged = !!existing && existing.type !== scalarType - const uniqueChanged = !!existing && !!existing.unique !== uniqueInput - - const updates: { - name?: string - type?: ColumnDefinition['type'] - unique?: boolean - } = { - ...(renamed ? { name: trimmedName } : {}), - ...(typeChanged ? { type: scalarType } : {}), - ...(uniqueChanged ? { unique: uniqueInput } : {}), - } - - if (Object.keys(updates).length === 0) { - onClose() - return - } - - await updateColumn.mutateAsync({ - columnName: configState.columnName, - updates, - }) - toast.success(`Saved "${trimmedName}"`) - } - - onClose() - } catch (err) { - setSaveError(toError(err).message) - } - } - - const saveDisabled = updateColumn.isPending || addColumn.isPending - - return ( - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index dfe0523ba8d..f7c6f4a27a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -5,7 +5,17 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/emcn' -import { ArrowDown, ArrowUp, Duplicate, Eye, Pencil, Trash } from '@/components/emcn/icons' +import { + ArrowDown, + ArrowUp, + Duplicate, + Eye, + Pencil, + PlayOutline, + RefreshCw, + Square, + Trash, +} from '@/components/emcn/icons' import type { ContextMenuState } from '../../types' interface ContextMenuProps { @@ -20,6 +30,18 @@ interface ContextMenuProps { canViewExecution?: boolean canEditCell?: boolean selectedRowCount?: number + /** Fires every workflow group on the row(s), skipping already-completed + * cells. Mirrors the action bar's Play. */ + onRunWorkflows?: () => void + /** Re-runs every workflow group on the row(s), including already-completed + * cells. Mirrors the action bar's Refresh. */ + onRefreshWorkflows?: () => void + /** Cancels every running/queued execution on the row(s) the context menu is acting on. */ + onStopWorkflows?: () => void + /** Total running/queued executions across the row(s) under the context menu. Drives the Stop label and visibility. */ + runningInSelectionCount?: number + /** Whether the table has any workflow columns; gates the run-workflows item. */ + hasWorkflowColumns?: boolean disableEdit?: boolean disableInsert?: boolean disableDelete?: boolean @@ -37,11 +59,26 @@ export function ContextMenu({ canViewExecution = false, canEditCell = true, selectedRowCount = 1, + onRunWorkflows, + onRefreshWorkflows, + onStopWorkflows, + runningInSelectionCount = 0, + hasWorkflowColumns = false, disableEdit = false, disableInsert = false, disableDelete = false, }: ContextMenuProps) { const deleteLabel = selectedRowCount > 1 ? `Delete ${selectedRowCount} rows` : 'Delete row' + const runLabel = + selectedRowCount > 1 + ? `Run empty or failed cells on ${selectedRowCount} rows` + : 'Run empty or failed cells' + const refreshLabel = + selectedRowCount > 1 ? `Re-run all cells on ${selectedRowCount} rows` : 'Re-run all cells' + const stopLabel = + runningInSelectionCount === 1 + ? 'Stop running workflow' + : `Stop ${runningInSelectionCount} running workflows` return ( )} + {hasWorkflowColumns && onRunWorkflows && ( + + + {runLabel} + + )} + {hasWorkflowColumns && onRefreshWorkflows && ( + + + {refreshLabel} + + )} + {hasWorkflowColumns && onStopWorkflows && runningInSelectionCount > 0 && ( + + + {stopLabel} + + )} Insert row above diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts index bc0da8a0717..0fca186c0c6 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts @@ -1,4 +1,9 @@ +export * from './column-config-sidebar' export * from './context-menu' +export * from './new-column-dropdown' export * from './row-modal' -export * from './table' +export * from './run-status-control' +export * from './table-action-bar' export * from './table-filter' +export * from './table-grid' +export * from './workflow-sidebar' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/index.ts new file mode 100644 index 00000000000..026d9ff58f1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/index.ts @@ -0,0 +1 @@ +export { NewColumnDropdown } from './new-column-dropdown' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx new file mode 100644 index 00000000000..8330e581cf5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx @@ -0,0 +1,79 @@ +'use client' + +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/emcn' +import { Plus } from '@/components/emcn/icons' +import type { ColumnDefinition } from '@/lib/table' +import { COLUMN_TYPE_OPTIONS } from '../column-config-sidebar' + +const CELL_HEADER = + 'border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[7px] text-left align-middle' + +const HEADER_ADD_COLUMN_ICON = + +interface NewColumnDropdownProps { + /** `'header'` renders the page-header trigger (subtle Button); `'inline-header'` renders + * the in-table column-header `` trigger. Same dropdown content either way. */ + trigger: 'header' | 'inline-header' + disabled: boolean + onPickType: (type: ColumnDefinition['type']) => void + onPickWorkflow: () => void +} + +/** + * "+ New column" dropdown — the single entry point for creating a column. + * Lists every column type plus "Workflow"; picking a type opens the right + * sidebar pre-seeded. + */ +export function NewColumnDropdown({ + trigger, + disabled, + onPickType, + onPickWorkflow, +}: NewColumnDropdownProps) { + const menu = ( + + + {trigger === 'header' ? ( + + ) : ( + + )} + + + {COLUMN_TYPE_OPTIONS.map((option) => { + const Icon = option.icon + const onSelect = + option.type === 'workflow' + ? onPickWorkflow + : () => onPickType(option.type as ColumnDefinition['type']) + return ( + + + {option.label} + + ) + })} + + + ) + + // The in-table trigger lives inside a `` so it must be a ``. The + // header trigger lives in the page header so it sits inline. + return trigger === 'inline-header' ? {menu} : menu +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control.tsx new file mode 100644 index 00000000000..43640d2d8ae --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control.tsx @@ -0,0 +1,41 @@ +'use client' + +import { memo } from 'react' +import { Button } from '@/components/emcn' +import { Loader, Square } from '@/components/emcn/icons' + +interface RunStatusControlProps { + running: number + onStopAll: () => void + isStopping: boolean +} + +/** + * Run-status + Stop-all control rendered in the page header's leading actions + * row when any workflow runs are active. Matches the in-cell running indicator + * (Loader + tertiary text) for consistency. + */ +export const RunStatusControl = memo(function RunStatusControl({ + running, + onStopAll, + isStopping, +}: RunStatusControlProps) { + return ( +
+
+ + {running} + running +
+ +
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/index.ts new file mode 100644 index 00000000000..1e8041624b8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/index.ts @@ -0,0 +1 @@ +export { TableActionBar } from './table-action-bar' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx new file mode 100644 index 00000000000..d807b32a022 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx @@ -0,0 +1,172 @@ +'use client' + +import { AnimatePresence, motion } from 'framer-motion' +import { Button, Tooltip } from '@/components/emcn' +import { Eye, PlayOutline, RefreshCw, Square } from '@/components/emcn/icons' +import { cn } from '@/lib/core/utils/cn' + +interface TableActionBarProps { + /** Number of (row × group) cells the run/stop buttons would target. Drives + * the bar's leading label ("N cells"). */ + selectedCellCount: number + /** Total running/queued workflow cells in the selection. Drives Stop. */ + runningCount: number + /** Whether the table has any workflow columns. The bar hides entirely when + * there are none — Run/Stop have nothing to act on. */ + hasWorkflowColumns: boolean + /** Show the Play (incomplete-mode) button — true when any selected cell is + * empty / errored / cancelled. */ + showPlay: boolean + /** Show the Refresh (all-mode) button — true when any selected cell is + * already completed. */ + showRefresh: boolean + /** Smart run: fire workflows only on cells that are empty / errored / + * cancelled. Maps to server `runMode: 'incomplete'`. */ + onPlay: () => void + /** Forceful re-run: fire workflows on every selected cell, including + * completed ones. Maps to server `runMode: 'all'`. */ + onRefresh: () => void + /** Cancel running/queued cells in the selection. */ + onStopWorkflows: () => void + /** When the user has highlighted exactly one workflow cell (or N adjacent + * cells in the same row + group), surface a "View execution" affordance + * alongside the run buttons. Omit when no single-execution view applies. */ + onViewExecution?: () => void + /** Disables actions while a bulk mutation is in flight. */ + isLoading?: boolean + /** Additional className for the floating wrapper — used to lift the bar + * above bottom-anchored UI like a pagination row. */ + className?: string +} + +/** + * Floating action bar shown at the bottom of the table when one or more + * workflow cells are highlighted. Play / Refresh visibility is data-driven: + * Play appears when there's anything empty/failed in the selection; Refresh + * appears when there's anything already completed; both when the selection is + * mixed. + * + * Rendered with `position: absolute` inside the table's container (not + * `fixed`) so it scopes to the table's bounds — important for embedded mode, + * where the table sits inside a panel and a fixed-positioned bar would land + * centered on the whole viewport instead of the panel. + */ +export function TableActionBar({ + selectedCellCount, + runningCount, + hasWorkflowColumns, + showPlay, + showRefresh, + onPlay, + onRefresh, + onStopWorkflows, + onViewExecution, + isLoading = false, + className, +}: TableActionBarProps) { + const visible = + hasWorkflowColumns && + selectedCellCount > 0 && + (showPlay || showRefresh || runningCount > 0 || Boolean(onViewExecution)) + const stopLabel = + runningCount === 1 ? 'Stop running workflow' : `Stop ${runningCount} running workflows` + const playLabel = + selectedCellCount === 1 ? 'Run cell' : `Run ${selectedCellCount} empty or failed cells` + const refreshLabel = selectedCellCount === 1 ? 'Re-run cell' : `Re-run ${selectedCellCount} cells` + + return ( + + {visible && ( + +
+ + {selectedCellCount === 1 + ? 'Selected 1 workflow cell' + : `Selected ${selectedCellCount} workflow cells`} + + +
+ {showPlay && ( + + + + + {playLabel} + + )} + + {showRefresh && ( + + + + + {refreshLabel} + + )} + + {runningCount > 0 && ( + + + + + {stopLabel} + + )} + + {onViewExecution && ( + + + + + View execution + + )} +
+
+
+ )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.tsx rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.ts diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx new file mode 100644 index 00000000000..2fbbe78f194 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx @@ -0,0 +1,59 @@ +'use client' + +import type { RowExecutionMetadata } from '@/lib/table' +import type { SaveReason } from '../../../types' +import type { DisplayColumn } from '../types' +import { CellRender, resolveCellRender } from './cell-render' +import { InlineEditor } from './inline-editors' + +interface CellContentProps { + value: unknown + exec?: RowExecutionMetadata + column: DisplayColumn + isEditing: boolean + initialCharacter?: string | null + onSave: (value: unknown, reason: SaveReason) => void + onCancel: () => void + /** + * Human-readable labels for unmet deps on this row+group, used to render a + * "Waiting" pill when the cell hasn't run because something it depends on + * is empty. `undefined` (or empty) means no waiting state. + */ + waitingOnLabels?: string[] +} + +/** + * Glue layer: maps cell inputs to a typed `CellRenderKind` (via the pure + * resolver) and renders the corresponding JSX (via the dumb renderer). The + * inline editor sits on top when `isEditing` is true. Adding a new cell + * appearance is a three-step mechanical change in the colocated files. + */ +export function CellContent({ + value, + exec, + column, + isEditing, + initialCharacter, + onSave, + onCancel, + waitingOnLabels, +}: CellContentProps) { + const kind = resolveCellRender({ value, exec, column, waitingOnLabels }) + + return ( + <> + {isEditing && ( +
+ +
+ )} + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx new file mode 100644 index 00000000000..35eb3d2e8f9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -0,0 +1,265 @@ +'use client' + +import type React from 'react' +import { Badge, Checkbox, Tooltip } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import type { RowExecutionMetadata } from '@/lib/table' +import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils' +import { storageToDisplay } from '../../../utils' +import type { DisplayColumn } from '../types' + +/** + * Discriminated union describing every shape a table cell can take. + * + * Workflow-output cells follow a status state machine: they always render + * *something* (a value, a status pill, or a dash), driven by the combination + * of `executions[groupId]` state and dep satisfaction. Plain (non-workflow) + * cells just render the typed value or empty. + * + * `'empty'` is the universal fallback used by both workflow cells (no exec, + * no value, no waiting) and plain cells (null/undefined value). + * + * Adding a new cell appearance is a three-step mechanical change: add a + * variant here, pick it in `resolveCellRender`, render it in `CellRender`. + * TypeScript's exhaustiveness check on the renderer's `switch` (the + * unreachable default) flags any branch you forgot. + */ +export type CellRenderKind = + // Workflow-output cells + | { kind: 'value'; text: string } + | { kind: 'block-error' } + | { kind: 'running' } + | { kind: 'pending-upstream' } + | { kind: 'queued' } + | { kind: 'cancelled' } + | { kind: 'error' } + | { kind: 'waiting'; labels: string[] } + // Plain typed cells + | { kind: 'boolean'; checked: boolean } + | { kind: 'json'; text: string } + | { kind: 'date'; text: string } + | { kind: 'text'; text: string } + // Universal fallback + | { kind: 'empty' } + +interface ResolveCellRenderInput { + value: unknown + exec: RowExecutionMetadata | undefined + column: DisplayColumn + /** Empty / undefined → not waiting; non-empty → render the Waiting pill. */ + waitingOnLabels: string[] | undefined +} + +/** + * Decide which `CellRenderKind` to render for a cell. Pure — easily + * unit-testable in isolation, no JSX involved. + * + * Order matters for workflow cells: block-error wins over a value (the user + * cares about the failure), value wins over running/queued (we have data + * already), and the running/queued branch deliberately collapses pre-enqueue + * `pending` and post-enqueue `queued` into one `Queued` pill so the cell + * doesn't flicker as the row transitions from one to the other. + */ +export function resolveCellRender({ + value, + exec, + column, + waitingOnLabels, +}: ResolveCellRenderInput): CellRenderKind { + const isNull = value === null || value === undefined + + if (column.workflowGroupId) { + const blockId = column.outputBlockId + const blockError = blockId ? exec?.blockErrors?.[blockId] : undefined + const blockRunning = blockId ? (exec?.runningBlockIds?.includes(blockId) ?? false) : false + const groupHasBlockErrors = !!(exec?.blockErrors && Object.keys(exec.blockErrors).length > 0) + + if (blockError) return { kind: 'block-error' } + + // In-flight wins over the existing value: when the group is being re-run, + // the current value is about to be overwritten — surface the run state so + // the user sees the cell is changing. Without this, a queued / running + // re-run on a previously-completed cell looks like nothing happened until + // the new value lands. + const inFlight = + exec?.status === 'running' || exec?.status === 'queued' || exec?.status === 'pending' + if (inFlight && !(groupHasBlockErrors && !blockRunning)) { + if (blockRunning) return { kind: 'running' } + if (exec?.status === 'queued' || exec?.status === 'pending') return { kind: 'queued' } + // `running` with this block not in `runningBlockIds` = upstream block + // still going; surface as the amber Pending pill per logs convention. + return { kind: 'pending-upstream' } + } + + if (!isNull) return { kind: 'value', text: stringifyValue(value) } + + // Waiting wins over a stale terminal state: if deps are unmet right now, + // the prior `cancelled` / `error` is informational at best — the cell + // can't actually run until the user fills the missing input. Surface the + // actionable state instead of the stale one. + if (waitingOnLabels && waitingOnLabels.length > 0) { + return { kind: 'waiting', labels: waitingOnLabels } + } + if (exec?.status === 'cancelled') return { kind: 'cancelled' } + if (exec?.status === 'error') return { kind: 'error' } + return { kind: 'empty' } + } + + if (column.type === 'boolean') return { kind: 'boolean', checked: Boolean(value) } + if (isNull) return { kind: 'empty' } + if (column.type === 'json') return { kind: 'json', text: JSON.stringify(value) } + if (column.type === 'date') return { kind: 'date', text: String(value) } + return { kind: 'text', text: stringifyValue(value) } +} + +function stringifyValue(value: unknown): string { + if (typeof value === 'string') return value + if (value === null || value === undefined) return '' + return JSON.stringify(value) +} + +interface CellRenderProps { + kind: CellRenderKind + /** When true the static content sits underneath the InlineEditor overlay + * and should be visually hidden (but kept in flow to preserve cell size). */ + isEditing: boolean +} + +/** + * Pure renderer: takes a `CellRenderKind` and returns the JSX. No business + * logic — adding a new cell appearance means adding a new `case` here. The + * exhaustiveness check on the `switch` (the unreachable default) flags any + * variant you forgot to handle. + */ +export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactElement | null { + switch (kind.kind) { + case 'value': + return ( + + {kind.text} + + ) + + case 'block-error': + case 'error': + return ( + + + + ) + + case 'running': + return ( + + + + ) + + case 'pending-upstream': + return ( + + + + ) + + case 'cancelled': + return ( + + + + ) + + case 'queued': + return ( + + + Queued + + + ) + + case 'waiting': + return ( + + + + + + Waiting + + + + + Waiting on {kind.labels.map((l) => `"${l}"`).join(', ')} + + + + ) + + case 'boolean': + return ( +
+ +
+ ) + + case 'json': + return ( + + {kind.text} + + ) + + case 'date': + return ( + + {storageToDisplay(kind.text)} + + ) + + case 'text': + return ( + + {kind.text} + + ) + + case 'empty': + return null + + default: { + // Exhaustiveness guard: TypeScript flags this branch if a new + // `CellRenderKind` variant is added without a matching `case` above. + const _exhaustive: never = kind + return _exhaustive + } + } +} + +/** + * Workflow-output cells are hand-editable; while editing, the static content + * must stay in flow (so the cell doesn't collapse) but be visually hidden so + * the InlineEditor overlay shows through. Plain wrapper around any non-text + * variant. + */ +function Wrap({ isEditing, children }: { isEditing: boolean; children: React.ReactNode }) { + if (!isEditing) return <>{children} + return
{children}
+} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/expanded-cell-popover.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/expanded-cell-popover.tsx rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/index.ts new file mode 100644 index 00000000000..c54286afa5f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/index.ts @@ -0,0 +1,4 @@ +export { CellContent } from './cell-content' +export { CellRender, type CellRenderKind, resolveCellRender } from './cell-render' +export { ExpandedCellPopover } from './expanded-cell-popover' +export { InlineEditor } from './inline-editors' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/inline-editors.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/inline-editors.tsx rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/constants.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts similarity index 61% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/constants.ts rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts index 28aead32657..69db8b7b4f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts @@ -5,5 +5,6 @@ export const SELECTION_TINT_BG = 'bg-[rgba(37,99,235,0.06)]' * been measured yet and as the initial width for newly-added columns. */ export const COL_WIDTH = 160 -/** Column config sidebar width: roomy by default, bounded on narrow screens. */ -export const COLUMN_SIDEBAR_WIDTH_CSS = 'min(480px, calc(100vw - 48px))' +/** Column config sidebar width in pixels — drives both the sidebar's own width + * and the table's reserved padding-right while a sidebar is open. */ +export const COLUMN_SIDEBAR_WIDTH = 400 diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx similarity index 83% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx index da955ee1322..d7a80f4a507 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx @@ -35,8 +35,13 @@ interface ColumnHeaderMenuProps { onDragLeave?: () => void workflows?: WorkflowMetadata[] workflowGroups?: WorkflowGroup[] + /** Source-info entry for workflow-output columns; supplies the producing + * block's icon component. The block's color is intentionally not used. */ sourceInfo?: ColumnSourceInfo onOpenConfig: (columnName: string) => void + /** Opens a popup preview of the column's underlying workflow. Surfaced in + * the chevron menu for workflow-output columns. */ + onViewWorkflow?: (workflowId: string) => void } /** @@ -70,6 +75,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ workflowGroups, sourceInfo, onOpenConfig, + onViewWorkflow, }: ColumnHeaderMenuProps) { const renameInputRef = useRef(null) const didDragRef = useRef(false) @@ -90,10 +96,6 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ ? 'Hide column' : 'Delete workflow' : undefined - const workflowColor = configuredWorkflow?.color - const blockIconInfo = sourceInfo?.blockIconInfo - const blockName = sourceInfo?.blockName - useEffect(() => { if (isRenaming && renameInputRef.current) { renameInputRef.current.focus() @@ -142,8 +144,13 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', column.name) + // Workflow-output columns drag as a whole group, so the ghost shows + // the group's name (falling back to the workflow's name, then the + // column slug) rather than the individual column slug. + const ghostLabel = ownGroup?.name ?? configuredWorkflow?.name ?? column.name + const ghost = document.createElement('div') - ghost.textContent = column.name + ghost.textContent = ghostLabel ghost.style.cssText = 'position:absolute;top:-9999px;padding:4px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:13px;font-weight:500;white-space:nowrap;color:var(--text-primary)' document.body.appendChild(ghost) @@ -152,7 +159,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onDragStart?.(column.name) }, - [column.name, readOnly, isRenaming, onDragStart] + [column.name, ownGroup, configuredWorkflow, readOnly, isRenaming, onDragStart] ) const handleDragOver = useCallback( @@ -181,6 +188,11 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ const th = e.currentTarget as HTMLElement const related = e.relatedTarget as Node | null if (related && th.contains(related)) return + // Don't clear when the cursor is moving to another column header — the + // next dragover will set the right target. Clearing here causes the + // drop indicator to flicker between sibling columns of a workflow + // group (and any adjacent column hop in general). + if (related && related instanceof Element && related.closest('th')) return onDragLeave?.() }, [onDragLeave] @@ -238,8 +250,8 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
- {column.workflowGroupId ? ( -
- {blockName && ( - - {blockName} - - )} - - {column.headerLabel} - -
- ) : ( - - {column.name} - - )} + + {column.workflowGroupId ? column.headerLabel : column.name} +
) : (
@@ -288,25 +287,12 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ > - {column.workflowGroupId ? ( -
- {blockName && ( - - {blockName} - - )} - - {column.headerLabel} - -
- ) : ( - - {column.name} - - )} + + {column.workflowGroupId ? column.headerLabel : column.name} + onViewWorkflow(ownGroup.workflowId) : undefined + } />
)} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx new file mode 100644 index 00000000000..e4e4fc51b24 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx @@ -0,0 +1,50 @@ +'use client' + +import type React from 'react' +import { + Calendar as CalendarIcon, + PlayOutline, + TypeBoolean, + TypeJson, + TypeNumber, + TypeText, +} from '@/components/emcn/icons' +import type { BlockIconInfo } from '../types' + +export const COLUMN_TYPE_ICONS: Record = { + string: TypeText, + number: TypeNumber, + boolean: TypeBoolean, + date: CalendarIcon, + json: TypeJson, +} + +interface ColumnTypeIconProps { + type: string + /** True for workflow-output columns; renders the producing block's icon + * (or a workflow fallback) instead of the scalar type icon. Workflow + * columns ARE stored as scalar types, so without this `type` would + * otherwise resolve to e.g. `string` and read identically to a plain + * text column. */ + isWorkflowColumn?: boolean + /** Block-icon info from the source-info builder, used for workflow columns + * to surface the producing block's icon. The block's color is intentionally + * ignored — icons render in the plain `text-[var(--text-icon)]` tone like + * every other column-type icon, no per-block tint. */ + blockIconInfo?: BlockIconInfo +} + +/** + * Tiny icon shown next to a column header. Workflow-output columns get the + * producing block's icon (falling back to `PlayOutline`); plain columns get + * their scalar type icon. Both render in the same `text-[var(--text-icon)]` + * tone — no per-workflow color, no colored swatch. + */ +export function ColumnTypeIcon({ type, isWorkflowColumn, blockIconInfo }: ColumnTypeIconProps) { + if (isWorkflowColumn) { + const Icon = blockIconInfo?.icon ?? PlayOutline + return + } + const Icon = COLUMN_TYPE_ICONS[type] ?? TypeText + return +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/index.ts new file mode 100644 index 00000000000..8c8ef9f9dc2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/index.ts @@ -0,0 +1,3 @@ +export { ColumnHeaderMenu } from './column-header-menu' +export { COLUMN_TYPE_ICONS, ColumnTypeIcon } from './column-type-icon' +export { ColumnOptionsMenu, WorkflowGroupMetaCell } from './workflow-group-meta-cell' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/workflow-group-meta-cell.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx similarity index 59% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/workflow-group-meta-cell.tsx rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx index 8b3403053f6..51788975ec9 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/workflow-group-meta-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx @@ -1,7 +1,7 @@ 'use client' import type React from 'react' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { DropdownMenu, DropdownMenuContent, @@ -12,7 +12,16 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/emcn' -import { ArrowLeft, ArrowRight, EyeOff, Pencil, PlayOutline, Trash } from '@/components/emcn/icons' +import { + ArrowLeft, + ArrowRight, + Eye, + EyeOff, + Pencil, + PlayOutline, + Trash, +} from '@/components/emcn/icons' +import type { RunMode } from '@/lib/api/contracts/tables' import { cn } from '@/lib/core/utils/cn' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' import { SELECTION_TINT_BG } from '../constants' @@ -40,8 +49,14 @@ interface ColumnOptionsMenuProps { onDeleteGroup?: () => void /** When provided, the menu is being opened from a workflow-group header and * exposes group-level run actions above the column actions. */ - onRunGroupAll?: () => void - onRunGroupIncomplete?: () => void + onRunColumnAll?: () => void + onRunColumnIncomplete?: () => void + /** When set, surfaces a "Run N selected rows" item above Run all. */ + onRunColumnSelected?: () => void + selectedRowCount?: number + /** When set, the menu surfaces a "View workflow" item that opens a popup + * preview of the configured workflow. */ + onViewWorkflow?: () => void } /** @@ -62,10 +77,14 @@ export function ColumnOptionsMenu({ onInsertRight, onDeleteColumn, onDeleteGroup, - onRunGroupAll, - onRunGroupIncomplete, + onRunColumnAll, + onRunColumnIncomplete, + onRunColumnSelected, + selectedRowCount = 0, + onViewWorkflow, }: ColumnOptionsMenuProps) { - const showRunActions = Boolean(onRunGroupAll && onRunGroupIncomplete) + const showRunActions = Boolean(onRunColumnAll && onRunColumnIncomplete) + const showRunSelected = Boolean(onRunColumnSelected) && selectedRowCount > 0 return ( @@ -97,8 +116,15 @@ export function ColumnOptionsMenu({ Run - onRunGroupAll?.()}>Run all rows - onRunGroupIncomplete?.()}> + {showRunSelected && ( + onRunColumnSelected?.()}> + {`Run ${selectedRowCount} selected ${selectedRowCount === 1 ? 'row' : 'rows'}`} + + )} + onRunColumnAll?.()}> + Run all rows + + onRunColumnIncomplete?.()}> Run empty rows @@ -106,6 +132,12 @@ export function ColumnOptionsMenu({ )} + {onViewWorkflow && ( + onViewWorkflow()}> + + View workflow + + )} onOpenConfig(column.name)}> Edit column @@ -143,12 +175,26 @@ interface WorkflowGroupMetaCellProps { isGroupSelected: boolean onSelectGroup: (startColIndex: number, size: number) => void onOpenConfig: (columnName: string) => void - onRunGroup?: (groupId: string, workflowId: string, mode?: 'all' | 'incomplete') => void + onRunColumn?: (groupId: string, mode?: RunMode, rowIds?: string[]) => void onInsertLeft?: (columnName: string) => void onInsertRight?: (columnName: string) => void onDeleteColumn?: (columnName: string) => void /** Right-click delete on the group header drops the entire workflow group. */ onDeleteGroup?: (groupId: string) => void + /** Row ids in the user's current multi-row selection; when non-empty the + * run menu adds a "Run N selected rows" option. */ + selectedRowIds?: string[] | null + /** Opens a popup preview of the underlying workflow. */ + onViewWorkflow?: (workflowId: string) => void + /** When set, the meta cell becomes draggable and forwards events through + * the same column-reorder pipeline used by individual workflow column + * headers. The whole group moves together because downstream code groups + * fan-out siblings by `workflowGroupId`. */ + onDragStart?: (columnName: string) => void + onDragOver?: (columnName: string, side: 'left' | 'right') => void + onDragEnd?: () => void + onDragLeave?: () => void + readOnly?: boolean } /** @@ -167,11 +213,18 @@ export function WorkflowGroupMetaCell({ isGroupSelected, onSelectGroup, onOpenConfig, - onRunGroup, + onRunColumn, onInsertLeft, onInsertRight, onDeleteColumn, onDeleteGroup, + selectedRowIds, + onViewWorkflow, + onDragStart, + onDragOver, + onDragEnd, + onDragLeave, + readOnly, }: WorkflowGroupMetaCellProps) { const wf = workflows?.find((w) => w.id === workflowId) const color = wf?.color ?? 'var(--text-muted)' @@ -180,14 +233,23 @@ export function WorkflowGroupMetaCell({ const [optionsMenuOpen, setOptionsMenuOpen] = useState(false) const [optionsMenuPosition, setOptionsMenuPosition] = useState({ x: 0, y: 0 }) const [runMenuOpen, setRunMenuOpen] = useState(false) + const didDragRef = useRef(false) + + const selectedCount = selectedRowIds?.length ?? 0 const handleRunAll = useCallback(() => { - if (groupId && workflowId) onRunGroup?.(groupId, workflowId, 'all') - }, [groupId, workflowId, onRunGroup]) + if (groupId) onRunColumn?.(groupId, 'all') + }, [groupId, onRunColumn]) const handleRunIncomplete = useCallback(() => { - if (groupId && workflowId) onRunGroup?.(groupId, workflowId, 'incomplete') - }, [groupId, workflowId, onRunGroup]) + if (groupId) onRunColumn?.(groupId, 'incomplete') + }, [groupId, onRunColumn]) + + const handleRunSelected = useCallback(() => { + if (groupId && selectedRowIds && selectedRowIds.length > 0) { + onRunColumn?.(groupId, 'all', selectedRowIds) + } + }, [groupId, onRunColumn, selectedRowIds]) const handleContextMenu = useCallback( (e: React.MouseEvent) => { @@ -207,18 +269,88 @@ export function WorkflowGroupMetaCell({ // should select the group + open the config sidebar. const target = e.target as HTMLElement if (target.closest('button, [role="menuitem"], [role="menu"]')) return + // Drag-vs-click guard: when a drag just ended on this cell, swallow the + // synthetic click so we don't accidentally pop open the sidebar. + if (didDragRef.current) { + didDragRef.current = false + return + } onSelectGroup(startColIndex, size) if (columnName) onOpenConfig(columnName) }, [columnName, onOpenConfig, onSelectGroup, size, startColIndex] ) + const handleDragStart = useCallback( + (e: React.DragEvent) => { + if (readOnly || !onDragStart || !columnName) { + e.preventDefault() + return + } + didDragRef.current = true + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', columnName) + + const ghost = document.createElement('div') + ghost.textContent = name + ghost.style.cssText = + 'position:absolute;top:-9999px;padding:4px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:13px;font-weight:500;white-space:nowrap;color:var(--text-primary)' + document.body.appendChild(ghost) + e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) + requestAnimationFrame(() => ghost.parentNode?.removeChild(ghost)) + + onDragStart(columnName) + }, + [columnName, name, onDragStart, readOnly] + ) + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + if (!onDragOver || !columnName) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() + const midX = rect.left + rect.width / 2 + const side = e.clientX < midX ? 'left' : 'right' + onDragOver(columnName, side) + }, + [columnName, onDragOver] + ) + + const handleDragEnd = useCallback(() => { + didDragRef.current = false + onDragEnd?.() + }, [onDragEnd]) + + const handleDragLeave = useCallback( + (e: React.DragEvent) => { + const th = e.currentTarget as HTMLElement + const related = e.relatedTarget as Node | null + if (related && th.contains(related)) return + if (related && related instanceof Element && related.closest('th')) return + onDragLeave?.() + }, + [onDragLeave] + ) + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + }, []) + + const isDraggable = !readOnly && Boolean(onDragStart) + return (
{name} - {onRunGroup && ( + {onRunColumn && (