diff --git a/apps/docs/content/docs/integrations/elasticsearch.mdx b/apps/docs/content/docs/integrations/elasticsearch.mdx index a0fb33108fb..fc520cd52a9 100644 --- a/apps/docs/content/docs/integrations/elasticsearch.mdx +++ b/apps/docs/content/docs/integrations/elasticsearch.mdx @@ -306,7 +306,7 @@ Retrieve index information including settings, mappings, and aliases. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `index` | json | Index information including aliases, mappings, and settings | +| `indices` | json | Matched indices keyed by index name, each with its aliases, mappings, and settings | ### Elasticsearch Cluster Health @@ -324,7 +324,7 @@ Get the health status of the Elasticsearch cluster. | `username` | string | No | Username for basic auth | | `password` | string | No | Password for basic auth | | `waitForStatus` | string | No | Wait until cluster reaches this status: green, yellow, or red | -| `timeout` | string | No | Timeout for the wait operation \(e.g., 30s, 1m\) | +| `clusterTimeout` | string | No | How long Elasticsearch waits for the cluster to reach the requested status, as an Elasticsearch time value \(e.g., 30s, 1m\). Not named "timeout": that name is reserved by the tool transport as a client-side abort deadline in milliseconds. | #### Output @@ -377,12 +377,13 @@ List all indices in the Elasticsearch cluster with their health, status, and sta | `apiKey` | string | No | Elasticsearch API key | | `username` | string | No | Username for basic auth | | `password` | string | No | Password for basic auth | +| `includeSystemIndices` | boolean | No | Include Elasticsearch system indices \(names starting with "."\). Omitted by default. | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `message` | string | Summary message about the indices | -| `indices` | json | Array of index information objects | +| `indices` | json | Array of index information objects \(index, health, status, docsCount, storeSize, primaryShards, replicaShards\). System indices are omitted unless includeSystemIndices is set. | diff --git a/apps/docs/content/docs/integrations/file.mdx b/apps/docs/content/docs/integrations/file.mdx index 4222a602a7d..4bf764fcb8a 100644 --- a/apps/docs/content/docs/integrations/file.mdx +++ b/apps/docs/content/docs/integrations/file.mdx @@ -87,15 +87,16 @@ Fetch and parse a file from a URL with optional custom headers. ### File Write -Create a new workspace file. If a file with the same name already exists, a numeric suffix is added (e.g., "data (1).csv"). +Create a new workspace file, either from text content or from an existing file. If a file with the same name already exists, a numeric suffix is added (e.g., "data (1).csv"). #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `fileName` | string | Yes | File name \(e.g., "data.csv"\). If a file with this name exists, a numeric suffix is added automatically. | -| `content` | string | Yes | The text content to write to the file. | -| `contentType` | string | No | MIME type for new files \(e.g., "text/plain"\). Auto-detected from file extension if omitted. | +| `fileName` | string | No | File name \(e.g., "data.csv"\). Required when writing text; optional when storing a file, which keeps its own name unless this overrides it. If the name already exists, a numeric suffix is added automatically. | +| `content` | string | No | The text content to write to the file. Provide exactly one of content or fileInput. | +| `fileInput` | file | No | An existing file to store in the workspace, such as one produced by an earlier tool. Use this for anything that is not text — PDFs, images, audio, archives. Provide exactly one of content or fileInput. | +| `contentType` | string | No | MIME type for new files \(e.g., "text/plain"\). Auto-detected from the file extension, or taken from the stored file, if omitted. | #### Output diff --git a/apps/docs/content/docs/tables/index.mdx b/apps/docs/content/docs/tables/index.mdx index e44ca483464..41b821d995e 100644 --- a/apps/docs/content/docs/tables/index.mdx +++ b/apps/docs/content/docs/tables/index.mdx @@ -24,6 +24,7 @@ Every column has a type, which decides how its values are stored and validated. | **Currency** | An amount in a currency you pick per column | `$1,234.56` | | **Boolean** | `true` or `false` | `true` | | **Date** | A date | `2026-03-16` | +| **Expiration** | An absolute row expiration time, stored as Unix epoch seconds (seconds since January 1, 1970 UTC) | `1773671400` | | **JSON** | An object or array | `{ "tier": "pro" }` | | **Select** | One of a fixed set of options, or several | `Pro` | @@ -31,6 +32,8 @@ Types are enforced as you enter values, so a Number column only takes numbers. A Currency column stores a plain number and renders it in the currency you choose for that column, so filters, sorts, and exports all see the amount itself. Changing a column's currency relabels it — it does not convert the amounts. +A table can have one Expiration column. Adding it enables row expiration; rows with a non-empty expiration value become eligible for deletion after that time passes. Cleanup runs periodically, so actual row removal may happen after the expiration timestamp rather than exactly at it. Deleting the Expiration column disables expiration for the table. Expiration cells use the date editor, while APIs and workflows read and write integer Unix epoch seconds. + ## Editing a table Open the **Tables** section in the sidebar and click **New table** to create one. Add columns from the column header, type into a cell to edit it, and paste rows from a spreadsheet to bulk-load. Filter and sort from the toolbar without changing the underlying data. The editor has full keyboard support; see [keyboard shortcuts](/keyboard-shortcuts). diff --git a/apps/docs/content/docs/workflows/blocks/function.mdx b/apps/docs/content/docs/workflows/blocks/function.mdx index 2b130f2ad7b..b79425b698e 100644 --- a/apps/docs/content/docs/workflows/blocks/function.mdx +++ b/apps/docs/content/docs/workflows/blocks/function.mdx @@ -102,6 +102,49 @@ Sim supplies the rendered heredoc privately while preserving the quoted delimite | --- | --- | | `` | The value your code returns (object, array, string, number, …) | | `` | Anything printed with `console.log()` or `print()` | +| `` | Files your code wrote to `/tmp/sim/outputs`, ready to attach or upload | + +## Files + +**Reading.** Reference a file's `path` and it is mounted for you: + +```python +import pandas as pd + +frame = pd.read_csv() +frame.describe().to_csv('/tmp/sim/outputs/summary.csv') +``` + +`.path` resolves to the file's location on the sandbox filesystem, so any language +can open it — pandas, ffmpeg, a CLI. It is the counterpart to `.base64`, which +inlines the contents instead and works only in JavaScript. Both appear in the +reference dropdown next to `.name` and `.size`. + +**Writing.** Anything your code writes to `/tmp/sim/outputs` comes back as +``, a list of file objects any file-accepting block takes directly — +attach them to an email, upload them to storage, or save them to the workspace with +the File block. There is nothing to turn on. + +The one exception is a call that names an explicit `outputSandboxPath`. That asks +for particular paths to be exported and answers with that export's own result, so +the output directory is not harvested alongside it — choose one or the other rather +than expecting both in the same run. + + +Referencing `.path` runs the block in the remote sandbox, since the local +JavaScript VM has no filesystem — expect the slower start of a remote run even for +plain JavaScript. Referencing the file itself (``, `.name`, +`.url`) does not, and stays local. Up to 20 files come back per run, 50MB total, +nested no more than 11 directories deep; a run that exceeds any of these fails +rather than returning part of what your code wrote. + + + +Returned files live with the execution rather than in your workspace, and a text +file containing a resolved secret value is refused rather than returned — there is +nowhere on an execution file to record that it carries one. Write such a file to a +workspace path instead, or keep the secret out of the output. + ## Language @@ -401,8 +444,8 @@ The lazy `sim.files` and `sim.values` helpers are available only in JavaScript f { question: "What languages does the Function block support?", answer: "JavaScript, Python, and Shell. JavaScript is the default. Python remains a stable saved language choice; Shell and custom Sandbox controls appear when a remote sandbox provider is enabled. Python and Shell execution require that provider." }, { question: "When does code run locally vs. in a sandbox?", answer: "JavaScript without external imports runs in a local isolated sandbox for speed. JavaScript that uses import or require, Python, and Shell run in the configured remote sandbox." }, { question: "Does JavaScript still work without E2B or Daytona?", answer: "Yes. JavaScript without import or require runs in Sim's local isolated VM and does not require a remote provider. JavaScript with external imports, Python, Shell, and custom Sandboxes require E2B or Daytona and fail explicitly when it is unavailable." }, - { question: "How do I reference outputs from other blocks inside my code?", answer: "Use angle-bracket syntax directly, like or , with no quotes around the tag — Sim replaces it with the real value before execution. For environment variables, use double curly braces: {{API_KEY}}." }, - { question: "What does the Function block return?", answer: "Two outputs: result and stdout. Use return in JavaScript, assign __sim_result__ in Python, or print an __SIM_RESULT__= marker in Shell to set result. Ordinary console, print, and command output goes to stdout." }, + { question: "How do I reference outputs from other blocks inside my code?", answer: "Use angle-bracket syntax directly, like or , with no quotes around the tag — Sim replaces it with the real value before execution. For environment variables, use double curly braces: {{API_KEY}}. To read a file, reference its path — mounts it and resolves to a location any language can open." }, + { question: "What does the Function block return?", answer: "Three outputs: result, stdout, and files. Use return in JavaScript, assign __sim_result__ in Python, or print an __SIM_RESULT__= marker in Shell to set result. Ordinary console, print, and command output goes to stdout. Anything your code writes to /tmp/sim/outputs comes back in files as a file object later blocks can accept directly." }, { question: "Can I make HTTP requests from a Function block?", answer: "Yes. fetch() is available in JavaScript with async/await. In Python, use requests or httpx. In Shell, use curl or a CLI available on the selected sandbox." }, { question: "Is there a timeout for Function block execution?", answer: "Yes, a configurable execution timeout. If your code exceeds it, the run is terminated and the block reports an error. Keep this in mind for external calls or heavy processing." }, ]} /> diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 359c8e1d4b9..253692f499d 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -4979,7 +4979,16 @@ }, "type": { "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "enum": [ + "string", + "number", + "currency", + "boolean", + "date", + "ttl", + "json", + "select" + ], "description": "Data type of values stored in the column." }, "required": { @@ -5257,7 +5266,16 @@ }, "type": { "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "enum": [ + "string", + "number", + "currency", + "boolean", + "date", + "ttl", + "json", + "select" + ], "description": "Column data type." }, "required": { @@ -5436,7 +5454,16 @@ }, "type": { "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "enum": [ + "string", + "number", + "currency", + "boolean", + "date", + "ttl", + "json", + "select" + ], "description": "Data type of values stored in the column." }, "required": { @@ -5536,7 +5563,16 @@ }, "type": { "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "enum": [ + "string", + "number", + "currency", + "boolean", + "date", + "ttl", + "json", + "select" + ], "description": "Column data type." }, "required": { @@ -5633,7 +5669,7 @@ "type": { "description": "Replacement column data type.", "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"] + "enum": ["string", "number", "currency", "boolean", "date", "ttl", "json", "select"] }, "required": { "description": "Whether inserts must supply a value for this column.", @@ -7397,7 +7433,16 @@ }, "type": { "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "enum": [ + "string", + "number", + "currency", + "boolean", + "date", + "ttl", + "json", + "select" + ], "description": "Data type of values stored in the column." }, "required": { @@ -7597,7 +7642,16 @@ }, "type": { "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "enum": [ + "string", + "number", + "currency", + "boolean", + "date", + "ttl", + "json", + "select" + ], "description": "Output column data type." }, "required": { @@ -7738,7 +7792,16 @@ }, "type": { "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "enum": [ + "string", + "number", + "currency", + "boolean", + "date", + "ttl", + "json", + "select" + ], "description": "Output column data type." }, "required": { @@ -7856,7 +7919,16 @@ }, "type": { "type": "string", - "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "enum": [ + "string", + "number", + "currency", + "boolean", + "date", + "ttl", + "json", + "select" + ], "description": "Data type of values stored in the column." }, "required": { diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 443ff1d2da9..1e4c16df28c 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -201,6 +201,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic # DATA_DRAINS_ENABLED= / NEXT_PUBLIC_DATA_DRAINS_ENABLED= # Export streams # FORKING_ENABLED= # Workspace forks # CREDENTIAL_GROUPS= # Enterprise managed OAuth collections +# TABLE_ROW_TTL= # Table TTL columns and expired-row cleanup # ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only # Instance organization (Optional). Most enterprise features read their settings from the diff --git a/apps/sim/app/(landing)/integrations/data/seo-content.ts b/apps/sim/app/(landing)/integrations/data/seo-content.ts index e0b49d84bc3..4383ff13beb 100644 --- a/apps/sim/app/(landing)/integrations/data/seo-content.ts +++ b/apps/sim/app/(landing)/integrations/data/seo-content.ts @@ -58,15 +58,17 @@ export const INTEGRATION_SEO: Record = { 'slack workflow automation', 'slack integration', ], - h1: 'Slack Integrations for Workflow Automation', + h1: 'Slack Workflow Automation with Sim', tagline: 'Build Slack workflow automation in Sim. Send, update, delete, and read messages; manage channels, users, canvases, and modals; and trigger AI agents from mentions, messages, and reactions in real time.', overview: - 'Use Sim as your Slack integration for team communication and operations. Build Slack automation that routes requests, posts alerts, summarises threads, updates tickets, and keeps work moving. Sim supports messages, reactions, canvases, views, channel and user lookups, file downloads, and real-time Slack workflows in one workspace.', + 'Sim automates Slack workflows that depend on conversation context, including message routing, alerts, thread summaries, ticket updates, and incident response. Slack messages and events start agent workflows that interpret what was said and choose the next action in Slack or a connected tool, so routine coordination and time-sensitive operations keep moving without anyone relaying details by hand.', triggersIntro: - 'Connect the Slack Webhook trigger to Sim and run Slack workflow automation the moment a mention, message, or reaction happens, no polling, no delay.', + 'Sim supports one real-time Slack trigger. Select the Slack events you care about, such as mentions, messages, and reactions, and Sim starts the connected workflow the moment one arrives instead of waiting for a scheduled check. A monitoring alert posted in Slack can open an incident-response workflow, and a ticketing update posted in Slack can be summarised and passed to another connected tool.', templatesIntro: - 'Ready-to-use Slack automation templates for Q&A bots, sales alerts, incident response, standups, digests, and CRM updates. Click any template to launch a workflow faster.', + 'Pre-built agent templates turn common Slack workflows into editable starting points: routing templates classify messages and send them to the right channel or owner, summarisation templates condense long threads into updates that preserve decisions and action items, and ticket sync and incident response templates update connected records and coordinate follow-up. Every template is editable, so you can adapt its channels, routing rules, data sources, and approval requirements.', + toolsSubtitleSuffix: + ' across messaging, channels, threads, users, reactions and files, and canvases and views. Combine multiple Slack actions in one workflow to summarise a message, route it, update a ticket, and post the ticket update back in Slack', }, airtable: { title: 'Airtable Automation with Sim', diff --git a/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts b/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts new file mode 100644 index 00000000000..9e62e4eb1ce --- /dev/null +++ b/apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts @@ -0,0 +1,132 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEnqueue, mockGetJobQueue, mockIsTableRowTtlEnabled, mockVerifyCronAuth } = vi.hoisted( + () => ({ + mockEnqueue: vi.fn(), + mockGetJobQueue: vi.fn(), + mockIsTableRowTtlEnabled: vi.fn(), + mockVerifyCronAuth: vi.fn(), + }) +) + +vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth })) +vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: mockGetJobQueue })) +vi.mock('@/lib/table/ttl-availability', () => ({ + isTableRowTtlEnabled: mockIsTableRowTtlEnabled, +})) + +import { GET } from '@/app/api/cron/cleanup-table-row-ttl/route' + +describe('table row TTL cleanup route', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-22T17:01:00Z')) + mockVerifyCronAuth.mockReturnValue(null) + mockIsTableRowTtlEnabled.mockResolvedValue(true) + mockEnqueue.mockResolvedValue('job-ttl-1') + mockGetJobQueue.mockResolvedValue({ enqueue: mockEnqueue }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('enqueues one serialized cleanup job', async () => { + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/cron/cleanup-table-row-ttl' + ) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ triggered: true, jobId: 'job-ttl-1' }) + expect(mockEnqueue).toHaveBeenCalledWith( + 'cleanup-table-row-ttl', + {}, + expect.objectContaining({ + maxAttempts: 1, + jobId: 'cleanup-table-row-ttl:1986020', + concurrencyKey: 'cleanup:table-row-ttl', + concurrencyLimit: 1, + runner: expect.any(Function), + }) + ) + }) + + it('deduplicates retries within the same fifteen-minute schedule window', async () => { + const request = () => + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/cron/cleanup-table-row-ttl' + ) + + await GET(request()) + vi.advanceTimersByTime(13 * 60 * 1000) + await GET(request()) + + expect(mockEnqueue.mock.calls[0]?.[2]?.jobId).toBe(mockEnqueue.mock.calls[1]?.[2]?.jobId) + }) + + it('uses a new id immediately after the next fifteen-minute window begins', async () => { + const request = () => + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/cron/cleanup-table-row-ttl' + ) + + vi.setSystemTime(new Date('2026-08-22T17:14:59.999Z')) + await GET(request()) + vi.setSystemTime(new Date('2026-08-22T17:15:00.000Z')) + await GET(request()) + + expect(mockEnqueue.mock.calls[0]?.[2]?.jobId).not.toBe(mockEnqueue.mock.calls[1]?.[2]?.jobId) + }) + + it('returns the cron auth refusal without touching the queue', async () => { + mockVerifyCronAuth.mockReturnValue(new Response(null, { status: 401 })) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/cron/cleanup-table-row-ttl' + ) + ) + + expect(response.status).toBe(401) + expect(mockGetJobQueue).not.toHaveBeenCalled() + }) + + it('does not enqueue cleanup while the feature is disabled', async () => { + mockIsTableRowTtlEnabled.mockResolvedValue(false) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/cron/cleanup-table-row-ttl' + ) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + triggered: false, + reason: 'feature-disabled', + }) + expect(mockGetJobQueue).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/cron/cleanup-table-row-ttl/route.ts b/apps/sim/app/api/cron/cleanup-table-row-ttl/route.ts new file mode 100644 index 00000000000..a7bdab822e5 --- /dev/null +++ b/apps/sim/app/api/cron/cleanup-table-row-ttl/route.ts @@ -0,0 +1,47 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { getJobQueue } from '@/lib/core/async-jobs' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('CleanupTableRowTtlApi') +const TTL_CLEANUP_INTERVAL_MS = 15 * 60 * 1000 + +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const authError = verifyCronAuth(request, 'table row TTL cleanup') + if (authError) return authError + + if (!(await isTableRowTtlEnabled())) { + logger.info('Table row TTL cleanup skipped because the feature is disabled') + return NextResponse.json({ triggered: false, reason: 'feature-disabled' }) + } + + const queue = await getJobQueue() + const scheduleWindow = Math.floor(Date.now() / TTL_CLEANUP_INTERVAL_MS) + const jobId = await queue.enqueue( + 'cleanup-table-row-ttl', + {}, + { + maxAttempts: 1, + jobId: `cleanup-table-row-ttl:${scheduleWindow}`, + name: 'Table row TTL cleanup', + concurrencyKey: 'cleanup:table-row-ttl', + concurrencyLimit: 1, + runner: async (_payload, signal) => { + const { runCleanupTableRowTtl } = await import('@/background/cleanup-table-row-ttl') + return runCleanupTableRowTtl(signal) + }, + } + ) + + logger.info('Table row TTL cleanup dispatched', { jobId }) + return NextResponse.json({ triggered: true, jobId }) + } catch (error) { + logger.error('Failed to dispatch table row TTL cleanup', { error }) + return NextResponse.json({ error: 'Failed to dispatch table row TTL cleanup' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index fcafbdb8af5..3336b84ad1b 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -134,8 +134,15 @@ export default async function CredentialGroupEnrollmentPage({ Connect your accounts

- {enrollment.inviterName}{' '} - invited you to connect accounts for{' '} + {enrollment.inviterName ? ( + <> + {enrollment.inviterName}{' '} + invited you + + ) : ( + 'You have been invited' + )}{' '} + to connect accounts for{' '} {enrollment.workspaceName}.

diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 1cf2246b119..01d1c56062a 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -3,6 +3,7 @@ import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' +import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner' import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session-expired' @@ -15,6 +16,7 @@ import { import { BlockVisibilityLoader } from '@/app/workspace/[workspaceId]/providers/block-visibility-loader' import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader' import { DesktopOAuthConnectListener } from '@/app/workspace/[workspaceId]/providers/desktop-oauth-connect-listener' +import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader' import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader' @@ -44,7 +46,7 @@ export default async function WorkspaceLayout({ } const activeOrganizationId = getActiveOrganizationId(session) - const [cookieStore, initialOrgSettings] = await Promise.all([ + const [cookieStore, initialOrgSettings, , tableRowTtlEnabled] = await Promise.all([ cookies(), hostContext.hostOrganizationId ? getOrgWhitelabelSettings(hostContext.hostOrganizationId) @@ -56,36 +58,39 @@ export default async function WorkspaceLayout({ hostContext, activeOrganizationId ), + isTableRowTtlEnabled(), ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' return ( - - - - - - - - -
- - - - - - {children} - - -
-
-
-
+ + + + + + + + + +
+ + + + + + {children} + + +
+
+
+
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx new file mode 100644 index 00000000000..631b6ed2094 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx @@ -0,0 +1,26 @@ +'use client' + +import { createContext, type ReactNode, useContext } from 'react' + +export interface WorkspaceFeatureFlags { + 'table-row-ttl': boolean +} + +const FeatureFlagsContext = createContext(null) + +interface FeatureFlagsProviderProps { + children: ReactNode + flags: WorkspaceFeatureFlags +} + +/** Makes server-resolved runtime flags available to workspace client surfaces. */ +export function FeatureFlagsProvider({ children, flags }: FeatureFlagsProviderProps) { + return {children} +} + +/** Reads one server-resolved runtime flag without exposing AppConfig to the browser. */ +export function useFeatureFlag(name: keyof WorkspaceFeatureFlags): boolean { + const flags = useContext(FeatureFlagsContext) + if (!flags) throw new Error('useFeatureFlag must be used within FeatureFlagsProvider') + return flags[name] +} 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 index ffcef8ce465..303742fdd2b 100644 --- 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 @@ -17,7 +17,7 @@ import { } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields' import { useAddTableColumn, useUpdateColumn } from '@/hooks/queries/tables' import { SelectOptionsEditor } from '../select-field' -import { PLAIN_COLUMN_TYPE_OPTIONS } from './column-types' +import { columnTypeOptionsForTable } from './column-types' /** Whether a column type carries an option set. */ function isSelectType(type: ColumnDefinition['type']): boolean { @@ -52,6 +52,8 @@ interface ColumnConfigSidebarProps { onClose: () => void /** Existing column record for `mode: 'edit'`; ignored otherwise. */ existingColumn: ColumnDefinition | null + allColumns: readonly ColumnDefinition[] + tableRowTtlEnabled: boolean workspaceId: string tableId: string /** Notify parent of a rename so it can rewrite local `columnOrder` / @@ -102,6 +104,8 @@ function ColumnConfigBody({ config, onClose, existingColumn, + allColumns, + tableRowTtlEnabled, workspaceId, tableId, onColumnRename, @@ -274,11 +278,16 @@ function ColumnConfigBody({
Type ({ - label: o.label, - value: o.type, - icon: o.icon, - }))} + options={columnTypeOptionsForTable(allColumns, existingColumn, { + tableRowTtlEnabled, + }) + .filter((option) => option.type !== 'workflow') + .map((option) => ({ + label: option.label, + value: option.type, + icon: option.icon, + disabled: option.disabledReason !== undefined, + }))} value={typeInput} onChange={(v) => setTypeInput(v as ColumnDefinition['type'])} placeholder='Select type' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-type-limits.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-type-limits.test.ts new file mode 100644 index 00000000000..f4325de8ae2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-type-limits.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it } from 'vitest' +import { COLUMN_TYPE_REGISTRY } from '@/lib/table/column-types' +import { + COLUMN_TYPE_OPTIONS, + columnTypeOptionsForTable, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types' + +const option = COLUMN_TYPE_OPTIONS.find((candidate) => candidate.type === 'string') +if (!option) throw new Error('String column type option is missing') +const originalMaxPerTable = option.maxPerTable +const definition = COLUMN_TYPE_REGISTRY.string +const originalDefinitionMaxPerTable = definition.maxPerTable + +afterEach(() => { + if (originalMaxPerTable === undefined) { + Reflect.deleteProperty(option, 'maxPerTable') + } else { + option.maxPerTable = originalMaxPerTable + } + + if (originalDefinitionMaxPerTable === undefined) { + Reflect.deleteProperty(definition, 'maxPerTable') + } else { + Object.assign(definition, { maxPerTable: originalDefinitionMaxPerTable }) + } +}) + +describe('column type picker limits', () => { + it('keeps a limited type visible but disables it once the limit is reached', () => { + option.maxPerTable = 1 + Object.assign(definition, { maxPerTable: 1 }) + + const result = columnTypeOptionsForTable([{ name: 'first', type: 'string' }], undefined, { + tableRowTtlEnabled: true, + }) + const stringOption = result.find((candidate) => candidate.type === 'string') + + expect(stringOption?.disabledReason).toBe('Only one Text column allowed per table') + }) + + it('keeps the current type selectable while editing its existing column', () => { + option.maxPerTable = 1 + Object.assign(definition, { maxPerTable: 1 }) + const currentColumn = { name: 'first', type: 'string' } as const + + const result = columnTypeOptionsForTable([currentColumn], currentColumn, { + tableRowTtlEnabled: true, + }) + const stringOption = result.find((candidate) => candidate.type === 'string') + + expect(stringOption?.disabledReason).toBeUndefined() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.test.ts new file mode 100644 index 00000000000..c59d36132b1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.test.ts @@ -0,0 +1,43 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { ColumnDefinition } from '@/lib/table' +import { columnTypeOptionsForTable } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types' + +describe('columnTypeOptionsForTable', () => { + const ttlColumn: ColumnDefinition = { name: 'expires_at', type: 'ttl' } + + it('disables TTL with an explanation when the table already has one', () => { + const availableTtl = columnTypeOptionsForTable([{ name: 'name', type: 'string' }], undefined, { + tableRowTtlEnabled: true, + }).find((option) => option.type === 'ttl') + const unavailableTtl = columnTypeOptionsForTable([ttlColumn], undefined, { + tableRowTtlEnabled: true, + }).find((option) => option.type === 'ttl') + + expect(availableTtl?.disabledReason).toBeUndefined() + expect(unavailableTtl?.disabledReason).toBe('Only one Expiration column allowed per table') + }) + + it('keeps TTL enabled while editing the existing TTL column', () => { + const ttlOption = columnTypeOptionsForTable([ttlColumn], ttlColumn, { + tableRowTtlEnabled: true, + }).find((option) => option.type === 'ttl') + + expect(ttlOption?.disabledReason).toBeUndefined() + }) + + it('hides TTL while disabled unless editing an existing TTL column', () => { + expect( + columnTypeOptionsForTable([], undefined, { tableRowTtlEnabled: false }).some( + (option) => option.type === 'ttl' + ) + ).toBe(false) + expect( + columnTypeOptionsForTable([ttlColumn], ttlColumn, { tableRowTtlEnabled: false }).some( + (option) => option.type === 'ttl' + ) + ).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts index a6ea0ba2ac1..85ef1ad1045 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts @@ -1,19 +1,25 @@ import type React from 'react' import { PlayOutline } from '@sim/emcn/icons' import type { ColumnDefinition } from '@/lib/table' -import { ALL_COLUMN_TYPES } from '@/lib/table/column-types' +import { ALL_COLUMN_TYPES, wouldExceedColumnTypeLimit } from '@/lib/table/column-types' /** * 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'`). */ -type SidebarColumnType = ColumnDefinition['type'] | 'workflow' +export type SidebarColumnType = ColumnDefinition['type'] | 'workflow' -interface ColumnTypeOption { +export interface ColumnTypeOption { type: SidebarColumnType label: string icon: React.ComponentType<{ className?: string }> + maxPerTable?: number + disabledReason?: string +} + +interface ColumnTypeAvailability { + tableRowTtlEnabled: boolean } /** @@ -26,9 +32,39 @@ export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [ type: definition.id, label: definition.label, icon: definition.icon, + maxPerTable: definition.maxPerTable, })), { 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') +/** Plain column types (no workflow). Used by the column type combobox in edit mode. */ +export const PLAIN_COLUMN_TYPE_OPTIONS = COLUMN_TYPE_OPTIONS.filter( + (option) => option.type !== 'workflow' +) + +function columnTypeLimitMessage(label: string, maxPerTable: number): string { + return maxPerTable === 1 + ? `Only one ${label} column allowed per table` + : `Only ${maxPerTable} ${label} columns allowed per table` +} + +/** Picker entries with unavailable cardinality-limited types marked as disabled. */ +export function columnTypeOptionsForTable( + columns: readonly ColumnDefinition[], + currentColumn: ColumnDefinition | null | undefined, + availability: ColumnTypeAvailability +): ColumnTypeOption[] { + return COLUMN_TYPE_OPTIONS.filter( + (option) => + option.type !== 'ttl' || availability.tableRowTtlEnabled || currentColumn?.type === 'ttl' + ).map((option) => { + if (option.type === 'workflow') return option + if (currentColumn?.type === option.type) return option + if (option.maxPerTable === undefined) return option + if (!wouldExceedColumnTypeLimit(columns, option.type, 1)) return option + return { + ...option, + disabledReason: columnTypeLimitMessage(option.label, option.maxPerTable), + } + }) +} 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 index 0308447977f..f5d7d9a197d 100644 --- 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 @@ -1,3 +1,9 @@ export type { ColumnConfig } from './column-config-sidebar' export { ColumnConfigSidebar } from './column-config-sidebar' -export { COLUMN_TYPE_OPTIONS, PLAIN_COLUMN_TYPE_OPTIONS } from './column-types' +export { + COLUMN_TYPE_OPTIONS, + type ColumnTypeOption, + columnTypeOptionsForTable, + PLAIN_COLUMN_TYPE_OPTIONS, + type SidebarColumnType, +} from './column-types' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx index 6409eb7f513..e4321a7eb59 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx @@ -29,6 +29,8 @@ describe('ColumnDropdown', () => { act(() => { root.render( ` trigger. Same dropdown content either way. */ trigger: 'header' | 'inline-header' @@ -36,12 +40,48 @@ interface ColumnDropdownProps { onBlocked: () => void } +interface ColumnTypeMenuItemProps { + option: ColumnTypeOption + onSelect: () => void +} + +function ColumnTypeMenuItem({ option, onSelect }: ColumnTypeMenuItemProps) { + const Icon = option.icon + const item = ( + { + if (option.disabledReason) { + event.preventDefault() + return + } + onSelect() + }} + > + + {option.label} + + ) + + if (!option.disabledReason) return item + + return ( + + {item} + {option.disabledReason} + + ) +} + /** * "+ New column" dropdown — the single entry point for creating a column. * Lists every column type plus "Workflow" and "Enrichments"; picking a type * opens the right sidebar pre-seeded. */ export function ColumnDropdown({ + columns, + tableRowTtlEnabled, trigger, disabled, onPickType, @@ -86,18 +126,12 @@ export function ColumnDropdown({ {triggerButton} - {COLUMN_TYPE_OPTIONS.map((option) => { - const Icon = option.icon + {columnTypeOptionsForTable(columns, undefined, { tableRowTtlEnabled }).map((option) => { const onSelect = option.type === 'workflow' ? onPickWorkflow : () => onPickType(option.type as ColumnDefinition['type']) - return ( - - - {option.label} - - ) + return })} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx new file mode 100644 index 00000000000..851a755b22a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx @@ -0,0 +1,148 @@ +/** + * @vitest-environment jsdom + */ +import { act, createElement, type ReactNode } from 'react' +import { createRoot } from 'react-dom/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableInfo, TableRow } from '@/lib/table' +import { RowModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal' + +const { mockUseTimezoneState, mockUpdateRow, mockDeleteRow, mockDeleteRows } = vi.hoisted(() => ({ + mockUseTimezoneState: vi.fn(), + mockUpdateRow: vi.fn(), + mockDeleteRow: vi.fn(), + mockDeleteRows: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useParams: () => ({ workspaceId: 'workspace-1' }), +})) +vi.mock('@/hooks/queries/general-settings', () => ({ + useTimezoneState: mockUseTimezoneState, +})) +vi.mock('@/hooks/queries/tables', () => ({ + useUpdateTableRow: () => ({ mutateAsync: mockUpdateRow, isPending: false }), + useDeleteTableRow: () => ({ mutateAsync: mockDeleteRow, isPending: false }), + useDeleteTableRows: () => ({ mutateAsync: mockDeleteRows, isPending: false }), +})) +vi.mock('@sim/emcn', () => { + const passthrough = ({ children }: { children?: ReactNode }) => children ?? null + return { + Checkbox: () => null, + ChipConfirmModal: passthrough, + ChipDatePicker: ({ value, onChange }: { value?: string; onChange: (value: string) => void }) => + createElement( + 'button', + { type: 'button', 'data-testid': 'date', onClick: () => onChange(value ?? '2026-11-01') }, + value + ), + ChipModal: passthrough, + ChipModalBody: passthrough, + ChipModalError: passthrough, + ChipModalField: passthrough, + ChipModalFooter: ({ + primaryAction, + }: { + primaryAction: { disabled?: boolean; onClick?: () => void } + }) => + createElement( + 'button', + { + type: 'button', + 'data-testid': 'submit', + disabled: primaryAction.disabled, + onClick: primaryAction.onClick, + }, + 'Update Row' + ), + ChipModalHeader: passthrough, + ChipTimePicker: ({ value, onChange }: { value?: string; onChange: (value: string) => void }) => + createElement('input', { + 'data-testid': 'time', + value: value ?? '', + onChange: (event: { currentTarget: { value: string } }) => + onChange(event.currentTarget.value), + }), + Label: passthrough, + } +}) + +const table: TableInfo = { + id: 'table-1', + name: 'Expiring rows', + schema: { columns: [{ name: 'expires_at', type: 'ttl' }] }, +} + +const row: TableRow = { + id: 'row-1', + data: { expires_at: Date.parse('2026-11-01T08:00:00Z') / 1000 }, + executions: {}, + position: 0, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', +} + +function changeInput(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +describe('RowModal expiration editing', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUpdateRow.mockResolvedValue(undefined) + }) + + it('waits for the saved timezone, freezes it, and chooses the later repeated hour', async () => { + mockUseTimezoneState.mockReturnValue({ timezone: 'Asia/Tokyo', status: 'loading' }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const props = { + mode: 'edit' as const, + isOpen: true, + onClose: vi.fn(), + table, + row, + onSuccess: vi.fn(), + } + + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + act(() => root.render(createElement(RowModal, props))) + + expect(container.querySelector('[role="status"]')?.textContent).toBe('Loading timezone…') + expect(container.querySelector('[data-testid="time"]')).toBeNull() + expect(container.querySelector('[data-testid="submit"]')?.disabled).toBe( + true + ) + + mockUseTimezoneState.mockReturnValue({ + timezone: 'America/Los_Angeles', + status: 'ready', + }) + act(() => root.render(createElement(RowModal, props))) + + mockUseTimezoneState.mockReturnValue({ + timezone: 'America/New_York', + status: 'ready', + }) + act(() => root.render(createElement(RowModal, props))) + + const timeInput = container.querySelector('[data-testid="time"]') + expect(timeInput?.value).toBe('01:00') + act(() => changeInput(timeInput as HTMLInputElement, '01:30')) + + const submit = container.querySelector('[data-testid="submit"]') + await act(async () => submit?.click()) + + expect(mockUpdateRow).toHaveBeenCalledWith({ + rowId: 'row-1', + data: { expires_at: Date.parse('2026-11-01T09:30:00Z') / 1000 }, + }) + expect(props.onSuccess).toHaveBeenCalledTimes(1) + + act(() => root.unmount()) + container.remove() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx index d46e2193330..e139b38b849 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useId, useState } from 'react' +import { useId, useRef, useState } from 'react' import { Checkbox, ChipConfirmModal, @@ -20,7 +20,7 @@ import { useParams } from 'next/navigation' import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table' import { columnTypeOf } from '@/lib/table/column-types' import { resolveCurrencyCode } from '@/lib/table/currency' -import { useTimezone } from '@/hooks/queries/general-settings' +import { useTimezoneState } from '@/hooks/queries/general-settings' import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables' import { cleanCellValue, @@ -78,7 +78,14 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess const schema = table?.schema const columns = schema?.columns || [] - const timeZone = useTimezone() + const timezoneState = useTimezoneState() + const editTimeZoneRef = useRef(null) + if (timezoneState.status === 'ready' && editTimeZoneRef.current === null) { + editTimeZoneRef.current = timezoneState.timezone + } + const hasTtlColumn = mode === 'edit' && columns.some((column) => column.type === 'ttl') + const ttlTimezoneUnavailable = hasTtlColumn && editTimeZoneRef.current === null + const timeZone = editTimeZoneRef.current ?? timezoneState.timezone const [rowData, setRowData] = useState>(() => mode === 'edit' && row ? row.data : {} ) @@ -92,6 +99,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess const handleFormSubmit = async (e?: React.FormEvent) => { e?.preventDefault() setError(null) + if (ttlTimezoneUnavailable) return try { const cleanData = cleanRowData(columns, rowData, timeZone) @@ -169,15 +177,22 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess Update values for {table?.name ?? 'table'}

-