Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions apps/docs/content/docs/integrations/table.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,118 @@ Get the schema configuration of a table
| `maxRows` | number | Max rows per table for the workspace's plan |
| `message` | string | Status message |

### List Table Folders

List the folders that organize a workspace’s tables. Lists direct children by default; set Recursive to walk the whole subtree. Use List Tables to see the tables themselves.

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `path` | string | No | Folder to list. Omit to list from the workspace root. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
| `recursive` | boolean | No | List every folder beneath the path rather than only its direct children. Each entry carries its depth below the listed folder. |
| `depth` | number | No | Deepest level to include, counted from the listed folder. 1 is direct children. Setting it implies Recursive, so it can be used on its own. |
| `search` | string | No | Case-insensitive substring match against a folder name. Filters the result, so a deep match is still reported even when its parent folders do not match. |
| `limit` | number | No | Most folders to return, 200 by default. A listing cut short comes back with truncated set. |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `path` | string | The folder that was listed. |
| `folders` | array | The folders inside it. Each carries its name, canonical path, parent path, timestamps, and its depth below the listed folder. |
| `truncated` | boolean | True when the limit cut the listing short, so more folders exist. |

### Create Table Folder

Create a table folder at a path. The parent folder must already exist, so build a nested path one level at a time, top down. Fails if a folder already exists at the path.

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `path` | string | Yes | Path of the folder to create. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `folder` | object | The created folder, with its name, canonical path, parent path, and timestamps. |

### Move Table Folder

Move or rename a table folder by giving its full destination path. Everything inside the folder, including its tables, moves with it.

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `path` | string | Yes | Folder to move. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
| `destinationPath` | string | Yes | Full path the folder should have afterwards. Renaming is a destination whose parent is unchanged. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `folder` | object | The folder at its new path. |
| `previousPath` | string | The path the folder had before the move. |

### Delete Table Folder

Delete a table folder. It moves to Recently deleted and can be brought back with Restore Table Folder. Deleting a folder that still has contents requires the recursive option.

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `path` | string | Yes | Folder to delete. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
| `recursive` | boolean | No | Also delete the folder’s nested folders and the tables inside them. Without it, deleting a non-empty folder fails. |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `path` | string | The folder that was deleted. |
| `deleted` | boolean | Always true when the operation succeeded. |
| `deletedItems` | object | Counts of the folders and tables deleted alongside it. |

### Restore Table Folder

Restore a deleted table folder and its contents from Recently deleted. Addressed by the path the folder held when it was deleted; the response reports where it actually landed, which can differ if that path is taken or its parent is still deleted.

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `path` | string | Yes | Path the folder held when it was deleted. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `folder` | object | The restored folder at its live path. |
| `requestedPath` | string | The path the folder was addressed by. |
| `restoredItems` | object | Counts of the folders and tables restored alongside it. |

### Move Table

File an existing table into a folder. Moves the table itself; use Move Table Folder to relocate a whole folder.

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | ID of the table to move. Use List Tables to find it. |
| `folderPath` | string | No | Destination folder. Omit to move the table to the workspace root. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableId` | string | The table that was moved. |
| `name` | string | Name of the moved table. |
| `folderPath` | string | The folder the table now lives in. |

{/* MANUAL-CONTENT-START:notes */}
## Filter Operators

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
isTableInFolderScope,
parseFolderScope,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table-selector/scope'
import type { WorkflowFolder } from '@/stores/folders/types'

function folder(id: string, name: string, parentId: string | null = null): WorkflowFolder {
return {
id,
name,
parentId,
resourceType: 'table',
userId: 'user-1',
workspaceId: 'ws-1',
locked: false,
sortOrder: 0,
createdAt: new Date('2026-08-27T00:00:00.000Z'),
updatedAt: new Date('2026-08-27T00:00:00.000Z'),
deletedAt: null,
} as unknown as WorkflowFolder
}

const FOLDERS: Record<string, WorkflowFolder> = {
reports: folder('reports', 'Reports'),
q3: folder('q3', 'Q3', 'reports'),
slashy: folder('slashy', 'Q3/Q4', 'reports'),
archive: folder('archive', 'Archive'),
}

describe('isTableInFolderScope', () => {
it('offers everything when no folder is picked', () => {
expect(isTableInFolderScope({ folderId: 'archive' }, FOLDERS, '')).toBe(true)
expect(isTableInFolderScope({ folderId: null }, FOLDERS, '')).toBe(true)
})

it('keeps a table in the scoped folder and its descendants', () => {
expect(isTableInFolderScope({ folderId: 'reports' }, FOLDERS, '/Reports')).toBe(true)
expect(isTableInFolderScope({ folderId: 'q3' }, FOLDERS, '/Reports')).toBe(true)
})

it('drops a table outside the scope, including one at the workspace root', () => {
expect(isTableInFolderScope({ folderId: 'archive' }, FOLDERS, '/Reports')).toBe(false)
expect(isTableInFolderScope({ folderId: null }, FOLDERS, '/Reports')).toBe(false)
})

it('reads a slash inside a folder name as one level', () => {
/* `Q3/Q4` is ONE folder, so it is not inside a folder called `Q3`. */
expect(isTableInFolderScope({ folderId: 'slashy' }, FOLDERS, '/Reports/Q3%2FQ4')).toBe(true)
expect(isTableInFolderScope({ folderId: 'slashy' }, FOLDERS, '/Reports/Q3')).toBe(false)
})

it('does not mistake a shared name prefix for containment', () => {
const folders = { ...FOLDERS, archive2: folder('archive2', 'Reports Archive') }
expect(isTableInFolderScope({ folderId: 'archive2' }, folders, '/Reports')).toBe(false)
})

/*
* The failure mode is the point. A picker that silently empties reads as
* "these tables are gone" rather than "your filter is malformed", so a scope
* that cannot be parsed and a folder the cache has not loaded both fail OPEN.
*/
it('offers the table when the scope path cannot be parsed', () => {
expect(isTableInFolderScope({ folderId: 'q3' }, FOLDERS, 'Reports')).toBe(true)
expect(isTableInFolderScope({ folderId: 'q3' }, FOLDERS, '/Reports/')).toBe(true)
})

it('offers the table when its folder is missing from the loaded map', () => {
expect(isTableInFolderScope({ folderId: 'not-loaded-yet' }, FOLDERS, '/Reports')).toBe(true)
})

it('offers the table when the folder map contains a cycle', () => {
const cyclic: Record<string, WorkflowFolder> = {
a: folder('a', 'A', 'b'),
b: folder('b', 'B', 'a'),
}
expect(isTableInFolderScope({ folderId: 'a' }, cyclic, '/Reports')).toBe(true)
})
})

describe('parseFolderScope', () => {
it('decodes a canonical path once for the whole list', () => {
expect(parseFolderScope('/Reports/Q3%20Results')).toEqual(['Reports', 'Q3 Results'])
expect(parseFolderScope('/Reports/Q3%2FQ4')).toEqual(['Reports', 'Q3/Q4'])
})

it('reads an empty scope as no scope', () => {
expect(parseFolderScope('')).toEqual([])
})

it('reports an unusable scope as null rather than throwing', () => {
expect(parseFolderScope('Reports')).toBeNull()
expect(parseFolderScope('/Reports/')).toBeNull()
})

it('feeds pre-parsed segments back through the filter unchanged', () => {
const segments = parseFolderScope('/Reports')
expect(isTableInFolderScope({ folderId: 'q3' }, FOLDERS, segments)).toBe(true)
expect(isTableInFolderScope({ folderId: 'archive' }, FOLDERS, segments)).toBe(false)
})

it('fails open when handed a null scope', () => {
expect(isTableInFolderScope({ folderId: 'archive' }, FOLDERS, null)).toBe(true)
})
})

/*
* The clearing rule the component applies when a scope change hides the current
* selection. Expressed here as the predicate the effect guards on, so the
* "don't clear a table that merely has not loaded" case is pinned.
*/
describe('a selection hidden by a scope change', () => {
const TABLES = [
{ id: 'in-scope', folderId: 'q3' },
{ id: 'out-of-scope', folderId: 'archive' },
]

function shouldClear(tableId: string, scope: string): boolean {
const loaded = TABLES.some((t) => t.id === tableId)
if (!loaded) return false
const segments = parseFolderScope(scope)
return !TABLES.filter((t) => isTableInFolderScope(t, FOLDERS, segments)).some(
(t) => t.id === tableId
)
}

it('clears a loaded table the new scope excludes', () => {
expect(shouldClear('out-of-scope', '/Reports')).toBe(true)
})

it('keeps a table the scope still admits', () => {
expect(shouldClear('in-scope', '/Reports')).toBe(false)
})

it('keeps everything when the scope is cleared', () => {
expect(shouldClear('out-of-scope', '')).toBe(false)
})

it('never clears a table that is not in the loaded list', () => {
/* Still loading, or deleted - clearing there would destroy a valid config. */
expect(shouldClear('not-loaded', '/Reports')).toBe(false)
})

it('never clears on a scope that cannot be parsed', () => {
expect(shouldClear('out-of-scope', 'Reports')).toBe(false)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { parseFolderPath } from '@/lib/folders/paths'
import { isWithinFolderScope } from '@/lib/folders/scope'
import { getCanonicalFolderPath } from '@/hooks/queries/utils/folder-tree'
import type { WorkflowFolder } from '@/stores/folders/types'

/** The shape the scope filter needs from a table row. */
export interface ScopableTable {
folderId?: string | null
}

/**
* Decodes a scope path once for a whole list.
*
* {@link isTableInFolderScope} would otherwise re-parse the same scope for every
* row, and parsing re-encodes each segment to prove canonicality — real work,
* repeated per table. `null` means the scope is unusable, which fails open.
*/
export function parseFolderScope(scopePath: string): string[] | null {
if (!scopePath) return []
try {
return parseFolderPath(scopePath)
} catch {
return null
}
}

/**
* Whether a table survives the folder scope its picker is narrowed by.
*
* Pure and separate from the component because the interesting behaviour is the
* failure mode, not the happy path: an unparseable scope or a folder the client
* cache has not loaded yet must leave the table OFFERED. A table nobody can
* select reads as "this table is gone" rather than "the filter is malformed",
* and failing open is the recoverable direction.
*
* An empty scope is no scope at all, so everything passes without touching the
* folder map.
*/
export function isTableInFolderScope(
table: ScopableTable,
folders: Record<string, WorkflowFolder>,
scope: string | string[] | null
): boolean {
const scopeSegments = typeof scope === 'string' ? parseFolderScope(scope) : scope
if (scopeSegments === null || scopeSegments.length === 0) return true
try {
return isWithinFolderScope(
parseFolderPath(getCanonicalFolderPath(table.folderId, folders)),
scopeSegments
)
} catch {
return true
}
}
Loading