From ef3a80e667161ac625d8f5fdb7427f409eac2812 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 19 Aug 2026 13:27:58 -0700 Subject: [PATCH 1/2] improvement(search): search every folder, and document real API error bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search on Files, Tables, and Knowledge was ANDed with the open folder, so a query only ever matched that folder's direct children — and the query was not cleared when you entered a folder, filtering the folder you just opened down to the same matches. A non-empty query now searches the whole workspace, a Location column names each result's folder, and opening a folder ends the search. Also gives GET /api/v2/files a `recursive` flag, and replaces the single shared OpenAPI error example — which showed `BAD_REQUEST` under every status tab — with one real body per status. --- apps/docs/content/docs/en/cli/files.mdx | 1 + apps/docs/content/docs/en/cli/reference.mdx | 1 + apps/docs/openapi-v2-billing.json | 123 ++++------ apps/docs/openapi-v2-files-audit.json | 143 +++++++----- apps/docs/openapi-v2-knowledge.json | 107 ++++++--- apps/docs/openapi-v2-logs.json | 123 ++++------ apps/docs/openapi-v2-resources.json | 115 +++++----- apps/docs/openapi-v2-tables.json | 114 ++++++---- apps/docs/openapi-v2-workflows.json | 101 +++++++- apps/sim/app/api/v2/files/route.ts | 9 + apps/sim/app/api/v2/lib/response.ts | 44 +--- .../api/v2/workflows/[id]/execute/route.ts | 3 +- .../[id]/runs/[runId]/resume/route.ts | 3 +- .../folders/drag-move-pruning.test.ts | 76 +++++++ .../folders/folder-search-scope.test.ts | 115 ++++++++++ .../components/folders/folder-search-scope.ts | 111 +++++++++ .../[workspaceId]/components/folders/index.ts | 7 + .../folders/use-folder-navigation.ts | 46 +++- .../folders/use-folder-row-drag-drop.ts | 95 ++++++-- .../[workspaceId]/components/index.ts | 5 +- .../components/resource-empty-state/index.ts | 1 + .../resource-no-results.tsx | 49 ++++ .../resource/is-resource-list-empty.ts | 37 ++- .../workspace/[workspaceId]/files/files.tsx | 172 ++++++++++---- .../[workspaceId]/knowledge/knowledge.tsx | 126 ++++++---- .../[workspaceId]/knowledge/utils/filter.ts | 18 -- .../workspace/[workspaceId]/tables/tables.tsx | 117 ++++++---- apps/sim/hooks/use-search-filter-value.ts | 23 ++ .../v2/__tests__/files-recursive.test.ts | 47 ++++ .../v2/__tests__/list-pagination.test.ts | 11 +- .../lib/api/contracts/v2/error-codes.test.ts | 44 ++++ apps/sim/lib/api/contracts/v2/error-codes.ts | 63 +++++ apps/sim/lib/api/contracts/v2/files.ts | 39 +++- .../api/contracts/v2/openapi/files-audit.ts | 6 +- .../lib/api/contracts/v2/openapi/knowledge.ts | 6 +- .../lib/api/contracts/v2/openapi/resources.ts | 6 +- .../lib/api/contracts/v2/openapi/shared.ts | 215 ++++++++++++++---- .../lib/api/contracts/v2/openapi/tables.ts | 11 +- apps/sim/lib/api/openapi/types.ts | 13 ++ apps/sim/lib/folders/subtree.ts | 2 +- .../workspace/workspace-file-manager.ts | 27 ++- .../application/list-workspace-files.test.ts | 149 ++++++++++++ .../application/list-workspace-files.ts | 33 ++- packages/sim-cli/src/generated/v2-api.ts | 34 ++- scripts/openapi/generator.test.ts | 105 ++++++++- scripts/openapi/generator.ts | 33 ++- 46 files changed, 2105 insertions(+), 624 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/drag-move-pruning.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/folder-search-scope.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/folder-search-scope.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/utils/filter.ts create mode 100644 apps/sim/hooks/use-search-filter-value.ts create mode 100644 apps/sim/lib/api/contracts/v2/__tests__/files-recursive.test.ts create mode 100644 apps/sim/lib/api/contracts/v2/error-codes.test.ts create mode 100644 apps/sim/lib/api/contracts/v2/error-codes.ts create mode 100644 apps/sim/lib/workspace-files/application/list-workspace-files.test.ts diff --git a/apps/docs/content/docs/en/cli/files.mdx b/apps/docs/content/docs/en/cli/files.mdx index 77cf0ca74c2..65ee0f24985 100644 --- a/apps/docs/content/docs/en/cli/files.mdx +++ b/apps/docs/content/docs/en/cli/files.mdx @@ -239,6 +239,7 @@ sim files list [options] | Option | Required | Description | | --- | --- | --- | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | +| `--recursive ` | No | Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected. Accepted values: `true`, `1`, `yes`, `on`, `y`, `enabled`, `false`, `0`, `no`, `off`, `n`, `disabled`. | | `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--search ` | No | Case-insensitive substring match against the file name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | diff --git a/apps/docs/content/docs/en/cli/reference.mdx b/apps/docs/content/docs/en/cli/reference.mdx index 3208568d6cc..a0f59c28f6d 100644 --- a/apps/docs/content/docs/en/cli/reference.mdx +++ b/apps/docs/content/docs/en/cli/reference.mdx @@ -741,6 +741,7 @@ sim files list [options] | Option | Required | Description | | --- | --- | --- | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | +| `--recursive ` | No | Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected. Accepted values: `true`, `1`, `yes`, `on`, `y`, `enabled`, `false`, `0`, `no`, `off`, `n`, `disabled`. | | `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--search ` | No | Case-insensitive substring match against the file name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 122cc4f3e99..c0e6b736aa1 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -313,6 +313,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } } } } @@ -323,16 +329,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UsageLimitExceeded": { - "description": "The workspace has exceeded its usage or billing limits.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } } } } @@ -343,6 +345,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } } } } @@ -353,61 +364,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "Conflict": { - "description": "The request conflicts with current resource state.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "RunIdConflict": { - "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", - "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "Locked": { - "description": "The resource is locked and cannot be modified.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } } } } @@ -423,16 +385,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } } } } @@ -443,6 +404,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } } } } @@ -458,6 +425,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } } } } diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index f2e459d88fa..f5fa23ada8b 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -59,12 +59,36 @@ "name": "folderPath", "in": "query", "required": false, - "description": "Restrict results to files directly inside this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict results to files inside this folder — its direct children, or its whole subtree when `recursive` is true. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "schema": { - "description": "Restrict results to files directly inside this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict results to files inside this folder — its direct children, or its whole subtree when `recursive` is true. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", "$ref": "#/components/schemas/FolderPathInput" } }, + { + "name": "recursive", + "in": "query", + "required": false, + "description": "Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "schema": { + "description": "Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "type": "string" + } + }, { "name": "scope", "in": "query", @@ -2102,6 +2126,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } } } } @@ -2112,16 +2142,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UsageLimitExceeded": { - "description": "The workspace has exceeded its usage or billing limits.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } } } } @@ -2132,6 +2158,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } } } } @@ -2142,6 +2177,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } } } } @@ -2152,21 +2193,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "RunIdConflict": { - "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", - "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "File already exists" + } } } } @@ -2177,26 +2209,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "Locked": { - "description": "The resource is locked and cannot be modified.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } } } } @@ -2212,16 +2230,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } } } } @@ -2232,6 +2249,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } } } } @@ -2247,6 +2270,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } } } } diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index a1b2d4d6eaf..401eb99a249 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -2047,6 +2047,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } } } } @@ -2057,6 +2063,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } } } } @@ -2067,6 +2079,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "USAGE_LIMIT_EXCEEDED", + "message": "Usage limit exceeded. Please upgrade your plan to continue." + } } } } @@ -2077,6 +2095,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } } } } @@ -2087,6 +2114,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } } } } @@ -2097,21 +2130,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "RunIdConflict": { - "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", - "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Upload has already been completed" + } } } } @@ -2122,6 +2146,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } } } } @@ -2132,16 +2162,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "Locked": { - "description": "The resource is locked and cannot be modified.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNSUPPORTED_MEDIA_TYPE", + "message": "Request body must be sent as application/json" + } } } } @@ -2157,16 +2183,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } } } } @@ -2177,6 +2202,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } } } } @@ -2192,6 +2223,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } } } } diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index cc4cfd86202..af7bfe5e2dd 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -435,6 +435,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } } } } @@ -445,16 +451,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UsageLimitExceeded": { - "description": "The workspace has exceeded its usage or billing limits.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } } } } @@ -465,6 +467,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } } } } @@ -475,61 +486,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "Conflict": { - "description": "The request conflicts with current resource state.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "RunIdConflict": { - "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", - "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "Locked": { - "description": "The resource is locked and cannot be modified.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } } } } @@ -545,16 +507,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } } } } @@ -565,6 +526,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } } } } @@ -580,6 +547,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } } } } diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 8caf0604abb..f34b36a578e 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -2426,6 +2426,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } } } } @@ -2436,16 +2442,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UsageLimitExceeded": { - "description": "The workspace has exceeded its usage or billing limits.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } } } } @@ -2456,6 +2458,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } } } } @@ -2466,6 +2477,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } } } } @@ -2476,21 +2493,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "RunIdConflict": { - "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", - "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "API key name already exists" + } } } } @@ -2501,26 +2509,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "Locked": { - "description": "The resource is locked and cannot be modified.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } } } } @@ -2536,16 +2530,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } } } } @@ -2556,6 +2549,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } } } } @@ -2571,6 +2570,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } } } } diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index c4a6b1ce6fd..877c8b329ce 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -3821,6 +3821,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } } } } @@ -3831,16 +3837,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UsageLimitExceeded": { - "description": "The workspace has exceeded its usage or billing limits.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } } } } @@ -3851,6 +3853,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } } } } @@ -3861,6 +3872,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } } } } @@ -3871,21 +3888,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "RunIdConflict": { - "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", - "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "A table named \"Orders\" already exists in this workspace" + } } } } @@ -3896,16 +3904,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } } } } @@ -3916,6 +3920,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "This table is insert-locked: new rows cannot be added.", + "details": { + "lock": "insert" + } + } } } } @@ -3931,16 +3944,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } } } } @@ -3951,6 +3963,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } } } } @@ -3966,6 +3984,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } } } } diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 541863f37d3..6d43438c88b 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -2129,6 +2129,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } } } } @@ -2139,6 +2145,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } } } } @@ -2149,6 +2161,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "USAGE_LIMIT_EXCEEDED", + "message": "Usage limit exceeded. Please upgrade your plan to continue." + } } } } @@ -2159,6 +2177,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } } } } @@ -2169,6 +2196,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } } } } @@ -2179,6 +2212,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Webhook path already in use" + } } } } @@ -2194,6 +2233,16 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Run ID has already been used", + "details": { + "code": "RUN_ID_CONFLICT", + "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" + } + } } } } @@ -2204,16 +2253,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" - } - } - } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } } } } @@ -2224,6 +2269,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "Workflow is locked" + } } } } @@ -2239,6 +2290,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } } } } @@ -2249,6 +2309,15 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CLIENT_CLOSED_REQUEST", + "message": "Client cancelled request", + "details": { + "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" + } + } } } } @@ -2259,6 +2328,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } } } } @@ -2274,6 +2349,12 @@ "application/json": { "schema": { "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } } } } diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index e544f4bf231..4f38ee453fb 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -1,4 +1,5 @@ import { + listsSubfolders, type V2File, v2CreateFileContract, v2ListFilesContract, @@ -23,12 +24,19 @@ function fileCursorFilters(query: { scope?: string folderPath?: string search?: string + recursive?: boolean }) { return cursorScopeKey(cursorRoute(v2ListFilesContract), { workspaceId: query.workspaceId, scope: query.scope, folderPath: query.folderPath, search: query.search, + /** + * Keyed on the resolved value, not the raw parameter: omitting `recursive` beside a + * search asks for the same page as sending `recursive=true`, so keying on the parameter + * would reject a cursor between two requests that select identical rows. + */ + recursive: String(listsSubfolders(query)), }) } @@ -44,6 +52,7 @@ export const GET = defineV2JsonRoute({ scope: query.scope, folderPath: query.folderPath, search: query.search, + recursive: listsSubfolders(query), sortBy: query.sortBy, sortOrder: query.sortOrder, limit: query.limit, diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index c96763ac4e9..4351f193aec 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -1,5 +1,10 @@ import { NextResponse } from 'next/server' import type { ZodError } from 'zod' +import { + V2_ERROR_CODE_BY_STATUS, + V2_ERROR_STATUS_BY_CODE, + type V2ErrorCode, +} from '@/lib/api/contracts/v2/error-codes' import { REFILTERED_CURSOR_MESSAGE, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' @@ -21,41 +26,6 @@ import type { RateLimitResult } from '@/app/api/v1/middleware' * the HTTP envelope. */ -export type V2ErrorCode = - | 'BAD_REQUEST' - | 'UNAUTHORIZED' - | 'FORBIDDEN' - | 'NOT_FOUND' - | 'CONFLICT' - | 'PAYLOAD_TOO_LARGE' - | 'UNSUPPORTED_MEDIA_TYPE' - | 'USAGE_LIMIT_EXCEEDED' - | 'LOCKED' - | 'RATE_LIMITED' - | 'CLIENT_CLOSED_REQUEST' - | 'INTERNAL_ERROR' - | 'SERVICE_UNAVAILABLE' - -const STATUS_BY_CODE: Record = { - BAD_REQUEST: 400, - UNAUTHORIZED: 401, - USAGE_LIMIT_EXCEEDED: 402, - FORBIDDEN: 403, - NOT_FOUND: 404, - CONFLICT: 409, - PAYLOAD_TOO_LARGE: 413, - UNSUPPORTED_MEDIA_TYPE: 415, - LOCKED: 423, - RATE_LIMITED: 429, - CLIENT_CLOSED_REQUEST: 499, - INTERNAL_ERROR: 500, - SERVICE_UNAVAILABLE: 503, -} - -const V2_CODE_BY_HTTP_STATUS: Partial> = Object.fromEntries( - Object.entries(STATUS_BY_CODE).map(([code, status]) => [status, code as V2ErrorCode]) -) - /** * Every v2 response is authed, per-caller data (ids/filters appear in query * strings) — keep it out of shared HTTP caches unconditionally. @@ -195,7 +165,7 @@ export function v2Error( ): NextResponse { const error: { code: V2ErrorCode; message: string; details?: unknown } = { code, message } if (options.details !== undefined) error.details = options.details - const status = options.status ?? STATUS_BY_CODE[code] + const status = options.status ?? V2_ERROR_STATUS_BY_CODE[code] const retryAfterSeconds = options.omitRetryAfter ? undefined : RETRY_AFTER_SECONDS_BY_STATUS[status] @@ -215,7 +185,7 @@ export function v2Error( /** Renders a trusted typed HTTP error without changing the v2 envelope. */ export function v2HttpError(error: HttpError): NextResponse { - const code = V2_CODE_BY_HTTP_STATUS[error.statusCode] + const code = V2_ERROR_CODE_BY_STATUS[error.statusCode] if (!code) return v2Error('INTERNAL_ERROR', 'Internal server error') return v2Error(code, error.message) } diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts index 1ac2c88dbec..002fa26b1c3 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -5,6 +5,7 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/work import { getErrorMessage } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import type { V2ErrorCode } from '@/lib/api/contracts/v2/error-codes' import { V2_WORKFLOW_RUN_ID_HEADER, v2ExecuteWorkflowContract, @@ -45,7 +46,7 @@ import { hasAgentStreamPolicy, } from '@/lib/workflows/streaming/agent-stream-protocol' import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { type V2ErrorCode, v2Data, v2Error } from '@/app/api/v2/lib/response' +import { v2Data, v2Error } from '@/app/api/v2/lib/response' import { PublicApiNotAllowedError, validatePublicApiAllowed, diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts index d3e3410117e..1b5b87070e9 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/resume/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' +import type { V2ErrorCode } from '@/lib/api/contracts/v2/error-codes' import { V2_WORKFLOW_RUN_ID_HEADER, v2ResumeWorkflowContract, @@ -19,7 +20,7 @@ import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { workflowOperations } from '@/lib/workflows/application/operations' import { resumeWorkflowRun } from '@/lib/workflows/application/resume-run' import { ResumeWorkflowExecutionError } from '@/lib/workflows/executor/resume-execution' -import { type V2ErrorCode, v2Data, v2Error } from '@/app/api/v2/lib/response' +import { v2Data, v2Error } from '@/app/api/v2/lib/response' import { classifyExecutionError } from '@/executor/utils/errors' const logger = createLogger('V2WorkflowResumeAPI') diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/drag-move-pruning.test.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/drag-move-pruning.test.ts new file mode 100644 index 00000000000..d21b86dae56 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/drag-move-pruning.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { dropRowsCarriedByDraggedFolders } from '@/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop' + +/** + * reports/ (a) + * └── 2024/ (b) + * └── q3/ (c) + * archive/ (d) + */ +const PARENT_BY_FOLDER: Record = { a: null, b: 'a', c: 'b', d: null } +const FOLDER_BY_RESOURCE: Record = { + 'file-in-a': 'a', + 'file-in-c': 'c', + 'file-in-d': 'd', + 'file-at-root': null, +} + +const DESCENDANTS = new Map>([ + ['a', new Set(['b', 'c'])], + ['b', new Set(['c'])], + ['c', new Set()], + ['d', new Set()], +]) + +const accessors = { + descendantsByFolderId: DESCENDANTS, + getFolderParentId: (id: string) => PARENT_BY_FOLDER[id], + getResourceFolderId: (id: string) => FOLDER_BY_RESOURCE[id], +} + +const prune = (folderIds: string[], resourceIds: string[]) => + dropRowsCarriedByDraggedFolders({ folderIds, resourceIds }, accessors) + +describe('dropRowsCarriedByDraggedFolders', () => { + it('leaves a move with no folders untouched', () => { + expect(prune([], ['file-in-a', 'file-in-d'])).toEqual({ + folderIds: [], + resourceIds: ['file-in-a', 'file-in-d'], + }) + }) + + it('drops a file that the dragged folder directly contains', () => { + expect(prune(['a'], ['file-in-a', 'file-in-d'])).toEqual({ + folderIds: ['a'], + resourceIds: ['file-in-d'], + }) + }) + + it('drops a file nested deeper inside the dragged folder', () => { + expect(prune(['a'], ['file-in-c'])).toEqual({ folderIds: ['a'], resourceIds: [] }) + }) + + it('drops a descendant folder dragged alongside its ancestor', () => { + expect(prune(['a', 'c'], [])).toEqual({ folderIds: ['a'], resourceIds: [] }) + expect(prune(['a', 'b', 'c'], [])).toEqual({ folderIds: ['a'], resourceIds: [] }) + }) + + it('keeps unrelated folders and root-level files', () => { + expect(prune(['a', 'd'], ['file-at-root'])).toEqual({ + folderIds: ['a', 'd'], + resourceIds: ['file-at-root'], + }) + }) + + it('never drops the only dragged folder', () => { + expect(prune(['c'], [])).toEqual({ folderIds: ['c'], resourceIds: [] }) + }) + + it('can empty the move entirely when every row rides along', () => { + expect(prune(['a'], ['file-in-a'])).toEqual({ folderIds: ['a'], resourceIds: [] }) + expect(prune(['a', 'b'], ['file-in-c'])).toEqual({ folderIds: ['a'], resourceIds: [] }) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-search-scope.test.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-search-scope.test.ts new file mode 100644 index 00000000000..ef411851586 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-search-scope.test.ts @@ -0,0 +1,115 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + folderLocationLabel, + isSearchingResources, + scopeFolderedItems, +} from '@/app/workspace/[workspaceId]/components/folders/folder-search-scope' + +interface Item { + id: string + name: string + description?: string | null + folderId: string | null +} + +const ITEMS: Item[] = [ + { id: 'root-report', name: 'report.pdf', folderId: null }, + { id: 'a-report', name: 'report.pdf', folderId: 'a' }, + { id: 'b-budget', name: 'budget.xlsx', folderId: 'b' }, + { id: 'a-notes', name: 'notes.md', description: 'quarterly report', folderId: 'a' }, +] + +const scope = (currentFolderId: string | null, search: string) => + scopeFolderedItems(ITEMS, { + currentFolderId, + search, + getParentId: (item) => item.folderId, + getSearchText: (item) => [item.name], + }).map((item) => item.id) + +describe('scopeFolderedItems', () => { + it('shows only direct children when there is no query', () => { + expect(scope(null, '')).toEqual(['root-report']) + expect(scope('a', '')).toEqual(['a-report', 'a-notes']) + }) + + it('treats a whitespace-only query as no query', () => { + expect(scope('a', ' ')).toEqual(['a-report', 'a-notes']) + }) + + it('searches every folder, not just the open one', () => { + expect(scope('b', 'report')).toEqual(['root-report', 'a-report']) + }) + + it('finds nested matches from the workspace root', () => { + expect(scope(null, 'budget')).toEqual(['b-budget']) + }) + + it('matches case-insensitively', () => { + expect(scope(null, 'BUDGET')).toEqual(['b-budget']) + }) + + it('matches any of several fields independently', () => { + const ids = scopeFolderedItems(ITEMS, { + currentFolderId: null, + search: 'quarterly', + getParentId: (item) => item.folderId, + getSearchText: (item) => [item.name, item.description], + }).map((item) => item.id) + expect(ids).toEqual(['a-notes']) + }) + + it('never lets a query straddle two fields', () => { + const ids = scopeFolderedItems([{ id: 'x', name: 'ab', description: 'cd', folderId: null }], { + currentFolderId: null, + search: 'bc', + getParentId: (item) => item.folderId, + getSearchText: (item) => [item.name, item.description], + }) + expect(ids).toEqual([]) + }) + + it('tolerates absent fields', () => { + const ids = scopeFolderedItems(ITEMS, { + currentFolderId: null, + search: 'report', + getParentId: (item) => item.folderId, + getSearchText: (item) => [item.description], + }).map((item) => item.id) + expect(ids).toEqual(['a-notes']) + }) +}) + +describe('isSearchingResources', () => { + it('ignores whitespace-only queries', () => { + expect(isSearchingResources('')).toBe(false) + expect(isSearchingResources(' ')).toBe(false) + expect(isSearchingResources('a')).toBe(true) + }) +}) + +describe('folderLocationLabel', () => { + const folders = new Map([ + ['a', { id: 'a', name: 'Projects', parentId: null }], + ['b', { id: 'b', name: 'Q3', parentId: 'a' }], + ['orphan', { id: 'orphan', name: 'Lost', parentId: 'gone' }], + ]) + + it('names the root when the item sits at the root', () => { + expect(folderLocationLabel(null, folders, 'Files')).toBe('Files') + expect(folderLocationLabel(undefined, folders, 'Files')).toBe('Files') + }) + + it('joins the ancestor chain root-first', () => { + expect(folderLocationLabel('a', folders, 'Files')).toBe('Projects') + expect(folderLocationLabel('b', folders, 'Files')).toBe('Projects / Q3') + }) + + it('says it does not know rather than claiming a partial path or the root', () => { + expect(folderLocationLabel('orphan', folders, 'Files')).toBe('Unknown') + expect(folderLocationLabel('missing', folders, 'Files')).toBe('Unknown') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-search-scope.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-search-scope.ts new file mode 100644 index 00000000000..8ede6b91fa8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-search-scope.ts @@ -0,0 +1,111 @@ +import type { BreadcrumbFolder } from '@/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs' +import { breadcrumbFolderChain } from '@/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs' +import type { + ResourceCell, + ResourceColumn, +} from '@/app/workspace/[workspaceId]/components/resource/resource' + +/** + * Whether the list is showing search results rather than a folder's contents. + * + * The distinction drives more than the filter: while searching, a row's folder is no + * longer implied by the page, so the list has to say where each row lives, and an empty + * result is a failed search rather than an empty place. + */ +export function isSearchingResources(search: string): boolean { + return search.trim().length > 0 +} + +/** + * The rows a foldered list should show. + * + * With no query the list is a place: the open folder's direct children, and nothing else. + * With a query it stops being a place and searches the whole workspace, because a name you + * only half-remember is precisely the case where you do not know which folder it is in. + * Intersecting the two — matching the query only against the open folder — answers a + * question nobody asks, and is indistinguishable from "no such file" when the file exists + * one level down. + * + * Callers pass an already-debounced query; this runs on every keystroke's worth of state. + */ +export function scopeFolderedItems( + items: readonly T[], + { + currentFolderId, + search, + getParentId, + getSearchText, + }: { + /** The open folder, or `null` at the workspace root. */ + currentFolderId: string | null + search: string + getParentId: (item: T) => string | null + /** + * The item's searchable fields. Matched one at a time rather than joined, so a query can + * never straddle two of them — concatenating a name and a description would let the tail + * of one and the head of the other match text that appears nowhere. + */ + getSearchText: (item: T) => readonly (string | null | undefined)[] + } +): T[] { + if (!isSearchingResources(search)) { + return items.filter((item) => getParentId(item) === currentFolderId) + } + const needle = search.trim().toLowerCase() + return items.filter((item) => + getSearchText(item).some((field) => field?.toLowerCase().includes(needle)) + ) +} + +/** + * What the location column shows when a row's folder cannot be resolved to a full path — + * its ancestor chain is broken, so the honest answer is that we do not know where it is. + * + * Deliberately not `rootLabel`: this column exists to answer "where does this row live", and + * naming the workspace root there is a specific wrong answer rather than a missing one. The + * root case is a row with no folder at all, which is genuinely at the root. + */ +export const UNKNOWN_FOLDER_LOCATION = 'Unknown' + +/** + * Where a row lives, for the list's location column — ancestor names root-first joined by + * `/`, or `rootLabel` for a row sitting at the workspace root. + * + * A chain that does not reach the root yields {@link UNKNOWN_FOLDER_LOCATION}, matching + * {@link breadcrumbFolderChain}'s own rule that a partial path is not a shorter path, it is + * a wrong one. Reachable when an ancestor folder was archived out from under the row. + */ +export function folderLocationLabel( + folderId: string | null | undefined, + folderById: ReadonlyMap, + rootLabel: string +): string { + if (!folderId) return rootLabel + const chain = breadcrumbFolderChain(folderId, folderById) + return chain.length > 0 ? chain.map((folder) => folder.name).join(' / ') : UNKNOWN_FOLDER_LOCATION +} + +/** + * The column naming each row's folder, carried only while searching: results span every + * folder, so a name alone no longer says where a row lives. When the list is a folder's + * contents the breadcrumb already says it. + * + * Defined here rather than per page so one conceptual column has one header and one width, + * and beside {@link folderLocationLabel} because that is what fills it. Each page appends it + * to its own columns at module scope, so the table swaps between two stable arrays rather + * than building one per render. + */ +export const FOLDER_LOCATION_COLUMN: ResourceColumn = { + id: 'location', + header: 'Location', + widthMultiplier: 1.1, +} + +/** + * The location cell a row carries while the list is not searching. + * + * {@link FOLDER_LOCATION_COLUMN} is absent then, so nothing renders this — which is the + * point: resolving an ancestor chain per row to fill a column that is not on screen is work + * every row throws away. Shared and empty so the skipped cell still has a stable identity. + */ +export const EMPTY_LOCATION_CELL: ResourceCell = {} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts index 20c6a275243..93f2ecf13f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts @@ -7,6 +7,13 @@ export type { FolderRowOptions } from './folder-row' export { folderRow } from './folder-row' export type { FolderedRowKind, ParsedFolderedRowId } from './folder-row-id' export { folderRowId, parseFolderedRowId, splitFolderedRowIds } from './folder-row-id' +export { + EMPTY_LOCATION_CELL, + FOLDER_LOCATION_COLUMN, + folderLocationLabel, + isSearchingResources, + scopeFolderedItems, +} from './folder-search-scope' export type { FolderedHeaderResourceType, FolderedResourceHeaderMeta, diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts index a739111c83a..ccd3822ea12 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { useQueryStates } from 'nuqs' import type { ServedFolderResourceType } from '@/lib/api/contracts/folders' import { @@ -15,17 +15,36 @@ import { export interface UseFolderNavigationOptions { resourceType: ServedFolderResourceType workspaceId?: string + /** + * Runs before {@link FolderNavigation.openFolder} moves, for state the destination + * invalidates — in practice, clearing the list's search. Not run by + * {@link FolderNavigation.setCurrentFolderId}. + */ + onBeforeOpenFolder?: () => void } export interface FolderNavigation extends FolderAncestors { /** The open folder, or `null` at the workspace root. */ currentFolderId: string | null /** - * Opens a folder. Defaults to the param group's `history: 'push'` — a folder the user chose - * to open is a destination. Pass `{ history: 'replace' }` for a write that is not a chosen - * navigation, such as the second and later spring-opens within a single drag. + * Moves to a folder without side effects. For writes that are not a chosen navigation — + * a spring-open mid-drag, which is undone when the drag ends without a drop, or the heal + * below. Pass `{ history: 'replace' }` to keep such a write out of the back stack. */ setCurrentFolderId: (folderId: string | null, options?: { history?: 'push' | 'replace' }) => void + /** + * Opens a folder because the user chose to, running {@link + * UseFolderNavigationOptions.onBeforeOpenFolder} first. + * + * Separate from {@link FolderNavigation.setCurrentFolderId} because opening a folder ends a + * search — the results span every folder, so the one the user picked out of them is a + * destination, not a narrower place to keep searching — while a spring-open must not, or an + * abandoned drag would discard the search that produced the row being dragged. + * + * Defaults to the param group's `history: 'push'`: a chosen folder is a destination, and + * Back returns to the results that led there. + */ + openFolder: (folderId: string | null, options?: { history?: 'push' | 'replace' }) => void } /** @@ -40,6 +59,7 @@ export interface FolderNavigation extends FolderAncestors { export function useFolderNavigation({ resourceType, workspaceId, + onBeforeOpenFolder, }: UseFolderNavigationOptions): FolderNavigation { const [{ folderId: currentFolderId }, setFolderParams] = useQueryStates( folderNavParsers, @@ -60,6 +80,22 @@ export function useFolderNavigation({ [setFolderParams] ) + const onBeforeOpenFolderRef = useRef(onBeforeOpenFolder) + onBeforeOpenFolderRef.current = onBeforeOpenFolder + + /** + * Both writes land in one URL update: nuqs batches same-tick writes across param groups and + * escalates the batch to `push` when any of them pushes, so clearing a `history: 'replace'` + * search alongside the folder change stays a single history entry. + */ + const openFolder = useCallback( + (folderId: string | null, options?: { history?: 'push' | 'replace' }) => { + onBeforeOpenFolderRef.current?.() + void setFolderParams({ folderId }, options) + }, + [setFolderParams] + ) + /** * Heals a `?folderId=` that no longer resolves — a bookmark to a folder since deleted, or a * link from someone whose workspace it was not. @@ -84,5 +120,5 @@ export function useFolderNavigation({ void setFolderParams({ folderId: null }, { history: 'replace' }) }, [foldersResolved, currentFolderId, folderById, setFolderParams]) - return { ...ancestry, currentFolderId, setCurrentFolderId } + return { ...ancestry, currentFolderId, setCurrentFolderId, openFolder } } diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts index 4ac69bb78b8..61c981dc349 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts @@ -72,6 +72,47 @@ export interface FolderedRowMove { resourceIds: string[] } +/** + * Drops every row that one of the dragged folders already carries. + * + * Moving a folder takes its contents with it, so naming both a folder and something inside it + * would move the parent AND separately pull the child out of it: the two land as siblings and + * the hierarchy the user dragged is gone. The folder wins, because it is the thing they + * grabbed the outside of. + * + * Only reachable since search began returning rows from across the workspace — a list showing + * one folder's direct children can never show a row alongside its own ancestor. + */ +export function dropRowsCarriedByDraggedFolders( + move: FolderedRowMove, + { + descendantsByFolderId, + getFolderParentId, + getResourceFolderId, + }: { + descendantsByFolderId: Map> + getFolderParentId: (folderId: string) => string | null | undefined + getResourceFolderId: (resourceId: string) => string | null | undefined + } +): FolderedRowMove { + const draggedFolderIds = new Set(move.folderIds) + if (draggedFolderIds.size === 0) return move + + const isCarried = (ownerFolderId: string | null | undefined): boolean => { + if (!ownerFolderId) return false + for (const folderId of draggedFolderIds) { + if (ownerFolderId === folderId) return true + if (descendantsByFolderId.get(folderId)?.has(ownerFolderId)) return true + } + return false + } + + return { + folderIds: move.folderIds.filter((id) => !isCarried(getFolderParentId(id))), + resourceIds: move.resourceIds.filter((id) => !isCarried(getResourceFolderId(id))), + } +} + export interface UseFolderRowDragDropOptions { /** * This list's private drag MIME. Each surface owns one so a drag started in another list is @@ -121,6 +162,21 @@ export interface UseFolderRowDragDropOptions { * empty folder, which has no row to drop on. */ currentFolderId?: string | null + /** + * The folder the list body drops into, or `undefined` when the body does not stand for a + * folder at all and must decline. + * + * It differs from {@link currentFolderId} whenever the visible rows are not that folder's + * contents. Dropping on the blank area below the rows would then file a row into a folder + * the drop UI never names — harmless when every row already lives there (the target + * declines the no-op), destructive when they do not. A row drop is unaffected: that target + * names its own destination. + * + * Required, and deliberately without a default: a destructuring default fires on an + * explicit `undefined` too, so `= currentFolderId` would silently swallow the very value a + * searching caller passes to decline and leave the guard unreachable. + */ + bodyDropFolderId: string | null | undefined /** * OS file drops, which Files accepts and the other lists do not. * @@ -157,6 +213,7 @@ export function useFolderRowDragDrop({ selection, onSpringOpenFolder, currentFolderId = null, + bodyDropFolderId, externalDrop, }: UseFolderRowDragDropOptions): FolderRowDragDrop { const [activeDropTarget, setActiveDropTarget] = useState(null) @@ -192,6 +249,9 @@ export function useFolderRowDragDrop({ const currentFolderIdRef = useRef(currentFolderId) currentFolderIdRef.current = currentFolderId + const bodyDropFolderIdRef = useRef(bodyDropFolderId) + bodyDropFolderIdRef.current = bodyDropFolderId + const dragGhost = useRowDragGhost() /** Returns the list to its resting state once a drag is over, however it ended. */ @@ -207,9 +267,10 @@ export function useFolderRowDragDrop({ /** * Splits the drag into the rows that would actually move into `targetFolderId`, dropping any - * row already sitting directly there. `null` when the drop is illegal outright — the target is - * one of the dragged folders or inside one, which would orphan a subtree into itself — or when - * nothing would actually change. + * row already sitting directly there and any row a dragged folder already carries (see + * {@link dropRowsCarriedByDraggedFolders}). `null` when the drop is illegal outright — the + * target is one of the dragged folders or inside one, which would orphan a subtree into + * itself — or when nothing would actually change. * * Takes a folder id rather than a row id because the destination is not always a row: the * list body files into the folder currently open, which has no row of its own, and `null` @@ -235,8 +296,12 @@ export function useFolderRowDragDrop({ resourceIds.push(source.id) } - if (folderIds.length === 0 && resourceIds.length === 0) return null - return { folderIds, resourceIds } + const moved = dropRowsCarriedByDraggedFolders( + { folderIds, resourceIds }, + { descendantsByFolderId, getFolderParentId, getResourceFolderId } + ) + if (moved.folderIds.length === 0 && moved.resourceIds.length === 0) return null + return moved }, [] ) @@ -437,9 +502,11 @@ export function useFolderRowDragDrop({ * early return would leave the body overlay showing from the previous folder. Setting * the same value repeatedly is free — React bails on an unchanged state write. */ + const targetFolderId = bodyDropFolderIdRef.current const canDrop = + targetFolderId !== undefined && sourceRowIds.length > 0 && - resolveMoveToFolder(currentFolderIdRef.current, sourceRowIds) !== null + resolveMoveToFolder(targetFolderId, sourceRowIds) !== null setActiveDropTarget((current) => canDrop ? armDropTarget(current, { kind: 'body' }) : null ) @@ -454,17 +521,19 @@ export function useFolderRowDragDrop({ }, onDrop: (e: DragEvent) => { if (optionsRef.current.externalDrop?.matches(e.dataTransfer)) return - e.preventDefault() - e.stopPropagation() - const sourceRowIds = - readRowDragPayload(e.dataTransfer, dragMime) ?? draggedRowIdsRef.current /** * Read from the ref, not the closure. This config is memoized, and during a drag the * only dep that routinely changes is the hovered row — so after a spring-open into an - * empty folder, which has no rows to hover, a captured `currentFolderId` would still - * name the folder the drag came FROM and file the rows back into it. + * empty folder, which has no rows to hover, a captured folder id would still name + * the folder the drag came FROM and file the rows back into it. */ - const targetFolderId = currentFolderIdRef.current + const targetFolderId = bodyDropFolderIdRef.current + /** The body never armed, so a release here is a miss rather than a move. */ + if (targetFolderId === undefined) return + e.preventDefault() + e.stopPropagation() + const sourceRowIds = + readRowDragPayload(e.dataTransfer, dragMime) ?? draggedRowIdsRef.current const move = sourceRowIds.length > 0 ? resolveMoveToFolder(targetFolderId, sourceRowIds) : null if (move) springNav.markDropHandled() diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 870cc069692..2c16cc15fb3 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -1,4 +1,7 @@ -export { isResourceListEmpty } from '@/app/workspace/[workspaceId]/components/resource/is-resource-list-empty' +export { + isResourceListEmpty, + resourceListState, +} from '@/app/workspace/[workspaceId]/components/resource/is-resource-list-empty' export { ResourceNotFound } from '@/app/workspace/[workspaceId]/components/resource/resource-not-found' export { ConversationListItem } from './conversation-list-item' export type { ErrorBoundaryProps, ErrorStateProps } from './error' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts index 59e06a779dd..e7d9ddd4474 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts @@ -2,4 +2,5 @@ export { DocumentsEmptyState } from '@/app/workspace/[workspaceId]/components/re export { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state' export { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state' export { LogsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state' +export { ResourceNoResults } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results' export { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results.tsx new file mode 100644 index 00000000000..650be72ed63 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results.tsx @@ -0,0 +1,49 @@ +import { Chip } from '@sim/emcn' +import { EmptyState } from '@/components/empty-state/empty-state' + +interface ResourceNoResultsProps { + /** + * The query the rows were actually filtered by — the debounced value, never the instant + * URL one, or the copy names a query the list has not run yet. Empty when only filters + * narrowed the list. + */ + search: string + /** Applied filter chips, so the copy can name filters as the thing that matched nothing. */ + filterCount: number + /** Clears the query and every filter, restoring the open folder's contents. */ + onClear: () => void +} + +/** + * What a foldered list shows when a search or filter matched nothing. + * + * Distinct from the resource's zero-data graphic, which invites you to create your first + * item and would be a lie here — see {@link resourceListState}. Without this the table + * renders an unexplained blank, which reads as a broken page rather than an empty result. + * + * Both descriptions state a scope the user cannot otherwise see, which is what earns them a + * line at all: a search spans every folder, so a bare "no results" while standing inside one + * invites exactly the wrong conclusion — that they should go looking elsewhere themselves — + * while filters narrow only the open folder. The search branch wins when both are set, + * because the wider scope is the more surprising of the two. + */ +export function ResourceNoResults({ search, filterCount, onClear }: ResourceNoResultsProps) { + const trimmed = search.trim() + return ( + + {trimmed && filterCount > 0 + ? 'Clear search and filters' + : trimmed + ? 'Clear search' + : 'Clear filters'} + + } + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/is-resource-list-empty.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/is-resource-list-empty.ts index c9b8ece3273..6ad8ddbc927 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/is-resource-list-empty.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/is-resource-list-empty.ts @@ -42,7 +42,24 @@ interface ResourceListEmptyInput { * - The folder tree still resolving, for the same reason as the rows themselves: a * workspace whose only contents are folders reads as empty until they land. */ -export function isResourceListEmpty({ +export function isResourceListEmpty(input: ResourceListEmptyInput): boolean { + return resourceListState(input) === 'empty' +} + +/** + * What a resource list should render in place of rows. + * + * - `rows` — rows are showing, or none have settled yet, so the table renders itself. + * - `empty` — the workspace genuinely holds nothing; the zero-data graphic is the true answer. + * - `no-results` — a search or filter matched nothing. Distinct from `empty`, because the + * copy that invites you to create your first item would be a lie, and distinct from `rows`, + * because rendering neither leaves an unexplained blank table. + * + * One function rather than two predicates: the two states share every "the rows have actually + * arrived" condition, and when those lived in both places a new condition could be added to + * one and silently left off the other. + */ +export function resourceListState({ rowCount, isLoading, isPlaceholderData, @@ -51,15 +68,11 @@ export function isResourceListEmpty({ filterCount, folderId = null, foldersResolved = true, -}: ResourceListEmptyInput): boolean { - return ( - rowCount === 0 && - !isLoading && - !isPlaceholderData && - !error && - foldersResolved && - folderId === null && - !search.trim() && - filterCount === 0 - ) +}: ResourceListEmptyInput): 'rows' | 'empty' | 'no-results' { + const settled = rowCount === 0 && !isLoading && !isPlaceholderData && !error && foldersResolved + if (!settled) return 'rows' + const narrowed = Boolean(search.trim()) || filterCount > 0 + if (narrowed) return 'no-results' + /** An empty subfolder is not an empty workspace, so it gets neither state. */ + return folderId === null ? 'empty' : 'rows' } diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 7c8e0825148..79f96559e3f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -59,10 +59,10 @@ import type { import { EMPTY_CELL_PLACEHOLDER, FILTER_SECTION_LABEL_CLASS, - isResourceListEmpty, OwnerAvatar, ownerCell, Resource, + resourceListState, selectionLabel, timeCell, useResourceRowSelection, @@ -75,18 +75,26 @@ import { breadcrumbFolderChain, buildDescendantIndex, buildMoveOptionsExcludingSubtrees, + EMPTY_LOCATION_CELL, + FOLDER_LOCATION_COLUMN, FOLDERED_RESOURCE_HEADERS, folderBreadcrumbItems, folderedResourceListHref, + folderLocationLabel, folderRowId, + isSearchingResources, parseFolderedRowId, parseMoveOptionValue, + scopeFolderedItems, sortResources, splitFolderedRowIds, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' -import { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' +import { + FilesEmptyState, + ResourceNoResults, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal' import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-row-context-menu' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' @@ -136,10 +144,10 @@ import { useUploadWorkspaceFile, useWorkspaceFiles, } from '@/hooks/queries/workspace-files' -import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useSearchFilterValue } from '@/hooks/use-search-filter-value' import { useUrlSort } from '@/hooks/use-url-sort' type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' @@ -193,6 +201,13 @@ const COLUMNS: ResourceColumn[] = [ { id: 'updated', header: 'Last Updated' }, ] +/** + * Deliberately absent from {@link filesSortParams}, so the location column is not offered in + * the sort menu — those columns are a URL contract, and ordering by path is not worth + * persisting in a shared link before anyone asks for it. + */ +const SEARCH_COLUMNS: ResourceColumn[] = [...COLUMNS, FOLDER_LOCATION_COLUMN] + const MIME_TYPE_LABELS: Record = { 'application/pdf': 'PDF', 'application/zip': 'ZIP', @@ -368,7 +383,30 @@ export function Files() { (value, options) => setFileFilters({ search: value }, options), { debounceMs: FILES_SEARCH_DEBOUNCE_MS } ) - const debouncedSearchTerm = useDebounce(urlSearchTerm, FILES_SEARCH_DEBOUNCE_MS) + const debouncedSearchTerm = useSearchFilterValue(urlSearchTerm, FILES_SEARCH_DEBOUNCE_MS) + + /** + * Files' equivalent of `useFolderNavigation`'s `openFolder`, which Tables and Knowledge + * use — kept local because this page owns its own param group and has to clear `new` in + * the same batch. Change the two together. + * + * Opens a folder, clearing any active query on the way in. Search spans every folder, so a + * folder in the results is a destination the user picked out of them — not a narrower place + * to keep searching. Carrying the term across would filter the folder they just opened down + * to the same matches they were already looking at, which is how this read as "the folder is + * empty". + * + * The writes land in one URL update: nuqs batches same-tick writes across param groups and + * escalates the batch to `push`, so this stays a single history entry and Back returns to + * the results that led here. + */ + const navigateToFolder = useCallback( + (folderId: string | null, options?: { history?: 'push' | 'replace' }) => { + setSearchTerm('') + void setFilesParams({ folderId, new: null }, options) + }, + [setSearchTerm, setFilesParams] + ) const { sort: sortColumn, @@ -528,21 +566,31 @@ export function Files() { return totalSize }, [files, folders]) - const visibleFolders = useMemo(() => { - const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) - const needle = debouncedSearchTerm.trim().toLowerCase() - return needle - ? siblings.filter((folder) => folder.name.toLowerCase().includes(needle)) - : siblings - }, [folders, currentFolderId, debouncedSearchTerm]) + /** + * A query stops scoping the list to the open folder — see {@link scopeFolderedItems}. A + * matching folder anywhere in the workspace is a result in its own right, since opening it + * is often what the user was looking for. + */ + const isSearching = isSearchingResources(debouncedSearchTerm) + + const visibleFolders = useMemo( + () => + scopeFolderedItems(folders, { + currentFolderId, + search: debouncedSearchTerm, + getParentId: (folder) => folder.parentId ?? null, + getSearchText: (folder) => [folder.name], + }), + [folders, currentFolderId, debouncedSearchTerm] + ) const filteredFiles = useMemo(() => { - const needle = debouncedSearchTerm.trim().toLowerCase() - let result = needle - ? files.filter( - (f) => (f.folderId ?? null) === currentFolderId && f.name.toLowerCase().includes(needle) - ) - : files.filter((f) => (f.folderId ?? null) === currentFolderId) + let result = scopeFolderedItems(files, { + currentFolderId, + search: debouncedSearchTerm, + getParentId: (f) => f.folderId ?? null, + getSearchText: (f) => [f.name], + }) if (typeFilter.length > 0) { result = result.filter((f) => { @@ -666,6 +714,16 @@ export function Files() { created: timeCell(folder.createdAt), owner: ownerCell(folder.userId, membersById), updated: timeCell(folder.updatedAt), + /** + * A folder's location is its parent's path, not its own. Built only while + * searching: the column is absent otherwise, so resolving an ancestor chain + * per row would be work every row throws away. + */ + location: isSearching + ? { + label: folderLocationLabel(folder.parentId, folderById, FILES_HEADER.rootLabel), + } + : EMPTY_LOCATION_CELL, }, } } @@ -690,10 +748,13 @@ export function Files() { created: timeCell(file.uploadedAt), owner: ownerCell(file.uploadedBy, membersById), updated: timeCell(file.updatedAt), + location: isSearching + ? { label: folderLocationLabel(file.folderId, folderById, FILES_HEADER.rootLabel) } + : EMPTY_LOCATION_CELL, }, } }), - [sortedEntries, membersById, folderSizeMap] + [sortedEntries, membersById, folderSizeMap, folderById, isSearching] ) const rows: ResourceRow[] = useMemo(() => { @@ -743,6 +804,14 @@ export function Files() { async (filesToUpload: File[], targetFolderId = currentFolderId) => { if (!workspaceId || filesToUpload.length === 0 || !canEdit) return + /** + * Uploads land in a folder, but a live query is showing results from across the + * workspace and an uploaded name rarely matches it — the new rows would not render and + * the upload would read as having failed. Cleared up front so the list is already + * showing the destination as the progress counter runs. + */ + setSearchTerm('') + const oversized: string[] = [] const sizeFiltered = filesToUpload.filter((f) => { if (f.size > MAX_WORKSPACE_FILE_SIZE) { @@ -807,7 +876,7 @@ export function Files() { setUploadProgress({ completed: 0, total: 0, currentPercent: 0 }) } }, - [workspaceId, canEdit, currentFolderId, notifyLimit] + [workspaceId, canEdit, currentFolderId, notifyLimit, setSearchTerm] ) const rowDragDropConfig = useFolderRowDragDrop({ @@ -830,10 +899,20 @@ export function Files() { .catch((error) => logger.error('Failed to move items:', error)) }, selection: { selectedRowIds, visibleRowIds, replaceSelection }, + /** + * Moves the folder without touching the query, unlike every other navigation here. + * + * A spring-open is a step inside a drag, not a destination the user chose, and it is + * undone when the drag ends without a drop. Clearing the query on the way in would be + * clearing it on the way back out too — the restore runs through this same callback — + * so an abandoned drag would silently discard the search that produced the row being + * dragged, with `history: 'replace'` leaving nothing for Back to recover. + */ onSpringOpenFolder: (folderId, options) => { void setFilesParams({ folderId, new: null }, options) }, currentFolderId, + bodyDropFolderId: isSearching ? undefined : currentFolderId, /** * The one thing this list does that the others do not. Folder rows still highlight and * spring open for an OS file drag — filing an upload into a nested folder is the same @@ -1171,12 +1250,19 @@ export function Files() { name, parentId: currentFolderId, }) + /** + * The new folder goes into the open folder, but a live query is showing results from + * across the workspace and "New folder" almost never matches it — the row would not + * render and the rename it opens would have nothing to attach to, so creating would + * read as having done nothing at all. + */ + setSearchTerm('') listRename.startRename(folderRowId(folder.id), folder.name) } catch (error) { logger.error('Failed to create folder:', error) toast.error(toError(error).message) } - }, [workspaceId, folders, currentFolderId, listRename.startRename]) + }, [workspaceId, folders, currentFolderId, listRename.startRename, setSearchTerm]) const handleRowContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { @@ -1202,7 +1288,7 @@ export function Files() { const item = contextMenuItemRef.current if (!item) return if (item.kind === 'folder') { - void setFilesParams({ folderId: item.folder.id, new: null }) + navigateToFolder(item.folder.id) closeContextMenu() return } @@ -1212,7 +1298,7 @@ export function Files() { : `/workspace/${workspaceId}/files/${item.file.id}` ) closeContextMenu() - }, [closeContextMenu, router, workspaceId, setFilesParams]) + }, [closeContextMenu, router, workspaceId, navigateToFolder]) const handleContextMenuDownload = useCallback(() => { const item = contextMenuItemRef.current @@ -1448,7 +1534,7 @@ export function Files() { if (listRenameRef.current.editingId !== rowId && !headerRenameRef.current.editingId) { const parsed = parseFolderedRowId(rowId) if (parsed.kind === 'folder') { - void setFilesParams({ folderId: parsed.id, new: null }) + navigateToFolder(parsed.id) return } const file = fileByIdRef.current.get(parsed.id) @@ -1456,14 +1542,20 @@ export function Files() { setExtractTargetId(file.id) return } + /** + * The file's own folder, not the open one. A search result usually lives elsewhere, + * and this param is what the viewer returns to on close or delete — carrying the open + * folder would send the user to a folder the file was never in. + */ + const fileFolderId = file?.folderId ?? null router.push( - currentFolderId - ? `/workspace/${workspaceId}/files/${parsed.id}?folderId=${currentFolderId}` + fileFolderId + ? `/workspace/${workspaceId}/files/${parsed.id}?folderId=${fileFolderId}` : `/workspace/${workspaceId}/files/${parsed.id}` ) } }, - [router, workspaceId, currentFolderId, setFilesParams] + [router, workspaceId, navigateToFolder] ) const handleExtract = async () => { @@ -1546,13 +1638,6 @@ export function Files() { ] ) - const handleNavigateToListFolder = useCallback( - (folderId: string | null) => { - void setFilesParams({ folderId, new: null }) - }, - [setFilesParams] - ) - const listFolderChain = useMemo( () => breadcrumbFolderChain(currentFolderId, folderById), [currentFolderId, folderById] @@ -1587,7 +1672,7 @@ export function Files() { rootLabel: FILES_HEADER.rootLabel, rootIcon: FILES_HEADER.rootIcon, breadcrumbs: listFolderChain, - onNavigate: handleNavigateToListFolder, + onNavigate: navigateToFolder, currentFolderEditing: openListFolder && breadcrumbRename.editingId === openListFolder.id ? { @@ -1614,7 +1699,7 @@ export function Files() { [ listFolderChain, openListFolder, - handleNavigateToListFolder, + navigateToFolder, canEdit, userPermissions.isLoading, breadcrumbRename.editingId, @@ -1817,7 +1902,7 @@ export function Files() { return tags }, [typeFilter, sizeFilter, uploadedByFilter, membersById]) - const showEmptyState = isResourceListEmpty({ + const listState = resourceListState({ rowCount: rows.length, isLoading, isPlaceholderData, @@ -1828,6 +1913,11 @@ export function Files() { foldersResolved, }) + const clearSearchAndFilters = () => { + setSearchTerm('') + void setFileFilters({ type: null, size: null, uploadedBy: null }) + } + if (fileIdFromRoute && !selectedFile && isLoading) { return ( @@ -1928,14 +2018,20 @@ export function Files() { filter={filterConfig} /> + ) : listState === 'no-results' ? ( + ) : undefined } selectable={selectableConfig} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index b1828f0eb09..d6227a72955 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -24,11 +24,11 @@ import type { import { EMPTY_CELL_PLACEHOLDER, FILTER_SECTION_LABEL_CLASS, - isResourceListEmpty, OwnerAvatar, ownerCell, Resource, reportBulkOutcome, + resourceListState, selectionLabel, timeCell, useResourceRowSelection, @@ -41,21 +41,29 @@ import { buildDescendantIndex, buildMoveOptions, buildMoveOptionsExcludingSubtrees, + EMPTY_LOCATION_CELL, + FOLDER_LOCATION_COLUMN, FOLDERED_RESOURCE_HEADERS, FolderContextMenu, folderBreadcrumbItems, + folderLocationLabel, folderRow, folderRowId, + isSearchingResources, nextUntitledFolderName, parseFolderedRowId, parseMoveOptionValue, + scopeFolderedItems, sortResources, splitFolderedRowIds, useFolderNavigation, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' -import { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' +import { + KnowledgeEmptyState, + ResourceNoResults, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { CreateBaseModal, @@ -69,7 +77,6 @@ import { knowledgeSortParams, knowledgeUrlKeys, } from '@/app/workspace/[workspaceId]/knowledge/search-params' -import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/filter' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' @@ -84,10 +91,10 @@ import { } from '@/hooks/queries/kb/knowledge' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' -import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useSearchFilterValue } from '@/hooks/use-search-filter-value' import { useUrlSort } from '@/hooks/use-url-sort' import type { WorkflowFolder } from '@/stores/folders/types' @@ -112,6 +119,8 @@ const COLUMNS: ResourceColumn[] = [ { id: 'updated', header: 'Last Updated' }, ] +const SEARCH_COLUMNS: ResourceColumn[] = [...COLUMNS, FOLDER_LOCATION_COLUMN] + const KNOWLEDGE_BASE_ICON = const CONNECTOR_FILTER_OPTIONS: ChipDropdownOption[] = [ @@ -230,6 +239,7 @@ export function Knowledge() { const { currentFolderId, setCurrentFolderId, + openFolder, ancestors: breadcrumbs, folders, folderById, @@ -237,6 +247,8 @@ export function Knowledge() { } = useFolderNavigation({ resourceType: FOLDER_RESOURCE_TYPE, workspaceId, + /** Declared below; only ever called from a click, long after this render initializes it. */ + onBeforeOpenFolder: () => setSearchQuery(''), }) const createFolder = useCreateFolder() @@ -261,7 +273,7 @@ export function Knowledge() { const setSearchQuery = useDebouncedSearchSetter((value, options) => setKnowledgeFilters({ search: value }, options) ) - const debouncedSearchQuery = useDebounce(urlSearchQuery, SEARCH_DEBOUNCE_MS) + const debouncedSearchQuery = useSearchFilterValue(urlSearchQuery, SEARCH_DEBOUNCE_MS) const { sort: sortColumn, @@ -454,29 +466,39 @@ export function Knowledge() { * Files page. The resource filters (connectors/content/owner) describe properties a folder * does not have, so folders answer only to the search term. */ - const visibleFolders = useMemo(() => { - const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) - const needle = debouncedSearchQuery.trim().toLowerCase() - return needle - ? siblings.filter((folder) => folder.name.toLowerCase().includes(needle)) - : siblings - }, [folders, currentFolderId, debouncedSearchQuery]) + /** A query stops scoping the list to the open folder — see {@link scopeFolderedItems}. */ + const isSearching = isSearchingResources(debouncedSearchQuery) + + const visibleFolders = useMemo( + () => + scopeFolderedItems(folders, { + currentFolderId, + search: debouncedSearchQuery, + getParentId: (folder) => folder.parentId ?? null, + getSearchText: (folder) => [folder.name], + }), + [folders, currentFolderId, debouncedSearchQuery] + ) const processedKBs = useMemo(() => { - /** - * A `folderId` that no longer names an active folder — a base restored on its own out of - * Recently Deleted while its folder stayed archived, or a cascade that failed partway — - * would otherwise match no level at all and leave the base unreachable from every view. - * Fall it back to the root instead — but only once `foldersResolved` says the index is the - * complete set for THIS workspace. Gating on a loading flag instead would treat an errored - * fetch, a disabled query, or the previous workspace's cached folders as "no such folder" - * and drag every foldered base to the root. - */ - let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery).filter((kb) => { - const folderId = kb.folderId ?? null - const effectiveFolderId = - !foldersResolved || !folderId || folderById.has(folderId) ? folderId : null - return effectiveFolderId === currentFolderId + let result = scopeFolderedItems(knowledgeBases, { + currentFolderId, + search: debouncedSearchQuery, + /** + * A `folderId` that no longer names an active folder — a base restored on its own out of + * Recently Deleted while its folder stayed archived, or a cascade that failed partway — + * would otherwise match no level at all and leave the base unreachable from every view. + * Fall it back to the root instead — but only once `foldersResolved` says the index is the + * complete set for THIS workspace. Gating on a loading flag instead would treat an errored + * fetch, a disabled query, or the previous workspace's cached folders as "no such folder" + * and drag every foldered base to the root. + */ + getParentId: (kb) => { + const folderId = kb.folderId ?? null + return !foldersResolved || !folderId || folderById.has(folderId) ? folderId : null + }, + /** A base is findable by its description as well as its name. */ + getSearchText: (kb) => [kb.name, kb.description], }) if (connectorFilter.length > 0) { @@ -589,6 +611,16 @@ export function Knowledge() { created: timeCell(item.folder.createdAt), owner: ownerCell(item.folder.userId, membersById), updated: timeCell(item.folder.updatedAt), + /** A folder's location is its parent's path, not its own. */ + location: isSearching + ? { + label: folderLocationLabel( + item.folder.parentId, + folderById, + ROOT_BREADCRUMB_LABEL + ), + } + : EMPTY_LOCATION_CELL, }, }) } @@ -612,10 +644,13 @@ export function Knowledge() { created: timeCell(base.createdAt), owner: ownerCell(base.userId, membersById), updated: timeCell(base.updatedAt), + location: isSearching + ? { label: folderLocationLabel(base.folderId, folderById, ROOT_BREADCRUMB_LABEL) } + : EMPTY_LOCATION_CELL, }, } }), - [sortedEntries, membersById] + [sortedEntries, membersById, folderById, isSearching] ) /** @@ -710,7 +745,7 @@ export function Knowledge() { const parsed = parseFolderedRowId(rowId) if (parsed.kind === 'folder') { - setCurrentFolderId(parsed.id) + openFolder(parsed.id) return } @@ -719,7 +754,7 @@ export function Knowledge() { const urlParams = new URLSearchParams({ kbName: kb.name }) router.push(`/workspace/${workspaceId}/knowledge/${parsed.id}?${urlParams.toString()}`) }, - [router, workspaceId, setCurrentFolderId] + [router, workspaceId, openFolder] ) const handleRowContextMenu = useCallback( @@ -830,8 +865,8 @@ export function Knowledge() { const handleOpenFolder = useCallback(() => { const folder = activeFolderRef.current - if (folder) setCurrentFolderId(folder.id) - }, [setCurrentFolderId]) + if (folder) openFolder(folder.id) + }, [openFolder]) const handleCopyFolderId = useCallback(() => { const folder = activeFolderRef.current @@ -858,16 +893,17 @@ export function Knowledge() { setActiveFolder(null) // Deleting the folder you are standing in leaves the list pointed at an archived // folder, which renders as an empty page with a dead breadcrumb — step out to its - // parent instead. + // parent instead. Not `openFolder`: this is a forced correction, so it must neither + // clear an active search nor push a back-stack entry aimed at the deleted folder. if (currentFolderIdRef.current === folder.id) { - setCurrentFolderId(folder.parentId) + setCurrentFolderId(folder.parentId, { history: 'replace' }) } } catch (deleteError) { logger.error('Failed to delete folder', deleteError) toast.error(getErrorMessage(deleteError, 'Failed to delete folder')) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspaceId, setCurrentFolderId]) + }, [workspaceId, openFolder]) const descendantsByFolderId = useMemo(() => buildDescendantIndex(folders), [folders]) @@ -1100,6 +1136,7 @@ export function Knowledge() { selection: { selectedRowIds, visibleRowIds, replaceSelection }, onSpringOpenFolder: setCurrentFolderId, currentFolderId, + bodyDropFolderId: isSearching ? undefined : currentFolderId, }) const headerActions: ResourceAction[] = useMemo( @@ -1127,7 +1164,7 @@ export function Knowledge() { rootLabel: ROOT_BREADCRUMB_LABEL, rootIcon: FOLDERED_RESOURCE_HEADERS[FOLDER_RESOURCE_TYPE].rootIcon, breadcrumbs, - onNavigate: setCurrentFolderId, + onNavigate: openFolder, currentFolderEditing: breadcrumbRename.editingId && breadcrumbRename.editingId === currentFolderId ? { @@ -1161,7 +1198,7 @@ export function Knowledge() { [ breadcrumbs, currentFolderId, - setCurrentFolderId, + openFolder, canEdit, breadcrumbRename.editingId, breadcrumbRename.editValue, @@ -1340,7 +1377,7 @@ export function Knowledge() { return tags }, [connectorFilter, contentFilter, ownerFilter, members]) - const showEmptyState = isResourceListEmpty({ + const listState = resourceListState({ rowCount: rows.length, isLoading, isPlaceholderData, @@ -1351,6 +1388,11 @@ export function Knowledge() { foldersResolved, }) + const clearSearchAndFilters = () => { + setSearchQuery('') + void setKnowledgeFilters({ connector: null, content: null, owner: null }) + } + return ( <> @@ -1368,11 +1410,17 @@ export function Knowledge() { filter={filterConfig} /> + ) : listState === 'no-results' ? ( + ) : undefined } selectable={canEdit ? selectableConfig : undefined} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/utils/filter.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/utils/filter.ts deleted file mode 100644 index 1656b04f25d..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/utils/filter.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { KnowledgeBaseData } from '@/lib/knowledge/types' - -/** - * Filter knowledge bases by search query - */ -export function filterKnowledgeBases( - knowledgeBases: KnowledgeBaseData[], - searchQuery: string -): KnowledgeBaseData[] { - if (!searchQuery.trim()) { - return knowledgeBases - } - - const query = searchQuery.trim().toLowerCase() - return knowledgeBases.filter( - (kb) => kb.name.toLowerCase().includes(query) || kb.description?.toLowerCase().includes(query) - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 783c80b3a91..b5ffc797b04 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -23,11 +23,11 @@ import type { import { EMPTY_CELL_PLACEHOLDER, FILTER_SECTION_LABEL_CLASS, - isResourceListEmpty, OwnerAvatar, ownerCell, Resource, reportBulkOutcome, + resourceListState, selectionLabel, timeCell, useResourceRowSelection, @@ -40,21 +40,29 @@ import { buildDescendantIndex, buildMoveOptions, buildMoveOptionsExcludingSubtrees, + EMPTY_LOCATION_CELL, + FOLDER_LOCATION_COLUMN, FOLDERED_RESOURCE_HEADERS, FolderContextMenu, folderBreadcrumbItems, + folderLocationLabel, folderRow, folderRowId, + isSearchingResources, nextUntitledFolderName, parseFolderedRowId, parseMoveOptionValue, + scopeFolderedItems, sortResources, splitFolderedRowIds, useFolderNavigation, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' -import { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' +import { + ResourceNoResults, + TablesEmptyState, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { @@ -85,10 +93,10 @@ import { } from '@/hooks/queries/tables' import { getCanonicalFolderPath } from '@/hooks/queries/utils/folder-tree' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' -import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useSearchFilterValue } from '@/hooks/use-search-filter-value' import { useUrlSort } from '@/hooks/use-url-sort' import type { WorkflowFolder } from '@/stores/folders/types' import { useImportTrayStore } from '@/stores/table/import-tray/store' @@ -104,6 +112,8 @@ const COLUMNS: ResourceColumn[] = [ { id: 'updated', header: 'Last Updated' }, ] +const SEARCH_COLUMNS: ResourceColumn[] = [...COLUMNS, FOLDER_LOCATION_COLUMN] + /** This list's private drag MIME, so a drag started on another list is never mistaken for one * of these rows. */ const TABLE_ROW_DRAG_MIME = 'application/x-sim-workspace-table-rows' @@ -154,6 +164,7 @@ export function Tables() { const { currentFolderId, setCurrentFolderId, + openFolder, ancestors: folderChain, folders, folderById, @@ -161,6 +172,8 @@ export function Tables() { } = useFolderNavigation({ resourceType: 'table', workspaceId, + /** Declared below; only ever called from a click, long after this render initializes it. */ + onBeforeOpenFolder: () => setSearchTerm(''), }) /** @@ -250,7 +263,7 @@ export function Tables() { const setSearchTerm = useDebouncedSearchSetter((value, options) => setTableFilters({ search: value }, options) ) - const debouncedSearchTerm = useDebounce(urlSearchTerm, SEARCH_DEBOUNCE_MS) + const debouncedSearchTerm = useSearchFilterValue(urlSearchTerm, SEARCH_DEBOUNCE_MS) const setRowCountFilter = useCallback( (next: string[]) => setTableFilters({ rows: next }), @@ -306,32 +319,39 @@ export function Tables() { */ const descendantFolderIds = useMemo(() => buildDescendantIndex(folders), [folders]) - const visibleFolders = useMemo(() => { - const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) - const needle = debouncedSearchTerm.trim().toLowerCase() - return needle - ? siblings.filter((folder) => folder.name.toLowerCase().includes(needle)) - : siblings - }, [folders, currentFolderId, debouncedSearchTerm]) + /** A query stops scoping the list to the open folder — see {@link scopeFolderedItems}. */ + const isSearching = isSearchingResources(debouncedSearchTerm) + + const visibleFolders = useMemo( + () => + scopeFolderedItems(folders, { + currentFolderId, + search: debouncedSearchTerm, + getParentId: (folder) => folder.parentId ?? null, + getSearchText: (folder) => [folder.name], + }), + [folders, currentFolderId, debouncedSearchTerm] + ) const processedTables = useMemo(() => { - const query = debouncedSearchTerm.trim().toLowerCase() - /** - * A `folderId` that no longer names an active folder — restored on its own out - * of Recently Deleted while its folder stayed archived — would otherwise match - * no level at all and leave the table unreachable from every view. Fall it back - * to the root instead — but only once `foldersResolved` says the index is the complete - * set for THIS workspace. Gating on a loading flag instead would treat an errored fetch, - * a disabled query, or the previous workspace's cached folders as "no such folder" and - * drag every foldered table to the root. - */ - let result = tables.filter((t) => { - const folderId = t.folderId ?? null - const effectiveFolderId = - !foldersResolved || !folderId || folderById.has(folderId) ? folderId : null - return effectiveFolderId === currentFolderId + let result = scopeFolderedItems(tables, { + currentFolderId, + search: debouncedSearchTerm, + /** + * A `folderId` that no longer names an active folder — restored on its own out + * of Recently Deleted while its folder stayed archived — would otherwise match + * no level at all and leave the table unreachable from every view. Fall it back + * to the root instead — but only once `foldersResolved` says the index is the complete + * set for THIS workspace. Gating on a loading flag instead would treat an errored fetch, + * a disabled query, or the previous workspace's cached folders as "no such folder" and + * drag every foldered table to the root. + */ + getParentId: (t) => { + const folderId = t.folderId ?? null + return !foldersResolved || !folderId || folderById.has(folderId) ? folderId : null + }, + getSearchText: (t) => [t.name], }) - if (query) result = result.filter((t) => t.name.toLowerCase().includes(query)) if (rowCountFilter.length > 0) { result = result.filter((t) => { @@ -428,6 +448,10 @@ export function Tables() { created: timeCell(item.folder.createdAt), owner: ownerCell(item.folder.userId, membersById), updated: timeCell(item.folder.updatedAt), + /** A folder's location is its parent's path, not its own. */ + location: isSearching + ? { label: folderLocationLabel(item.folder.parentId, folderById, ROOT_LABEL) } + : EMPTY_LOCATION_CELL, }, }) } @@ -452,10 +476,13 @@ export function Tables() { created: timeCell(table.createdAt), owner: ownerCell(table.createdBy, membersById), updated: timeCell(table.updatedAt), + location: isSearching + ? { label: folderLocationLabel(table.folderId, folderById, ROOT_LABEL) } + : EMPTY_LOCATION_CELL, }, } }), - [sortedEntries, membersById] + [sortedEntries, membersById, folderById, isSearching] ) /** @@ -586,11 +613,11 @@ export function Tables() { breadcrumbs: folderChain, rootLabel: ROOT_LABEL, rootIcon: FOLDERED_RESOURCE_HEADERS.table.rootIcon, - onNavigate: setCurrentFolderId, + onNavigate: openFolder, currentFolderActions, currentFolderEditing, }), - [folderChain, setCurrentFolderId, currentFolderActions, currentFolderEditing] + [folderChain, openFolder, currentFolderActions, currentFolderEditing] ) const searchConfig: SearchConfig = useMemo( @@ -738,7 +765,7 @@ export function Tables() { return tags }, [rowCountFilter, ownerFilter, membersById, setRowCountFilter, setOwnerFilter]) - const showEmptyState = isResourceListEmpty({ + const listState = resourceListState({ rowCount: rows.length, isLoading, isPlaceholderData, @@ -749,6 +776,11 @@ export function Tables() { foldersResolved, }) + const clearSearchAndFilters = () => { + setSearchTerm('') + void setTableFilters({ rows: null, owner: null }) + } + const handleContentContextMenu = useCallback( (e: React.MouseEvent) => { const target = e.target as HTMLElement @@ -768,12 +800,12 @@ export function Tables() { if (isRowContextMenuOpen || listRename.editingId === rowId) return const parsed = parseFolderedRowId(rowId) if (parsed.kind === 'folder') { - setCurrentFolderId(parsed.id) + openFolder(parsed.id) return } router.push(`/workspace/${workspaceId}/tables/${parsed.id}`) }, - [isRowContextMenuOpen, listRename.editingId, router, workspaceId, setCurrentFolderId] + [isRowContextMenuOpen, listRename.editingId, router, workspaceId, openFolder] ) const resolveRowItem = useCallback( @@ -1023,6 +1055,7 @@ export function Tables() { selection: { selectedRowIds, visibleRowIds, replaceSelection }, onSpringOpenFolder: setCurrentFolderId, currentFolderId, + bodyDropFolderId: isSearching ? undefined : currentFolderId, }) const handleDelete = async () => { @@ -1069,9 +1102,11 @@ export function Tables() { id: activeFolder.id, }) // The open folder just disappeared — fall back to its parent rather than - // leaving a `?folderId=` pointing at an archived folder. + // leaving a `?folderId=` pointing at an archived folder. Not `openFolder`: + // this is a forced correction, so it must neither clear an active search nor + // push a back-stack entry aimed at the folder that was just deleted. if (currentFolderId === activeFolder.id) { - setCurrentFolderId(activeFolder.parentId) + setCurrentFolderId(activeFolder.parentId, { history: 'replace' }) } setIsDeleteFolderDialogOpen(false) setActiveFolder(null) @@ -1285,14 +1320,20 @@ export function Tables() { filter={filterConfig} /> + ) : listState === 'no-results' ? ( + ) : undefined } selectable={canEdit ? selectableConfig : undefined} @@ -1361,7 +1402,7 @@ export function Tables() { position={rowContextMenuPosition} onClose={closeRowContextMenu} onOpen={() => { - if (activeFolder) setCurrentFolderId(activeFolder.id) + if (activeFolder) openFolder(activeFolder.id) closeRowContextMenu() }} onRename={() => { diff --git a/apps/sim/hooks/use-search-filter-value.ts b/apps/sim/hooks/use-search-filter-value.ts new file mode 100644 index 00000000000..4e5837f045b --- /dev/null +++ b/apps/sim/hooks/use-search-filter-value.ts @@ -0,0 +1,23 @@ +'use client' + +import { useDebounce } from '@/hooks/use-debounce' + +/** + * The search term a list should actually filter by: debounced while the user types, but + * applied immediately when the term is cleared. + * + * {@link useDebounce} is trailing-only, so a cleared term keeps filtering for a full window. + * Typing can afford that — nobody expects results before they stop typing — but clearing + * cannot, because clearing is how a list stops being a search. On a foldered list the term + * is cleared as part of opening a folder, so the trailing edge leaves the previous + * whole-workspace results on screen after the breadcrumb has already changed, and the rows + * then snap a fifth of a second later. The click reads as having done nothing, twice. + * + * Returns the raw value's emptiness, not the debounced one's, so the transition out of + * searching is instant in both directions it matters: the filter widens on a beat, and + * narrows back to the open folder at once. + */ +export function useSearchFilterValue(value: string, delayMs: number): string { + const debounced = useDebounce(value, delayMs) + return value.trim() ? debounced : '' +} diff --git a/apps/sim/lib/api/contracts/v2/__tests__/files-recursive.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/files-recursive.test.ts new file mode 100644 index 00000000000..6eb3dc91a4c --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/files-recursive.test.ts @@ -0,0 +1,47 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { v2ListFilesQuerySchema } from '@/lib/api/contracts/v2/files' + +const WORKSPACE_ID = 'a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64' + +/** Parses what a query string actually delivers: strings, never booleans. */ +function parseRecursive(raw: string | undefined) { + const result = v2ListFilesQuerySchema.safeParse({ + workspaceId: WORKSPACE_ID, + ...(raw === undefined ? {} : { recursive: raw }), + }) + return result.success ? result.data.recursive : { error: true as const } +} + +describe('v2ListFilesQuerySchema recursive', () => { + it('omits to undefined so the route can pick a default from the search', () => { + expect(parseRecursive(undefined)).toBeUndefined() + }) + + /** + * The bug this guards: `z.coerce.boolean()` is `Boolean(input)` over a query string, so + * every non-empty spelling — `false` included — arrives as `true`, silently inverting the + * one value a caller sends explicitly to turn recursion off. + */ + it('reads "false" as false, not as a non-empty string', () => { + expect(parseRecursive('false')).toBe(false) + expect(parseRecursive('0')).toBe(false) + expect(parseRecursive('no')).toBe(false) + }) + + it('reads the true spellings as true', () => { + expect(parseRecursive('true')).toBe(true) + expect(parseRecursive('1')).toBe(true) + expect(parseRecursive('yes')).toBe(true) + }) + + /** Case-sensitive by design: an unpublished spelling is a 400, never a silent default. */ + it('rejects spellings outside the published vocabulary', () => { + expect(parseRecursive('True')).toEqual({ error: true }) + expect(parseRecursive('TRUE')).toEqual({ error: true }) + expect(parseRecursive('maybe')).toEqual({ error: true }) + expect(parseRecursive('')).toEqual({ error: true }) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index 248c4069cc4..d52ec521993 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -132,7 +132,16 @@ const CURSOR_BINDINGS: Record = { 'GET /api/v2/billing/logs': ['source', 'workspaceId', 'period', 'startDate', 'endDate'], 'GET /api/v2/credentials': ['workspaceId', 'type', 'providerId', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/custom-tools': ['workspaceId', 'search', 'sortBy', 'sortOrder'], - 'GET /api/v2/files': ['workspaceId', 'scope', 'folderPath', 'search', 'sortBy', 'sortOrder'], + 'GET /api/v2/files': [ + 'workspaceId', + 'scope', + 'folderPath', + 'search', + 'sortBy', + 'sortOrder', + /** Decides whether `folderPath` covers one folder or its whole subtree. */ + 'recursive', + ], 'GET /api/v2/knowledge': ['workspaceId', 'folderPath', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/knowledge/[id]/documents': [ 'workspaceId', diff --git a/apps/sim/lib/api/contracts/v2/error-codes.test.ts b/apps/sim/lib/api/contracts/v2/error-codes.test.ts new file mode 100644 index 00000000000..88e479635fb --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/error-codes.test.ts @@ -0,0 +1,44 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + V2_ERROR_CODE_BY_STATUS, + V2_ERROR_STATUS_BY_CODE, + type V2ErrorCode, +} from '@/lib/api/contracts/v2/error-codes' + +describe('v2 error codes', () => { + /** + * The property the OpenAPI layer depends on: a documented error response derives its + * `error.code` from the status it is declared under, so two codes sharing a status would + * make one of those examples name a code that status never carries. + */ + it('maps each code onto a status no other code claims', () => { + const claimedBy = new Map() + for (const [code, status] of Object.entries(V2_ERROR_STATUS_BY_CODE) as [ + V2ErrorCode, + number, + ][]) { + expect(claimedBy.get(status), `${status} is claimed by more than one code`).toBeUndefined() + claimedBy.set(status, code) + } + expect(claimedBy.size).toBe(Object.keys(V2_ERROR_STATUS_BY_CODE).length) + }) + + it('inverts without losing an entry', () => { + for (const [code, status] of Object.entries(V2_ERROR_STATUS_BY_CODE) as [ + V2ErrorCode, + number, + ][]) { + expect(V2_ERROR_CODE_BY_STATUS[status]).toBe(code) + } + }) + + it('uses statuses in the HTTP error range', () => { + for (const status of Object.values(V2_ERROR_STATUS_BY_CODE)) { + expect(status).toBeGreaterThanOrEqual(400) + expect(status).toBeLessThan(600) + } + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/error-codes.ts b/apps/sim/lib/api/contracts/v2/error-codes.ts new file mode 100644 index 00000000000..38f00240bc5 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/error-codes.ts @@ -0,0 +1,63 @@ +/** + * The closed set of `error.code` values the public v2 API emits, and the HTTP status each + * one is sent with. + * + * Lives in the contract layer rather than beside the response helpers because two consumers + * need it and they must not drift: the runtime (`app/api/v2/lib/response.ts`, which renders + * the envelope) and the published OpenAPI documents (`contracts/v2/openapi/shared.ts`, which + * document one example per status). Documenting a code the runtime cannot emit — or a status + * paired with the wrong code — is how the reference stops describing the API. + */ + +export type V2ErrorCode = + | 'BAD_REQUEST' + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'CONFLICT' + | 'PAYLOAD_TOO_LARGE' + | 'UNSUPPORTED_MEDIA_TYPE' + | 'USAGE_LIMIT_EXCEEDED' + | 'LOCKED' + | 'RATE_LIMITED' + | 'CLIENT_CLOSED_REQUEST' + | 'INTERNAL_ERROR' + | 'SERVICE_UNAVAILABLE' + +/** + * The status each code is sent with. `Record` is the completeness gate: + * adding a code fails to compile until it declares its status. + */ +export const V2_ERROR_STATUS_BY_CODE: Record = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + USAGE_LIMIT_EXCEEDED: 402, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + PAYLOAD_TOO_LARGE: 413, + UNSUPPORTED_MEDIA_TYPE: 415, + LOCKED: 423, + RATE_LIMITED: 429, + CLIENT_CLOSED_REQUEST: 499, + INTERNAL_ERROR: 500, + SERVICE_UNAVAILABLE: 503, +} + +/** + * The inverse map. + * + * The pairing is one-to-one, which is what lets a documented error response derive its code + * from the status it is declared under instead of restating it. That property is load-bearing + * for the API reference, so it is asserted in `error-codes.test.ts` rather than trusted — a + * second code claiming a status would silently drop an entry here and make one documented + * example name the wrong code. + * + * This describes how a code chooses its status, not every status the surface can send. A + * route may pass an explicit status alongside a code — `POST /workflows/{id}/execute` answers + * `408` with `BAD_REQUEST` — so a status absent from this map is one no documented error + * response may claim, which is what the OpenAPI layer enforces. + */ +export const V2_ERROR_CODE_BY_STATUS: Partial> = Object.fromEntries( + Object.entries(V2_ERROR_STATUS_BY_CODE).map(([code, status]) => [status, code as V2ErrorCode]) +) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index b422d7792c0..8b0ba62b39b 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -9,7 +9,9 @@ import { import { shareAuthTypeSchema, shareRecordSchema } from '@/lib/api/contracts/public-shares' import { defineRouteContract } from '@/lib/api/contracts/types' import { + V2_FALSE_VALUES, V2_FOLDER_FILTER_MISS, + V2_TRUE_VALUES, v2CreateFolderBodySchema, v2CursorListResponse, v2DataResponse, @@ -306,7 +308,30 @@ export const v2ListFilesQuerySchema = z /** Restrict to one file folder. Omit to list the whole workspace. */ folderPath: v2FolderPathInputSchema .optional() - .describe(`Restrict results to files directly inside this folder. ${V2_FOLDER_FILTER_MISS}`), + .describe( + `Restrict results to files inside this folder — its direct children, or its whole subtree when \`recursive\` is true. ${V2_FOLDER_FILTER_MISS}` + ), + /** + * Descend into subfolders. Meaningful only alongside `folderPath`: with no folder filter + * the listing already spans the workspace. + * + * Defaults to `true` when `search` is set and `false` otherwise, so the two verbs this + * endpoint serves each get the scope they imply — listing a folder shows that folder, + * searching one looks through everything in it. Send it explicitly to force either. + * + * `z.stringbool({ case: 'sensitive' })` rather than `z.coerce.boolean()`, which is + * `Boolean(input)` over a query string and so reads `recursive=false` as `true` — see + * `booleanQueryFlagSchema` in `contracts/primitives.ts`. Matches the sibling `recursive` + * on folder delete: the accepted spellings are closed, published as an enum, and + * case-sensitive, so an unpublished spelling is a `400` rather than a silent default. + */ + recursive: z + .stringbool({ case: 'sensitive' }) + .optional() + .describe( + 'Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.' + ) + .meta({ enum: [...V2_TRUE_VALUES, ...V2_FALSE_VALUES] }), scope: v2FileScopeSchema .default('active') .describe( @@ -325,6 +350,18 @@ export const v2ListFilesQuerySchema = z export type V2ListFilesQuery = z.output +/** + * Resolves the `recursive` default the schema above promises: true alongside a search, false + * otherwise, and whatever the caller sent when they sent one. + * + * Lives beside the `.describe()` that publishes the rule to every SDK and CLI rather than in + * the route that applies it. The promise and the implementation were two modules apart with + * nothing binding them, which is how a documented default drifts from the served one. + */ +export function listsSubfolders(query: { recursive?: boolean; search?: string }): boolean { + return query.recursive ?? query.search !== undefined +} + /** Download/delete both target a single file within a workspace-scoped query. */ export const v2FileWorkspaceQuerySchema = z .object({ diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index 32074b7ed7e..26182ca344e 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -24,7 +24,6 @@ import { } from '@/lib/api/contracts/v2/files' import { documentedSchema, - ERROR_RESPONSES, type ErrorResponseId, FOLDER_TREE_TOO_LARGE, FULL_SET_LIST, @@ -39,6 +38,7 @@ import { V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, WORKSPACE_ERRORS, + withErrorExamples, withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { @@ -902,6 +902,8 @@ export const filesAuditOpenApiDocument = defineOpenApiDocument({ ...V2_COMMON_HEADERS, }, errorSchema: V2_ERROR_SCHEMA, - errorResponses: ERROR_RESPONSES, + errorResponses: withErrorExamples({ + Conflict: { message: 'File already exists' }, + }), routes, }) diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts index fa8e2e06453..3ce43fea60e 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge.ts @@ -24,7 +24,6 @@ import { } from '@/lib/api/contracts/v2/knowledge' import { documentedSchema, - ERROR_RESPONSES, type ErrorResponseId, FOLDER_TREE_TOO_LARGE, FULL_SET_LIST, @@ -37,6 +36,7 @@ import { V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, WORKSPACE_ERRORS, + withErrorExamples, withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { @@ -781,6 +781,8 @@ export const knowledgeOpenApiDocument = defineOpenApiDocument({ securitySchemes: V2_API_KEY_SECURITY_SCHEMES, headers: V2_COMMON_HEADERS, errorSchema: V2_ERROR_SCHEMA, - errorResponses: ERROR_RESPONSES, + errorResponses: withErrorExamples({ + Conflict: { message: 'Upload has already been completed' }, + }), routes, }) diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 3c0a0cc3d3f..50ebb34a817 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -22,7 +22,6 @@ import { } from '@/lib/api/contracts/v2/mcp-servers' import { documentedSchema, - ERROR_RESPONSES, type ErrorResponseId, FULL_SET_LIST, HEAD_MIRRORS_GET, @@ -34,6 +33,7 @@ import { V2_COMMON_HEADERS, V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, + withErrorExamples, withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { @@ -1158,6 +1158,8 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ securitySchemes: V2_API_KEY_SECURITY_SCHEMES, headers: V2_COMMON_HEADERS, errorSchema: V2_ERROR_SCHEMA, - errorResponses: ERROR_RESPONSES, + errorResponses: withErrorExamples({ + Conflict: { message: 'API key name already exists' }, + }), routes, }) diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index 74804f1053a..0a774a71c5b 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { V2_ERROR_CODE_BY_STATUS } from '@/lib/api/contracts/v2/error-codes' import { v2ErrorResponseSchema } from '@/lib/api/contracts/v2/shared' import type { OpenApiErrorResponse, @@ -7,6 +8,47 @@ import type { OpenApiSecurityScheme, } from '@/lib/api/openapi/types' +interface ErrorResponseOptions { + /** + * The `message` a caller actually receives for this status. Every one below is a literal + * the runtime emits — the generic path for that status, not a domain's phrasing of it — + * so the reference can be read as the response rather than as an illustration. + */ + message: string + /** `error.details`, for the statuses that populate it. Omitted where none is sent. */ + details?: unknown + headers?: readonly string[] +} + +/** + * One documented error response, with `error.code` derived from the status rather than + * restated beside it. + * + * Deriving is what keeps the reference honest: a hand-written pair can drift into naming a + * code the status never carries, and that mistake reads as authoritative. The v2 codes map + * one-to-one onto statuses ({@link V2_ERROR_CODE_BY_STATUS} throws if that ever stops being + * true), so the status is + * enough to determine the code. + */ +function errorResponse( + status: number, + description: string, + { message, details, headers }: ErrorResponseOptions +): OpenApiErrorResponse { + const code = V2_ERROR_CODE_BY_STATUS[status] + if (!code) { + throw new Error( + `No v2 error code is sent with status ${status}; documenting it would publish a response the API cannot produce.` + ) + } + return { + status, + description, + ...(headers ? { headers } : {}), + example: { error: { code, message, ...(details === undefined ? {} : { details }) } }, + } +} + export const RATE_LIMIT_HEADERS = [ 'X-RateLimit-Limit', 'X-RateLimit-Remaining', @@ -46,40 +88,79 @@ const FORBIDDEN_DESCRIPTION = 'The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.' export const ERROR_RESPONSES = { - BadRequest: { - status: 400, - description: - 'The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.', - }, - Unauthorized: { status: 401, description: 'The API key is missing or invalid.' }, - UsageLimitExceeded: { - status: 402, - description: 'The workspace has exceeded its usage or billing limits.', - }, - Forbidden: { status: 403, description: FORBIDDEN_DESCRIPTION }, - NotFound: { status: 404, description: 'The requested resource was not found.' }, - Conflict: { status: 409, description: 'The request conflicts with current resource state.' }, - RunIdConflict: { - status: 409, - description: - 'The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.', - headers: ['X-Run-Id'], - }, - PayloadTooLarge: { - status: 413, - description: - 'The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.', - }, - UnsupportedMediaType: { - status: 415, - description: 'The request uses an unsupported media type.', - }, - Locked: { status: 423, description: 'The resource is locked and cannot be modified.' }, - RateLimited: { - status: 429, - description: 'The caller exceeded the request rate limit.', + BadRequest: errorResponse( + 400, + 'The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.', + { + /** + * The fallback `getValidationErrorMessage` uses when an issue carries no message of its + * own; a real failure usually names the offending field instead (`limit must be at + * least 1`). The generic form is documented because this response is shared by every + * operation, and `details` is omitted because only the validation path populates it — + * the cursor and malformed-JSON 400s send none. + */ + message: 'Invalid request', + } + ), + Unauthorized: errorResponse(401, 'The API key is missing or invalid.', { + message: 'API key required', + }), + UsageLimitExceeded: errorResponse( + 402, + 'The workspace has exceeded its usage or billing limits.', + { message: 'Usage limit exceeded. Please upgrade your plan to continue.' } + ), + Forbidden: errorResponse(403, FORBIDDEN_DESCRIPTION, { + message: 'Insufficient workspace permissions', + /** The description tells callers to branch on this, so the example has to show it. */ + details: { code: 'INSUFFICIENT_WORKSPACE_ROLE' }, + }), + NotFound: errorResponse(404, 'The requested resource was not found.', { + /** + * The surface-wide literal. A resource route answers with its own noun instead — `Workflow + * not found`, `Table not found` — which this response cannot name because every domain + * shares it. + */ + message: 'Not found', + }), + Conflict: errorResponse(409, 'The request conflicts with current resource state.', { + /** + * The workflows phrasing, which is the default because that document does not override + * it. Nothing in the response layer supplies a 409 message, so every other document + * carrying this status names its own through {@link withErrorExamples}. + */ + message: 'Webhook path already in use', + }), + RunIdConflict: errorResponse( + 409, + 'The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.', + { + message: 'Run ID has already been used', + details: { code: 'RUN_ID_CONFLICT', runId: '0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01' }, + headers: ['X-Run-Id'], + } + ), + PayloadTooLarge: errorResponse( + 413, + 'The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.', + { message: 'Request body is too large' } + ), + UnsupportedMediaType: errorResponse(415, 'The request uses an unsupported media type.', { + message: 'Request body must be sent as application/json', + }), + Locked: errorResponse(423, 'The resource is locked and cannot be modified.', { + /** + * Also domain-supplied. Workflows and tables are the only documents that carry a `423`, + * and tables names its own four lock kinds through {@link withErrorExamples}. + */ + message: 'Workflow is locked', + }), + RateLimited: errorResponse(429, 'The caller exceeded the request rate limit.', { + message: 'API rate limit exceeded', + /** Mirrors `Retry-After`, which the description already sends callers to. */ + details: { retryAfter: '2026-01-01T00:00:30.000Z' }, headers: ['Retry-After'], - }, + }), /** * Published on exactly one operation, and deliberately not on the rest. * @@ -98,22 +179,66 @@ export const ERROR_RESPONSES = { * leaves nothing behind to reconcile. Publish a 499 on a new operation only * when the same is true of it. */ - ClientClosedRequest: { - status: 499, - description: - 'The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.', - }, - InternalError: { status: 500, description: 'An unexpected server error occurred.' }, - ServiceUnavailable: { - status: 503, - description: - 'A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.', - headers: ['Retry-After'], - }, + ClientClosedRequest: errorResponse( + 499, + 'The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.', + { + message: 'Client cancelled request', + /** The run this abort may have left running — the reason the status is published. */ + details: { runId: '0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01' }, + } + ), + InternalError: errorResponse(500, 'An unexpected server error occurred.', { + /** + * Hardcoded on every path. `v2ErrorForOrchestration` replaces a domain's `internal` + * message with this literal, so a 500 never leaks one — the reference showing a + * descriptive message here would suggest callers can parse something they never get. + */ + message: 'Internal server error', + }), + ServiceUnavailable: errorResponse( + 503, + 'A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.', + { message: 'Service temporarily unavailable', headers: ['Retry-After'] } + ), } as const satisfies Readonly> export type ErrorResponseId = keyof typeof ERROR_RESPONSES +/** + * {@link ERROR_RESPONSES} with a document's own body for the statuses whose message is the + * domain's rather than the surface's. + * + * Most statuses read the same everywhere — a `401` is `API key required` whatever you were + * asking for. `409` and `423` are not: nothing in the response layer supplies them, so the + * only real strings are each domain's, and one shared example necessarily shows four of the + * seven documents a message they never send. Tables answering `Workflow is locked` is the + * same class of wrongness as every status answering `BAD_REQUEST`, just smaller. + * + * Status, description, and headers are kept — a document may restate what its errors *say*, + * never what they *mean* — and the code is re-derived, so an override cannot introduce the + * mismatch this whole mechanism exists to prevent. + */ +export function withErrorExamples( + overrides: Partial> +): Record { + const entries = Object.entries(ERROR_RESPONSES) as [ErrorResponseId, OpenApiErrorResponse][] + return Object.fromEntries( + entries.map(([id, response]) => { + const override = overrides[id] + if (!override) return [id, response] + return [ + id, + errorResponse(response.status, response.description, { + message: override.message, + details: override.details, + headers: response.headers, + }), + ] + }) + ) as Record +} + /** * The three sets below are the base shapes every workspace-scoped resource * operation in the v2 API actually emits, so they live here once rather than diff --git a/apps/sim/lib/api/contracts/v2/openapi/tables.ts b/apps/sim/lib/api/contracts/v2/openapi/tables.ts index d83ca48fbaa..dfff91921c5 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/tables.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/tables.ts @@ -1,6 +1,5 @@ import { documentedSchema, - ERROR_RESPONSES, type ErrorResponseId, FOLDER_TREE_TOO_LARGE, FULL_SET_LIST, @@ -13,6 +12,7 @@ import { V2_COMMON_HEADERS, V2_ERROR_SCHEMA, WORKSPACE_ERRORS, + withErrorExamples, withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' import { @@ -1603,6 +1603,13 @@ export const tablesOpenApiDocument = defineOpenApiDocument({ securitySchemes: V2_API_KEY_SECURITY_SCHEMES, headers: V2_COMMON_HEADERS, errorSchema: V2_ERROR_SCHEMA, - errorResponses: ERROR_RESPONSES, + errorResponses: withErrorExamples({ + Conflict: { message: 'A table named "Orders" already exists in this workspace' }, + Locked: { + message: 'This table is insert-locked: new rows cannot be added.', + /** Names which of the four locks refused the write, so a caller can say which. */ + details: { lock: 'insert' }, + }, + }), routes, }) diff --git a/apps/sim/lib/api/openapi/types.ts b/apps/sim/lib/api/openapi/types.ts index 49873c7fcf8..59225b968ae 100644 --- a/apps/sim/lib/api/openapi/types.ts +++ b/apps/sim/lib/api/openapi/types.ts @@ -50,6 +50,19 @@ export interface OpenApiErrorResponse { status: number description: string headers?: readonly string[] + /** + * The body this status is documented with, validated against the document's `errorSchema` + * at generation time. + * + * Required, because the alternative is what every status rendered before it existed: the + * error schema's single shared example, so the reference answered `400 BAD_REQUEST` under + * the `401`, `404`, and `500` tabs alike. A reference that confidently shows the wrong + * body is worse than one that shows none. + * + * The document's own layer assembles it — only that layer knows how its codes pair with + * statuses — and this generator checks it parses. + */ + example: unknown } export interface OpenApiSuccessMetadata { diff --git a/apps/sim/lib/folders/subtree.ts b/apps/sim/lib/folders/subtree.ts index 42e727dd9cc..ccf2da25dd3 100644 --- a/apps/sim/lib/folders/subtree.ts +++ b/apps/sim/lib/folders/subtree.ts @@ -15,7 +15,7 @@ export type FolderChildrenIndex = ReadonlyMap * it on every call, which is O(rows) each time, and a workspace's tree is * bounded only by `MAX_FOLDERS_PER_WORKSPACE`. */ -export function indexFolderChildren(folders: readonly FolderNode[]): FolderChildrenIndex { +export function indexFolderChildren(folders: Iterable): FolderChildrenIndex { const childrenByParent = new Map() for (const folder of folders) { diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 9531643db52..2b67cf01897 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -14,7 +14,7 @@ import { getPostgresErrorCode, } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' -import { and, eq, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm' +import { and, eq, inArray, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm' import type { ShareRecord } from '@/lib/api/contracts/public-shares' import type { V2FileSortBy } from '@/lib/api/contracts/v2/files' import type { ListSortOrder } from '@/lib/api/list-query' @@ -1252,7 +1252,14 @@ export interface QueryWorkspaceFilesOptions { scope?: WorkspaceFileScope /** Restrict to one file folder. */ /** `undefined` lists every folder, `null` lists only root files. */ - folderId?: string | null + /** + * The folder to match: one id, `null` for the workspace root, or several ids for a folder + * and its descendants. Omit to match every folder. + * + * An empty array matches nothing — the honest answer for an empty set of folders, and the + * shape Drizzle already emits (`false`) for an empty `IN`. + */ + folderId?: string | null | readonly string[] /** Case-insensitive substring match on the file name. */ search?: string sortBy: V2FileSortBy @@ -1268,6 +1275,16 @@ export interface QueryWorkspaceFilesResult { nextKeys: CursorKey[] | null } +/** The folder predicate for {@link QueryWorkspaceFilesOptions.folderId}'s three shapes. */ +function workspaceFileFolderCondition( + folderId: string | null | readonly string[] | undefined +): SQL | undefined { + if (folderId === undefined) return undefined + if (folderId === null) return isNull(workspaceFiles.folderId) + if (Array.isArray(folderId)) return inArray(workspaceFiles.folderId, folderId) + return eq(workspaceFiles.folderId, folderId as string) +} + /** * One filtered, sorted, bounded page of a workspace's files. * @@ -1297,11 +1314,7 @@ export async function queryWorkspaceFiles( const conditions = [ workspaceFileScopeCondition(workspaceId, scope), - folderId === undefined - ? undefined - : folderId === null - ? isNull(workspaceFiles.folderId) - : eq(workspaceFiles.folderId, folderId), + workspaceFileFolderCondition(folderId), searchFilter(workspaceFiles.originalName, search), resumeAfter, ] diff --git a/apps/sim/lib/workspace-files/application/list-workspace-files.test.ts b/apps/sim/lib/workspace-files/application/list-workspace-files.test.ts new file mode 100644 index 00000000000..51dd6dbbe3d --- /dev/null +++ b/apps/sim/lib/workspace-files/application/list-workspace-files.test.ts @@ -0,0 +1,149 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + loadFolderIndex: vi.fn(), + queryFiles: vi.fn(), + resolvePermission: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: {}, + AuditResourceType: { FILE: 'FILE' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + listWorkspaceFiles: vi.fn(), + loadActiveWorkspaceContext: mocks.loadWorkspace, + queryWorkspaceFiles: mocks.queryFiles, +})) + +vi.mock('@/lib/public-shares/share-manager', () => ({ getWorkspaceShares: vi.fn() })) + +vi.mock('@/lib/folders/queries', async () => { + const { resolveFolderPathFilter } = + await vi.importActual('@/lib/folders/queries') + return { loadActiveFolderPathIndex: mocks.loadFolderIndex, resolveFolderPathFilter } +}) + +import { queryWorkspaceFilePage } from '@/lib/workspace-files/application/list-workspace-files' + +/** + * Projects / (a) + * └── Q3 (b) + * └── Drafts (c) + * Archive (d) + */ +const ROWS = [ + { id: 'a', name: 'Projects', parentId: null }, + { id: 'b', name: 'Q3', parentId: 'a' }, + { id: 'c', name: 'Drafts', parentId: 'b' }, + { id: 'd', name: 'Archive', parentId: null }, +] + +/** `files.list` accepts a session principal — see `ALL_COPILOT_PRINCIPAL_POLICY`. */ +const principal = { + kind: 'session' as const, + userId: 'user-1', + workspaceId: 'workspace-1', +} + +function buildIndex() { + const rowById = new Map(ROWS.map((row) => [row.id, row])) + const pathById = new Map([ + ['a', '/Projects'], + ['b', '/Projects/Q3'], + ['c', '/Projects/Q3/Drafts'], + ['d', '/Archive'], + ]) + const idByPath = new Map([...pathById].map(([id, path]) => [path, id])) + return { rowById, pathById, idByPath } +} + +const baseInput = { + workspaceId: 'workspace-1', + sortBy: 'uploadedAt' as const, + sortOrder: 'asc' as const, + limit: 100, +} + +type PageInput = Parameters[0]['input'] + +async function execute(input: Partial = {}) { + return queryWorkspaceFilePage.execute({ + input: { ...baseInput, ...input } as PageInput, + principal, + }) +} + +/** The folder scoping the use case handed to the query layer. */ +async function run(input: Partial = {}) { + await execute(input) + return mocks.queryFiles.mock.calls.at(-1)?.[1] +} + +describe('queryWorkspaceFilePage folder scoping', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('admin') + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', + }) + mocks.loadFolderIndex.mockResolvedValue(buildIndex()) + mocks.queryFiles.mockResolvedValue({ files: [], nextKeys: null }) + }) + + it('applies no folder predicate when folderPath is omitted', async () => { + const options = await run() + expect(options.folderId).toBeUndefined() + }) + + it('matches one folder when not recursive', async () => { + const options = await run({ folderPath: '/Projects' }) + expect(options.folderId).toBe('a') + }) + + it('matches the whole subtree when recursive', async () => { + const options = await run({ folderPath: '/Projects', recursive: true }) + expect(options.folderId).toEqual(['a', 'b', 'c']) + }) + + it('stops at the subtree it was asked for', async () => { + const options = await run({ folderPath: '/Projects/Q3', recursive: true }) + expect(options.folderId).toEqual(['b', 'c']) + }) + + it('includes a leaf folder itself', async () => { + const options = await run({ folderPath: '/Projects/Q3/Drafts', recursive: true }) + expect(options.folderId).toEqual(['c']) + }) + + it('treats a recursive root filter as the whole workspace, not root-level files', async () => { + const options = await run({ folderPath: '/', recursive: true }) + expect(options.folderId).toBeUndefined() + }) + + it('still means root-level files only when the root filter is not recursive', async () => { + const options = await run({ folderPath: '/' }) + expect(options.folderId).toBeNull() + }) + + it('returns an empty page for a folder that does not resolve', async () => { + const result = await execute({ folderPath: '/Nope', recursive: true }) + expect(result).toEqual({ files: [], nextKeys: null }) + expect(mocks.queryFiles).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/list-workspace-files.ts b/apps/sim/lib/workspace-files/application/list-workspace-files.ts index ac855b1ed57..a81f391ed9b 100644 --- a/apps/sim/lib/workspace-files/application/list-workspace-files.ts +++ b/apps/sim/lib/workspace-files/application/list-workspace-files.ts @@ -2,6 +2,7 @@ import type { CursorKey } from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries' +import { collectDescendantFolderIdsFrom, indexFolderChildren } from '@/lib/folders/subtree' import { getWorkspaceShares } from '@/lib/public-shares/share-manager' import { listWorkspaceFiles, @@ -21,6 +22,12 @@ export interface QueryWorkspaceFilePageInput { /** Lifecycle set to page over. Omission preserves the active-only default. */ scope?: 'active' | 'archived' folderPath?: string + /** + * Whether `folderPath` covers its whole subtree rather than its direct children. Ignored + * without a `folderPath`, which already spans the workspace. The surface decides the + * default — see the v2 route, where a `search` implies a recursive look. + */ + recursive?: boolean search?: string sortBy: 'name' | 'size' | 'uploadedAt' | 'updatedAt' sortOrder: 'asc' | 'desc' @@ -28,6 +35,30 @@ export interface QueryWorkspaceFilePageInput { after?: CursorKey[] } +/** + * Which folders a page covers, in the shape {@link queryWorkspaceFiles} takes: one id, `null` + * for the workspace root, several ids for a subtree, or `undefined` for the whole workspace. + * + * `unfiltered` (no `folderPath`) and a recursive filter on the root both mean the whole + * workspace, so both drop the folder predicate. A recursive filter on a real folder names + * every folder in its subtree, which the query takes as one `IN (...)` over the index already + * loaded for the path lookup — no second read, and no recursive CTE. + */ +function resolveFolderScope( + folderIndex: Awaited>, + folderFilter: ReturnType, + recursive: boolean | undefined +): string | null | string[] | undefined { + if (folderFilter.kind !== 'folder') return undefined + if (!recursive) return folderFilter.folderId + if (folderFilter.folderId === null) return undefined + const childrenByParent = indexFolderChildren(folderIndex.rowById.values()) + return [ + folderFilter.folderId, + ...collectDescendantFolderIdsFrom(childrenByParent, folderFilter.folderId), + ] +} + async function resolveListWorkspaceFileContext(workspaceId: string) { const workspace = await loadActiveWorkspaceContext(workspaceId) if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') @@ -68,7 +99,7 @@ export const queryWorkspaceFilePage = defineAuthorizedWorkspaceFileUseCase({ const { files, nextKeys } = await queryWorkspaceFiles(context.workspaceId, { scope: input.scope, - folderId: folderFilter.kind === 'folder' ? folderFilter.folderId : undefined, + folderId: resolveFolderScope(folderIndex, folderFilter, input.recursive), search: input.search, sortBy: input.sortBy, sortOrder: input.sortOrder, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 2cae5e43b6d..62bec70c50e 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -3461,6 +3461,19 @@ type ListFilesQueryRef0 = string export type ListFilesQuery = { workspaceId: string folderPath?: ListFilesQueryRef0 + recursive?: + | 'true' + | '1' + | 'yes' + | 'on' + | 'y' + | 'enabled' + | 'false' + | '0' + | 'no' + | 'off' + | 'n' + | 'disabled' scope?: 'active' | 'archived' search?: string sortBy?: 'name' | 'size' | 'uploadedAt' | 'updatedAt' @@ -7492,7 +7505,26 @@ export const V2_OPERATIONS = { folderPath: { kind: 'string', describe: - 'Restrict results to files directly inside this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.', + 'Restrict results to files inside this folder — its direct children, or its whole subtree when `recursive` is true. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.', + }, + recursive: { + kind: 'enum', + values: [ + 'true', + '1', + 'yes', + 'on', + 'y', + 'enabled', + 'false', + '0', + 'no', + 'off', + 'n', + 'disabled', + ] as const, + describe: + 'Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.', }, scope: { kind: 'enum', diff --git a/scripts/openapi/generator.test.ts b/scripts/openapi/generator.test.ts index 252cdcce25c..028f7466c7e 100644 --- a/scripts/openapi/generator.test.ts +++ b/scripts/openapi/generator.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import { z } from 'zod' import { defineRouteContract } from '../../apps/sim/lib/api/contracts/types' +import { + V2_ERROR_STATUS_BY_CODE, + type V2ErrorCode, +} from '../../apps/sim/lib/api/contracts/v2/error-codes' import { billingOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/billing' import { filesAuditOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/files-audit' import { workflowsOpenApiDocument } from '../../apps/sim/lib/api/contracts/v2/openapi/workflows' @@ -53,6 +57,24 @@ function operation( } } +/** A minimal route, for assertions about document-level output rather than the route itself. */ +function simpleRoute(): OpenApiRouteDefinition { + const response = z.object({ ok: z.boolean().describe('Whether the call succeeded.') }).meta({ + id: 'SimpleResponse', + title: 'Simple response', + description: 'Response body.', + }) + return defineOpenApiRoute( + defineRouteContract({ + method: 'GET', + path: '/simple', + response: { mode: 'json', schema: response }, + }), + operation('simple', { description: 'Simple.' }), + { response } + ) +} + function document(routes: readonly OpenApiRouteDefinition[]) { return defineOpenApiDocument({ output: 'unused.json', @@ -75,8 +97,22 @@ function document(routes: readonly OpenApiRouteDefinition[]) { headers: { Location: { schema: LOCATION_HEADER_SCHEMA } }, errorSchema: ERROR_SCHEMA, errorResponses: { - Unauthorized: { status: 401, description: 'Unauthorized.' }, - RateLimited: { status: 429, description: 'Rate limited.' }, + Unauthorized: { + status: 401, + description: 'Unauthorized.', + example: { error: { code: 'UNAUTHORIZED', message: 'API key required' } }, + }, + RateLimited: { + status: 429, + description: 'Rate limited.', + example: { error: { code: 'RATE_LIMITED', message: 'API rate limit exceeded' } }, + }, + /** Declared but referenced by no operation below, so it must not be published. */ + NotFound: { + status: 404, + description: 'Not found.', + example: { error: { code: 'NOT_FOUND', message: 'Not found' } }, + }, }, routes, }) @@ -635,4 +671,69 @@ describe('OpenAPI generator', () => { 'Content-Length': { $ref: '#/components/headers/Content-Length' }, }) }) + + it('gives each error response its own example beside the shared schema ref', () => { + const spec = generateOpenApiDocument(document([simpleRoute()])) + const responses = (spec.components as JsonObject).responses as JsonObject + const contentFor = (id: string) => + ((responses[id] as JsonObject).content as JsonObject)['application/json'] as JsonObject + + expect(contentFor('Unauthorized').schema).toEqual({ $ref: '#/components/schemas/TestError' }) + expect(contentFor('RateLimited').schema).toEqual({ $ref: '#/components/schemas/TestError' }) + expect(contentFor('Unauthorized').example).toEqual({ + error: { code: 'UNAUTHORIZED', message: 'API key required' }, + }) + expect(contentFor('RateLimited').example).toEqual({ + error: { code: 'RATE_LIMITED', message: 'API rate limit exceeded' }, + }) + }) + + it('rejects an error example that does not fit the error schema', () => { + expect(() => + generateOpenApiDocument({ + ...document([simpleRoute()]), + errorResponses: { + Unauthorized: { + status: 401, + description: 'Unauthorized.', + example: { error: { code: 'UNAUTHORIZED' } }, + }, + RateLimited: { + status: 429, + description: 'Rate limited.', + example: { error: { code: 'RATE_LIMITED', message: 'API rate limit exceeded' } }, + }, + }, + }) + ).toThrow(/Unauthorized example/) + }) + + it('publishes only the error responses its operations reference', () => { + const spec = generateOpenApiDocument(document([simpleRoute()])) + const responses = (spec.components as JsonObject).responses as JsonObject + + /** `NotFound` is defined on the document but no operation declares it. */ + expect(Object.keys(responses).sort()).toEqual(['RateLimited', 'Unauthorized']) + }) + + it('publishes a distinct example under every documented error status', () => { + const spec = generateOpenApiDocument(workflowsOpenApiDocument) + const responses = (spec.components as JsonObject).responses as JsonObject + const byStatus = new Map>() + + for (const response of Object.values(responses) as JsonObject[]) { + const content = (response.content as JsonObject)['application/json'] as JsonObject + const example = content.example as { error: { code: string } } + const status = V2_ERROR_STATUS_BY_CODE[example.error.code as V2ErrorCode] + expect(status, `${example.error.code} is not a v2 error code`).toBeDefined() + const codes = byStatus.get(status) ?? new Set() + codes.add(example.error.code) + byStatus.set(status, codes) + } + + /** Every status documents exactly one code — the property the derivation relies on. */ + for (const [status, codes] of byStatus) { + expect([...codes], `status ${status}`).toHaveLength(1) + } + }) }) diff --git a/scripts/openapi/generator.ts b/scripts/openapi/generator.ts index 16968910b54..cc8cd84c0f2 100644 --- a/scripts/openapi/generator.ts +++ b/scripts/openapi/generator.ts @@ -692,7 +692,20 @@ function validateSecurity( } } -function errorComponents(definition: OpenApiDocumentDefinition, schemas: JsonObject): JsonObject { +/** + * The error responses this document's operations actually reference, as `components.responses`. + * + * Only the referenced ones are built. An error response carries a worked example, and the + * message in one is often the domain's rather than the surface's — a `423` reads `Workflow is + * locked` in one document and `This table is insert-locked` in another. Emitting the whole set + * everywhere would ship each document a body for a status it never answers, phrased by a + * domain it does not contain. + */ +function errorComponents( + definition: OpenApiDocumentDefinition, + schemas: JsonObject, + referencedErrors: ReadonlySet +): JsonObject { const generated = generateSchema( definition.errorSchema, 'output', @@ -707,6 +720,7 @@ function errorComponents(definition: OpenApiDocumentDefinition, schemas: JsonObj ) const responses: JsonObject = {} for (const [id, response] of Object.entries(definition.errorResponses)) { + if (!referencedErrors.has(id)) continue nonEmpty(id, 'Error response id') nonEmpty(response.description, `${id} description`) invariant( @@ -718,6 +732,11 @@ function errorComponents(definition: OpenApiDocumentDefinition, schemas: JsonObj for (const header of response.headers ?? []) { invariant(definition.headers[header], `${id} references unknown response header ${header}`) } + /** + * Held to the same standard as every other documented example: it must parse against + * the error schema, so a documented body cannot describe a shape the API never sends. + */ + validateExamples(definition.errorSchema, [response.example], 'output', `${id} example`) responses[id] = { description: response.description, ...(referencedHeaders(response.headers) @@ -726,6 +745,13 @@ function errorComponents(definition: OpenApiDocumentDefinition, schemas: JsonObj content: { 'application/json': { schema: { $ref: `#/components/schemas/${generated.name}` }, + /** + * Sits beside the `$ref` rather than on the shared schema: one schema serves every + * status, so a schema-level example is necessarily one status's body shown under + * all of them. A Media Type Object example overrides the schema's, which is what + * makes each status tab show its own. + */ + example: response.example, }, }, } @@ -742,7 +768,10 @@ export function generateOpenApiDocument(definition: OpenApiDocumentDefinition): validateSecurity(definition.security, definition, 'OpenAPI document') const schemas: JsonObject = {} - const responses = errorComponents(definition, schemas) + const referencedErrors = new Set( + definition.routes.flatMap((route) => [...route.operation.errors]) + ) + const responses = errorComponents(definition, schemas, referencedErrors) const paths: JsonObject = {} const operationIds = new Set() const routeKeys = new Set() From 16dd34b99309fbc0df94f0287fa3ceb55f78d430 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 19 Aug 2026 13:38:26 -0700 Subject: [PATCH 2/2] fix(search): discard the search term on clear instead of masking it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `useSearchFilterValue` returned the debounced term whenever the input was non-empty, so clearing only hid the settled needle. The mask lifted on the next keystroke while the debounce still held the pre-clear term — opening a folder and typing within the window searched the whole workspace for the query the user had just abandoned. A clear now resets the settled term rather than hiding it, adjusted during render so the reset is visible to the render that follows the clear. The initial state is seeded from the first value so a deep-linked `?search=` still filters on the first render. --- .../hooks/use-search-filter-value.test.tsx | 114 ++++++++++++++++++ apps/sim/hooks/use-search-filter-value.ts | 46 +++++-- 2 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 apps/sim/hooks/use-search-filter-value.test.tsx diff --git a/apps/sim/hooks/use-search-filter-value.test.tsx b/apps/sim/hooks/use-search-filter-value.test.tsx new file mode 100644 index 00000000000..8ec813e5d5d --- /dev/null +++ b/apps/sim/hooks/use-search-filter-value.test.tsx @@ -0,0 +1,114 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useSearchFilterValue } from '@/hooks/use-search-filter-value' + +const DELAY_MS = 200 +const mountedRoots: Root[] = [] + +/** Drives the hook the way a search box does: re-render with each new input value. */ +function renderSearchFilterValue(initial: string) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + + let result = '' + + function Probe({ value }: { value: string }) { + result = useSearchFilterValue(value, DELAY_MS) + return null + } + + act(() => root.render()) + + return { + get current() { + return result + }, + type(value: string) { + act(() => root.render()) + }, + settle() { + act(() => { + vi.advanceTimersByTime(DELAY_MS) + }) + }, + wait(ms: number) { + act(() => { + vi.advanceTimersByTime(ms) + }) + }, + } +} + +describe('useSearchFilterValue', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + vi.useRealTimers() + }) + + it('filters on a deep-linked term from the first render', () => { + expect(renderSearchFilterValue('report').current).toBe('report') + }) + + it('starts empty and applies a typed term only once it settles', () => { + const probe = renderSearchFilterValue('') + expect(probe.current).toBe('') + probe.type('rep') + expect(probe.current).toBe('') + probe.settle() + expect(probe.current).toBe('rep') + }) + + it('drops the term the instant it is cleared, without waiting out the window', () => { + const probe = renderSearchFilterValue('report') + probe.type('') + expect(probe.current).toBe('') + }) + + /** + * The regression this hook exists to prevent, and the one masking alone did not: opening a + * folder clears the term, and typing again inside the same debounce window must not resurrect + * the term from before the clear — the list would search the whole workspace for something + * the user had already abandoned. + */ + it('never resurrects the pre-clear term when the user types again straight away', () => { + const probe = renderSearchFilterValue('report') + probe.type('') + probe.wait(DELAY_MS / 4) + probe.type('b') + expect(probe.current).toBe('') + probe.wait(DELAY_MS / 4) + expect(probe.current).toBe('') + probe.settle() + expect(probe.current).toBe('b') + }) + + it('keeps showing the previous term while a longer one is still being typed', () => { + const probe = renderSearchFilterValue('') + probe.type('re') + probe.settle() + expect(probe.current).toBe('re') + probe.type('rep') + expect(probe.current).toBe('re') + probe.settle() + expect(probe.current).toBe('rep') + }) + + it('treats a whitespace-only term as cleared', () => { + const probe = renderSearchFilterValue('report') + probe.type(' ') + expect(probe.current).toBe('') + probe.settle() + expect(probe.current).toBe('') + }) +}) diff --git a/apps/sim/hooks/use-search-filter-value.ts b/apps/sim/hooks/use-search-filter-value.ts index 4e5837f045b..37eedcd6977 100644 --- a/apps/sim/hooks/use-search-filter-value.ts +++ b/apps/sim/hooks/use-search-filter-value.ts @@ -1,23 +1,45 @@ 'use client' -import { useDebounce } from '@/hooks/use-debounce' +import { useEffect, useState } from 'react' /** * The search term a list should actually filter by: debounced while the user types, but - * applied immediately when the term is cleared. + * discarded the moment the term is cleared. * - * {@link useDebounce} is trailing-only, so a cleared term keeps filtering for a full window. + * A plain trailing debounce keeps filtering by the old term for a full window after a clear. * Typing can afford that — nobody expects results before they stop typing — but clearing - * cannot, because clearing is how a list stops being a search. On a foldered list the term - * is cleared as part of opening a folder, so the trailing edge leaves the previous - * whole-workspace results on screen after the breadcrumb has already changed, and the rows - * then snap a fifth of a second later. The click reads as having done nothing, twice. + * cannot, because clearing is how a list stops being a search. On a foldered list the term is + * cleared as part of opening a folder, so a trailing edge leaves the previous whole-workspace + * results on screen after the breadcrumb has already changed, and the rows snap a fifth of a + * second later. The click reads as having done nothing, twice. * - * Returns the raw value's emptiness, not the debounced one's, so the transition out of - * searching is instant in both directions it matters: the filter widens on a beat, and - * narrows back to the open folder at once. + * Masking the settled term while the input is empty is not enough, because the mask lifts as + * soon as the user types again: between that keystroke and the end of its own window the hook + * would hand back the term from *before* the clear, and the list would search the whole + * workspace for something the user had already abandoned. So a clear resets the settled term + * rather than hiding it, and the next term applies only once it settles on its own. */ export function useSearchFilterValue(value: string, delayMs: number): string { - const debounced = useDebounce(value, delayMs) - return value.trim() ? debounced : '' + const isSearching = value.trim().length > 0 + /** Seeded from the first value so a deep-linked `?search=` filters on the first render. */ + const [settled, setSettled] = useState(() => (value.trim() ? value : '')) + const [wasSearching, setWasSearching] = useState(isSearching) + + /** + * Adjusted during render rather than in an effect so the reset is already visible to the + * render that follows the clear — an effect would land a frame later, which is the same + * stale window in a smaller costume. See `.claude/rules/sim-hooks.md`, "State shape". + */ + if (wasSearching !== isSearching) { + setWasSearching(isSearching) + if (!isSearching) setSettled('') + } + + useEffect(() => { + if (!isSearching) return + const timer = setTimeout(() => setSettled(value), delayMs) + return () => clearTimeout(timer) + }, [value, isSearching, delayMs]) + + return isSearching ? settled : '' }