From 326f63b2956889fc3785826754ba2f0b35dbebac Mon Sep 17 00:00:00 2001 From: Othman Abu Ajamieh <52608229+othmanemad@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:45:13 +0300 Subject: [PATCH 1/4] docs: clean up heading formatting in structure-projects guide (#15245) --- docs/admin-guide/guides/structure-projects.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/admin-guide/guides/structure-projects.mdx b/docs/admin-guide/guides/structure-projects.mdx index 349f7617066..835683b0720 100644 --- a/docs/admin-guide/guides/structure-projects.mdx +++ b/docs/admin-guide/guides/structure-projects.mdx @@ -11,13 +11,13 @@ Projects help you organize automations and resources for different teams or area Being invited to the platform does not automatically give a user access to every project. Users only see the projects they have access to, while **platform admins can access all projects**. -### **Why you need projects: ** +### **Why you need projects:** For example, each department can have its own project, so other departments can’t access its connections or credentials. In the following parts under this project section, we will explore everything that you do within a project. -### **Personal and Team Projects: ** +### **Personal and Team Projects:** In Activepieces, you have two types of projects: @@ -59,7 +59,7 @@ New users get a personal project on signup by default. Platform admins can turn **Global Connection:** You can also add or remove a global connection from a project. However, note that removing a global connection from a project that has a flow using it will break that flow. -- **Inviting & Removing People From Projects: ** +- **Inviting & Removing People From Projects:** **How to invite someone to a project:** @@ -134,7 +134,7 @@ Platform Admin → Pieces = Where platform admin controls pieces that are availa The Environment settings let you connect a project to a Git repository and manage [project releases](https://app.mintlify.com/activepieces/activepieces/editor/ginika%2Fdraft-aug-31/~/75eedf3e-853e-40f7-9be3-e4e61b448d4d). This is useful for teams that want a more controlled way to manage and track changes to their automations. -The page contains two main options**: Git and Releases.** The page contains two main options: **Git and Releases.** +The page contains two main options: **Git and Releases.** ### **Navigating Projects Via API** From 2bc59c058b1c6fc5a8f3b0eee5cefaeb2d0fcac2 Mon Sep 17 00:00:00 2001 From: Kishan Parmar <135701940+kishanprmr@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:32:52 +0530 Subject: [PATCH 2/4] feat(pieces-tally): add agent atomics (#15244) --- packages/pieces/community/tally/package.json | 2 +- packages/pieces/community/tally/src/index.ts | 50 +- .../tally/src/lib/actions/create-folder.ts | 36 ++ .../tally/src/lib/actions/create-form.ts | 67 +++ .../tally/src/lib/actions/create-workspace.ts | 28 ++ .../tally/src/lib/actions/delete-folder.ts | 32 ++ .../tally/src/lib/actions/delete-form.ts | 27 + .../src/lib/actions/delete-submission.ts | 36 ++ .../tally/src/lib/actions/delete-workspace.ts | 27 + .../tally/src/lib/actions/get-current-user.ts | 25 + .../actions/get-form-analytics-dimensions.ts | 35 ++ .../actions/get-form-drop-off-analytics.ts | 35 ++ .../tally/src/lib/actions/get-form-metrics.ts | 37 ++ .../actions/get-form-submission-analytics.ts | 35 ++ .../lib/actions/get-form-visit-analytics.ts | 35 ++ .../tally/src/lib/actions/get-form.ts | 28 ++ .../tally/src/lib/actions/get-submission.ts | 37 ++ .../tally/src/lib/actions/get-workspace.ts | 28 ++ .../src/lib/actions/list-form-questions.ts | 28 ++ .../tally/src/lib/actions/list-forms.ts | 40 ++ .../tally/src/lib/actions/list-submissions.ts | 75 +++ .../src/lib/actions/list-workspace-folders.ts | 29 ++ .../tally/src/lib/actions/list-workspaces.ts | 31 ++ .../tally/src/lib/actions/rename-folder.ts | 37 ++ .../tally/src/lib/actions/rename-workspace.ts | 35 ++ .../tally/src/lib/actions/update-form.ts | 62 +++ .../community/tally/src/lib/common/client.ts | 461 +++++++++++++++++- .../community/tally/src/lib/common/props.ts | 167 +++++++ .../community/tally/src/lib/common/types.ts | 188 +++++++ .../community/tally/src/lib/output-schemas.ts | 154 ++++++ 30 files changed, 1904 insertions(+), 3 deletions(-) create mode 100644 packages/pieces/community/tally/src/lib/actions/create-folder.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/create-form.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/create-workspace.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/delete-folder.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/delete-form.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/delete-submission.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/delete-workspace.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/get-current-user.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/get-form-analytics-dimensions.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/get-form-drop-off-analytics.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/get-form-metrics.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/get-form-submission-analytics.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/get-form-visit-analytics.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/get-form.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/get-submission.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/get-workspace.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/list-form-questions.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/list-forms.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/list-submissions.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/list-workspace-folders.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/list-workspaces.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/rename-folder.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/rename-workspace.ts create mode 100644 packages/pieces/community/tally/src/lib/actions/update-form.ts create mode 100644 packages/pieces/community/tally/src/lib/output-schemas.ts diff --git a/packages/pieces/community/tally/package.json b/packages/pieces/community/tally/package.json index 35c490e2688..aa89d58b111 100644 --- a/packages/pieces/community/tally/package.json +++ b/packages/pieces/community/tally/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-tally", - "version": "0.4.6", + "version": "0.5.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/tally/src/index.ts b/packages/pieces/community/tally/src/index.ts index a5320f8b80f..a8284f0c00f 100644 --- a/packages/pieces/community/tally/src/index.ts +++ b/packages/pieces/community/tally/src/index.ts @@ -3,6 +3,30 @@ import { createPiece } from '@activepieces/pieces-framework'; import { PieceCategory } from '@activepieces/pieces-framework'; import { tallyAuth } from './lib/auth'; +import { createFolderAction } from './lib/actions/create-folder'; +import { createFormAction } from './lib/actions/create-form'; +import { createWorkspaceAction } from './lib/actions/create-workspace'; +import { deleteFolderAction } from './lib/actions/delete-folder'; +import { deleteFormAction } from './lib/actions/delete-form'; +import { deleteSubmissionAction } from './lib/actions/delete-submission'; +import { deleteWorkspaceAction } from './lib/actions/delete-workspace'; +import { getCurrentUserAction } from './lib/actions/get-current-user'; +import { getFormAction } from './lib/actions/get-form'; +import { getFormAnalyticsDimensionsAction } from './lib/actions/get-form-analytics-dimensions'; +import { getFormDropOffAnalyticsAction } from './lib/actions/get-form-drop-off-analytics'; +import { getFormMetricsAction } from './lib/actions/get-form-metrics'; +import { getFormSubmissionAnalyticsAction } from './lib/actions/get-form-submission-analytics'; +import { getFormVisitAnalyticsAction } from './lib/actions/get-form-visit-analytics'; +import { getSubmissionAction } from './lib/actions/get-submission'; +import { getWorkspaceAction } from './lib/actions/get-workspace'; +import { listFormQuestionsAction } from './lib/actions/list-form-questions'; +import { listFormsAction } from './lib/actions/list-forms'; +import { listSubmissionsAction } from './lib/actions/list-submissions'; +import { listWorkspaceFoldersAction } from './lib/actions/list-workspace-folders'; +import { listWorkspacesAction } from './lib/actions/list-workspaces'; +import { renameFolderAction } from './lib/actions/rename-folder'; +import { renameWorkspaceAction } from './lib/actions/rename-workspace'; +import { updateFormAction } from './lib/actions/update-form'; import { TALLY_API_BASE } from './lib/common/client'; import { newSubmissionTrigger } from './lib/triggers/new-submission'; @@ -10,11 +34,35 @@ export const tally = createPiece({ displayName: 'Tally', description: 'Receive form submissions from Tally forms', auth: tallyAuth, - minimumSupportedRelease: '0.27.1', + minimumSupportedRelease: '0.86.4', logoUrl: 'https://cdn.activepieces.com/pieces/tally.png', categories: [PieceCategory.FORMS_AND_SURVEYS], authors: ['kishanprmr', 'abuaboud', 'bst1n'], actions: [ + listFormsAction, + createFormAction, + getFormAction, + updateFormAction, + deleteFormAction, + listFormQuestionsAction, + listSubmissionsAction, + getSubmissionAction, + deleteSubmissionAction, + getFormMetricsAction, + getFormVisitAnalyticsAction, + getFormSubmissionAnalyticsAction, + getFormAnalyticsDimensionsAction, + getFormDropOffAnalyticsAction, + listWorkspacesAction, + createWorkspaceAction, + getWorkspaceAction, + renameWorkspaceAction, + deleteWorkspaceAction, + listWorkspaceFoldersAction, + createFolderAction, + renameFolderAction, + deleteFolderAction, + getCurrentUserAction, createCustomApiCallAction({ auth: tallyAuth, baseUrl: () => TALLY_API_BASE, diff --git a/packages/pieces/community/tally/src/lib/actions/create-folder.ts b/packages/pieces/community/tally/src/lib/actions/create-folder.ts new file mode 100644 index 00000000000..2307e7eb154 --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/create-folder.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; +import { foldersDropdown, workspacesDropdown } from '../common/props'; + +export const createFolderAction = createAction({ + auth: tallyAuth, + name: 'create_folder', + classification: 'WRITE', + displayName: 'Create Folder', + description: 'Create a folder inside a workspace', + audience: 'ai', + aiMetadata: { + description: + 'Creates a folder inside a workspace, optionally nested under a parent folder. Requires a Pro (or higher) plan. Each call creates a new folder, so it is not idempotent (retries duplicate).', + idempotent: false, + }, + props: { + workspace_id: workspacesDropdown, + name: Property.ShortText({ + displayName: 'Name', + required: true, + }), + parent_id: foldersDropdown, + }, + async run(context) { + const { auth, propsValue } = context; + return tallyApiClient.createFolder({ + apiKey: auth.secret_text, + workspaceId: propsValue.workspace_id, + name: propsValue.name, + parentId: propsValue.parent_id, + }); + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/create-form.ts b/packages/pieces/community/tally/src/lib/actions/create-form.ts new file mode 100644 index 00000000000..05d0d46646d --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/create-form.ts @@ -0,0 +1,67 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; +import { foldersDropdown, optionalWorkspacesDropdown } from '../common/props'; +import { createFormActionOutputSchema } from '../output-schemas'; + +export const createFormAction = createAction({ + auth: tallyAuth, + name: 'create_form', + classification: 'WRITE', + displayName: 'Create Form', + description: 'Create a new form from a blocks definition', + audience: 'ai', + outputSchema: createFormActionOutputSchema, + aiMetadata: { + description: + 'Creates a new form from a raw Tally blocks array (the same structure the Tally editor produces/exports) and an initial status. Use Get Form on an existing form to see the exact block shape before authoring new blocks. Each call creates a new form, so it is not idempotent (retries duplicate).', + idempotent: false, + }, + props: { + blocks: Property.Json({ + displayName: 'Blocks', + description: + 'Array of Tally block objects that define the form content and fields (titles, inputs, choices, etc). Fetch an existing form with Get Form to see the exact shape blocks must take.', + required: true, + }), + status: Property.StaticDropdown({ + displayName: 'Status', + description: 'BLANK has no content yet; DRAFT is editable and not publicly accessible; PUBLISHED is live and accepting submissions.', + required: true, + options: { + disabled: false, + options: [ + { label: 'Blank', value: 'BLANK' }, + { label: 'Draft', value: 'DRAFT' }, + { label: 'Published', value: 'PUBLISHED' }, + ], + }, + }), + workspace_id: optionalWorkspacesDropdown, + folder_id: foldersDropdown, + template_id: Property.ShortText({ + displayName: 'Template ID', + description: 'Optional Tally template ID to base the form on.', + required: false, + }), + settings: Property.Json({ + displayName: 'Settings', + description: + 'Optional form settings object (language, notifications, redirects, data retention, styling, password protection, etc).', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + return tallyApiClient.createForm({ + apiKey: auth.secret_text, + blocks: propsValue.blocks, + status: propsValue.status, + workspaceId: propsValue.workspace_id, + folderId: propsValue.folder_id, + templateId: propsValue.template_id, + settings: propsValue.settings, + }); + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/create-workspace.ts b/packages/pieces/community/tally/src/lib/actions/create-workspace.ts new file mode 100644 index 00000000000..f5fc8fac719 --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/create-workspace.ts @@ -0,0 +1,28 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; + +export const createWorkspaceAction = createAction({ + auth: tallyAuth, + name: 'create_workspace', + classification: 'WRITE', + displayName: 'Create Workspace', + description: 'Create a new workspace', + audience: 'ai', + aiMetadata: { + description: + 'Creates a new workspace by name. Requires a Pro (or higher) subscription — fails with a permission error on a Free plan. Each call creates a new workspace, so it is not idempotent (retries duplicate).', + idempotent: false, + }, + props: { + name: Property.ShortText({ + displayName: 'Name', + required: true, + }), + }, + async run(context) { + const { auth, propsValue } = context; + return tallyApiClient.createWorkspace({ apiKey: auth.secret_text, name: propsValue.name }); + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/delete-folder.ts b/packages/pieces/community/tally/src/lib/actions/delete-folder.ts new file mode 100644 index 00000000000..eb92c3956ac --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/delete-folder.ts @@ -0,0 +1,32 @@ +import { createAction } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; +import { requiredFoldersDropdown, workspacesDropdown } from '../common/props'; + +export const deleteFolderAction = createAction({ + auth: tallyAuth, + name: 'delete_folder', + classification: 'DESTRUCTIVE', + displayName: 'Delete Folder', + description: 'Delete a folder and its subtree, moving contained forms to trash', + audience: 'ai', + aiMetadata: { + description: + 'Deletes a folder and its entire subtree of nested folders. Forms inside are moved to trash, not permanently deleted — recoverable from Tally\'s trash within its retention window. Requires a Pro (or higher) plan. A repeat call errors once the folder is gone, so this is not idempotent.', + idempotent: false, + }, + props: { + workspace_id: workspacesDropdown, + folder_id: requiredFoldersDropdown, + }, + async run(context) { + const { auth, propsValue } = context; + await tallyApiClient.deleteFolder({ + apiKey: auth.secret_text, + workspaceId: propsValue.workspace_id, + folderId: propsValue.folder_id, + }); + return { workspaceId: propsValue.workspace_id, folderId: propsValue.folder_id, deleted: true }; + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/delete-form.ts b/packages/pieces/community/tally/src/lib/actions/delete-form.ts new file mode 100644 index 00000000000..0c726494801 --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/delete-form.ts @@ -0,0 +1,27 @@ +import { createAction } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; +import { formsDropdown } from '../common/props'; + +export const deleteFormAction = createAction({ + auth: tallyAuth, + name: 'delete_form', + classification: 'DESTRUCTIVE', + displayName: 'Delete Form', + description: 'Permanently delete a form', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a form and its submissions — Tally has no trash/undo for this endpoint, so confirm the caller genuinely wants the whole form removed. A repeat call errors once the form is gone, so this is not idempotent.', + idempotent: false, + }, + props: { + form_id: formsDropdown, + }, + async run(context) { + const { auth, propsValue } = context; + await tallyApiClient.deleteForm({ apiKey: auth.secret_text, formId: propsValue.form_id }); + return { formId: propsValue.form_id, deleted: true }; + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/delete-submission.ts b/packages/pieces/community/tally/src/lib/actions/delete-submission.ts new file mode 100644 index 00000000000..3e24588c727 --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/delete-submission.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; +import { formsDropdown } from '../common/props'; + +export const deleteSubmissionAction = createAction({ + auth: tallyAuth, + name: 'delete_submission', + classification: 'DESTRUCTIVE', + displayName: 'Delete Submission', + description: 'Permanently delete a single submission', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes one submission by id — irreversible, no trash/undo. Use List Submissions or Get Submission first to confirm you have the right one. A repeat call errors once the submission is gone, so this is not idempotent.', + idempotent: false, + }, + props: { + form_id: formsDropdown, + submission_id: Property.ShortText({ + displayName: 'Submission ID', + description: 'Obtain from List Submissions or Get Submission.', + required: true, + }), + }, + async run(context) { + const { auth, propsValue } = context; + await tallyApiClient.deleteSubmission({ + apiKey: auth.secret_text, + formId: propsValue.form_id, + submissionId: propsValue.submission_id, + }); + return { formId: propsValue.form_id, submissionId: propsValue.submission_id, deleted: true }; + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/delete-workspace.ts b/packages/pieces/community/tally/src/lib/actions/delete-workspace.ts new file mode 100644 index 00000000000..b6f0060cb77 --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/delete-workspace.ts @@ -0,0 +1,27 @@ +import { createAction } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; +import { workspacesDropdown } from '../common/props'; + +export const deleteWorkspaceAction = createAction({ + auth: tallyAuth, + name: 'delete_workspace', + classification: 'DESTRUCTIVE', + displayName: 'Delete Workspace', + description: 'Permanently delete a workspace and everything inside it', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a workspace along with every form, folder, and submission inside it — irreversible, no trash/undo. Only use this when the caller explicitly wants to remove the entire workspace, not a single form (use Delete Form for that). A repeat call errors once the workspace is gone, so this is not idempotent.', + idempotent: false, + }, + props: { + workspace_id: workspacesDropdown, + }, + async run(context) { + const { auth, propsValue } = context; + await tallyApiClient.deleteWorkspace({ apiKey: auth.secret_text, workspaceId: propsValue.workspace_id }); + return { workspaceId: propsValue.workspace_id, deleted: true }; + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/get-current-user.ts b/packages/pieces/community/tally/src/lib/actions/get-current-user.ts new file mode 100644 index 00000000000..d3e58ff7c02 --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/get-current-user.ts @@ -0,0 +1,25 @@ +import { createAction } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; +import { getCurrentUserActionOutputSchema } from '../output-schemas'; + +export const getCurrentUserAction = createAction({ + auth: tallyAuth, + name: 'get_current_user', + classification: 'READ', + displayName: 'Get Current User', + description: 'Get the connected account\'s profile and subscription plan', + audience: 'ai', + outputSchema: getCurrentUserActionOutputSchema, + aiMetadata: { + description: + 'Returns the connected account\'s profile (name, email) and subscription plan (FREE, PRO, or BUSINESS). Use to check whether the account can call Pro-gated atomics like Create Workspace or the folder actions before attempting them. Read-only, safe to retry.', + idempotent: true, + }, + props: {}, + async run(context) { + const { auth } = context; + return tallyApiClient.getCurrentUser({ apiKey: auth.secret_text }); + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/get-form-analytics-dimensions.ts b/packages/pieces/community/tally/src/lib/actions/get-form-analytics-dimensions.ts new file mode 100644 index 00000000000..fb6973bc8f8 --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/get-form-analytics-dimensions.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient, TallyAnalyticsPeriod } from '../common/client'; +import { ANALYTICS_PERIOD_OPTIONS, formsDropdown } from '../common/props'; + +export const getFormAnalyticsDimensionsAction = createAction({ + auth: tallyAuth, + name: 'get_form_analytics_dimensions', + classification: 'READ', + displayName: 'Get Form Analytics Dimensions', + description: 'Get visitor breakdowns by source, browser, OS, device, and location for a form', + audience: 'ai', + aiMetadata: { + description: + 'Returns visitor counts broken down by traffic source, browser, OS, device, country, and city for a form over the given period. Use to answer "where are visitors coming from" style questions. Read-only, safe to retry.', + idempotent: true, + }, + props: { + form_id: formsDropdown, + period: Property.StaticDropdown({ + displayName: 'Period', + required: true, + options: { disabled: false, options: ANALYTICS_PERIOD_OPTIONS }, + }), + }, + async run(context) { + const { auth, propsValue } = context; + return tallyApiClient.getFormAnalyticsDimensions({ + apiKey: auth.secret_text, + formId: propsValue.form_id, + period: propsValue.period as TallyAnalyticsPeriod, + }); + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/get-form-drop-off-analytics.ts b/packages/pieces/community/tally/src/lib/actions/get-form-drop-off-analytics.ts new file mode 100644 index 00000000000..d52896b61b0 --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/get-form-drop-off-analytics.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient, TallyAnalyticsPeriod } from '../common/client'; +import { ANALYTICS_PERIOD_OPTIONS, formsDropdown } from '../common/props'; + +export const getFormDropOffAnalyticsAction = createAction({ + auth: tallyAuth, + name: 'get_form_drop_off_analytics', + classification: 'READ', + displayName: 'Get Form Drop-off Analytics', + description: 'Get per-question drop-off rates for a form', + audience: 'ai', + aiMetadata: { + description: + 'Returns per-question view/answer/drop counts and drop-off rates for a form over the given period, so you can identify which question loses the most respondents. Read-only, safe to retry.', + idempotent: true, + }, + props: { + form_id: formsDropdown, + period: Property.StaticDropdown({ + displayName: 'Period', + required: true, + options: { disabled: false, options: ANALYTICS_PERIOD_OPTIONS }, + }), + }, + async run(context) { + const { auth, propsValue } = context; + return tallyApiClient.getFormDropOffAnalytics({ + apiKey: auth.secret_text, + formId: propsValue.form_id, + period: propsValue.period as TallyAnalyticsPeriod, + }); + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/get-form-metrics.ts b/packages/pieces/community/tally/src/lib/actions/get-form-metrics.ts new file mode 100644 index 00000000000..0d00b4180ff --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/get-form-metrics.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient, TallyAnalyticsPeriod } from '../common/client'; +import { ANALYTICS_PERIOD_OPTIONS, formsDropdown } from '../common/props'; +import { getFormMetricsActionOutputSchema } from '../output-schemas'; + +export const getFormMetricsAction = createAction({ + auth: tallyAuth, + name: 'get_form_metrics', + classification: 'READ', + displayName: 'Get Form Metrics', + description: 'Get summary metrics for a form over a time period', + audience: 'ai', + outputSchema: getFormMetricsActionOutputSchema, + aiMetadata: { + description: + 'Returns summary metrics for a form over the given period: visits, submissions, unique respondents, starts, completions, and completion rate. For a breakdown over time use Get Form Visit Analytics or Get Form Submission Analytics instead. Read-only, safe to retry.', + idempotent: true, + }, + props: { + form_id: formsDropdown, + period: Property.StaticDropdown({ + displayName: 'Period', + required: true, + options: { disabled: false, options: ANALYTICS_PERIOD_OPTIONS }, + }), + }, + async run(context) { + const { auth, propsValue } = context; + return tallyApiClient.getFormMetrics({ + apiKey: auth.secret_text, + formId: propsValue.form_id, + period: propsValue.period as TallyAnalyticsPeriod, + }); + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/get-form-submission-analytics.ts b/packages/pieces/community/tally/src/lib/actions/get-form-submission-analytics.ts new file mode 100644 index 00000000000..2ad77467fa1 --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/get-form-submission-analytics.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient, TallyAnalyticsPeriod } from '../common/client'; +import { ANALYTICS_PERIOD_OPTIONS, formsDropdown } from '../common/props'; + +export const getFormSubmissionAnalyticsAction = createAction({ + auth: tallyAuth, + name: 'get_form_submission_analytics', + classification: 'READ', + displayName: 'Get Form Submission Analytics', + description: 'Get completed vs partial submission counts over time for a form', + audience: 'ai', + aiMetadata: { + description: + 'Returns a time-bucketed breakdown of completed vs. partial submission counts for a form over the given period. For a single summary number use Get Form Metrics instead. Read-only, safe to retry.', + idempotent: true, + }, + props: { + form_id: formsDropdown, + period: Property.StaticDropdown({ + displayName: 'Period', + required: true, + options: { disabled: false, options: ANALYTICS_PERIOD_OPTIONS }, + }), + }, + async run(context) { + const { auth, propsValue } = context; + return tallyApiClient.getFormSubmissionAnalytics({ + apiKey: auth.secret_text, + formId: propsValue.form_id, + period: propsValue.period as TallyAnalyticsPeriod, + }); + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/get-form-visit-analytics.ts b/packages/pieces/community/tally/src/lib/actions/get-form-visit-analytics.ts new file mode 100644 index 00000000000..42a02a57da9 --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/get-form-visit-analytics.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient, TallyAnalyticsPeriod } from '../common/client'; +import { ANALYTICS_PERIOD_OPTIONS, formsDropdown } from '../common/props'; + +export const getFormVisitAnalyticsAction = createAction({ + auth: tallyAuth, + name: 'get_form_visit_analytics', + classification: 'READ', + displayName: 'Get Form Visit Analytics', + description: 'Get visit counts over time for a form', + audience: 'ai', + aiMetadata: { + description: + 'Returns a time-bucketed breakdown of visit counts for a form over the given period. For a single summary number use Get Form Metrics instead. Read-only, safe to retry.', + idempotent: true, + }, + props: { + form_id: formsDropdown, + period: Property.StaticDropdown({ + displayName: 'Period', + required: true, + options: { disabled: false, options: ANALYTICS_PERIOD_OPTIONS }, + }), + }, + async run(context) { + const { auth, propsValue } = context; + return tallyApiClient.getFormVisitAnalytics({ + apiKey: auth.secret_text, + formId: propsValue.form_id, + period: propsValue.period as TallyAnalyticsPeriod, + }); + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/get-form.ts b/packages/pieces/community/tally/src/lib/actions/get-form.ts new file mode 100644 index 00000000000..9081e379ccc --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/get-form.ts @@ -0,0 +1,28 @@ +import { createAction } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; +import { formsDropdown } from '../common/props'; +import { getFormActionOutputSchema } from '../output-schemas'; + +export const getFormAction = createAction({ + auth: tallyAuth, + name: 'get_form', + classification: 'READ', + displayName: 'Get Form', + description: 'Get a single form by id, including its blocks and settings', + audience: 'ai', + outputSchema: getFormActionOutputSchema, + aiMetadata: { + description: + 'Fetches one form by id with its full definition — blocks (the form content/fields) and settings — in addition to the summary fields List Forms already returns. Use to inspect a form\'s exact block shape before calling Update Form or Create Form. Read-only, safe to retry.', + idempotent: true, + }, + props: { + form_id: formsDropdown, + }, + async run(context) { + const { auth, propsValue } = context; + return tallyApiClient.getForm({ apiKey: auth.secret_text, formId: propsValue.form_id }); + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/get-submission.ts b/packages/pieces/community/tally/src/lib/actions/get-submission.ts new file mode 100644 index 00000000000..13f5dbe23e2 --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/get-submission.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; +import { formsDropdown } from '../common/props'; +import { getSubmissionActionOutputSchema } from '../output-schemas'; + +export const getSubmissionAction = createAction({ + auth: tallyAuth, + name: 'get_submission', + classification: 'READ', + displayName: 'Get Submission', + description: 'Get a single submission by id', + audience: 'ai', + outputSchema: getSubmissionActionOutputSchema, + aiMetadata: { + description: + 'Fetches one submission by id with its full set of responses, plus the form\'s questions for label resolution. Use List Submissions or the New Submission trigger to obtain a submission id first. Read-only, safe to retry.', + idempotent: true, + }, + props: { + form_id: formsDropdown, + submission_id: Property.ShortText({ + displayName: 'Submission ID', + description: 'Obtain from List Submissions or the New Submission trigger payload.', + required: true, + }), + }, + async run(context) { + const { auth, propsValue } = context; + return tallyApiClient.getSubmission({ + apiKey: auth.secret_text, + formId: propsValue.form_id, + submissionId: propsValue.submission_id, + }); + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/get-workspace.ts b/packages/pieces/community/tally/src/lib/actions/get-workspace.ts new file mode 100644 index 00000000000..fd24e5c9a27 --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/get-workspace.ts @@ -0,0 +1,28 @@ +import { createAction } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; +import { workspacesDropdown } from '../common/props'; +import { getWorkspaceActionOutputSchema } from '../output-schemas'; + +export const getWorkspaceAction = createAction({ + auth: tallyAuth, + name: 'get_workspace', + classification: 'READ', + displayName: 'Get Workspace', + description: 'Get a single workspace by id', + audience: 'ai', + outputSchema: getWorkspaceActionOutputSchema, + aiMetadata: { + description: + 'Fetches one workspace by id with its members, pending invites, and folders. Use List Workspaces first to resolve a workspace id. Read-only, safe to retry.', + idempotent: true, + }, + props: { + workspace_id: workspacesDropdown, + }, + async run(context) { + const { auth, propsValue } = context; + return tallyApiClient.getWorkspace({ apiKey: auth.secret_text, workspaceId: propsValue.workspace_id }); + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/list-form-questions.ts b/packages/pieces/community/tally/src/lib/actions/list-form-questions.ts new file mode 100644 index 00000000000..569e0bdcd5d --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/list-form-questions.ts @@ -0,0 +1,28 @@ +import { createAction } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; +import { formsDropdown } from '../common/props'; +import { listFormQuestionsActionOutputSchema } from '../output-schemas'; + +export const listFormQuestionsAction = createAction({ + auth: tallyAuth, + name: 'list_form_questions', + classification: 'SEARCH', + displayName: 'List Form Questions', + description: 'List the questions on a form, with their ids and titles', + audience: 'ai', + outputSchema: listFormQuestionsActionOutputSchema, + aiMetadata: { + description: + 'Lists a form\'s questions with their ids, titles, and types. Use to resolve a questionId to its label before reading List Submissions / Get Submission responses, since raw submission answers are keyed by questionId, not by label. Read-only, safe to retry.', + idempotent: true, + }, + props: { + form_id: formsDropdown, + }, + async run(context) { + const { auth, propsValue } = context; + return tallyApiClient.listFormQuestions({ apiKey: auth.secret_text, formId: propsValue.form_id }); + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/list-forms.ts b/packages/pieces/community/tally/src/lib/actions/list-forms.ts new file mode 100644 index 00000000000..a223292195b --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/list-forms.ts @@ -0,0 +1,40 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; +import { listFormsActionOutputSchema } from '../output-schemas'; + +export const listFormsAction = createAction({ + auth: tallyAuth, + name: 'list_forms', + classification: 'SEARCH', + displayName: 'List Forms', + description: 'List all forms in your Tally account', + audience: 'ai', + outputSchema: listFormsActionOutputSchema, + aiMetadata: { + description: + 'Lists forms with pagination, including each form\'s id, name, workspace id, status, submission count, and closed state. Use to discover form ids before calling other form/submission/analytics atomics. Read-only, safe to retry.', + idempotent: true, + }, + props: { + page: Property.Number({ + displayName: 'Page', + description: 'Page number, starting at 1. Defaults to 1.', + required: false, + }), + limit: Property.Number({ + displayName: 'Limit', + description: 'Number of forms per page (max 500). Defaults to 50.', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + return tallyApiClient.listFormsPage({ + apiKey: auth.secret_text, + page: propsValue.page, + limit: propsValue.limit, + }); + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/list-submissions.ts b/packages/pieces/community/tally/src/lib/actions/list-submissions.ts new file mode 100644 index 00000000000..57e78db1f16 --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/list-submissions.ts @@ -0,0 +1,75 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; +import { formsDropdown } from '../common/props'; +import { listSubmissionsActionOutputSchema } from '../output-schemas'; + +export const listSubmissionsAction = createAction({ + auth: tallyAuth, + name: 'list_submissions', + classification: 'SEARCH', + displayName: 'List Submissions', + description: 'List a form\'s submissions, with filters for status and date range', + audience: 'ai', + outputSchema: listSubmissionsActionOutputSchema, + aiMetadata: { + description: + 'Lists a form\'s submissions with pagination and optional completion/date filters, including each response keyed by questionId. Use List Form Questions to resolve questionId to a label. For a single known submission use Get Submission instead. Read-only, safe to retry.', + idempotent: true, + }, + props: { + form_id: formsDropdown, + filter: Property.StaticDropdown({ + displayName: 'Filter', + description: 'Filter by submission completion status. Defaults to all.', + required: false, + options: { + disabled: false, + options: [ + { label: 'All', value: 'all' }, + { label: 'Completed', value: 'completed' }, + { label: 'Partial', value: 'partial' }, + ], + }, + }), + start_date: Property.ShortText({ + displayName: 'Start Date', + description: 'ISO 8601 date-time. Include submissions on or after this date.', + required: false, + }), + end_date: Property.ShortText({ + displayName: 'End Date', + description: 'ISO 8601 date-time. Include submissions on or before this date.', + required: false, + }), + after_id: Property.ShortText({ + displayName: 'After Submission ID', + description: 'Return submissions after this submission id (cursor-style pagination).', + required: false, + }), + page: Property.Number({ + displayName: 'Page', + description: 'Page number, starting at 1. Defaults to 1.', + required: false, + }), + limit: Property.Number({ + displayName: 'Limit', + description: 'Submissions per page (max 500). Defaults to 50.', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + return tallyApiClient.listSubmissions({ + apiKey: auth.secret_text, + formId: propsValue.form_id, + filter: propsValue.filter, + startDate: propsValue.start_date, + endDate: propsValue.end_date, + afterId: propsValue.after_id, + page: propsValue.page, + limit: propsValue.limit, + }); + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/list-workspace-folders.ts b/packages/pieces/community/tally/src/lib/actions/list-workspace-folders.ts new file mode 100644 index 00000000000..224b4fce99f --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/list-workspace-folders.ts @@ -0,0 +1,29 @@ +import { createAction } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; +import { workspacesDropdown } from '../common/props'; + +export const listWorkspaceFoldersAction = createAction({ + auth: tallyAuth, + name: 'list_workspace_folders', + classification: 'SEARCH', + displayName: 'List Workspace Folders', + description: 'List folders inside a workspace', + audience: 'ai', + aiMetadata: { + description: + 'Lists the folders inside a workspace, including nested folders (via each folder\'s parentId). Use to resolve a folder id before calling Create Form, Rename Folder, or Delete Folder. Requires the workspace to be on a Pro (or higher) plan. Read-only, safe to retry.', + idempotent: true, + }, + props: { + workspace_id: workspacesDropdown, + }, + async run(context) { + const { auth, propsValue } = context; + return tallyApiClient.listWorkspaceFolders({ + apiKey: auth.secret_text, + workspaceId: propsValue.workspace_id, + }); + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/list-workspaces.ts b/packages/pieces/community/tally/src/lib/actions/list-workspaces.ts new file mode 100644 index 00000000000..d0ed8eb2f71 --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/list-workspaces.ts @@ -0,0 +1,31 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; +import { listWorkspacesActionOutputSchema } from '../output-schemas'; + +export const listWorkspacesAction = createAction({ + auth: tallyAuth, + name: 'list_workspaces', + classification: 'SEARCH', + displayName: 'List Workspaces', + description: 'List all workspaces in your Tally account', + audience: 'ai', + outputSchema: listWorkspacesActionOutputSchema, + aiMetadata: { + description: + 'Lists workspaces with their ids, names, members, pending invites, and folders. Use to resolve a workspace id before calling Create Form, Create Workspace-scoped Folder, or other workspace/folder atomics. Read-only, safe to retry.', + idempotent: true, + }, + props: { + page: Property.Number({ + displayName: 'Page', + description: 'Page number, starting at 1. Defaults to 1.', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + return tallyApiClient.listWorkspaces({ apiKey: auth.secret_text, page: propsValue.page }); + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/rename-folder.ts b/packages/pieces/community/tally/src/lib/actions/rename-folder.ts new file mode 100644 index 00000000000..b2db923068c --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/rename-folder.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; +import { requiredFoldersDropdown, workspacesDropdown } from '../common/props'; + +export const renameFolderAction = createAction({ + auth: tallyAuth, + name: 'rename_folder', + classification: 'WRITE', + displayName: 'Rename Folder', + description: 'Rename a folder', + audience: 'ai', + aiMetadata: { + description: + 'Renames a folder to the given name. Requires a Pro (or higher) plan. Repeating the same call converges on the same state, so it is idempotent.', + idempotent: true, + }, + props: { + workspace_id: workspacesDropdown, + folder_id: requiredFoldersDropdown, + name: Property.ShortText({ + displayName: 'New Name', + required: true, + }), + }, + async run(context) { + const { auth, propsValue } = context; + await tallyApiClient.renameFolder({ + apiKey: auth.secret_text, + workspaceId: propsValue.workspace_id, + folderId: propsValue.folder_id, + name: propsValue.name, + }); + return { workspaceId: propsValue.workspace_id, folderId: propsValue.folder_id, name: propsValue.name }; + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/rename-workspace.ts b/packages/pieces/community/tally/src/lib/actions/rename-workspace.ts new file mode 100644 index 00000000000..440cb187276 --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/rename-workspace.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; +import { workspacesDropdown } from '../common/props'; + +export const renameWorkspaceAction = createAction({ + auth: tallyAuth, + name: 'rename_workspace', + classification: 'WRITE', + displayName: 'Rename Workspace', + description: 'Rename a workspace', + audience: 'ai', + aiMetadata: { + description: + 'Renames a workspace to the given name. Repeating the same call converges on the same state, so it is idempotent.', + idempotent: true, + }, + props: { + workspace_id: workspacesDropdown, + name: Property.ShortText({ + displayName: 'New Name', + required: true, + }), + }, + async run(context) { + const { auth, propsValue } = context; + await tallyApiClient.renameWorkspace({ + apiKey: auth.secret_text, + workspaceId: propsValue.workspace_id, + name: propsValue.name, + }); + return { workspaceId: propsValue.workspace_id, name: propsValue.name }; + }, +}); diff --git a/packages/pieces/community/tally/src/lib/actions/update-form.ts b/packages/pieces/community/tally/src/lib/actions/update-form.ts new file mode 100644 index 00000000000..7162646affd --- /dev/null +++ b/packages/pieces/community/tally/src/lib/actions/update-form.ts @@ -0,0 +1,62 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; + +import { tallyAuth } from '../auth'; +import { tallyApiClient } from '../common/client'; +import { formsDropdown } from '../common/props'; +import { updateFormActionOutputSchema } from '../output-schemas'; + +export const updateFormAction = createAction({ + auth: tallyAuth, + name: 'update_form', + classification: 'WRITE', + displayName: 'Update Form', + description: 'Update a form\'s name, status, blocks, or settings', + audience: 'ai', + outputSchema: updateFormActionOutputSchema, + aiMetadata: { + description: + 'Partially updates a form: only the fields you provide are changed, everything else is left as-is. Passing blocks or settings replaces that whole field, so fetch the current form with Get Form first if you need to preserve unrelated blocks/settings. Repeating the same call converges on the same state, so it is idempotent.', + idempotent: true, + }, + props: { + form_id: formsDropdown, + name: Property.ShortText({ + displayName: 'Name', + required: false, + }), + status: Property.StaticDropdown({ + displayName: 'Status', + required: false, + options: { + disabled: false, + options: [ + { label: 'Blank', value: 'BLANK' }, + { label: 'Draft', value: 'DRAFT' }, + { label: 'Published', value: 'PUBLISHED' }, + ], + }, + }), + blocks: Property.Json({ + displayName: 'Blocks', + description: + 'Replaces the entire blocks array. Use Get Form first to fetch the current blocks so you can merge your change into the full array rather than dropping the rest of the form content.', + required: false, + }), + settings: Property.Json({ + displayName: 'Settings', + description: 'Replaces the entire settings object. Use Get Form first if you need to preserve other settings.', + required: false, + }), + }, + async run(context) { + const { auth, propsValue } = context; + return tallyApiClient.updateForm({ + apiKey: auth.secret_text, + formId: propsValue.form_id, + name: propsValue.name, + status: propsValue.status, + blocks: propsValue.blocks, + settings: propsValue.settings, + }); + }, +}); diff --git a/packages/pieces/community/tally/src/lib/common/client.ts b/packages/pieces/community/tally/src/lib/common/client.ts index bc3b9b9727e..727cc8ad376 100644 --- a/packages/pieces/community/tally/src/lib/common/client.ts +++ b/packages/pieces/community/tally/src/lib/common/client.ts @@ -1,13 +1,37 @@ import { AuthenticationType, HttpError, HttpMethod, httpClient } from '@activepieces/pieces-common'; -import { tryCatch } from '@activepieces/pieces-framework'; +import { spreadIfDefined, tryCatch } from '@activepieces/pieces-framework'; import type { + TallyFolder, TallyForm, + TallyFormDetail, + TallyFormDimensionsAnalytics, + TallyFormDropOffAnalytics, + TallyFormMetrics, + TallyFormSubmissionAnalytics, + TallyFormVisitAnalytics, TallyFormsResponse, + TallyGetSubmissionResponse, + TallyListFormQuestionsResponse, + TallyListSubmissionsResponse, + TallyListWorkspacesResponse, TallySubmissionsApiResponse, + TallyCurrentUser, TallyWebhookResponse, + TallyWorkspace, } from './types'; +export type TallyAnalyticsPeriod = + | 'today' + | 'yesterday' + | '24h' + | '7d' + | '30d' + | '3m' + | '6m' + | '12m' + | 'all'; + export const TALLY_API_BASE = 'https://api.tally.so'; export const tallyApiClient = { @@ -16,6 +40,30 @@ export const tallyApiClient = { createWebhook, deleteWebhook, fetchRecentSubmissions, + listFormsPage, + createForm, + getForm, + updateForm, + deleteForm, + listFormQuestions, + listSubmissions, + getSubmission, + deleteSubmission, + getFormMetrics, + getFormVisitAnalytics, + getFormSubmissionAnalytics, + getFormAnalyticsDimensions, + getFormDropOffAnalytics, + listWorkspaces, + createWorkspace, + getWorkspace, + renameWorkspace, + deleteWorkspace, + listWorkspaceFolders, + createFolder, + renameFolder, + deleteFolder, + getCurrentUser, }; async function validateApiKey(apiKey: string): Promise { @@ -103,6 +151,412 @@ async function fetchRecentSubmissions({ }); } +async function listFormsPage({ + apiKey, + page, + limit, +}: { + apiKey: string; + page?: number; + limit?: number; +}): Promise { + return makeApiCall({ + method: HttpMethod.GET, + path: '/forms', + apiKey, + queryParams: { + ...spreadIfDefined('page', page?.toString()), + ...spreadIfDefined('limit', limit?.toString()), + }, + }); +} + +async function createForm({ + apiKey, + blocks, + status, + workspaceId, + templateId, + folderId, + settings, +}: { + apiKey: string; + blocks: unknown; + status: string; + workspaceId?: string; + templateId?: string; + folderId?: string; + settings?: unknown; +}): Promise { + return makeApiCall({ + method: HttpMethod.POST, + path: '/forms', + apiKey, + body: { + blocks, + status, + ...spreadIfDefined('workspaceId', workspaceId), + ...spreadIfDefined('templateId', templateId), + ...spreadIfDefined('folderId', folderId), + ...spreadIfDefined('settings', settings), + }, + }); +} + +async function getForm({ apiKey, formId }: { apiKey: string; formId: string }): Promise { + return makeApiCall({ + method: HttpMethod.GET, + path: `/forms/${formId}`, + apiKey, + }); +} + +async function updateForm({ + apiKey, + formId, + name, + status, + blocks, + settings, +}: { + apiKey: string; + formId: string; + name?: string; + status?: string; + blocks?: unknown; + settings?: unknown; +}): Promise { + return makeApiCall({ + method: HttpMethod.PATCH, + path: `/forms/${formId}`, + apiKey, + body: { + ...spreadIfDefined('name', name), + ...spreadIfDefined('status', status), + ...spreadIfDefined('blocks', blocks), + ...spreadIfDefined('settings', settings), + }, + }); +} + +async function deleteForm({ apiKey, formId }: { apiKey: string; formId: string }): Promise { + await makeApiCall({ + method: HttpMethod.DELETE, + path: `/forms/${formId}`, + apiKey, + }); +} + +async function listFormQuestions({ + apiKey, + formId, +}: { + apiKey: string; + formId: string; +}): Promise { + return makeApiCall({ + method: HttpMethod.GET, + path: `/forms/${formId}/questions`, + apiKey, + }); +} + +async function listSubmissions({ + apiKey, + formId, + page, + limit, + filter, + startDate, + endDate, + afterId, +}: { + apiKey: string; + formId: string; + page?: number; + limit?: number; + filter?: string; + startDate?: string; + endDate?: string; + afterId?: string; +}): Promise { + return makeApiCall({ + method: HttpMethod.GET, + path: `/forms/${formId}/submissions`, + apiKey, + queryParams: { + ...spreadIfDefined('page', page?.toString()), + ...spreadIfDefined('limit', limit?.toString()), + ...spreadIfDefined('filter', filter), + ...spreadIfDefined('startDate', startDate), + ...spreadIfDefined('endDate', endDate), + ...spreadIfDefined('afterId', afterId), + }, + }); +} + +async function getSubmission({ + apiKey, + formId, + submissionId, +}: { + apiKey: string; + formId: string; + submissionId: string; +}): Promise { + return makeApiCall({ + method: HttpMethod.GET, + path: `/forms/${formId}/submissions/${submissionId}`, + apiKey, + }); +} + +async function deleteSubmission({ + apiKey, + formId, + submissionId, +}: { + apiKey: string; + formId: string; + submissionId: string; +}): Promise { + await makeApiCall({ + method: HttpMethod.DELETE, + path: `/forms/${formId}/submissions/${submissionId}`, + apiKey, + }); +} + +async function getFormMetrics({ + apiKey, + formId, + period, +}: { + apiKey: string; + formId: string; + period: TallyAnalyticsPeriod; +}): Promise { + return makeApiCall({ + method: HttpMethod.GET, + path: `/forms/${formId}/analytics/metrics`, + apiKey, + queryParams: { period }, + }); +} + +async function getFormVisitAnalytics({ + apiKey, + formId, + period, +}: { + apiKey: string; + formId: string; + period: TallyAnalyticsPeriod; +}): Promise { + return makeApiCall({ + method: HttpMethod.GET, + path: `/forms/${formId}/analytics/visits`, + apiKey, + queryParams: { period }, + }); +} + +async function getFormSubmissionAnalytics({ + apiKey, + formId, + period, +}: { + apiKey: string; + formId: string; + period: TallyAnalyticsPeriod; +}): Promise { + return makeApiCall({ + method: HttpMethod.GET, + path: `/forms/${formId}/analytics/submissions`, + apiKey, + queryParams: { period }, + }); +} + +async function getFormAnalyticsDimensions({ + apiKey, + formId, + period, +}: { + apiKey: string; + formId: string; + period: TallyAnalyticsPeriod; +}): Promise { + return makeApiCall({ + method: HttpMethod.GET, + path: `/forms/${formId}/analytics/dimensions`, + apiKey, + queryParams: { period }, + }); +} + +async function getFormDropOffAnalytics({ + apiKey, + formId, + period, +}: { + apiKey: string; + formId: string; + period: TallyAnalyticsPeriod; +}): Promise { + return makeApiCall({ + method: HttpMethod.GET, + path: `/forms/${formId}/analytics/drop-off`, + apiKey, + queryParams: { period }, + }); +} + +async function listWorkspaces({ + apiKey, + page, +}: { + apiKey: string; + page?: number; +}): Promise { + return makeApiCall({ + method: HttpMethod.GET, + path: '/workspaces', + apiKey, + queryParams: { ...spreadIfDefined('page', page?.toString()) }, + }); +} + +async function createWorkspace({ apiKey, name }: { apiKey: string; name: string }): Promise { + return makeApiCall({ + method: HttpMethod.POST, + path: '/workspaces', + apiKey, + body: { name }, + }); +} + +async function getWorkspace({ + apiKey, + workspaceId, +}: { + apiKey: string; + workspaceId: string; +}): Promise { + return makeApiCall({ + method: HttpMethod.GET, + path: `/workspaces/${workspaceId}`, + apiKey, + }); +} + +async function renameWorkspace({ + apiKey, + workspaceId, + name, +}: { + apiKey: string; + workspaceId: string; + name: string; +}): Promise { + await makeApiCall({ + method: HttpMethod.PATCH, + path: `/workspaces/${workspaceId}`, + apiKey, + body: { name }, + }); +} + +async function deleteWorkspace({ + apiKey, + workspaceId, +}: { + apiKey: string; + workspaceId: string; +}): Promise { + await makeApiCall({ + method: HttpMethod.DELETE, + path: `/workspaces/${workspaceId}`, + apiKey, + }); +} + +async function listWorkspaceFolders({ + apiKey, + workspaceId, +}: { + apiKey: string; + workspaceId: string; +}): Promise { + return makeApiCall({ + method: HttpMethod.GET, + path: `/workspaces/${workspaceId}/folders`, + apiKey, + }); +} + +async function createFolder({ + apiKey, + workspaceId, + name, + parentId, +}: { + apiKey: string; + workspaceId: string; + name: string; + parentId?: string; +}): Promise { + return makeApiCall({ + method: HttpMethod.POST, + path: `/workspaces/${workspaceId}/folders`, + apiKey, + body: { name, ...spreadIfDefined('parentId', parentId) }, + }); +} + +async function renameFolder({ + apiKey, + workspaceId, + folderId, + name, +}: { + apiKey: string; + workspaceId: string; + folderId: string; + name: string; +}): Promise { + await makeApiCall({ + method: HttpMethod.PATCH, + path: `/workspaces/${workspaceId}/folders/${folderId}`, + apiKey, + body: { name }, + }); +} + +async function deleteFolder({ + apiKey, + workspaceId, + folderId, +}: { + apiKey: string; + workspaceId: string; + folderId: string; +}): Promise { + await makeApiCall({ + method: HttpMethod.DELETE, + path: `/workspaces/${workspaceId}/folders/${folderId}`, + apiKey, + }); +} + +async function getCurrentUser({ apiKey }: { apiKey: string }): Promise { + return makeApiCall({ + method: HttpMethod.GET, + path: '/users/me', + apiKey, + }); +} + async function makeApiCall({ method, path, @@ -128,6 +582,11 @@ async function makeApiCall({ const status = error.response.status; const responseBody = error.response.body as { message?: string } | undefined; if (status === 401) throw new Error('Authentication failed. Check your API key.'); + if (status === 403) + throw new Error( + responseBody?.message ?? 'Forbidden. Your API key does not have permission for this operation, or it requires a Pro subscription.', + ); + if (status === 404) throw new Error(responseBody?.message ?? 'Not found. Check the ID and try again.'); throw new Error(`API error (${status}): ${responseBody?.message ?? 'Unknown error'}`); } throw error; diff --git a/packages/pieces/community/tally/src/lib/common/props.ts b/packages/pieces/community/tally/src/lib/common/props.ts index 15904cbdf03..668117d3256 100644 --- a/packages/pieces/community/tally/src/lib/common/props.ts +++ b/packages/pieces/community/tally/src/lib/common/props.ts @@ -4,6 +4,18 @@ import { tryCatch } from '@activepieces/pieces-framework'; import { tallyAuth } from '../auth'; import { tallyApiClient } from './client'; +export const ANALYTICS_PERIOD_OPTIONS = [ + { label: 'Today', value: 'today' }, + { label: 'Yesterday', value: 'yesterday' }, + { label: 'Last 24 hours', value: '24h' }, + { label: 'Last 7 days', value: '7d' }, + { label: 'Last 30 days', value: '30d' }, + { label: 'Last 3 months', value: '3m' }, + { label: 'Last 6 months', value: '6m' }, + { label: 'Last 12 months', value: '12m' }, + { label: 'All time', value: 'all' }, +]; + export const formsDropdown = Property.Dropdown({ auth: tallyAuth, displayName: 'Form', @@ -36,3 +48,158 @@ export const formsDropdown = Property.Dropdown({ return { disabled: false, placeholder: 'Select a form', options }; }, }); + +export const workspacesDropdown = Property.Dropdown({ + auth: tallyAuth, + displayName: 'Workspace', + required: true, + refreshers: [], + async options({ auth }) { + if (!auth) { + return { + disabled: true, + placeholder: 'Connect your account first', + options: [], + }; + } + + const { data, error } = await tryCatch(() => tallyApiClient.listWorkspaces({ apiKey: auth.secret_text })); + + if (error) { + return { + disabled: true, + placeholder: 'Failed to load workspaces — check your connection', + options: [], + }; + } + + const options: DropdownOption[] = data.items.map((workspace) => ({ + label: workspace.name ?? 'Untitled Workspace', + value: workspace.id, + })); + + return { disabled: false, placeholder: 'Select a workspace', options }; + }, +}); + +export const optionalWorkspacesDropdown = Property.Dropdown({ + auth: tallyAuth, + displayName: 'Workspace', + description: 'Defaults to your account\'s default workspace if left empty.', + required: false, + refreshers: [], + async options({ auth }) { + if (!auth) { + return { + disabled: true, + placeholder: 'Connect your account first', + options: [], + }; + } + + const { data, error } = await tryCatch(() => tallyApiClient.listWorkspaces({ apiKey: auth.secret_text })); + + if (error) { + return { + disabled: true, + placeholder: 'Failed to load workspaces — check your connection', + options: [], + }; + } + + const options: DropdownOption[] = data.items.map((workspace) => ({ + label: workspace.name ?? 'Untitled Workspace', + value: workspace.id, + })); + + return { disabled: false, placeholder: 'Select a workspace', options }; + }, +}); + +export const foldersDropdown = Property.Dropdown({ + auth: tallyAuth, + displayName: 'Folder', + description: 'Only folders inside the selected workspace are shown.', + required: false, + refreshers: ['workspace_id'], + async options({ auth, workspace_id }) { + if (!auth) { + return { + disabled: true, + placeholder: 'Connect your account first', + options: [], + }; + } + + if (typeof workspace_id !== 'string' || workspace_id.length === 0) { + return { + disabled: true, + placeholder: 'Select a workspace first', + options: [], + }; + } + + const { data: folders, error } = await tryCatch(() => + tallyApiClient.listWorkspaceFolders({ apiKey: auth.secret_text, workspaceId: workspace_id }), + ); + + if (error) { + return { + disabled: true, + placeholder: 'Failed to load folders — check your connection', + options: [], + }; + } + + const options: DropdownOption[] = folders.map((folder) => ({ + label: folder.name, + value: folder.id, + })); + + return { disabled: false, placeholder: 'Select a folder', options }; + }, +}); + +export const requiredFoldersDropdown = Property.Dropdown({ + auth: tallyAuth, + displayName: 'Folder', + description: 'Only folders inside the selected workspace are shown.', + required: true, + refreshers: ['workspace_id'], + async options({ auth, workspace_id }) { + if (!auth) { + return { + disabled: true, + placeholder: 'Connect your account first', + options: [], + }; + } + + if (typeof workspace_id !== 'string' || workspace_id.length === 0) { + return { + disabled: true, + placeholder: 'Select a workspace first', + options: [], + }; + } + + const { data: folders, error } = await tryCatch(() => + tallyApiClient.listWorkspaceFolders({ apiKey: auth.secret_text, workspaceId: workspace_id }), + ); + + if (error) { + return { + disabled: true, + placeholder: 'Failed to load folders — check your connection', + options: [], + }; + } + + const options: DropdownOption[] = folders.map((folder) => ({ + label: folder.name, + value: folder.id, + })); + + return { disabled: false, placeholder: 'Select a folder', options }; + }, +}); diff --git a/packages/pieces/community/tally/src/lib/common/types.ts b/packages/pieces/community/tally/src/lib/common/types.ts index bdcfd0e30c9..bbe9ef68b30 100644 --- a/packages/pieces/community/tally/src/lib/common/types.ts +++ b/packages/pieces/community/tally/src/lib/common/types.ts @@ -2,12 +2,30 @@ export type TallyForm = { id: string; name: string; status: string; + isNameModifiedByUser?: boolean; + workspaceId?: string; + folderId?: string | null; + organizationId?: string; + hasDraftBlocks?: boolean; + numberOfSubmissions?: number; + isClosed?: boolean; + index?: number; + payments?: { amount: number; currency: string }[]; + createdAt?: string; + updatedAt?: string; +}; + +export type TallyFormDetail = TallyForm & { + settings?: unknown; + blocks?: unknown[]; }; export type TallyFormsResponse = { items: TallyForm[]; hasMore: boolean; page: number; + limit?: number; + total?: number; }; export type TallyWebhookResponse = { id: string }; @@ -50,6 +68,176 @@ export type TallyField = { columns?: TallyQuestionOption[]; }; +export type TallyQuestionListItem = { + id: string; + type: string; + title: string; + isTitleModifiedByUser: boolean; + formId: string; + isDeleted: boolean; + numberOfResponses: number; + createdAt: string; + updatedAt: string; + fields: { uuid: string; type: string; questionType?: string; blockGroupUuid: string; title: string }[]; +}; + +export type TallyListFormQuestionsResponse = { + questions: TallyQuestionListItem[]; + hasResponses: boolean; +}; + +export type TallySubmissionResponseItem = { + id: string; + formId: string; + questionId: string; + respondentId: string; + submissionId: string | null; + sessionUuid: string; + answer: unknown; + formattedAnswer?: string; + createdAt: string; + updatedAt: string; +}; + +export type TallySubmissionListItem = { + id: string; + formId: string; + isCompleted: boolean; + submittedAt: string; + previewUrl: string; + pdfUrl: string; + responses: TallySubmissionResponseItem[]; +}; + +export type TallyListSubmissionsResponse = { + page: number; + limit: number; + hasMore: boolean; + totalNumberOfSubmissionsPerFilter: { all: number; completed: number; partial: number }; + questions: TallyQuestionListItem[]; + submissions: TallySubmissionListItem[]; +}; + +export type TallyGetSubmissionResponse = { + questions: TallyQuestionListItem[]; + submission: TallySubmissionListItem; +}; + +export type TallyFormMetrics = { + visits: number; + visitDuration: number; + submissions: number; + uniqueRespondents: number; + totalViews: number; + starts: number; + completions: number; + completionDuration: number; + completionRate: number; +}; + +export type TallyFormVisitAnalytics = { + data: Record; + interval: number; +}; + +export type TallyFormSubmissionAnalytics = { + data: Record; + interval: number; +}; + +export type TallyFormDimensionsAnalytics = { + source: Record; + browser: Record; + os: Record; + device: Record; + country: Record; + city: Record; +}; + +export type TallyFormDropOffAnalytics = { + stats: { + totalVisitors: number; + formStarts: number; + formCompletes: number; + completionRate: number; + completionTimeInSeconds: number; + visitDurationInSeconds: number; + }; + dataAvailableSince: string; + data: { + blockGroupUuid: string; + views: number; + startedViews: number; + answers: number; + drops: number; + title: string; + type: string; + answerRate: number; + dropRate: number; + isRequired: boolean; + }[]; + hasConditionalLogic: boolean; +}; + +export type TallyUserSummary = { + id: string; + firstName: string; + lastName: string; + fullName: string; + email: string; + avatarUrl: string | null; + organizationId: string; + isBlocked: boolean; + isDeleted: boolean; + timezone: string; + hasTwoFactorEnabled: boolean; + emailDomain: string | null; + createdAt: string; + updatedAt: string; +}; + +export type TallyCurrentUser = TallyUserSummary & { + isOrganizationOwner: boolean; + organizationOwner: TallyUserSummary; + canAccessBilling: boolean; + subscriptionPlan: 'FREE' | 'PRO' | 'BUSINESS'; + hasPendingSubscriptionCancellation: boolean; + hasAccess: boolean; + excessUsage: unknown; +}; + +export type TallyWorkspaceInvite = { id: string; email: string; workspaceIds: string[] }; + +export type TallyFolder = { + id: string; + name: string; + workspaceId: string; + parentId: string | null; + createdByUserId: string; + createdAt: string; + updatedAt: string; +}; + +export type TallyWorkspace = { + id: string; + name: string | null; + index: number; + members: TallyUserSummary[]; + invites: TallyWorkspaceInvite[]; + folders?: TallyFolder[]; + createdByUserId: string; + createdAt: string; + updatedAt: string; +}; + +export type TallyListWorkspacesResponse = { + items: TallyWorkspace[]; + page: number; + limit: number; + total: number; + hasMore: boolean; +}; + export type TallyWebhookPayload = { eventId: string; eventType: string; diff --git a/packages/pieces/community/tally/src/lib/output-schemas.ts b/packages/pieces/community/tally/src/lib/output-schemas.ts new file mode 100644 index 00000000000..b7044373054 --- /dev/null +++ b/packages/pieces/community/tally/src/lib/output-schemas.ts @@ -0,0 +1,154 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +const userFields: OutputSchema['fields'] = [ + { key: 'id', label: 'User ID' }, + { key: 'fullName', label: 'Full Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'avatarUrl', label: 'Avatar URL', format: 'image' }, + { key: 'timezone', label: 'Timezone' }, + { key: 'organizationId', label: 'Organization ID' }, + { key: 'isBlocked', label: 'Blocked', format: 'boolean' }, + { key: 'createdAt', label: 'Created At', format: 'datetime' }, + { key: 'updatedAt', label: 'Updated At', format: 'datetime' }, +]; + +const formSummaryFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Form ID' }, + { key: 'name', label: 'Name' }, + { key: 'workspaceId', label: 'Workspace ID' }, + { key: 'folderId', label: 'Folder ID' }, + { key: 'status', label: 'Status' }, + { key: 'isClosed', label: 'Closed', format: 'boolean' }, + { key: 'hasDraftBlocks', label: 'Has Draft Blocks', format: 'boolean' }, + { key: 'numberOfSubmissions', label: 'Number Of Submissions', format: 'number' }, + { key: 'createdAt', label: 'Created At', format: 'datetime' }, + { key: 'updatedAt', label: 'Updated At', format: 'datetime' }, +]; + +const questionFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Question ID' }, + { key: 'type', label: 'Type' }, + { key: 'title', label: 'Title' }, + { key: 'formId', label: 'Form ID' }, + { key: 'isDeleted', label: 'Deleted', format: 'boolean' }, + { key: 'numberOfResponses', label: 'Number Of Responses', format: 'number' }, +]; + +const responseFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Response ID' }, + { key: 'questionId', label: 'Question ID' }, + { key: 'respondentId', label: 'Respondent ID' }, + { key: 'answer', label: 'Answer' }, +]; + +const submissionFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Submission ID' }, + { key: 'formId', label: 'Form ID' }, + { key: 'isCompleted', label: 'Completed', format: 'boolean' }, + { key: 'submittedAt', label: 'Submitted At', format: 'datetime' }, + { key: 'previewUrl', label: 'Preview URL', format: 'url' }, + { key: 'pdfUrl', label: 'PDF URL', format: 'url' }, + { key: 'responses', label: 'Responses', labelKey: 'questionId', listItems: responseFields }, +]; + +const workspaceFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Workspace ID' }, + { key: 'name', label: 'Name' }, + { key: 'createdByUserId', label: 'Created By User ID' }, + { key: 'createdAt', label: 'Created At', format: 'datetime' }, + { key: 'updatedAt', label: 'Updated At', format: 'datetime' }, + { key: 'members', label: 'Members', labelKey: 'fullName', listItems: userFields }, +]; + +export const listFormsActionOutputSchema: OutputSchema = { + fields: [ + { key: 'items', label: 'Forms', labelKey: 'name', listItems: formSummaryFields }, + { key: 'page', label: 'Page', format: 'number' }, + { key: 'total', label: 'Total', format: 'number' }, + { key: 'hasMore', label: 'Has More', format: 'boolean' }, + ], +}; + +export const getFormActionOutputSchema: OutputSchema = { + fields: [ + ...formSummaryFields, + { key: 'settings', label: 'Settings' }, + { key: 'blocks', label: 'Blocks' }, + ], +}; + +export const createFormActionOutputSchema: OutputSchema = { + fields: formSummaryFields, +}; + +export const updateFormActionOutputSchema: OutputSchema = { + fields: formSummaryFields, +}; + +export const listFormQuestionsActionOutputSchema: OutputSchema = { + fields: [ + { key: 'hasResponses', label: 'Has Responses', format: 'boolean' }, + { key: 'questions', label: 'Questions', labelKey: 'title', listItems: questionFields }, + ], +}; + +export const listSubmissionsActionOutputSchema: OutputSchema = { + fields: [ + { key: 'page', label: 'Page', format: 'number' }, + { key: 'hasMore', label: 'Has More', format: 'boolean' }, + { + key: 'totalNumberOfSubmissionsPerFilter', + label: 'Totals', + children: [ + { key: 'all', label: 'All', format: 'number' }, + { key: 'completed', label: 'Completed', format: 'number' }, + { key: 'partial', label: 'Partial', format: 'number' }, + ], + }, + { key: 'questions', label: 'Questions', labelKey: 'title', listItems: questionFields }, + { key: 'submissions', label: 'Submissions', labelKey: 'id', listItems: submissionFields }, + ], +}; + +export const getSubmissionActionOutputSchema: OutputSchema = { + fields: [ + { key: 'questions', label: 'Questions', labelKey: 'title', listItems: questionFields }, + { key: 'submission', label: 'Submission', children: submissionFields }, + ], +}; + +export const getFormMetricsActionOutputSchema: OutputSchema = { + fields: [ + { key: 'visits', label: 'Visits', format: 'number' }, + { key: 'submissions', label: 'Submissions', format: 'number' }, + { key: 'uniqueRespondents', label: 'Unique Respondents', format: 'number' }, + { key: 'starts', label: 'Starts', format: 'number' }, + { key: 'completions', label: 'Completions', format: 'number' }, + { key: 'completionRate', label: 'Completion Rate', format: 'number' }, + { key: 'visitDuration', label: 'Visit Duration (seconds)', format: 'number' }, + { key: 'completionDuration', label: 'Completion Duration (seconds)', format: 'number' }, + { key: 'totalViews', label: 'Total Views', format: 'number' }, + ], +}; + +export const listWorkspacesActionOutputSchema: OutputSchema = { + fields: [ + { key: 'items', label: 'Workspaces', labelKey: 'name', listItems: workspaceFields }, + { key: 'page', label: 'Page', format: 'number' }, + { key: 'total', label: 'Total', format: 'number' }, + { key: 'hasMore', label: 'Has More', format: 'boolean' }, + ], +}; + +export const getWorkspaceActionOutputSchema: OutputSchema = { + fields: workspaceFields, +}; + +export const getCurrentUserActionOutputSchema: OutputSchema = { + fields: [ + ...userFields, + { key: 'isOrganizationOwner', label: 'Is Organization Owner', format: 'boolean' }, + { key: 'subscriptionPlan', label: 'Subscription Plan' }, + { key: 'canAccessBilling', label: 'Can Access Billing', format: 'boolean' }, + ], +}; From ecd58d73e0bda90d0921ed0e394dd2c1271d0104 Mon Sep 17 00:00:00 2001 From: Odai Ahmad Date: Thu, 3 Sep 2026 15:52:26 +0300 Subject: [PATCH 3/4] fix(fillout-forms): don't double-parse the webhook body in new-form-response (#15247) --- bun.lock | 3 +- .../community/fillout-forms/package.json | 8 ++- .../lib/triggers/new-form-response.test.ts | 69 +++++++++++++++++++ .../src/lib/triggers/new-form-response.ts | 3 +- .../community/fillout-forms/vitest.config.ts | 19 +++++ 5 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 packages/pieces/community/fillout-forms/src/lib/triggers/new-form-response.test.ts create mode 100644 packages/pieces/community/fillout-forms/vitest.config.ts diff --git a/bun.lock b/bun.lock index 36b37c98e2a..f2e9542e04b 100644 --- a/bun.lock +++ b/bun.lock @@ -3154,7 +3154,7 @@ }, "packages/pieces/community/fillout-forms": { "name": "@activepieces/piece-fillout-forms", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -3163,6 +3163,7 @@ }, "devDependencies": { "tslib": "2.6.2", + "vitest": "3.2.6", }, }, "packages/pieces/community/fireberry": { diff --git a/packages/pieces/community/fillout-forms/package.json b/packages/pieces/community/fillout-forms/package.json index 88845ca7e97..7a7ca718881 100644 --- a/packages/pieces/community/fillout-forms/package.json +++ b/packages/pieces/community/fillout-forms/package.json @@ -1,12 +1,13 @@ { "name": "@activepieces/piece-fillout-forms", - "version": "0.1.8", + "version": "0.1.9", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { "build": "tsc -p tsconfig.lib.json && cp package.json dist/", "bundle": "node ../../../../dist/packages/cli/src/index.js pieces bundle", - "lint": "eslint 'src/**/*.ts'" + "lint": "eslint 'src/**/*.ts'", + "test": "vitest run" }, "dependencies": { "@activepieces/pieces-common": "workspace:*", @@ -15,6 +16,7 @@ "@activepieces/core-utils": "workspace:*" }, "devDependencies": { - "tslib": "2.6.2" + "tslib": "2.6.2", + "vitest": "3.2.6" } } diff --git a/packages/pieces/community/fillout-forms/src/lib/triggers/new-form-response.test.ts b/packages/pieces/community/fillout-forms/src/lib/triggers/new-form-response.test.ts new file mode 100644 index 00000000000..34ec6ff5c0f --- /dev/null +++ b/packages/pieces/community/fillout-forms/src/lib/triggers/new-form-response.test.ts @@ -0,0 +1,69 @@ +/// + +import { newFormResponse } from './new-form-response'; + +const SUBMISSION = { + submissionId: 'abc123', + submissionTime: '2026-08-31T10:00:00.000Z', + questions: [ + { + id: '5AtgG35AAZVcrSVfRubvp1', + name: 'What is your name?', + type: 'ShortAnswer', + value: 'John Doe', + }, + ], + calculations: [], + urlParameters: [], +}; + +const WEBHOOK_BODY = { + formId: 'vs1PXaHmRfus', + formName: 'Contact form', + submission: SUBMISSION, +}; + +const buildContext = (body: unknown) => + ({ + payload: { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + queryParams: {}, + }, + } as never); + +describe('fillout new-form-response run()', () => { + test('returns the submission when the body is an already-parsed object', async () => { + const output = await newFormResponse.run(buildContext(WEBHOOK_BODY)); + + expect(output).toEqual([SUBMISSION]); + }); + + test('still returns the submission when the body is a raw JSON string', async () => { + const output = await newFormResponse.run( + buildContext(JSON.stringify(WEBHOOK_BODY)) + ); + + expect(output).toEqual([SUBMISSION]); + }); + + test('returns an array holding exactly one item', async () => { + const output = await newFormResponse.run(buildContext(WEBHOOK_BODY)); + + expect(Array.isArray(output)).toBe(true); + expect(output).toHaveLength(1); + }); + + test('a parsed body without a submission yields one undefined item', async () => { + const output = await newFormResponse.run(buildContext({})); + + expect(output).toEqual([undefined]); + }); + + test('a string body that is not JSON still raises', async () => { + await expect( + newFormResponse.run(buildContext('not json')) + ).rejects.toThrow(SyntaxError); + }); +}); diff --git a/packages/pieces/community/fillout-forms/src/lib/triggers/new-form-response.ts b/packages/pieces/community/fillout-forms/src/lib/triggers/new-form-response.ts index bdbbd98d0c4..8baf2716f8a 100644 --- a/packages/pieces/community/fillout-forms/src/lib/triggers/new-form-response.ts +++ b/packages/pieces/community/fillout-forms/src/lib/triggers/new-form-response.ts @@ -68,7 +68,8 @@ export const newFormResponse = createTrigger({ return submissions.responses; }, async run(context) { - const payload = JSON.parse(context.payload.body as string) as { + const body = context.payload.body; + const payload = (typeof body === 'string' ? JSON.parse(body) : body) as { submission: Record; }; return [payload.submission]; diff --git a/packages/pieces/community/fillout-forms/vitest.config.ts b/packages/pieces/community/fillout-forms/vitest.config.ts new file mode 100644 index 00000000000..1bd3d1e5fe7 --- /dev/null +++ b/packages/pieces/community/fillout-forms/vitest.config.ts @@ -0,0 +1,19 @@ +import path from 'path' +import { defineConfig } from 'vitest/config' + +const repoRoot = path.resolve(__dirname, '../../../..') + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, + resolve: { + alias: { + '@activepieces/pieces-framework': path.resolve(repoRoot, 'packages/pieces/framework/src/index.ts'), + '@activepieces/pieces-common': path.resolve(repoRoot, 'packages/pieces/common/src/index.ts'), + '@activepieces/core-piece-types': path.resolve(repoRoot, 'packages/core/piece-types/src/index.ts'), + '@activepieces/core-utils': path.resolve(repoRoot, 'packages/core/utils/src/index.ts'), + }, + }, +}) From e84f14864495e1abb20459878ad8a10000203c4c Mon Sep 17 00:00:00 2001 From: Louai Boumediene <92324961+Louai-Zokerburg@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:26:03 +0100 Subject: [PATCH 4/4] fix(web): added a user friendly fallback for any failure on fetching data (#15167) Co-authored-by: AbdulTheActivePiecer Co-authored-by: Claude Opus 5 (1M context) --- AGENTS.md | 7 ++- CLAUDE.md | 7 ++- .../knowledge/ai-intelligence/ai-providers.md | 2 +- ...h-is-reported-in-place-never-as-a-toast.md | 19 ++++++ brain/knowledge/engineering/index.md | 2 +- .../engineering/web-feature-anatomy.md | 22 ++++++- bun.lock | 1 + packages/web/AGENTS.md | 14 ++++- packages/web/package.json | 1 + .../web/public/locales/en/translation.json | 29 ++++++++- .../project-settings/members/index.tsx | 14 ++++- .../project-settings/pieces/index.tsx | 5 +- packages/web/src/app/query-client.ts | 51 ++++++++++----- packages/web/src/app/routes/agents/index.tsx | 5 ++ .../web/src/app/routes/automations/index.tsx | 16 ++++- .../web/src/app/routes/connections/index.tsx | 5 +- .../src/app/routes/impact/details/index.tsx | 4 ++ packages/web/src/app/routes/impact/index.tsx | 10 +-- .../routes/mcp-server/grants/grants-tab.tsx | 6 +- .../app/routes/mcp-server/mcp-grants-hooks.ts | 4 +- .../routes/mcp-server/recently-connected.tsx | 1 - .../app/routes/platform/connections/index.tsx | 11 +++- .../infra/health/components/runs-tab.tsx | 11 +++- .../routes/platform/infra/health/index.tsx | 15 ++++- .../infra/health/lib/health-metrics-hooks.ts | 1 - .../routes/platform/infra/triggers/index.tsx | 10 ++- .../infra/workers/by-project-view.tsx | 11 +++- .../app/routes/platform/projects/index.tsx | 2 + .../platform/security/audit-logs/index.tsx | 10 ++- .../routes/platform/security/embed/index.tsx | 17 ++++- .../security/secret-managers/index.tsx | 16 +++-- .../setup/ai/capabilities-tab/index.tsx | 51 ++++++++------- .../platform/setup/ai/providers-tab/index.tsx | 11 +++- .../providers-tab/model-selection-panel.tsx | 2 + .../providers-tab/project-selection-panel.tsx | 2 + .../platform/setup/connections/index.tsx | 5 +- .../app/routes/platform/setup/mcp/index.tsx | 22 ++++++- .../platform/setup/mcp/platform-mcp-hooks.ts | 1 - .../routes/platform/setup/pieces/index.tsx | 4 ++ .../piece-sets/piece-set-pieces-tab.tsx | 5 +- .../pieces/piece-sets/piece-sets-tab.tsx | 4 ++ .../routes/platform/setup/templates/index.tsx | 6 +- .../src/app/routes/platform/users/index.tsx | 6 ++ .../src/app/routes/project-release/index.tsx | 5 +- .../web/src/app/routes/variables/index.tsx | 5 +- .../custom/data-fetch-error-state.tsx | 63 +++++++++++++++++++ .../components/custom/data-table/index.tsx | 20 ++++++ .../src/features/agents/hooks/agents-hooks.ts | 2 - .../automations/hooks/use-automations-data.ts | 21 ++++--- .../feature-usage/projects-usage-table.tsx | 5 +- .../features/billing/hooks/billing-hooks.ts | 1 - .../hooks/app-connections-hooks.ts | 5 -- .../hooks/global-connections-hooks.ts | 5 -- .../flow-runs/components/runs-table/index.tsx | 6 +- .../src/features/flows/api/trigger-run-api.ts | 1 - .../members/hooks/project-members-hooks.ts | 1 + .../members/hooks/user-invitations-hooks.ts | 1 + .../piece-sets/hooks/piece-sets-hooks.ts | 2 - .../src/features/pieces/hooks/pieces-hooks.ts | 6 -- .../platform-admin/hooks/ai-provider-hooks.ts | 1 - .../hooks/ai-tool-config-hooks.ts | 1 - .../platform-admin/hooks/analytics-hooks.ts | 9 ++- .../platform-admin/hooks/audit-log-hooks.ts | 1 - .../hooks/embed-subdomain-hooks.ts | 6 +- .../hooks/platform-app-connections-hooks.ts | 1 - .../hooks/platform-user-hooks.ts | 1 - .../hooks/project-release-hooks.ts | 1 - .../hooks/secret-managers-hooks.ts | 5 -- .../variables/hooks/variables-hooks.ts | 11 +--- packages/web/src/lib/error-reporting.ts | 16 +++-- packages/web/src/query-meta.d.ts | 7 --- 71 files changed, 488 insertions(+), 169 deletions(-) create mode 100644 brain/knowledge/decisions/000032-a-failed-fetch-is-reported-in-place-never-as-a-toast.md create mode 100644 packages/web/src/components/custom/data-fetch-error-state.tsx delete mode 100644 packages/web/src/query-meta.d.ts diff --git a/AGENTS.md b/AGENTS.md index 3c25c3ac804..4ef35617243 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,9 +59,10 @@ Open-source AI-first workflow automation platform. Self-hosted or cloud. 400+ pi ## Query Error Handling -- **Global error dialog via `meta`** — `app.tsx` has a `QueryCache.onError` handler that shows an error dialog when `query.meta?.showErrorDialog` is truthy. When adding a new `useQuery` that fetches primary page data (e.g. table rows, list data), add `meta: { showErrorDialog: true }` to the query options. -- **Do NOT add** `showErrorDialog` to minor/auxiliary queries (feature flags, piece metadata, single-item fetches, filter options, user details). These should fail silently. -- Rule of thumb: if the query failure would leave the user staring at an empty table or blank page with no explanation, it should have `meta: { showErrorDialog: true }`. +- **A failed fetch is reported in place, never as a toast.** When adding a `useQuery` that fetches primary page data (table rows, list data), render `DataFetchErrorState` (`components/custom/data-fetch-error-state.tsx`) where the rows would go: pass `isError` / `errorStateEntity` / `onRetry` to `DataTable`, or branch on `isError` ahead of the empty state in a custom list. `errorStateEntity` is the already-translated, lowercase noun that reads inside "Trouble loading {entity}". +- **There is no global error toast.** `QueryCache.onError` in `query-client.ts` only `console.error`s. A toast on top of the placeholder is two notifications for one failure, and a toast on its own leaves an empty table behind that reads as data loss. +- **Do NOT add** an error state to minor/auxiliary queries (feature flags, piece metadata, single-item fetches, filter options, user details). These should fail silently. +- Rule of thumb: if the query failure would leave the user staring at an empty table or blank page with no explanation, that surface needs the placeholder. ## Key Utilities (`@activepieces/shared`) diff --git a/CLAUDE.md b/CLAUDE.md index 3c25c3ac804..4ef35617243 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,9 +59,10 @@ Open-source AI-first workflow automation platform. Self-hosted or cloud. 400+ pi ## Query Error Handling -- **Global error dialog via `meta`** — `app.tsx` has a `QueryCache.onError` handler that shows an error dialog when `query.meta?.showErrorDialog` is truthy. When adding a new `useQuery` that fetches primary page data (e.g. table rows, list data), add `meta: { showErrorDialog: true }` to the query options. -- **Do NOT add** `showErrorDialog` to minor/auxiliary queries (feature flags, piece metadata, single-item fetches, filter options, user details). These should fail silently. -- Rule of thumb: if the query failure would leave the user staring at an empty table or blank page with no explanation, it should have `meta: { showErrorDialog: true }`. +- **A failed fetch is reported in place, never as a toast.** When adding a `useQuery` that fetches primary page data (table rows, list data), render `DataFetchErrorState` (`components/custom/data-fetch-error-state.tsx`) where the rows would go: pass `isError` / `errorStateEntity` / `onRetry` to `DataTable`, or branch on `isError` ahead of the empty state in a custom list. `errorStateEntity` is the already-translated, lowercase noun that reads inside "Trouble loading {entity}". +- **There is no global error toast.** `QueryCache.onError` in `query-client.ts` only `console.error`s. A toast on top of the placeholder is two notifications for one failure, and a toast on its own leaves an empty table behind that reads as data loss. +- **Do NOT add** an error state to minor/auxiliary queries (feature flags, piece metadata, single-item fetches, filter options, user details). These should fail silently. +- Rule of thumb: if the query failure would leave the user staring at an empty table or blank page with no explanation, that surface needs the placeholder. ## Key Utilities (`@activepieces/shared`) diff --git a/brain/knowledge/ai-intelligence/ai-providers.md b/brain/knowledge/ai-intelligence/ai-providers.md index e2d2efd80f5..1e78274d6de 100644 --- a/brain/knowledge/ai-intelligence/ai-providers.md +++ b/brain/knowledge/ai-intelligence/ai-providers.md @@ -111,7 +111,7 @@ renders and preserves every real price; the cheapest in the set is 0.01. - **Attribution headers are for `ACTIVEPIECES` only, and go through the factory's `extraHeaders` option rather than a local `createOpenRouter` call.** The managed provider is OpenRouter under the hood on our own key, so the `x-ap-*` headers are what tag *our* account's events: `x-ap-platform-id` / `x-ap-conversation-id` / `x-ap-run-id` on the agent path, `x-ap-project-id` / `x-ap-flow-id` / `x-ap-run-id` on the piece path. BYOK `OPENROUTER` is a customer's own account and must not get them. Constructing the provider inline to attach headers is also what silently drops `openRouterSettings` (the web-search plugin), since the factory is the only place that still passes them. (`CUSTOM` separately receives the piece-path metadata headers — that is #11700's metadata forwarding for self-hosted OpenAI-compatible endpoints, older than either the rename or the Autumn work and unrelated to OpenRouter attribution. Its precedence is deliberate: admin-configured `defaultHeaders` override the `x-ap-*` metadata, and the api key is applied last.) - **`mistralViaOpenRouter` does not mean "the managed provider"; it is read only inside the `MISTRAL` case, and that branch looks like dead legacy.** `ACTIVEPIECES` routes through OpenRouter unconditionally and ignores the flag, so the only thing the agent path's `mistralViaOpenRouter: true` does is send a `MISTRAL` chat row to openrouter.ai — carrying that row's *Mistral* key, which cannot authenticate there. `MISTRAL` also has no `ALLOWED_CHAT_MODELS_BY_PROVIDER` entry, so `getCuratedChatModels` returns `undefined` for it and the resolver falls back to a tier's OpenRouter-shaped id. The fall-through arrived as a drive-by in #13489, not as a routing decision. Don't infer "this provider is AP-managed" from that case group. - **AI Tool Configs** are a *sibling* feature (same `ai/` dir), distinct from AI Providers: they give the chat assistant external capabilities via `/v1/ai-tools` (platform-admin, EE/Cloud). **AiToolCapability** = `WEB_SEARCH`/`WEB_SCRAPING`/`IMAGE_GENERATION`; **AiToolProvider** = `TAVILY`/`FIRECRAWL`/`APIFY`/`FAL`. One config per capability (unique on platformId+capability); consumed by chat via `getEnabledTools()`. **Because the config is per-platform, it can never serve a first-run flow on Cloud.** A self-serve signup lands on a brand-new platform with no configs at all, so `getEnabledTools()` returns `{}` for exactly the users a new-signup feature is aimed at, and any capability read from it silently no-ops rather than failing loudly. A capability that has to work for someone who just signed up needs a cloud-wide `AppSystemProp` key instead, the way `TURNSTILE_SECRET_KEY`, `FEATUREBASE_API_KEY` and `APPSUMO_TOKEN` are sourced. Note there is no `ENRICHMENT` capability here, so anything needing people or company enrichment has nowhere to read a key from today. -- **`/v1/ai-tools` is registered only in the CLOUD and ENTERPRISE branches of `app.ts`, but the AI Center page that reads it is not edition-gated** — so a Community admin opening the Capabilities tab fired `useAiToolConfigs`, got Fastify's `Route not found`, and the query's `meta.showErrorDialog` popped the global "Failed to load data" dialog. Shipped that way from #13911 until the tab was gated on `ApFlagId.EDITION` in the page. Two things make this class of bug hard to place: the dialog is opened from `QueryCache.onError` in `query-client.ts`, so it is page-independent, and React Query's 3 default retries mean it lands several seconds later on whatever page you navigated to next (the report was against `/platform/setup/general`). When a screenshot's edition is in doubt, read the sidebar: **Billing & subscription** and **Usage** carry a lock only when `edition === COMMUNITY`, every other lock there is plan-driven. Any new EE-only route needs its UI entry point gated the same way, `enabled:` on the query or hiding the surface. +- **`/v1/ai-tools` is registered only in the CLOUD and ENTERPRISE branches of `app.ts`, but the AI Center page that reads it is not edition-gated** — so a Community admin opening the Capabilities tab fired `useAiToolConfigs`, got Fastify's `Route not found`, and the page showed the global "Failed to load data" dialog of the day. Shipped that way from #13911 until the tab was gated on `ApFlagId.EDITION` in the page. What made this class of bug hard to place is that the surface was then page-independent (raised from `QueryCache.onError` in `query-client.ts`) and React Query's 3 default retries meant it landed several seconds later on whatever page you navigated to next (the report was against `/platform/setup/general`). The tab now renders `DataFetchErrorState` in place instead, so a repeat would at least accuse the right page. When a screenshot's edition is in doubt, read the sidebar: **Billing & subscription** and **Usage** carry a lock only when `edition === COMMUNITY`, every other lock there is plan-driven. Any new EE-only route needs its UI entry point gated the same way, `enabled:` on the query or hiding the surface. ### Key files diff --git a/brain/knowledge/decisions/000032-a-failed-fetch-is-reported-in-place-never-as-a-toast.md b/brain/knowledge/decisions/000032-a-failed-fetch-is-reported-in-place-never-as-a-toast.md new file mode 100644 index 00000000000..7df8441023d --- /dev/null +++ b/brain/knowledge/decisions/000032-a-failed-fetch-is-reported-in-place-never-as-a-toast.md @@ -0,0 +1,19 @@ +--- +status: accepted +--- + +# A failed fetch is reported in place, never as a toast + +## Decision +When a query for a page's primary data fails, the surface that would have shown the rows shows `DataFetchErrorState` instead — a calm placeholder naming the entity, saying the data is safe, offering Try again. Nothing global fires: `QueryCache.onError` in `app/query-client.ts` only `console.error`s, and query `meta` carries no error flag at all. + +## Context +This surface has now been rebuilt three times against the same customer report — a failed fetch reading as deleted data. First a blocking modal with the raw JSON payload (`meta.showErrorDialog`), which turned a 404 from an EE-only route into a wall of technical text. Then a global toast keyed on `meta.errorToastEntity`, which named what failed but still left the empty table sitting behind it. Both were page-independent, so React Query's three retries landed them seconds later on whatever page the user had moved to. + +## Why +An empty table is the actual bug, and only the table can fix it. Once every list renders its own placeholder, a toast is either a second notification for one failure or — when it fires alone — an explanation floating next to an unexplained blank. Rejected: keeping the toast but firing it only when the query still holds cached data, so the two could never appear together. It is the more precise design and it covers a real gap (React Query keeps rendering stale rows after a failed refetch, and no placeholder can appear in that state), but it keeps a whole subsystem — meta typing, a `WeakSet` dedupe, an `isActive()` guard, an entity noun threaded through every query — alive to serve one case, and the team chose the smaller surface. + +## Consequences +A silently-stale list is the accepted cost: if a refetch fails while data is cached, the rows stay and nothing says they are old. Losing the meta flag also removed the only marker a lint rule could have keyed on, so enforcement moved to the type system instead: `isError` and `errorStateEntity` are **required** props on `DataTable`, and the compiler refuses any table that has not decided. That is deliberately stronger than a lint rule — it fires while the component is being written, and it immediately surfaced four tables that had silently gone without an error state. A table whose rows are already-loaded props rather than its own query answers `isError={false}`, which is a statement rather than an omission. Hand-written lists (automations, agents, the AI Center tabs, the platform MCP page, the embed subdomain steps, the health runs tab) have no equivalent guard and rely on review until a `QueryBoundary` wrapper exists. + +On one of those, forgetting the placeholder now fails silently and actively misleads: with no toast left, a failed query leaves `data` undefined, the component falls through to its *empty* state, and the app tells the user "No connections found" — an affirmative claim that their data is gone, which is the exact illusion this decision exists to prevent. The only remaining signal is the Sentry report from `QueryCache.onError`, and that is Cloud-only: `errorReporting` initialises from the `FRONTEND_SENTRY_DSN` flag, so on a self-hosted instance the report sits in a buffer that never flushes and the failure is invisible end to end. diff --git a/brain/knowledge/engineering/index.md b/brain/knowledge/engineering/index.md index 84039feb6af..eeb77e2d8a1 100644 --- a/brain/knowledge/engineering/index.md +++ b/brain/knowledge/engineering/index.md @@ -30,7 +30,7 @@ The **Activepieces engineering brain**: how the system works, and *why* it was b - **Engineering Handbook & Playbooks** — how we build and ship - **API & Endpoints** — route conventions and the security contract - **Server Module Anatomy** — the six files of a server module (entity → migration → repo → service → controller → module), and the manual registration steps nothing auto-discovers -- **Web Feature Anatomy** — the frontend feature folder, its barrel, route guards, and when a query gets the global error dialog +- **Web Feature Anatomy** — the frontend feature folder, its barrel, route guards, and how a failed primary query reports itself in place - **Cloud Deployment Paths** — canary → prod, the `cloud-hotfix` override, and the breaking-migration gate that blocks both - **Helm Chart** — the Kubernetes install we ship to self-hosters, its two competing paths for an `AP_*` variable, and the secrets it never creates - **CI PR Review Hygiene** — draft-first Greptile review, the per-area PR size gate, and the workflow conventions reviewers keep re-litigating diff --git a/brain/knowledge/engineering/web-feature-anatomy.md b/brain/knowledge/engineering/web-feature-anatomy.md index cdbac5e38c3..ca5168a9e12 100644 --- a/brain/knowledge/engineering/web-feature-anatomy.md +++ b/brain/knowledge/engineering/web-feature-anatomy.md @@ -25,7 +25,9 @@ Everything crossing the feature boundary goes through `index.ts`. See `features/ API client: `features/tables/api/tables-api.ts`. Hooks: `features/tables/hooks/table-hooks.ts`. -On any query that fetches a page's **primary** data — the table rows, the list, the thing the page exists to show — set `meta: { showErrorDialog: true }`. `QueryCache.onError` in `app/query-client.ts` turns that into the global error dialog. Leave it off for auxiliary queries (feature flags, piece metadata, single-item fetches, filter options, user details) — those should fail silently rather than throw a modal over the page. +On any query that fetches a page's **primary** data — the table rows, the list, the thing the page exists to show — render `DataFetchErrorState` (`components/custom/data-fetch-error-state.tsx`) in place of the rows when it fails. `DataTable` takes `isError` / `errorStateEntity` / `onRetry` and swaps it in ahead of the empty state; a surface that is not a `DataTable` (automations, agents, the AI providers and capabilities tabs, the platform MCP page, the embed subdomain steps, the health runs tab) branches on `isError` before its own empty state. `errorStateEntity` is the already-translated lowercase noun that reads inside "Trouble loading {entity}", so it names the thing the user was looking at rather than the endpoint. Leave it off auxiliary queries (feature flags, piece metadata, single-item fetches, filter options, user details) — those should fail silently. + +The copy is deliberately unalarming and says the data is safe, because the failure mode being designed against is a user believing their flows are gone. `QueryCache.onError` in `app/query-client.ts` does nothing but `console.error`. ## Route @@ -58,15 +60,29 @@ The page component itself is `React.lazy()`-imported. `requiredPermissions` take Every customer-facing surface must be checked on all five edition paths — CE, EE self-hosted, Cloud freemium, Cloud self-serve paid, Cloud enterprise. Nothing user-visible hardcodes "Activepieces": name, colours, and logos come from platform appearance. Community always gets the default theme, Cloud always applies platform branding, EE requires `platform.plan.customAppearanceEnabled`. See `ee/helper/appearance-helper.ts`. +A default local dev instance runs `edition=ce` (check `/api/v1/flags`), and most of the platform-admin surface is unreachable there — Global Connections, Pieces, Templates, Billing, Usage, Embedding, SSO, Project Roles, API Keys, Secret Managers, Audit Logs and Event Streaming all render `LockedFeatureGuard` instead of their body, and the AI Center's Capabilities tab is not rendered at all. So a change to any of those cannot be seen locally without first flipping the `platform_plan` flags in the dev Postgres; Embedding needs more than that, since `useEmbedSubdomain` is gated on `edition === CLOUD` and so needs `AP_EDITION=cloud` and a restart. Plan for that before promising a screenshot of a gated page. + Verify with `npx turbo run lint --filter=web`, or `npm run lint-dev` for the whole repo. ## Gotchas - **A `packages/web` test runs in the `node` environment by default, so importing anything that touches `window` at module load fails at collection.** `vitest.config.ts` sets `environment: 'node'`; ~26 suites opt into a DOM with a `// @vitest-environment jsdom` docblock on line 1. The failure is a bare `ReferenceError: window is not defined` pointing at a *transitive* import (`embed-provider.tsx` reading `window.opener`, reached via `@/features/projects`), not at the test — so read the stack, don't hunt in your own file. Missing the docblock is why `chunk-reducer.test.ts` was red for as long as it was: CI did not run the web suite at all, so nothing surfaced it. - **A panel that hand-rolls its draft state gets none of the form validation the rest of the app assumes.** react-hook-form + `zodResolver` is what surfaces `formErrors.required` and friends; a `useState` draft with a Save button has no schema, so the usual mistake is to *substitute* a fallback for an empty field (`name.trim().length > 0 ? name.trim() : existing.name`) instead of rejecting it. That reads as a silent failure: the request succeeds, the old value returns, and nothing explains why. When a surface cannot use react-hook-form, derive the invalid state, render the message next to the field, and disable the submit — do not paper over the empty value. Bit the AI Center key-detail panel while its sibling connect dialog, on a zod resolver, was correct. The second failure mode is that such a draft never resyncs: seeded once from a prop, it outlives any refetch of the row it mirrors, so a mutation that changes the row without changing its `key` (the AI Center replaces a key's credentials, and the panel is keyed on the config id) leaves the draft describing the old row — phantom "unsaved changes", and a save that reverts what the mutation just wrote. Bump a version segment into the `key` at the site that performs the mutation rather than diffing props inside the panel: TanStack Query hands back a new object identity on every refetch, so a naive identity comparison discards the admin's unsaved edits on a window refocus. +- **Sonner centres its icon against the whole toast, so a two-line toast puts the icon beside the wrong line.** `[data-sonner-toast]` is a centred flex row: fine for one line, visibly wrong the moment a description wraps or carries a disclosure — the icon drifts down next to the body instead of the title. Pass `classNames: { toast: 'items-start!', icon: 'mt-0.5' }` on that toast (the icon is 16px against 13px title text, so it needs the nudge to sit on the title's baseline). The `!` is not optional: sonner ships its own stylesheet, and a plain Tailwind `items-start` loses to it. Per-toast rather than on the `Toaster`, unless every toast in the app is meant to move. +- **To see a fetch-failure placeholder in the dev app, force the branch in code — do not try to break the network.** Patching `XMLHttpRequest.prototype.open` to rewrite the path (the api client is axios, so patching `fetch` alone does nothing) works only sometimes and costs a lot of fiddling: React Query keeps rendering the last good data, so the placeholder needs a query key with no cache; a full reload wipes the patch before the app boots, so navigation has to stay client-side; and some surfaces never error at all even when the rewritten path is confirmed to 404. Temporarily flipping the branch itself — `) : isError ? (` to `) : true || isError ? (` in `DataTable`, plus the same in each hand-written list — makes every page reachable by plain URL with no timing at all. Two cautions: it proves the *rendering* and not that `isError` is ever set, and `true || x` breaks TypeScript's narrowing after the guard, so a forced early return can throw "possibly undefined" errors into the Vite overlay — force it from the caller's prop instead when that happens. Forcing the branch is often not enough on a Community instance: agents, the AI Capabilities tab and the embed subdomain steps are behind route guards, edition checks and `LockedFeatureGuard`, so those have to be forced open too (`AgentsFlagGuard`'s redirect, `capabilitiesEnabled`, `isCloud` + `locked`) before the page renders at all. Revert with a grep for `true ||` / `false &&` / `locked={false}` before finishing. + +- **A table that ORs a secondary query into `isLoading` can never reach its error state.** The runs table passes `isLoading={isLoading || isFetchingFlows}`, where `isFetchingFlows` belongs to the flow list behind the *filter dropdown*. While that second query is fetching or retrying, the skeleton branch wins over `isError`, so a failing runs endpoint shows spinning rows rather than the placeholder — and a failing flows endpoint traps the table there indefinitely. Gate the skeleton on the query that owns the rows, and let a secondary query resolve on its own. + +- **Frontend errors go to Sentry through `lib/error-reporting.ts`, and a failed React Query fetch was structurally invisible to it.** `errorReporting.report({ error, source })` is the only entry point — it wraps `@sentry/react`, initialises lazily off the `FRONTEND_SENTRY_DSN` flag, and stamps user/project/platform, page and browser context. Its `FrontendErrorSource` union covers thrown errors (`react-error-boundary`, `route-error`, `window-error`, `unhandled-rejection`, `chunk-preload`), so it never saw a query failure: React Query stores a rejection as state rather than throwing it, unless the query opts into `throwOnError` or Suspense. `QueryCache.onError` in `app/query-client.ts` now reports every failure under the `query` source with the query hash, HTTP status and request url. Two things that path needs and the thrown-error paths do not: skip only what the app has genuinely already handled — a 401 carrying `SESSION_EXPIRED` or `INVALID_BEARER_TOKEN`, which `globalErrorHandler` in `lib/api.ts` turns into a logout and redirect. Everything else reports, including 402 and 403, because both mean the frontend fired a request it should have prevented: 402 is a query missing its `enabled: platform.plan.` guard, 403 is `PERMISSION_DENIED`/`AUTHORIZATION` slipping past `RoutePermissionGuard`/`checkAccess`. Filtering by bare status is the trap here — "4xx auth-ish" reads as expected and is mostly the opposite, and pass a `dedupeKey`, because the dedupe signature is `name:message:stack` and every axios failure shares a message, so four lists failing together would otherwise report once and hide three endpoints. Nothing reaches Sentry at all without the DSN flag, which self-hosted instances do not set. + +- **`api.isApError` throws on any error that has no response.** It reads `(error.response?.data as ApErrorParams).code` — optional-chaining the `response` but then dereferencing `.code` on the `undefined` that comes back, so a network failure, a timeout, or a CORS rejection raises a `TypeError` from inside whatever error handler called it. `queryClient`'s `mutationCache.onError` calls it unguarded on every mutation error, so a mutation that fails offline crashes there rather than showing its toast. When you need the `ApErrorParams` code on a path that can see transport failures, read it defensively (`(error.response?.data as ApErrorParams | undefined)?.code`) instead of reaching for the helper. + +- **`isLoading` is false while a failed query is retrying, so a retry button gated on it looks dead.** React Query sets `isLoading = isPending && isFetching`; once a query has errored its status is `error`, not `pending`, so `refetch()` raises only `isFetching`. Any skeleton or spinner keyed on `isLoading` therefore never fires on a retry — the user clicks and nothing visibly happens until the request resolves. `DataFetchErrorState` handles this itself rather than pushing `isFetching` out to every caller: it awaits whatever `onRetry` returns and drives the `Button`'s own `loading` prop, which is also the only option that works on the surfaces that have no skeleton branch to reuse. A retry wired to `invalidateQueries` needs the promise returned (`return Promise.all([...])`), or the spinner flashes for a single tick. + - **Exported types and constants belong at the *end* of the file**, after the components and logic. Reading a file should start with what it does, not its type declarations. -- **`showErrorDialog` on the wrong query is worse than missing it.** On an auxiliary query it throws a modal over a page that was working fine; on the primary query, omitting it leaves the user staring at an empty table with no explanation. -- **A `data ?? []` default turns a failed query into an empty state, and `showErrorDialog` does not cover for it.** The modal is page-independent (`QueryCache.onError`), so the body still renders "nothing here" underneath it — and its copy ("your data is safe, refresh the page") is wrong for a permission denial the user caused by editing a URL param. When a page can render its own inline error state, branch on `isError` *before* the empty state and drop `showErrorDialog` on that query rather than showing both. `api.isApError(error, ErrorCode.X)` is how you tell an access denial apart from a network blip — note it reads the *response body's* `code`, so it needs the server's `ActivepiecesError` code, not an HTTP status. +- **The web has two independent "something went wrong" surfaces, and they cover different failures.** `GlobalErrorBoundary` (`app/components/global-error-boundary.tsx`) is a React error boundary: it catches *render* crashes and replaces the page with a reload/go-home fallback. It structurally cannot see a React Query failure — a failed query is stored as state, not thrown during render, unless the query opts into `throwOnError` or Suspense. Nothing global covers that async gap any more: a failed primary query is reported by the surface itself, through `DataFetchErrorState`. The two landed independently (the query surface first, in #12476 for tables; the boundary later, in #13743) and were never calibrated against each other, so for a long stretch a single 404 got a *blocking modal with raw JSON* while an actual app crash got a friendly reload button. Keep that ordering right: a failed fetch on a page that still renders is an in-place placeholder, a dead render tree is the full-page fallback. A modal is only correct when the error payload is something the user must read and copy — flow publish showing the trigger piece's stderr (`flow-hooks.tsx`) is the one case that still earns `ApErrorDialog`. +- **An error state on the wrong query is worse than missing it.** On an auxiliary query it accuses a page that was working fine; on the primary query, omitting it leaves the user staring at an empty table with no explanation. This surface has been rebuilt twice: a blocking modal with raw JSON (`showErrorDialog`), then a global toast keyed on `meta.errorToastEntity`, and now an in-place placeholder and nothing else. Each move was driven by the same report — a failed fetch reading to customers as deleted data — and the toast went because it either duplicated the placeholder or, on its own, left the empty table unexplained. +- **A `data ?? []` default turns a failed query into an empty state, and nothing else will catch it.** Defaulting the data away means `isError` is the only remaining evidence the fetch failed — the body just renders "nothing here", which is the data-loss illusion this whole surface exists to prevent, and since the global toast was removed there is no second line of defence. Branch on `isError` *before* the empty state, always. `api.isApError(error, ErrorCode.X)` is how you tell an access denial apart from a network blip — note it reads the *response body's* `code`, so it needs the server's `ActivepiecesError` code, not an HTTP status. - **A ref assigned during render (`const ref = useRef(x); ref.current = x`) is stale inside socket/event callbacks.** The value only advances when React commits a render, so two events handled before that commit both read the same base — a read-modify-write (merging a step into `run.steps`) silently drops the earlier event. Read the zustand store directly instead: `useBuilderStore().getState()` (`app/builder/builder-hooks.ts`) always returns current state. Bit the test-flow widget's progress merge, PR #14453. - **Builder overlays share one stacking context, so a big `z-` wins over everything — including portalled popovers.** Nothing between an overlay in the canvas panel and `` creates a stacking context (the middle panel is `relative` + `z-auto`; `ResizablePanel` sets only flex/overflow), so a canvas child's `z-index` competes directly with Radix portals. The working ladder: canvas `z-30` (opaque `bg-builder-background` — anything below it is invisible), header and floating corner chrome `z-40`, data selector / canvas controls / popovers `z-50`. That is why the powered-by note at `z-10000` painted over the piece selector. - **The flow "download as image" only captures `.react-flow__viewport`.** `flowScreenshotUtils` (`flow-canvas/utils/flow-screenshot-utils.ts`) clones that one element into an SVG, so anything outside it — the dot-grid background, the powered-by note, canvas controls — is absent unless handled explicitly. Two seams: mark in-viewport chrome you want *omitted* (step chevron, badges) with `data-flow-screenshot-exclude`; anything *outside* the viewport you want *included* has to be redrawn onto the composited 2D canvas in `composeImageWithCanvasBackground` (that's how the background dots and the powered-by mark get there). diff --git a/bun.lock b/bun.lock index f2e9542e04b..aead8a63845 100644 --- a/bun.lock +++ b/bun.lock @@ -11027,6 +11027,7 @@ "fast-average-color": "9.5.0", "fuse.js": "7.0.0", "html-to-image": "1.11.13", + "http-status-codes": "2.2.0", "i18next": "23.13.0", "i18next-browser-languagedetector": "8.0.0", "i18next-http-backend": "3.0.5", diff --git a/packages/web/AGENTS.md b/packages/web/AGENTS.md index 4a92609659e..4bd00645266 100644 --- a/packages/web/AGENTS.md +++ b/packages/web/AGENTS.md @@ -91,9 +91,21 @@ You are working in the Activepieces web application (`packages/web`). - **Transforming data for rendering** — Calculate it inline during render instead. - **Passing data upward to a parent** — Lift state up or use a shared store. +## Data Fetch Failures + +**Every component that fetches data the user came to see must render something when that fetch fails.** An empty table or a blank panel reads as deleted data — that is the failure mode this rule exists to prevent, and it has been reported by customers more than once. Never leave the error path to render nothing. + +- Render `DataFetchErrorState` (`@/components/custom/data-fetch-error-state`) where the content would have been. It takes `entity` (an already-translated lowercase noun, reading inside "Trouble loading {entity}") and an optional `onRetry`. +- `DataTable` has `isError` and `errorStateEntity` as **required** props, so the compiler will not let you render a table without deciding. Pass `onRetry={refetch}` whenever the query exposes it. For a table whose rows are already-loaded props rather than its own query, `isError={false}` is the correct answer. +- Any surface that is **not** a `DataTable` — a card grid, a config panel, a tab, a chart — has no such guard. Branch on `isError` **before** the empty state, otherwise a failure silently renders the "you have nothing yet" copy. +- A hook that returns a shaped object instead of the raw query result must pass `isError` (and `refetch`) through, or its callers cannot comply. +- The copy is deliberately calm and says the data is safe. Do not escalate it to a destructive/red treatment. +- **Do not** add an error state to auxiliary queries (feature flags, piece metadata, single-item fetches, filter options, user details) — those should fail silently. +- There is **no** global error toast. `QueryCache.onError` in `app/query-client.ts` only reports to Sentry via `errorReporting`. Do not reintroduce a toast: on top of the placeholder it is two notifications for one failure, and on its own it leaves the empty surface unexplained. + ## Query Feature Guards -When a server endpoint is gated by `platformMustHaveFeatureEnabled` (returns HTTP 402 `FEATURE_DISABLED` when the plan lacks the feature), the corresponding `useQuery` hook **must** include `enabled: platform.plan.` so the request never fires when the feature is off. Without this, queries with `meta: { showErrorDialog: true }` will trigger a misleading "Failed to load data" error dialog via the global `QueryCache.onError` handler in `app.tsx`. +When a server endpoint is gated by `platformMustHaveFeatureEnabled` (returns HTTP 402 `FEATURE_DISABLED` when the plan lacks the feature), the corresponding `useQuery` hook **must** include `enabled: platform.plan.` so the request never fires when the feature is off. Without this, a list whose query is wired to `DataFetchErrorState` will show a misleading "Trouble loading …" placeholder on a page that is simply not entitled to the feature. **Pattern** (see `secret-managers-hooks.ts`): ```ts diff --git a/packages/web/package.json b/packages/web/package.json index f6a6a4d3a8e..b536db61182 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -66,6 +66,7 @@ "fast-average-color": "9.5.0", "fuse.js": "7.0.0", "html-to-image": "1.11.13", + "http-status-codes": "2.2.0", "i18next": "23.13.0", "i18next-browser-languagedetector": "8.0.0", "i18next-http-backend": "3.0.5", diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 4d2f10a4bfc..f46f24b4770 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -730,8 +730,33 @@ "Select connection": "Select connection", "Invalid Access": "Invalid Access", "You tried to access a project that you do not have access to.": "You tried to access a project that you do not have access to.", - "Failed to load data": "Failed to load data", - "Something went wrong while loading your data. Your data is safe — please try again by refreshing the page.": "Something went wrong while loading your data. Your data is safe — please try again by refreshing the page.", + "members": "members", + "models": "models", + "flows": "flows", + "Trouble loading {entity}": "Trouble loading {entity}", + "Nothing has been lost — your data is safe. Try again in a moment.": "Nothing has been lost — your data is safe. Try again in a moment.", + "Try again": "Try again", + "this list": "this list", + "trigger status": "trigger status", + "connections": "connections", + "releases": "releases", + "runs": "runs", + "variables": "variables", + "agents": "agents", + "project usage": "project usage", + "automations": "automations", + "pieces": "pieces", + "the embed subdomain": "the embed subdomain", + "audit logs": "audit logs", + "piece sets": "piece sets", + "the MCP server": "the MCP server", + "templates": "templates", + "secret managers": "secret managers", + "AI providers": "AI providers", + "AI tools": "AI tools", + "health metrics": "health metrics", + "projects": "projects", + "users": "users", "New Table": "New Table", "Response stopped": "Response stopped", "Retry": "Retry", diff --git a/packages/web/src/app/components/project-settings/members/index.tsx b/packages/web/src/app/components/project-settings/members/index.tsx index 8afffbcbf25..6bac1fcb792 100644 --- a/packages/web/src/app/components/project-settings/members/index.tsx +++ b/packages/web/src/app/components/project-settings/members/index.tsx @@ -23,15 +23,20 @@ export const MembersSettings = () => { const { projectMembers, isLoading: projectMembersIsPending, + isError: projectMembersFailed, refetch: refetchProjectMembers, } = projectMembersHooks.useProjectMembers(); const { invitations, isLoading: invitationsIsPending, + isError: invitationsFailed, refetch: refetchInvitations, } = userInvitationsHooks.useInvitations(); - const { data: platformUsersData, isLoading: platformUsersIsPending } = - platformUserHooks.useUsers(); + const { + data: platformUsersData, + isLoading: platformUsersIsPending, + isError: platformUsersFailed, + } = platformUserHooks.useUsers(); const [filterValue, setFilterValue] = useState(''); const [inviteOpen, setInviteOpen] = useState(false); @@ -154,6 +159,11 @@ export const MembersSettings = () => { invitationsIsPending || platformUsersIsPending } + isError={ + projectMembersFailed || invitationsFailed || platformUsersFailed + } + errorStateEntity={t('members')} + onRetry={refetch} hidePagination={true} emptyStateTextTitle={t('No members found')} emptyStateTextDescription={t( diff --git a/packages/web/src/app/components/project-settings/pieces/index.tsx b/packages/web/src/app/components/project-settings/pieces/index.tsx index b5382251b73..ad456735bf6 100644 --- a/packages/web/src/app/components/project-settings/pieces/index.tsx +++ b/packages/web/src/app/components/project-settings/pieces/index.tsx @@ -82,7 +82,7 @@ const PiecesSettings = () => { const { platform } = platformHooks.useCurrentPlatform(); const { project } = projectCollectionUtils.useCurrentProject(); const [searchQuery, setSearchQuery] = useState(''); - const { pieces, isLoading } = piecesHooks.usePieces({ + const { pieces, isLoading, isError, refetch } = piecesHooks.usePieces({ searchQuery, isTableQuery: true, }); @@ -148,6 +148,9 @@ const PiecesSettings = () => { previous: null, }} isLoading={isLoading} + isError={isError} + errorStateEntity={t('pieces')} + onRetry={refetch} hidePagination={true} /> diff --git a/packages/web/src/app/query-client.ts b/packages/web/src/app/query-client.ts index 0ea98e88bde..e925eb8835d 100644 --- a/packages/web/src/app/query-client.ts +++ b/packages/web/src/app/query-client.ts @@ -1,28 +1,47 @@ -import { ErrorCode, isNil } from '@activepieces/core-utils'; +import { ApErrorParams, ErrorCode, isNil } from '@activepieces/core-utils'; import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'; -import { t } from 'i18next'; +import { StatusCodes } from 'http-status-codes'; -import { useApErrorDialogStore } from '@/components/custom/ap-error-dialog/ap-error-dialog-store'; import { internalErrorToast } from '@/components/ui/sonner'; import { useManagePlanDialogStore } from '@/features/billing'; import { api } from '@/lib/api'; +import { errorReporting } from '@/lib/error-reporting'; + +function isHandledSessionExpiry(error: unknown): boolean { + if (!api.isError(error)) { + return false; + } + if (error.response?.status !== StatusCodes.UNAUTHORIZED) { + return false; + } + const code = (error.response?.data as ApErrorParams | undefined)?.code; + return ( + code === ErrorCode.SESSION_EXPIRED || + code === ErrorCode.INVALID_BEARER_TOKEN + ); +} + +function reportQueryFailure(error: unknown, queryHash: string): void { + if (isHandledSessionExpiry(error)) { + return; + } + const status = api.isError(error) ? error.response?.status : undefined; + errorReporting.report({ + error, + source: 'query', + dedupeKey: queryHash, + extra: { + query_hash: queryHash, + http_status: status, + request_url: api.isError(error) ? error.config?.url : undefined, + }, + }); +} export const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error, query) => { - if (query.meta?.showErrorDialog) { - const { openDialog } = useApErrorDialogStore.getState(); - openDialog({ - title: t('Failed to load data'), - description: t( - 'Something went wrong while loading your data. Your data is safe — please try again by refreshing the page.', - ), - error: { - queryKey: query.queryKey, - details: api.isError(error) ? error.response?.data : String(error), - }, - }); - } + reportQueryFailure(error, query.queryHash); }, }), mutationCache: new MutationCache({ diff --git a/packages/web/src/app/routes/agents/index.tsx b/packages/web/src/app/routes/agents/index.tsx index 22156738fd2..d233bc0cc31 100644 --- a/packages/web/src/app/routes/agents/index.tsx +++ b/packages/web/src/app/routes/agents/index.tsx @@ -24,6 +24,7 @@ import { useNavigate } from 'react-router-dom'; import { useDebounce } from 'use-debounce'; import { LockedFeatureGuard } from '@/app/components/locked-feature-guard'; +import { DataFetchErrorState } from '@/components/custom/data-fetch-error-state'; import { Empty, EmptyDescription, @@ -140,6 +141,8 @@ const AgentsPageContent = () => { data, isLoading, isSuccess, + isError, + refetch, hasNextPage, fetchNextPage, isFetchingNextPage, @@ -593,6 +596,8 @@ const AgentsPageContent = () => { ))} + ) : isError ? ( + ) : agents.length === 0 ? ( showsNoMatchNotice({ matchCount: agents.length, diff --git a/packages/web/src/app/routes/automations/index.tsx b/packages/web/src/app/routes/automations/index.tsx index 62bd7cc7bb6..7c35ba29f29 100644 --- a/packages/web/src/app/routes/automations/index.tsx +++ b/packages/web/src/app/routes/automations/index.tsx @@ -5,6 +5,7 @@ import { useCallback } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { recordAccess } from '@/app/components/global-search/access-history'; +import { DataFetchErrorState } from '@/components/custom/data-fetch-error-state'; import { useEmbedding } from '@/components/providers/embed-provider'; import { AutomationsEmptyState } from '@/features/automations/components/automations-empty-state'; import { AutomationsFilters as AutomationsFiltersComponent } from '@/features/automations/components/automations-filters'; @@ -84,6 +85,7 @@ const AutomationsPageContent = ({ projectId }: { projectId: string }) => { rootFlows, rootTables, isLoading, + isError, expandedFolders, toggleFolder, loadMoreInFolder, @@ -267,9 +269,11 @@ const AutomationsPageContent = ({ projectId }: { projectId: string }) => { const hasAnyItems = rootFlows.length > 0 || rootTables.length > 0 || folders.length > 0; - const isEmptyState = !hasAnyItems && !isLoading && !filtersActive; + const isErrorState = isError && !hasAnyItems && !isLoading; + const isEmptyState = + !hasAnyItems && !isLoading && !filtersActive && !isErrorState; const isNoResultsState = - treeItems.length === 0 && filtersActive && !isLoading; + treeItems.length === 0 && filtersActive && !isLoading && !isErrorState; if (isEmptyState) { return invalidateAll()} />; @@ -314,7 +318,13 @@ const AutomationsPageContent = ({ projectId }: { projectId: string }) => { isCreatingTable={mutations.isCreatingTable} /> - {isNoResultsState ? ( + {isErrorState ? ( + + ) : isNoResultsState ? ( ) : ( <> diff --git a/packages/web/src/app/routes/connections/index.tsx b/packages/web/src/app/routes/connections/index.tsx index 00261084130..5affb113862 100644 --- a/packages/web/src/app/routes/connections/index.tsx +++ b/packages/web/src/app/routes/connections/index.tsx @@ -91,6 +91,7 @@ function AppConnectionsPage() { const { data: connections, isLoading: connectionsLoading, + isError: connectionsError, refetch, } = appConnectionsQueries.useAppConnections({ request: { @@ -102,7 +103,6 @@ function AppConnectionsPage() { displayName, }, extraKeys: [location.search, projectId], - showErrorDialog: true, }); const { mutateAsync: deleteConnections } = @@ -449,6 +449,9 @@ function AppConnectionsPage() { columns={columns} page={filteredData} isLoading={connectionsLoading} + isError={connectionsError} + errorStateEntity={t('connections')} + onRetry={refetch} filters={filters} selectColumn={true} onSelectedRowsChange={setSelectedRows} diff --git a/packages/web/src/app/routes/impact/details/index.tsx b/packages/web/src/app/routes/impact/details/index.tsx index 8a67fb081c8..0e5e4e8128c 100644 --- a/packages/web/src/app/routes/impact/details/index.tsx +++ b/packages/web/src/app/routes/impact/details/index.tsx @@ -55,12 +55,14 @@ import { EditTimeSavedPopover } from './edit-time-saved-popover'; type FlowsDetailsProps = { report?: PlatformAnalyticsReport; isLoading: boolean; + isError: boolean; projects?: ProjectWithLimits[]; }; export function FlowsDetails({ report, isLoading, + isError, projects, }: FlowsDetailsProps) { const { @@ -321,6 +323,8 @@ export function FlowsDetails({ previous: null, }} isLoading={isLoading} + isError={isError} + errorStateEntity={t('flows')} clientPagination={true} initialSorting={[{ id: 'minutesSaved', desc: true }]} emptyStateTextTitle={t('No Flows Found')} diff --git a/packages/web/src/app/routes/impact/index.tsx b/packages/web/src/app/routes/impact/index.tsx index 25bd977d23c..659eb38eafc 100644 --- a/packages/web/src/app/routes/impact/index.tsx +++ b/packages/web/src/app/routes/impact/index.tsx @@ -50,10 +50,11 @@ export default function ImpactPage() { const activeTab = (searchParams.get('tab') as TabValue) || 'analytics'; const { data: projects } = projectCollectionUtils.useAll(); - const { data, isLoading } = platformAnalyticsHooks.useAnalyticsTimeBased( - selectedTimePeriod, - selectedProjectId, - ); + const { data, isLoading, isError } = + platformAnalyticsHooks.useAnalyticsTimeBased( + selectedTimePeriod, + selectedProjectId, + ); const { mutate: refreshAnalytics } = platformAnalyticsHooks.useRefreshAnalytics(); @@ -222,6 +223,7 @@ export default function ImpactPage() { diff --git a/packages/web/src/app/routes/mcp-server/grants/grants-tab.tsx b/packages/web/src/app/routes/mcp-server/grants/grants-tab.tsx index 58943b93da9..80524483f97 100644 --- a/packages/web/src/app/routes/mcp-server/grants/grants-tab.tsx +++ b/packages/web/src/app/routes/mcp-server/grants/grants-tab.tsx @@ -59,9 +59,8 @@ export function GrantsTab() { request.memberIds !== undefined || request.clientKeys !== undefined; - const { data, isLoading, isError } = mcpGrantsQueries.useGrants({ + const { data, isLoading, isError, refetch } = mcpGrantsQueries.useGrants({ request, - showErrorDialog: true, }); const revoke = mcpGrantsMutations.useRevoke(); @@ -106,6 +105,9 @@ export function GrantsTab() { columns={columns} page={data} isLoading={isLoading} + isError={isError} + errorStateEntity={t('connected clients')} + onRetry={refetch} filters={buildFilters({ projects, members: users?.data ?? [] })} selectColumn={true} bordered={true} diff --git a/packages/web/src/app/routes/mcp-server/mcp-grants-hooks.ts b/packages/web/src/app/routes/mcp-server/mcp-grants-hooks.ts index 0fdef034453..ef71664a571 100644 --- a/packages/web/src/app/routes/mcp-server/mcp-grants-hooks.ts +++ b/packages/web/src/app/routes/mcp-server/mcp-grants-hooks.ts @@ -13,12 +13,11 @@ import { mcpGrantsApi } from './mcp-grants-api'; const GRANTS_QUERY_KEY = ['mcp-oauth-grants']; export const mcpGrantsQueries = { - useGrants({ request, showErrorDialog }: UseGrantsParams) { + useGrants({ request }: UseGrantsParams) { return useQuery({ queryKey: [...GRANTS_QUERY_KEY, request], queryFn: () => mcpGrantsApi.list(request), placeholderData: keepPreviousData, - meta: { showErrorDialog, loadSubsetOptions: {} }, }); }, }; @@ -41,5 +40,4 @@ export const mcpGrantsMutations = { type UseGrantsParams = { request: ListMcpOAuthGrantsRequestQuery; - showErrorDialog: boolean; }; diff --git a/packages/web/src/app/routes/mcp-server/recently-connected.tsx b/packages/web/src/app/routes/mcp-server/recently-connected.tsx index 17fbf2231c0..b4c2e9c64fb 100644 --- a/packages/web/src/app/routes/mcp-server/recently-connected.tsx +++ b/packages/web/src/app/routes/mcp-server/recently-connected.tsx @@ -18,7 +18,6 @@ export function RecentlyConnected() { const nav = useMcpNav(); const { data, isLoading, isError } = mcpGrantsQueries.useGrants({ request: { limit: MAX_SHOWN }, - showErrorDialog: false, }); const recent = data?.data ?? []; diff --git a/packages/web/src/app/routes/platform/connections/index.tsx b/packages/web/src/app/routes/platform/connections/index.tsx index 42526a67615..2f3f019ce60 100644 --- a/packages/web/src/app/routes/platform/connections/index.tsx +++ b/packages/web/src/app/routes/platform/connections/index.tsx @@ -43,8 +43,12 @@ import { getProjectName, projectCollectionUtils } from '@/features/projects'; import { formatUtils } from '@/lib/format-utils'; export default function PlatformConnectionsPage() { - const { data: connections, isLoading } = - platformAppConnectionsQueries.useList(); + const { + data: connections, + isLoading, + isError, + refetch, + } = platformAppConnectionsQueries.useList(); const { data: owners } = platformAppConnectionsQueries.useOwners(); const { data: projects } = projectCollectionUtils.useAllPlatformProjects(); const { pieces } = piecesHooks.usePieces({}); @@ -242,6 +246,9 @@ export default function PlatformConnectionsPage() { columns={columns} page={connections} isLoading={isLoading} + isError={isError} + errorStateEntity={t('connections')} + onRetry={refetch} filters={filters} /> diff --git a/packages/web/src/app/routes/platform/infra/health/components/runs-tab.tsx b/packages/web/src/app/routes/platform/infra/health/components/runs-tab.tsx index cb2c1da4be6..c049343b600 100644 --- a/packages/web/src/app/routes/platform/infra/health/components/runs-tab.tsx +++ b/packages/web/src/app/routes/platform/infra/health/components/runs-tab.tsx @@ -4,6 +4,7 @@ import { t } from 'i18next'; import { CheckCircle2, ListChecks } from 'lucide-react'; import { ReactNode } from 'react'; +import { DataFetchErrorState } from '@/components/custom/data-fetch-error-state'; import { formatUtils } from '@/lib/format-utils'; import { cn } from '@/lib/utils'; @@ -31,11 +32,19 @@ function renderDelta(current: number, previous: number): ReactNode { type RunsTabProps = { report: PlatformMetricsReport | undefined; isLoading: boolean; + isError: boolean; + onRetry: () => void; }; -export function RunsTab({ report, isLoading }: RunsTabProps) { +export function RunsTab({ report, isLoading, isError, onRetry }: RunsTabProps) { const summary = report?.summary; + if (isError) { + return ( + + ); + } + return (
{report && ( diff --git a/packages/web/src/app/routes/platform/infra/health/index.tsx b/packages/web/src/app/routes/platform/infra/health/index.tsx index c19a9c93bb6..1c4b116d7fb 100644 --- a/packages/web/src/app/routes/platform/infra/health/index.tsx +++ b/packages/web/src/app/routes/platform/infra/health/index.tsx @@ -48,8 +48,12 @@ export default function SettingsHealthPage() { }; }, [selectedMonth]); - const { data: report, isLoading: isReportLoading } = - healthMetricsQueries.useRunMetrics(range, activeTab === 'runs'); + const { + data: report, + isLoading: isReportLoading, + isError: isReportError, + refetch: refetchReport, + } = healthMetricsQueries.useRunMetrics(range, activeTab === 'runs'); const { data: live, isLoading: isLiveLoading } = healthMetricsQueries.useQueueMetrics(range, activeTab === 'queue'); @@ -117,7 +121,12 @@ export default function SettingsHealthPage() { - + diff --git a/packages/web/src/app/routes/platform/infra/health/lib/health-metrics-hooks.ts b/packages/web/src/app/routes/platform/infra/health/lib/health-metrics-hooks.ts index bc21b507f09..fd7f3488cfa 100644 --- a/packages/web/src/app/routes/platform/infra/health/lib/health-metrics-hooks.ts +++ b/packages/web/src/app/routes/platform/infra/health/lib/health-metrics-hooks.ts @@ -17,7 +17,6 @@ export const healthMetricsQueries = { ], queryFn: () => healthMetricsApi.getRunMetrics(range), enabled, - meta: { showErrorDialog: true, loadSubsetOptions: {} }, }); }, useQueueMetrics: ( diff --git a/packages/web/src/app/routes/platform/infra/triggers/index.tsx b/packages/web/src/app/routes/platform/infra/triggers/index.tsx index 23562875b21..4ebe266be14 100644 --- a/packages/web/src/app/routes/platform/infra/triggers/index.tsx +++ b/packages/web/src/app/routes/platform/infra/triggers/index.tsx @@ -76,7 +76,12 @@ const generateLastXDays = (days: number): string[] => { }; export default function TriggerHealthPage() { - const { data: report, isLoading } = triggerRunHooks.useStatusReport(); + const { + data: report, + isLoading, + isError, + refetch, + } = triggerRunHooks.useStatusReport(); const triggerHealthData: TriggerHealthRow[] = isLoading ? [] @@ -263,6 +268,9 @@ export default function TriggerHealthPage() { columns={columns} page={{ data: triggerHealthData, previous: '', next: '' }} isLoading={isLoading} + isError={isError} + errorStateEntity={t('trigger status')} + onRetry={refetch} />
); diff --git a/packages/web/src/app/routes/platform/infra/workers/by-project-view.tsx b/packages/web/src/app/routes/platform/infra/workers/by-project-view.tsx index 18bcb496aa5..d05e9b1d1f9 100644 --- a/packages/web/src/app/routes/platform/infra/workers/by-project-view.tsx +++ b/packages/web/src/app/routes/platform/infra/workers/by-project-view.tsx @@ -45,7 +45,12 @@ export function ByProjectView({ const queryClient = useQueryClient(); - const { data: page, isLoading } = usePlatformProjectsPage({ + const { + data: page, + isLoading, + isError, + refetch, + } = usePlatformProjectsPage({ cursor, limit, displayName, @@ -96,6 +101,9 @@ export function ByProjectView({ columns={columns} page={page} isLoading={isLoading} + isError={isError} + errorStateEntity={t('projects')} + onRetry={refetch} emptyStateTextTitle={t('No projects yet')} emptyStateTextDescription={t( 'Start by creating projects to manage your automation teams', @@ -124,7 +132,6 @@ function usePlatformProjectsPage({ limit: Number(limit) || 10, displayName, }), - meta: { showErrorDialog: true, loadSubsetOptions: {} }, }); } diff --git a/packages/web/src/app/routes/platform/projects/index.tsx b/packages/web/src/app/routes/platform/projects/index.tsx index 1073ad7637f..821f6478a96 100644 --- a/packages/web/src/app/routes/platform/projects/index.tsx +++ b/packages/web/src/app/routes/platform/projects/index.tsx @@ -437,6 +437,8 @@ export default function ProjectsPage() { previous: null, }} isLoading={false} + isError={false} + errorStateEntity={t('projects')} clientPagination={true} bulkActions={bulkActions} toolbarButtons={toolbarButtons} diff --git a/packages/web/src/app/routes/platform/security/audit-logs/index.tsx b/packages/web/src/app/routes/platform/security/audit-logs/index.tsx index 477de80fc53..7a79f75e8a3 100644 --- a/packages/web/src/app/routes/platform/security/audit-logs/index.tsx +++ b/packages/web/src/app/routes/platform/security/audit-logs/index.tsx @@ -101,7 +101,12 @@ export default function AuditLogsPage() { }, ]; - const { data: auditLogsData, isLoading } = auditLogQueries.useAuditLogs(); + const { + data: auditLogsData, + isLoading, + isError, + refetch, + } = auditLogQueries.useAuditLogs(); const isEnabled = platform.plan.auditLogEnabled; return ( @@ -247,6 +252,9 @@ export default function AuditLogsPage() { ]} page={auditLogsData} isLoading={isLoading} + isError={isError} + errorStateEntity={t('audit logs')} + onRetry={refetch} /> diff --git a/packages/web/src/app/routes/platform/security/embed/index.tsx b/packages/web/src/app/routes/platform/security/embed/index.tsx index d46a34b4994..e0823fdd562 100644 --- a/packages/web/src/app/routes/platform/security/embed/index.tsx +++ b/packages/web/src/app/routes/platform/security/embed/index.tsx @@ -15,6 +15,7 @@ import { import { useState } from 'react'; import LockedFeatureGuard from '@/app/components/locked-feature-guard'; +import { DataFetchErrorState } from '@/components/custom/data-fetch-error-state'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; import { SkeletonList } from '@/components/ui/skeleton'; @@ -36,8 +37,12 @@ const EmbedPage = () => { const { data: edition } = flagsHooks.useFlag(ApFlagId.EDITION); const isCloud = edition === ApEdition.CLOUD; - const { subdomain, isLoading: isSubdomainLoading } = - embedSubdomainQueries.useCurrentEmbedSubdomain(); + const { + subdomain, + isLoading: isSubdomainLoading, + isError: isSubdomainError, + refetch: refetchSubdomain, + } = embedSubdomainQueries.useCurrentEmbedSubdomain(); const { data, isLoading: isKeysLoading, @@ -116,6 +121,9 @@ const EmbedPage = () => { ); const displayedStep = steps[displayedIndex]; + const subdomainStepFailed = + isSubdomainError && + (displayedStep?.kind === 'hostname' || displayedStep?.kind === 'dns'); return ( {
{isLoading ? ( + ) : subdomainStepFailed ? ( + ) : displayedStep?.kind === 'hostname' ? ( ) : displayedStep?.kind === 'dns' ? ( diff --git a/packages/web/src/app/routes/platform/security/secret-managers/index.tsx b/packages/web/src/app/routes/platform/security/secret-managers/index.tsx index 5dc3c047616..bf8d853bd73 100644 --- a/packages/web/src/app/routes/platform/security/secret-managers/index.tsx +++ b/packages/web/src/app/routes/platform/security/secret-managers/index.tsx @@ -38,11 +38,14 @@ import AddEditSecretManagerConnectionDialog from './connect-secret-manager-dialo const SecretManagersPage = () => { const { platform } = platformHooks.useCurrentPlatform(); - const { data: connections, isLoading: isLoadingConnections } = - secretManagersHooks.useListSecretManagerConnections({ - listForPlatform: true, - showErrorDialog: true, - }); + const { + data: connections, + isLoading: isLoadingConnections, + isError: isConnectionsError, + refetch: refetchConnections, + } = secretManagersHooks.useListSecretManagerConnections({ + listForPlatform: true, + }); const { mutate: deleteConnection } = secretManagersHooks.useDeleteSecretManagerConnection(); @@ -214,6 +217,9 @@ const SecretManagersPage = () => { columns={columns} page={page} isLoading={isLoading} + isError={isConnectionsError} + errorStateEntity={t('secret managers')} + onRetry={refetchConnections} hidePagination={true} />
diff --git a/packages/web/src/app/routes/platform/setup/ai/capabilities-tab/index.tsx b/packages/web/src/app/routes/platform/setup/ai/capabilities-tab/index.tsx index c39b04b0082..fe4feccc3f6 100644 --- a/packages/web/src/app/routes/platform/setup/ai/capabilities-tab/index.tsx +++ b/packages/web/src/app/routes/platform/setup/ai/capabilities-tab/index.tsx @@ -12,6 +12,7 @@ import { Trash2, } from 'lucide-react'; +import { DataFetchErrorState } from '@/components/custom/data-fetch-error-state'; import { ConfirmationDeleteDialog } from '@/components/custom/delete-dialog'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; @@ -31,7 +32,11 @@ import { import { SectionHeader } from '../components/section-header'; export function CapabilitiesTab() { - const { data: configs, refetch } = aiToolConfigQueries.useAiToolConfigs(); + const { + data: configs, + isError, + refetch, + } = aiToolConfigQueries.useAiToolConfigs(); const { platform } = platformHooks.useCurrentPlatform(); const allowWrite = platform.plan.aiProvidersEnabled; @@ -51,26 +56,30 @@ export function CapabilitiesTab() { 'Connect external services so the AI assistant can search the web, scrape pages, and generate images.', )} /> -
- {AI_TOOL_CATALOG.map((capabilityInfo) => { - const config = configs?.find( - (c) => c.capability === capabilityInfo.capability, - ); - return ( - - config && toggle({ id: config.id, request: { enabled } }) - } - onDelete={() => config && remove(config.id)} - onSaved={() => refetch()} - /> - ); - })} -
+ {isError ? ( + + ) : ( +
+ {AI_TOOL_CATALOG.map((capabilityInfo) => { + const config = configs?.find( + (c) => c.capability === capabilityInfo.capability, + ); + return ( + + config && toggle({ id: config.id, request: { enabled } }) + } + onDelete={() => config && remove(config.id)} + onSaved={() => refetch()} + /> + ); + })} +
+ )} ); } diff --git a/packages/web/src/app/routes/platform/setup/ai/providers-tab/index.tsx b/packages/web/src/app/routes/platform/setup/ai/providers-tab/index.tsx index 79af658b97a..b6557c2cef1 100644 --- a/packages/web/src/app/routes/platform/setup/ai/providers-tab/index.tsx +++ b/packages/web/src/app/routes/platform/setup/ai/providers-tab/index.tsx @@ -7,6 +7,7 @@ import { useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; +import { DataFetchErrorState } from '@/components/custom/data-fetch-error-state'; import { ConfirmationDeleteDialog } from '@/components/custom/delete-dialog'; import { Button } from '@/components/ui/button'; import { @@ -51,7 +52,11 @@ export function ProvidersTab() { >(undefined); const queryClient = useQueryClient(); - const { data: providers, refetch } = aiProviderQueries.useAiProviderConfigs(); + const { + data: providers, + isError: isProvidersError, + refetch, + } = aiProviderQueries.useAiProviderConfigs(); const { platform } = platformHooks.useCurrentPlatform(); const allowWrite = platform.plan.aiProvidersEnabled; const { data: projects } = projectCollectionUtils.useAllPlatformProjects(); @@ -197,7 +202,9 @@ export function ProvidersTab() { )} - {configs.length === 0 ? ( + {isProvidersError ? ( + + ) : configs.length === 0 ? ( ) : ( <> diff --git a/packages/web/src/app/routes/platform/setup/ai/providers-tab/model-selection-panel.tsx b/packages/web/src/app/routes/platform/setup/ai/providers-tab/model-selection-panel.tsx index c6b067f4476..3e4a8a2040b 100644 --- a/packages/web/src/app/routes/platform/setup/ai/providers-tab/model-selection-panel.tsx +++ b/packages/web/src/app/routes/platform/setup/ai/providers-tab/model-selection-panel.tsx @@ -107,6 +107,8 @@ export function ModelSelectionPanel({ columns={columns} page={{ data: rows, next: null, previous: null }} isLoading={false} + isError={false} + errorStateEntity={t('models')} hidePagination={true} onRowClick={(row) => toggleModel(row.id)} emptyStateTextTitle={t('No models found')} diff --git a/packages/web/src/app/routes/platform/setup/ai/providers-tab/project-selection-panel.tsx b/packages/web/src/app/routes/platform/setup/ai/providers-tab/project-selection-panel.tsx index 681ef873d5b..cc8f0d5fc92 100644 --- a/packages/web/src/app/routes/platform/setup/ai/providers-tab/project-selection-panel.tsx +++ b/packages/web/src/app/routes/platform/setup/ai/providers-tab/project-selection-panel.tsx @@ -113,6 +113,8 @@ export function ProjectSelectionPanel({ columns={columns} page={{ data: rows, next: null, previous: null }} isLoading={false} + isError={false} + errorStateEntity={t('projects')} hidePagination={true} onRowClick={(row) => toggleProject(row.id)} emptyStateTextTitle={t('No projects found')} diff --git a/packages/web/src/app/routes/platform/setup/connections/index.tsx b/packages/web/src/app/routes/platform/setup/connections/index.tsx index 94dac08d928..8b9337c3e4c 100644 --- a/packages/web/src/app/routes/platform/setup/connections/index.tsx +++ b/packages/web/src/app/routes/platform/setup/connections/index.tsx @@ -211,6 +211,7 @@ const GlobalConnectionsTable = () => { const { data: globalConnections, isLoading: isLoadingGlobalConnections, + isError: isGlobalConnectionsError, refetch: refetchGlobalConnections, } = globalConnectionsQueries.useGlobalConnections({ request: { @@ -227,7 +228,6 @@ const GlobalConnectionsTable = () => { extraKeys: [location.search], staleTime: 0, gcTime: 0, - showErrorDialog: true, }); const userHasPermissionToWriteAppConnection = checkAccess( @@ -332,6 +332,9 @@ const GlobalConnectionsTable = () => { columns={columns} page={globalConnections} isLoading={isLoadingGlobalConnections} + isError={isGlobalConnectionsError} + errorStateEntity={t('connections')} + onRetry={refetchGlobalConnections} filters={filters} selectColumn={true} onSelectedRowsChange={setSelectedRows} diff --git a/packages/web/src/app/routes/platform/setup/mcp/index.tsx b/packages/web/src/app/routes/platform/setup/mcp/index.tsx index 518a76d839f..49c5fe14f7d 100644 --- a/packages/web/src/app/routes/platform/setup/mcp/index.tsx +++ b/packages/web/src/app/routes/platform/setup/mcp/index.tsx @@ -5,6 +5,7 @@ import { CenteredPage } from '@/app/components/centered-page'; import { McpTools } from '@/app/components/project-settings/mcp-server/mcp-tools'; import { CopyToClipboardInput } from '@/components/custom/clipboard/copy-to-clipboard'; import { CollapsibleJson } from '@/components/custom/collapsible-json'; +import { DataFetchErrorState } from '@/components/custom/data-fetch-error-state'; import { LoadingSpinner } from '@/components/custom/spinner'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { flagsHooks } from '@/hooks/flags-hooks'; @@ -12,8 +13,12 @@ import { flagsHooks } from '@/hooks/flags-hooks'; import { platformMcpHooks } from './platform-mcp-hooks'; export default function PlatformMcpPage() { - const { data: mcpServer, isLoading } = - platformMcpHooks.usePlatformMcpServer(); + const { + data: mcpServer, + isLoading, + isError, + refetch, + } = platformMcpHooks.usePlatformMcpServer(); const { mutate: updateTools, isPending: isToolsUpdating } = platformMcpHooks.useUpdatePlatformMcpTools(); const { data: publicUrl } = flagsHooks.useFlag(ApFlagId.PUBLIC_URL); @@ -33,6 +38,19 @@ export default function PlatformMcpPage() { ); } + if (isError) { + return ( + + + + ); + } + const serverUrl = `${(publicUrl ?? '').replace(/\/$/, '')}/mcp/platform`; const jsonConfiguration = { diff --git a/packages/web/src/app/routes/platform/setup/mcp/platform-mcp-hooks.ts b/packages/web/src/app/routes/platform/setup/mcp/platform-mcp-hooks.ts index 87499092704..9cf6a143b49 100644 --- a/packages/web/src/app/routes/platform/setup/mcp/platform-mcp-hooks.ts +++ b/packages/web/src/app/routes/platform/setup/mcp/platform-mcp-hooks.ts @@ -10,7 +10,6 @@ export const platformMcpHooks = { queryKey: QUERY_KEY, queryFn: () => platformMcpApi.get(), retry: false, - meta: { showErrorDialog: true, loadSubsetOptions: {} }, }); }, diff --git a/packages/web/src/app/routes/platform/setup/pieces/index.tsx b/packages/web/src/app/routes/platform/setup/pieces/index.tsx index daf66331904..b938bd0123f 100644 --- a/packages/web/src/app/routes/platform/setup/pieces/index.tsx +++ b/packages/web/src/app/routes/platform/setup/pieces/index.tsx @@ -55,6 +55,7 @@ const PiecesListTab = () => { pieces, refetch: refetchPieces, isLoading, + isError, } = piecesHooks.usePieces({ searchQuery, includeHidden: true, @@ -199,6 +200,9 @@ const PiecesListTab = () => { previous: null, }} isLoading={isLoading} + isError={isError} + errorStateEntity={t('pieces')} + onRetry={refetchPieces} toolbarButtons={[ , , diff --git a/packages/web/src/app/routes/platform/setup/pieces/piece-sets/piece-set-pieces-tab.tsx b/packages/web/src/app/routes/platform/setup/pieces/piece-sets/piece-set-pieces-tab.tsx index 35f1c80184e..61169d76503 100644 --- a/packages/web/src/app/routes/platform/setup/pieces/piece-sets/piece-set-pieces-tab.tsx +++ b/packages/web/src/app/routes/platform/setup/pieces/piece-sets/piece-set-pieces-tab.tsx @@ -152,7 +152,7 @@ const BulkPieceSetActions = ({ }; export const PieceSetPiecesTab = ({ pieceSet }: PieceSetPiecesTabProps) => { - const { pieces, isLoading } = piecesHooks.usePieces({ + const { pieces, isLoading, isError, refetch } = piecesHooks.usePieces({ includeHidden: true, isTableQuery: true, skipProjectFilter: true, @@ -374,6 +374,9 @@ export const PieceSetPiecesTab = ({ pieceSet }: PieceSetPiecesTabProps) => { previous: null, }} isLoading={isLoading} + isError={isError} + errorStateEntity={t('pieces')} + onRetry={refetch} clientFiltering={true} bulkActions={[ { diff --git a/packages/web/src/app/routes/platform/setup/pieces/piece-sets/piece-sets-tab.tsx b/packages/web/src/app/routes/platform/setup/pieces/piece-sets/piece-sets-tab.tsx index 16da129f02e..c71657c15c2 100644 --- a/packages/web/src/app/routes/platform/setup/pieces/piece-sets/piece-sets-tab.tsx +++ b/packages/web/src/app/routes/platform/setup/pieces/piece-sets/piece-sets-tab.tsx @@ -47,6 +47,7 @@ export const PieceSetsTab = () => { const { data: pieceSetsPage, isLoading, + isError, refetch, } = pieceSetQueries.usePieceSets({ cursor, limit }); const { mutate: deleteSet } = pieceSetMutations.useDeletePieceSet(); @@ -187,6 +188,9 @@ export const PieceSetsTab = () => { previous: pieceSetsPage?.previous ?? null, }} isLoading={isLoading} + isError={isError} + errorStateEntity={t('piece sets')} + onRetry={refetch} clientFiltering={true} toolbarButtons={[ refetch()} />, diff --git a/packages/web/src/app/routes/platform/setup/templates/index.tsx b/packages/web/src/app/routes/platform/setup/templates/index.tsx index 616c3fdd376..cb9d410b812 100644 --- a/packages/web/src/app/routes/platform/setup/templates/index.tsx +++ b/packages/web/src/app/routes/platform/setup/templates/index.tsx @@ -37,10 +37,9 @@ const PlatformTemplatesPage = () => { const { platform } = platformHooks.useCurrentPlatform(); const [searchParams] = useSearchParams(); - const { data, isLoading, refetch } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['templates', searchParams.toString()], staleTime: 0, - meta: { showErrorDialog: true, loadSubsetOptions: {} }, queryFn: () => { return templatesApi.list({ type: TemplateType.CUSTOM, @@ -233,6 +232,9 @@ const PlatformTemplatesPage = () => { page={data} hidePagination={true} isLoading={isLoading} + isError={isError} + errorStateEntity={t('templates')} + onRetry={refetch} bulkActions={bulkActions} toolbarButtons={toolbarButtons} actions={[ diff --git a/packages/web/src/app/routes/platform/users/index.tsx b/packages/web/src/app/routes/platform/users/index.tsx index c940253bb9c..69a856a53d6 100644 --- a/packages/web/src/app/routes/platform/users/index.tsx +++ b/packages/web/src/app/routes/platform/users/index.tsx @@ -42,12 +42,14 @@ export default function UsersPage() { const { data: usersData, isLoading: usersLoading, + isError: usersError, refetch: refetchUsers, } = platformUserHooks.useUsers(); const { data: invitationsData, isLoading: invitationsLoading, + isError: invitationsError, refetch: refetchInvitations, } = platformUserHooks.usePlatformInvitations(); @@ -75,6 +77,7 @@ export default function UsersPage() { }, [usersData, invitationsData]); const isLoading = usersLoading || invitationsLoading; + const isError = usersError || invitationsError; const { mutate: deleteUser } = platformUserMutations.useDeleteUser({ onSuccess: refetch, @@ -139,6 +142,9 @@ export default function UsersPage() { }} hidePagination={true} isLoading={isLoading} + isError={isError} + errorStateEntity={t('users')} + onRetry={refetch} toolbarButtons={[ + )} + + ); +} + +type DataFetchErrorStateProps = { + entity: string; + onRetry?: () => unknown; + className?: string; +}; diff --git a/packages/web/src/components/custom/data-table/index.tsx b/packages/web/src/components/custom/data-table/index.tsx index 316925d0d3e..072c9b75e4f 100644 --- a/packages/web/src/components/custom/data-table/index.tsx +++ b/packages/web/src/components/custom/data-table/index.tsx @@ -37,6 +37,8 @@ import { } from '@/components/ui/table'; import { cn } from '@/lib/utils'; +import { DataFetchErrorState } from '../data-fetch-error-state'; + import { DataTableBulkActions } from './data-table-bulk-actions'; import { DataTableColumnHeader } from './data-table-column-header'; import { DataTableFilter, DataTableFilterProps } from './data-table-filter'; @@ -75,6 +77,9 @@ interface DataTableProps< e: React.MouseEvent, ) => void; isLoading: boolean; + isError: boolean; + errorStateEntity: string; + onRetry?: () => void; filters?: DataTableFilters[]; customFilters?: React.ReactNode[]; onSelectedRowsChange?: (rows: RowDataWithActions[]) => void; @@ -117,6 +122,9 @@ export function DataTable< filters = [], actions = [], isLoading, + isError, + errorStateEntity, + onRetry, onSelectedRowsChange, hidePagination, bulkActions = [], @@ -610,6 +618,18 @@ export function DataTable< )) ) + ) : isError ? ( + + + + + ) : ( lastPage.next ?? undefined, enabled, - meta: { showErrorDialog: true, loadSubsetOptions: {} }, }), useMovePreview: ({ id, @@ -104,7 +103,6 @@ export const agentsQueries = { queryKey: [AGENTS_KEY, 'one', id, includeUsage ? 'usage' : 'plain'], queryFn: () => agentsApi.get(id, { includeUsage }), enabled, - meta: { showErrorDialog: !includeUsage, loadSubsetOptions: {} }, }), }; diff --git a/packages/web/src/features/automations/hooks/use-automations-data.ts b/packages/web/src/features/automations/hooks/use-automations-data.ts index b2259f8f4b4..9b96382cf1c 100644 --- a/packages/web/src/features/automations/hooks/use-automations-data.ts +++ b/packages/web/src/features/automations/hooks/use-automations-data.ts @@ -50,7 +50,6 @@ export function useAutomationsData( queryFn: () => foldersApi.list(), staleTime: STALE_TIME, refetchOnMount: 'always', - meta: { showErrorDialog: true, loadSubsetOptions: {} }, }); const folderIds = foldersQuery.data?.map((f) => f.id).join(',') ?? ''; @@ -93,7 +92,6 @@ export function useAutomationsData( enabled: !!foldersQuery.data && foldersQuery.data.length > 0, staleTime: STALE_TIME, refetchOnMount: 'always', - meta: { showErrorDialog: true, loadSubsetOptions: {} }, }); const skipFlows = @@ -122,7 +120,6 @@ export function useAutomationsData( enabled: !skipFlows, staleTime: STALE_TIME, refetchOnMount: 'always', - meta: { showErrorDialog: true, loadSubsetOptions: {} }, }); const rootTablesQuery = useQuery({ @@ -138,7 +135,6 @@ export function useAutomationsData( enabled: !skipTables && !hideTables, staleTime: STALE_TIME, refetchOnMount: 'always', - meta: { showErrorDialog: true, loadSubsetOptions: {} }, }); const toggleFolder = useCallback((folderId: string) => { @@ -268,11 +264,19 @@ export function useAutomationsData( (rootTablesQuery.isLoading && !skipTables && !hideTables) || folderContentsQuery.isLoading; + const isError = + foldersQuery.isError || + (rootFlowsQuery.isError && !skipFlows) || + (rootTablesQuery.isError && !skipTables && !hideTables) || + folderContentsQuery.isError; + const invalidateAll = useCallback(() => { - queryClient.invalidateQueries({ queryKey: ['folders'] }); - queryClient.invalidateQueries({ queryKey: ['root-flows'] }); - queryClient.invalidateQueries({ queryKey: ['root-tables'] }); - queryClient.invalidateQueries({ queryKey: ['all-folder-contents'] }); + return Promise.all([ + queryClient.invalidateQueries({ queryKey: ['folders'] }), + queryClient.invalidateQueries({ queryKey: ['root-flows'] }), + queryClient.invalidateQueries({ queryKey: ['root-tables'] }), + queryClient.invalidateQueries({ queryKey: ['all-folder-contents'] }), + ]); }, [queryClient]); const invalidateRoot = useCallback(() => { @@ -294,6 +298,7 @@ export function useAutomationsData( rootFlows: rootFlowsQuery.data?.data ?? [], rootTables: rootTablesQuery.data?.data ?? [], isLoading, + isError, isFiltered, expandedFolders: effectiveExpandedFolders, toggleFolder, diff --git a/packages/web/src/features/billing/components/feature-usage/projects-usage-table.tsx b/packages/web/src/features/billing/components/feature-usage/projects-usage-table.tsx index 151e3e504eb..ac2b561f069 100644 --- a/packages/web/src/features/billing/components/feature-usage/projects-usage-table.tsx +++ b/packages/web/src/features/billing/components/feature-usage/projects-usage-table.tsx @@ -31,7 +31,7 @@ export function ProjectsUsageTable({ const [searchParams] = useSearchParams(); const cursor = searchParams.get(CURSOR_QUERY_PARAM) ?? undefined; - const { data, isLoading } = billingQueries.useProjectsUsage( + const { data, isLoading, isError, refetch } = billingQueries.useProjectsUsage( platformId, { startDate: range.from.toISOString(), @@ -66,6 +66,9 @@ export function ProjectsUsageTable({ columns={COLUMNS} page={page} isLoading={isLoading} + isError={isError} + errorStateEntity={t('project usage')} + onRetry={refetch} emptyStateIcon={} emptyStateTextTitle={t('No project usage yet')} emptyStateTextDescription={t( diff --git a/packages/web/src/features/billing/hooks/billing-hooks.ts b/packages/web/src/features/billing/hooks/billing-hooks.ts index cfff30fede1..f2813aaa2ad 100644 --- a/packages/web/src/features/billing/hooks/billing-hooks.ts +++ b/packages/web/src/features/billing/hooks/billing-hooks.ts @@ -264,7 +264,6 @@ export const billingQueries = { queryKey: billingKeys.projectsUsage(platformId, params), queryFn: () => platformBillingApi.getProjectsUsage(params), enabled, - meta: { showErrorDialog: true, loadSubsetOptions: {} }, }); }, }; diff --git a/packages/web/src/features/connections/hooks/app-connections-hooks.ts b/packages/web/src/features/connections/hooks/app-connections-hooks.ts index 832bda8f489..0b746667497 100644 --- a/packages/web/src/features/connections/hooks/app-connections-hooks.ts +++ b/packages/web/src/features/connections/hooks/app-connections-hooks.ts @@ -342,7 +342,6 @@ type UseConnectionsProps = { enabled?: boolean; staleTime?: number; pieceAuth?: PieceAuthProperty | PieceAuthProperty[] | undefined; - showErrorDialog?: boolean; }; export const appConnectionsQueries = { @@ -352,13 +351,9 @@ export const appConnectionsQueries = { enabled, staleTime, pieceAuth, - showErrorDialog, }: UseConnectionsProps) => { return useQuery({ queryKey: ['app-connections', ...extraKeys], - meta: showErrorDialog - ? { showErrorDialog: true, loadSubsetOptions: {} } - : undefined, queryFn: async () => { const connections = await appConnectionsApi.list(request); if (pieceAuth) { diff --git a/packages/web/src/features/connections/hooks/global-connections-hooks.ts b/packages/web/src/features/connections/hooks/global-connections-hooks.ts index 8d24a711301..f6610d39c98 100644 --- a/packages/web/src/features/connections/hooks/global-connections-hooks.ts +++ b/packages/web/src/features/connections/hooks/global-connections-hooks.ts @@ -22,7 +22,6 @@ type UseGlobalConnectionsProps = { extraKeys: any[]; staleTime?: number; gcTime?: number; - showErrorDialog?: boolean; }; const GLOBAL_CONNECTIONS_QUERY_KEY = 'globalConnections'; @@ -36,7 +35,6 @@ export const globalConnectionsQueries = { extraKeys, staleTime, gcTime, - showErrorDialog, }: UseGlobalConnectionsProps) => { const { platform } = platformHooks.useCurrentPlatform(); return useQuery({ @@ -44,9 +42,6 @@ export const globalConnectionsQueries = { staleTime, gcTime, enabled: platform.plan.globalConnectionsEnabled, - meta: showErrorDialog - ? { showErrorDialog: true, loadSubsetOptions: {} } - : undefined, queryFn: () => { return globalConnectionsApi.list(request); }, diff --git a/packages/web/src/features/flow-runs/components/runs-table/index.tsx b/packages/web/src/features/flow-runs/components/runs-table/index.tsx index 70750e7a983..17d19b2206a 100644 --- a/packages/web/src/features/flow-runs/components/runs-table/index.tsx +++ b/packages/web/src/features/flow-runs/components/runs-table/index.tsx @@ -102,12 +102,11 @@ export const RunsTable = () => { setHasSeededDefaultRange(true); }, [hasSeededDefaultRange, setSearchParams]); - const { data, isLoading, refetch } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['flow-run-table', searchParams.toString(), projectId], enabled: hasSeededDefaultRange, staleTime: 0, gcTime: 0, - meta: { showErrorDialog: true, loadSubsetOptions: {} }, queryFn: () => { const status = searchParams.getAll('status') as FlowRunStatus[]; const flowId = searchParams.getAll('flowId'); @@ -593,6 +592,9 @@ export const RunsTable = () => { columns={columns} page={data} isLoading={isLoading || isFetchingFlows} + isError={isError} + errorStateEntity={t('runs')} + onRetry={refetch} filters={customFilters.length > 0 ? [] : filters} bulkActions={bulkActions} onRowClick={(row, newWindow) => handleRowClick(row, newWindow)} diff --git a/packages/web/src/features/flows/api/trigger-run-api.ts b/packages/web/src/features/flows/api/trigger-run-api.ts index f050c49d430..11b080b00b5 100644 --- a/packages/web/src/features/flows/api/trigger-run-api.ts +++ b/packages/web/src/features/flows/api/trigger-run-api.ts @@ -14,7 +14,6 @@ export const triggerRunHooks = { return useQuery({ queryKey: ['trigger-status-report'], queryFn: triggerRunApi.getStatusReport, - meta: { showErrorDialog: true, loadSubsetOptions: {} }, }); }, }; diff --git a/packages/web/src/features/members/hooks/project-members-hooks.ts b/packages/web/src/features/members/hooks/project-members-hooks.ts index 1f38ee2f579..ea74bf43902 100644 --- a/packages/web/src/features/members/hooks/project-members-hooks.ts +++ b/packages/web/src/features/members/hooks/project-members-hooks.ts @@ -30,6 +30,7 @@ export const projectMembersHooks = { return { projectMembers: query.data, isLoading: query.isLoading, + isError: query.isError, refetch: query.refetch, }; }, diff --git a/packages/web/src/features/members/hooks/user-invitations-hooks.ts b/packages/web/src/features/members/hooks/user-invitations-hooks.ts index a5e26d133d1..f7095a44daa 100644 --- a/packages/web/src/features/members/hooks/user-invitations-hooks.ts +++ b/packages/web/src/features/members/hooks/user-invitations-hooks.ts @@ -23,6 +23,7 @@ export const userInvitationsHooks = { return { invitations: query.data, isLoading: query.isLoading, + isError: query.isError, refetch: query.refetch, }; }, diff --git a/packages/web/src/features/piece-sets/hooks/piece-sets-hooks.ts b/packages/web/src/features/piece-sets/hooks/piece-sets-hooks.ts index 6dd31a5d99a..8ac27bdb514 100644 --- a/packages/web/src/features/piece-sets/hooks/piece-sets-hooks.ts +++ b/packages/web/src/features/piece-sets/hooks/piece-sets-hooks.ts @@ -29,7 +29,6 @@ export const pieceSetQueries = { queryKey: pieceSetKeys.page(cursor, limit), queryFn: () => pieceSetsApi.list({ cursor, limit }), enabled: platform.plan.managePiecesEnabled, - meta: { showErrorDialog: true, loadSubsetOptions: {} }, }); }, usePieceSet: (id: string) => { @@ -38,7 +37,6 @@ export const pieceSetQueries = { queryKey: pieceSetKeys.one(id), queryFn: () => pieceSetsApi.get(id), enabled: platform.plan.managePiecesEnabled && !!id, - // meta: { showErrorDialog: true }, }); }, }; diff --git a/packages/web/src/features/pieces/hooks/pieces-hooks.ts b/packages/web/src/features/pieces/hooks/pieces-hooks.ts index eba10851e17..a5d656680e8 100644 --- a/packages/web/src/features/pieces/hooks/pieces-hooks.ts +++ b/packages/web/src/features/pieces/hooks/pieces-hooks.ts @@ -88,7 +88,6 @@ type UsePiecesProps = { suggestionType?: SuggestionType; enabled?: boolean; keepPreviousResults?: boolean; - showErrorDialog?: boolean; }; type UsePrefetchPiecesProps = { skipProjectFilter?: boolean; @@ -194,7 +193,6 @@ export const piecesHooks = { suggestionType, enabled = true, keepPreviousResults = false, - showErrorDialog, }: UsePiecesProps) => { const { i18n } = useTranslation(); const query = useQuery({ @@ -209,10 +207,6 @@ export const piecesHooks = { keepPreviousResults, }), enabled, - meta: - showErrorDialog ?? isTableQuery - ? { showErrorDialog: true, loadSubsetOptions: {} } - : undefined, }); return { pieces: query.data, diff --git a/packages/web/src/features/platform-admin/hooks/ai-provider-hooks.ts b/packages/web/src/features/platform-admin/hooks/ai-provider-hooks.ts index af8bf1b7dd1..b70ace372cd 100644 --- a/packages/web/src/features/platform-admin/hooks/ai-provider-hooks.ts +++ b/packages/web/src/features/platform-admin/hooks/ai-provider-hooks.ts @@ -27,7 +27,6 @@ export const aiProviderQueries = { useQuery({ queryKey: aiProviderKeys.configs, queryFn: () => aiProviderApi.listConfigs(), - meta: { showErrorDialog: true, loadSubsetOptions: {} }, }), useProjectAiProviders: (forProjectId?: string) => { const projectId = forProjectId ?? authenticationSession.getProjectId(); diff --git a/packages/web/src/features/platform-admin/hooks/ai-tool-config-hooks.ts b/packages/web/src/features/platform-admin/hooks/ai-tool-config-hooks.ts index 0012599359f..19c639fd87a 100644 --- a/packages/web/src/features/platform-admin/hooks/ai-tool-config-hooks.ts +++ b/packages/web/src/features/platform-admin/hooks/ai-tool-config-hooks.ts @@ -16,7 +16,6 @@ export const aiToolConfigQueries = { useQuery({ queryKey: aiToolConfigKeys.all, queryFn: () => aiToolConfigApi.list(), - meta: { showErrorDialog: true, loadSubsetOptions: {} }, }), }; diff --git a/packages/web/src/features/platform-admin/hooks/analytics-hooks.ts b/packages/web/src/features/platform-admin/hooks/analytics-hooks.ts index 24d0d93166f..3afdf8b25ab 100644 --- a/packages/web/src/features/platform-admin/hooks/analytics-hooks.ts +++ b/packages/web/src/features/platform-admin/hooks/analytics-hooks.ts @@ -29,7 +29,11 @@ export const platformAnalyticsHooks = { useAnalyticsTimeBased: ( timePeriod: AnalyticsTimePeriod, projectId?: string, - ): { isLoading: boolean; data: PlatformAnalyticsReport | null } => { + ): { + isLoading: boolean; + isError: boolean; + data: PlatformAnalyticsReport | null; + } => { const selectFilteredByProject = useCallback( (report: PlatformAnalyticsReport) => { if (!projectId) { @@ -51,7 +55,7 @@ export const platformAnalyticsHooks = { ); const { platform } = platformHooks.useCurrentPlatform(); - const { data, isLoading } = useQuery({ + const { data, isLoading, isError } = useQuery({ queryKey: [...analyticsQueryKey, timePeriod], queryFn: () => analyticsApi.get(timePeriod), select: selectFilteredByProject, @@ -60,6 +64,7 @@ export const platformAnalyticsHooks = { return { isLoading, + isError, data: data ?? null, }; }, diff --git a/packages/web/src/features/platform-admin/hooks/audit-log-hooks.ts b/packages/web/src/features/platform-admin/hooks/audit-log-hooks.ts index 65d287634a1..ebfe93b26d1 100644 --- a/packages/web/src/features/platform-admin/hooks/audit-log-hooks.ts +++ b/packages/web/src/features/platform-admin/hooks/audit-log-hooks.ts @@ -22,7 +22,6 @@ export const auditLogQueries = { staleTime: 0, gcTime: 0, enabled: platform.plan.auditLogEnabled, - meta: { showErrorDialog: true, loadSubsetOptions: {} }, queryFn: async () => { const cursor = searchParams.get(CURSOR_QUERY_PARAM); const limit = searchParams.get(LIMIT_QUERY_PARAM); diff --git a/packages/web/src/features/platform-admin/hooks/embed-subdomain-hooks.ts b/packages/web/src/features/platform-admin/hooks/embed-subdomain-hooks.ts index 03e559e2c5d..f458282adfe 100644 --- a/packages/web/src/features/platform-admin/hooks/embed-subdomain-hooks.ts +++ b/packages/web/src/features/platform-admin/hooks/embed-subdomain-hooks.ts @@ -24,7 +24,6 @@ export const embedSubdomainQueries = { queryKey: embedSubdomainKeys.current, queryFn: () => embedSubdomainApi.get(), enabled: platform.plan.embeddingEnabled && edition === ApEdition.CLOUD, - meta: { showErrorDialog: true, loadSubsetOptions: {} }, refetchInterval: (query) => { const data = query.state.data; if (data?.status === EmbedSubdomainStatus.PENDING_VERIFICATION) { @@ -35,8 +34,9 @@ export const embedSubdomainQueries = { }); }, useCurrentEmbedSubdomain: () => { - const { data, isLoading } = embedSubdomainQueries.useEmbedSubdomain(); - return { subdomain: data ?? undefined, isLoading }; + const { data, isLoading, isError, refetch } = + embedSubdomainQueries.useEmbedSubdomain(); + return { subdomain: data ?? undefined, isLoading, isError, refetch }; }, }; diff --git a/packages/web/src/features/platform-admin/hooks/platform-app-connections-hooks.ts b/packages/web/src/features/platform-admin/hooks/platform-app-connections-hooks.ts index 03277c43b5d..8c4a415d5e1 100644 --- a/packages/web/src/features/platform-admin/hooks/platform-app-connections-hooks.ts +++ b/packages/web/src/features/platform-admin/hooks/platform-app-connections-hooks.ts @@ -22,7 +22,6 @@ export const platformAppConnectionsQueries = { queryKey: platformAppConnectionsKeys.list(searchParams.toString()), staleTime: 0, gcTime: 0, - meta: { showErrorDialog: true, loadSubsetOptions: {} }, queryFn: () => { const cursor = searchParams.get(CURSOR_QUERY_PARAM); const limit = searchParams.get(LIMIT_QUERY_PARAM); diff --git a/packages/web/src/features/platform-admin/hooks/platform-user-hooks.ts b/packages/web/src/features/platform-admin/hooks/platform-user-hooks.ts index 75f1e17ea4a..709cf954988 100644 --- a/packages/web/src/features/platform-admin/hooks/platform-user-hooks.ts +++ b/packages/web/src/features/platform-admin/hooks/platform-user-hooks.ts @@ -52,7 +52,6 @@ export const platformUserHooks = { }, queryKey: platformUserKeys.invitations, staleTime: 0, - meta: { showErrorDialog: true, loadSubsetOptions: {} }, }); }, }; diff --git a/packages/web/src/features/project-releases/hooks/project-release-hooks.ts b/packages/web/src/features/project-releases/hooks/project-release-hooks.ts index 72e0ef1f739..a3f0e708775 100644 --- a/packages/web/src/features/project-releases/hooks/project-release-hooks.ts +++ b/packages/web/src/features/project-releases/hooks/project-release-hooks.ts @@ -19,7 +19,6 @@ export const projectReleaseQueries = { projectReleaseApi.list({ projectId: authenticationSession.getProjectId()!, }), - meta: { showErrorDialog: true, loadSubsetOptions: {} }, }), useProjectRelease: (releaseId: string, enabled: boolean) => useQuery({ diff --git a/packages/web/src/features/secret-managers/hooks/secret-managers-hooks.ts b/packages/web/src/features/secret-managers/hooks/secret-managers-hooks.ts index b80aacefc66..04f96fd4eb3 100644 --- a/packages/web/src/features/secret-managers/hooks/secret-managers-hooks.ts +++ b/packages/web/src/features/secret-managers/hooks/secret-managers-hooks.ts @@ -15,11 +15,9 @@ export const secretManagersHooks = { useListSecretManagerConnections: ({ connectedOnly, listForPlatform, - showErrorDialog, }: { connectedOnly?: boolean; listForPlatform?: boolean; - showErrorDialog?: boolean; } = {}) => { const { platform } = platformHooks.useCurrentPlatform(); const projectId = listForPlatform @@ -37,9 +35,6 @@ export const secretManagersHooks = { return result.data; }, enabled: platform.plan.secretManagersEnabled, - meta: showErrorDialog - ? { showErrorDialog: true, loadSubsetOptions: {} } - : undefined, }); }, useCreateSecretManagerConnection: ({ diff --git a/packages/web/src/features/variables/hooks/variables-hooks.ts b/packages/web/src/features/variables/hooks/variables-hooks.ts index 1443c2cb246..bb397b72f53 100644 --- a/packages/web/src/features/variables/hooks/variables-hooks.ts +++ b/packages/web/src/features/variables/hooks/variables-hooks.ts @@ -17,21 +17,12 @@ type UseVariablesProps = { request: ListVariablesRequestQuery; extraKeys: unknown[]; enabled?: boolean; - showErrorDialog?: boolean; }; export const variablesQueries = { - useVariables: ({ - request, - extraKeys, - enabled, - showErrorDialog, - }: UseVariablesProps) => { + useVariables: ({ request, extraKeys, enabled }: UseVariablesProps) => { return useQuery({ queryKey: ['variables', ...extraKeys], - meta: showErrorDialog - ? { showErrorDialog: true, loadSubsetOptions: {} } - : undefined, queryFn: () => variablesApi.list(request), enabled, }); diff --git a/packages/web/src/lib/error-reporting.ts b/packages/web/src/lib/error-reporting.ts index 0e22ba37acf..cf3ee2ca563 100644 --- a/packages/web/src/lib/error-reporting.ts +++ b/packages/web/src/lib/error-reporting.ts @@ -18,10 +18,10 @@ const buffer: FrontendErrorReport[] = []; const recentSignatures = new Map(); -function isDuplicate(error: Error): boolean { - const signature = `${error.name}:${error.message}:${ - error.stack?.split('\n')[1] ?? '' - }`; +function isDuplicate(report: FrontendErrorReport, error: Error): boolean { + const signature = `${report.source}:${report.dedupeKey ?? ''}:${error.name}:${ + error.message + }:${error.stack?.split('\n')[1] ?? ''}`; const now = Date.now(); const lastSeen = recentSignatures.get(signature); recentSignatures.set(signature, now); @@ -82,6 +82,7 @@ function buildCaptureContext(report: FrontendErrorReport) { }, extra: { component_stack: report.componentStack, + ...report.extra, }, }; } @@ -92,7 +93,7 @@ function toError(raw: unknown): Error { function dispatch(report: FrontendErrorReport): void { const error = toError(report.error); - if (isDuplicate(error)) { + if (isDuplicate(report, error)) { return; } sentry.capture(error, buildCaptureContext(report)); @@ -143,6 +144,8 @@ export type FrontendErrorReport = { error: unknown; componentStack?: string | null; source: FrontendErrorSource; + dedupeKey?: string; + extra?: Record; }; export type FrontendErrorSource = @@ -150,4 +153,5 @@ export type FrontendErrorSource = | 'route-error' | 'window-error' | 'unhandled-rejection' - | 'chunk-preload'; + | 'chunk-preload' + | 'query'; diff --git a/packages/web/src/query-meta.d.ts b/packages/web/src/query-meta.d.ts deleted file mode 100644 index 1af85b2a739..00000000000 --- a/packages/web/src/query-meta.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -export {}; - -declare module '@tanstack/query-db-collection' { - interface QueryCollectionMeta { - showErrorDialog?: boolean; - } -}