From 1681b5d2988b062e42d64bca72e102fb4078b781 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:50:17 +0100 Subject: [PATCH] Source and connections changes to integrations (#7068) --- .../public/locales/en-US/translation.toml | 89 ++- frontend/editor/scripts/lint/theme-lint.mjs | 8 +- .../components/sidebarGroups.test.ts | 1 + frontend/editor/src/portal/ViewRouter.tsx | 10 +- .../src/portal/components/BrandMarks.tsx | 451 ++++++++++++ .../editor/src/portal/components/icons.tsx | 11 + .../components/pipelines/ToolPicker.tsx | 6 +- .../policies/PolicyExternalApiConfig.tsx | 11 +- .../src/portal/components/sidebarGroups.tsx | 4 +- .../components/sources/ConnectionForm.tsx | 9 +- .../sources/ConnectionModal.test.tsx | 6 +- .../components/sources/ConnectionPicker.tsx | 34 +- .../sources/ConnectionTypePicker.tsx | 13 +- .../sources/ConnectionsTab.test.tsx | 85 --- .../components/sources/ConnectionsTab.tsx | 223 ------ .../sources/S3ConnectionPicker.test.tsx | 7 +- .../components/sources/S3ConnectionPicker.tsx | 4 + .../portal/components/sources/SourceModal.css | 160 +++++ .../sources/SourceModal.test.tsx} | 148 ++-- .../portal/components/sources/SourceModal.tsx | 659 ++++++++++++++++++ .../components/sources/SourceTypeIcon.tsx | 38 +- .../components/sources/SourcesTable.tsx | 5 +- .../portal/components/sources/sourceTypes.ts | 33 +- .../src/portal/contexts/ViewContext.tsx | 3 + .../editor/src/portal/views/Integrations.css | 358 ++++++++++ .../src/portal/views/Integrations.test.tsx | 150 ++++ .../editor/src/portal/views/Integrations.tsx | 520 ++++++++++++++ .../src/portal/views/PipelineBuilder.css | 4 + .../src/portal/views/PipelineBuilder.test.tsx | 7 +- .../editor/src/portal/views/SourceBuilder.css | 116 --- .../editor/src/portal/views/SourceBuilder.tsx | 501 ------------- frontend/editor/src/portal/views/Sources.css | 80 +-- .../editor/src/portal/views/Sources.test.tsx | 84 ++- frontend/editor/src/portal/views/Sources.tsx | 131 ++-- frontend/eslint.config.mjs | 6 +- 35 files changed, 2734 insertions(+), 1241 deletions(-) create mode 100644 frontend/editor/src/portal/components/BrandMarks.tsx delete mode 100644 frontend/editor/src/portal/components/sources/ConnectionsTab.test.tsx delete mode 100644 frontend/editor/src/portal/components/sources/ConnectionsTab.tsx create mode 100644 frontend/editor/src/portal/components/sources/SourceModal.css rename frontend/editor/src/portal/{views/SourceBuilder.test.tsx => components/sources/SourceModal.test.tsx} (62%) create mode 100644 frontend/editor/src/portal/components/sources/SourceModal.tsx create mode 100644 frontend/editor/src/portal/views/Integrations.css create mode 100644 frontend/editor/src/portal/views/Integrations.test.tsx create mode 100644 frontend/editor/src/portal/views/Integrations.tsx delete mode 100644 frontend/editor/src/portal/views/SourceBuilder.css delete mode 100644 frontend/editor/src/portal/views/SourceBuilder.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 4c99d2a479..72099ba949 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -6523,12 +6523,8 @@ createTitleFor = "Connect {{name}}" delete = "Delete" edit = "Edit" editTitle = "Edit connection" -subtitle = "Reusable credentials that sources, pipeline outputs and integration steps connect to." unsupportedType = "This connection type cannot be edited here." -[portal.connections.actions] -new = "New connection" - [portal.connections.categories.advanced] description = "For a service we do not support yet." label = "Advanced" @@ -6580,13 +6576,9 @@ label = "Username" helperText = "Paste the URL the service gave you. Only paths under it can be called." label = "Webhook URL" -[portal.connections.empty] -description = "Add a connection to reuse the same credentials across sources, pipeline outputs and integration steps." -title = "No connections yet" - [portal.connections.fields] name = "Connection name" -namePlaceholder = "e.g. Claims bucket" +namePlaceholder = "e.g. Production" [portal.connections.picker] cancel = "Cancel" @@ -6601,11 +6593,6 @@ noResultsBody = "We do not support it yet - tell us about it and we will look at noResultsTitle = "Nothing matches \"{{query}}\"" searchPlaceholder = "Search integrations - try \"sign\", \"ocr\" or a product name" -[portal.connections.table] -detail = "Details" -name = "Name" -type = "Type" - [portal.connections.types.api] description = "Call any HTTP API from a policy. You choose the URL, authentication and payload." label = "Custom API" @@ -7553,6 +7540,41 @@ models = "Models" security = "Security" storage = "Storage" +[portal.integrations] +addAnother = "Add another" +availableHeading = "Available" +comingSoonHeading = "Coming soon" +connect = "Connect" +connectedHeading = "Connected" +connectionCount_one = "{{count}} connection" +connectionCount_other = "{{count}} connections" +customApi = "Custom API" +searchPlaceholder = "Search integrations" +subtitle = "Everything Stirling can talk to. Credentials are created once here, then referenced by sources, policies and pipelines." +title = "Integrations" +typedName = "{{tool}} name" + +[portal.integrations.filters] +all = "All" +audit = "Audit" +connected = "Connected" +notify = "Notify" +security = "Security" +signing = "Signing" +storage = "Storage" + +[portal.integrations.status] +connected = "Connected" + +[portal.integrations.table] +integration = "Integration" +worksWith = "Works with" + +[portal.integrations.worksWith] +pipelines = "Pipelines" +policies = "Policies" +sources = "Sources" + [portal.nav] agent-builder = "Agent Builder" components = "Components" @@ -7561,6 +7583,7 @@ documents = "Documents" editor = "Editor" home = "Home" infrastructure = "Infrastructure" +integrations = "Integrations" pipelines = "Pipelines" policies = "Policies" procurement = "Procurement" @@ -8517,7 +8540,11 @@ connectSource = "Connect source" [portal.sources.builder] back = "Back to sources" +backToTypes = "All source types" cancel = "Cancel" +chooseHint = "Choose where documents come from. Greyed-out connectors are on the way." +comingSoon = "Coming soon" +comingSoonHeading = "Coming soon" create = "Create source" createTitle = "Connect a source" delete = "Delete" @@ -8556,15 +8583,23 @@ source = "Source" status = "Status" usedBy = "Policies" -[portal.sources.tabs] -connections = "Connections" -sources = "Sources" +[portal.sources.types.box] +description = "Pull documents from Box folders your team already shares." +label = "Box" + +[portal.sources.types.dropbox] +description = "Pull documents from a shared Dropbox folder." +label = "Dropbox" [portal.sources.types.editor] description = "Documents your team has processed in the editor, across policy and AI runs." label = "Editor" noPolicies = "No policies run from the editor yet." +[portal.sources.types.email] +description = "Turn attachments sent to a dedicated mailbox into documents." +label = "Email inbox" + [portal.sources.types.folder] description = "Watch a directory on the server for new documents." label = "Folder" @@ -8596,6 +8631,18 @@ label = "Folder depth" all = "Include subfolders" top = "Top level only" +[portal.sources.types.googledrive] +description = "Pull documents from shared Google Drive folders." +label = "Google Drive" + +[portal.sources.types.network] +description = "Watch an SMB network share for new documents." +label = "Network drive" + +[portal.sources.types.onedrive] +description = "Watch OneDrive folders for new documents." +label = "OneDrive" + [portal.sources.types.s3] description = "Pull documents from an Amazon S3 or S3-compatible bucket." label = "Amazon S3" @@ -8617,6 +8664,14 @@ helperText = "Only objects whose keys start with this prefix are processed." label = "Key prefix" placeholder = "incoming/" +[portal.sources.types.sftp] +description = "Poll an SFTP drop folder for new documents." +label = "SFTP" + +[portal.sources.types.sharepoint] +description = "Pull documents from SharePoint document libraries." +label = "SharePoint" + [portal.sources.types.unknown] label = "Source" diff --git a/frontend/editor/scripts/lint/theme-lint.mjs b/frontend/editor/scripts/lint/theme-lint.mjs index 561fd5c1cc..71a9cf07e5 100644 --- a/frontend/editor/scripts/lint/theme-lint.mjs +++ b/frontend/editor/scripts/lint/theme-lint.mjs @@ -22,7 +22,6 @@ import { execSync } from "node:child_process"; import { relative, resolve, join } from "node:path"; const THEME = resolve(process.cwd(), "editor/src/core/theme"); -const PRIMITIVES = "editor/src/core/theme/primitives.css"; // Fixed list of theme CSS files to check, so every read takes a constant path // (no directory-listing feeding into a file read). readdir is used only to fail @@ -331,7 +330,10 @@ function check() { for (const name of THEME_FILES) { const rel = relative(process.cwd(), join(THEME, name)); - const isPrimitives = rel === PRIMITIVES; + // Compare by basename, not the full relative path: on Windows `relative` + // yields backslashes, so a forward-slash path constant never matched and + // primitives.css was wrongly linted as a non-primitive file. + const isPrimitives = name === "primitives.css"; const text = stripComments(readFileSync(join(THEME, name), "utf8")); for (const re of [HEX_RE, FUNC_RE]) { @@ -622,7 +624,7 @@ const CODE_EXEMPT_PATH = [ /\/viewer\/|Annotation|useViewerReadAloud|CommentsSidebar|\/constants\/search\.ts$|SignaturePreview/, /ColorPicker|ColorControl|WatchedFolderManagementModal|watchedFolderPresets|fileColors|unifiedBackground|folder\.ts$|policyFolders/, /OAuthButtons|oauthCallbackHtml/, - /mantineTheme|\/theme\.ts$|toolsTaxonomy|LayoutPreview|PageNumberPreview|CloudStorageIcons/, + /mantineTheme|\/theme\.ts$|toolsTaxonomy|LayoutPreview|PageNumberPreview|CloudStorageIcons|BrandMarks/, /\/onboarding\//, /addStamp|addWatermark|\/tooltips\//, /UpgradeBanner|AdminPlanSection/, diff --git a/frontend/editor/src/portal-saas/components/sidebarGroups.test.ts b/frontend/editor/src/portal-saas/components/sidebarGroups.test.ts index aaec0024f1..547f11351b 100644 --- a/frontend/editor/src/portal-saas/components/sidebarGroups.test.ts +++ b/frontend/editor/src/portal-saas/components/sidebarGroups.test.ts @@ -19,6 +19,7 @@ describe("sidebarGroups (SaaS)", () => { it("inherits the platform group unchanged from base", () => { expect(GROUP_PLATFORM.map((e) => e.id)).toEqual([ "users", + "integrations", "infrastructure", "usage", "docs", diff --git a/frontend/editor/src/portal/ViewRouter.tsx b/frontend/editor/src/portal/ViewRouter.tsx index 67b7c146a9..76adc1ad1d 100644 --- a/frontend/editor/src/portal/ViewRouter.tsx +++ b/frontend/editor/src/portal/ViewRouter.tsx @@ -6,7 +6,7 @@ import { Documents } from "@portal/views/Documents"; import { Pipelines } from "@portal/views/Pipelines"; import { PipelineBuilder } from "@portal/views/PipelineBuilder"; import { Sources } from "@portal/views/Sources"; -import { SourceBuilder } from "@portal/views/SourceBuilder"; +import { Integrations } from "@portal/views/Integrations"; import { Policies } from "@portal/views/Policies"; import { EditorAdmin } from "@portal/views/EditorAdmin"; import { Infrastructure } from "@portal/views/Infrastructure"; @@ -42,14 +42,18 @@ export function ViewRouter() { element={} /> } /> + {/* Source create/edit is a modal on the list now; old deep links land there. */} } + element={ + + } /> } + element={} /> + } /> } /> } /> } /> diff --git a/frontend/editor/src/portal/components/BrandMarks.tsx b/frontend/editor/src/portal/components/BrandMarks.tsx new file mode 100644 index 0000000000..04f1d79e83 --- /dev/null +++ b/frontend/editor/src/portal/components/BrandMarks.tsx @@ -0,0 +1,451 @@ +import type { CSSProperties, ReactNode } from "react"; +import { + DropboxIcon, + GoogleDriveIcon, + OneDriveIcon, +} from "@app/components/shared/CloudStorageIcons"; + +/** + * Full-colour brand marks for the integrations catalogue and source connectors, + * drawn on a transparent 24x24 canvas (no tinted badge behind them). Vendors + * with a recognisable mark get their brand geometry and colours; self-hosted or + * generic entries render neutral currentColor strokes so they follow the theme. + * Path-exempt from theme-lint's code-colors gate (brand hexes are the point). + */ + +interface MarkProps { + size?: number; + className?: string; + style?: CSSProperties; +} + +function Fill({ + size = 20, + className, + style, + children, + viewBox = "0 0 24 24", +}: MarkProps & { children: ReactNode; viewBox?: string }) { + return ( + + {children} + + ); +} + +function Stroke({ + size = 20, + className, + style, + children, +}: MarkProps & { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** Brand-coloured marks, keyed by connection-type/source-type id. */ +const BRAND: Record ReactNode> = { + s3: (p) => ( + + + + + + ), + sharepoint: (p) => ( + + + + + + ), + purview: (p) => ( + + + + + + ), + box: (p) => ( + + + + + + ), + slack: (p) => ( + + + + + + + + + + + ), + teams: (p) => ( + + + + + ), + discord: (p) => ( + + + + + + ), + googlechat: (p) => ( + + + + + ), + zapier: (p) => ( + + + + + + + + + + ), + jira: (p) => ( + + + + + ), + confluence: (p) => ( + + + + + ), + nextcloud: (p) => ( + + + + + + + + ), + splunk: (p) => ( + + + + ), + elastic: (p) => ( + + + + + + ), + sumologic: (p) => ( + + + + + ), + sendgrid: (p) => ( + + + + + + + + + + + ), + mailgun: (p) => ( + + + + + + + ), + cloudmersive: (p) => ( + + + + + ), + cloudmersiveadvanced: (p) => ( + + + + + + ), + presidio: (p) => ( + + + + + + ), + clamav: (p) => ( + + + + + ), + consigno: (p) => ( + + + + + ), +}; + +/** Neutral currentColor strokes for generic / self-hosted / roadmap entries. */ +const NEUTRAL: Record = { + folder: ( + + ), + webhook: , + editor: ( + <> + + + > + ), + api: ( + <> + + + > + ), + network: ( + <> + + + + > + ), + sftp: ( + <> + + + + + + > + ), + email: ( + <> + + + + + > + ), + _default: ( + <> + + + + + > + ), +}; + +/** One mark for any integration/source id; unknown ids get a neutral plug. */ +export function BrandMark({ + id, + size = 20, + className, + style, +}: MarkProps & { id: string }) { + if (id === "googledrive") { + return ( + + ); + } + if (id === "onedrive") { + return ( + + ); + } + if (id === "dropbox") { + return ( + + ); + } + const brand = BRAND[id]; + if (brand) return <>{brand({ size, className, style })}>; + return ( + + {NEUTRAL[id] ?? NEUTRAL._default} + + ); +} diff --git a/frontend/editor/src/portal/components/icons.tsx b/frontend/editor/src/portal/components/icons.tsx index ac0eb49142..5467995efc 100644 --- a/frontend/editor/src/portal/components/icons.tsx +++ b/frontend/editor/src/portal/components/icons.tsx @@ -289,3 +289,14 @@ export function ExternalLinkIcon(props: IconProps) { ); } + +export function IntegrationsIcon(props: IconProps) { + return ( + + + + + + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/ToolPicker.tsx b/frontend/editor/src/portal/components/pipelines/ToolPicker.tsx index 0c531a6874..99395a0ffe 100644 --- a/frontend/editor/src/portal/components/pipelines/ToolPicker.tsx +++ b/frontend/editor/src/portal/components/pipelines/ToolPicker.tsx @@ -11,6 +11,7 @@ import { searchOperations, type StepOperation, } from "@portal/components/policies/stepOperations"; +import { BrandMark } from "@portal/components/BrandMarks"; interface ToolPickerProps { tools: ExecutableTool[]; @@ -137,7 +138,10 @@ export function ToolPicker({ className="portal-pipelines__picker-icon" aria-hidden="true" > - {t(op.labelKey).slice(0, 1)} + } > diff --git a/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.tsx b/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.tsx index 9299f26071..76869bb77a 100644 --- a/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.tsx +++ b/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.tsx @@ -9,6 +9,7 @@ import { CONNECTION_CATEGORIES, type ConnectionCategory, } from "@portal/components/sources/connectionTypes"; +import { BrandMark } from "@portal/components/BrandMarks"; import { STEP_OPERATIONS, buildStepParameters, @@ -262,11 +263,11 @@ function OperationGrid({ } onClick={() => onPick(op)} > - - {label.slice(0, 1)} + + {label} diff --git a/frontend/editor/src/portal/components/sidebarGroups.tsx b/frontend/editor/src/portal/components/sidebarGroups.tsx index 7b461cb057..f56b54495a 100644 --- a/frontend/editor/src/portal/components/sidebarGroups.tsx +++ b/frontend/editor/src/portal/components/sidebarGroups.tsx @@ -4,6 +4,7 @@ import { HomeIcon, UsersIcon, SourcesIcon, + IntegrationsIcon, PoliciesIcon, PipelinesIcon, DocumentsIcon, @@ -37,9 +38,10 @@ export const GROUP_PROCESSOR: NavEntry[] = [ { id: "documents", icon: }, ]; -// The wider platform around the processor: people, infra, billing, docs. +// The wider platform around the processor: people, connections, infra, billing, docs. export const GROUP_PLATFORM: NavEntry[] = [ { id: "users", icon: }, + { id: "integrations", icon: }, { id: "infrastructure", icon: }, { id: "usage", icon: }, { id: "docs", icon: }, diff --git a/frontend/editor/src/portal/components/sources/ConnectionForm.tsx b/frontend/editor/src/portal/components/sources/ConnectionForm.tsx index a6cd42da89..79d6883bdb 100644 --- a/frontend/editor/src/portal/components/sources/ConnectionForm.tsx +++ b/frontend/editor/src/portal/components/sources/ConnectionForm.tsx @@ -31,7 +31,14 @@ export function ConnectionForm({ return ( - + {/* "S3 storage name", "Jira name": the vendor is in the label, so the + field explains itself and the placeholder stays a neutral example. */} + { ), ); - await fill("portal.connections.fields.name", "Corp Purview"); + await fill("portal.integrations.typedName", "Corp Purview"); await fill( "portal.connections.types.purview.fields.tenantId", "cb46c030-1825-4e81-a295-151c039dbf02", @@ -82,7 +82,7 @@ describe("ConnectionModal", () => { ), ); - await fill("portal.connections.fields.name", "Notarius"); + await fill("portal.integrations.typedName", "Notarius"); await fill( "portal.connections.types.consigno.fields.baseUrl", "https://acme.consignocloud.com/api/v1", @@ -163,7 +163,7 @@ describe("ConnectionModal", () => { const save = screen.getByText("portal.connections.picker.save"); expect(save.closest("button")).toBeDisabled(); - await fill("portal.connections.fields.name", "Corp"); + await fill("portal.integrations.typedName", "Corp"); // Name alone is not enough: Purview needs a tenant id. expect(save.closest("button")).toBeDisabled(); }); diff --git a/frontend/editor/src/portal/components/sources/ConnectionPicker.tsx b/frontend/editor/src/portal/components/sources/ConnectionPicker.tsx index be54916726..9e7f4dc143 100644 --- a/frontend/editor/src/portal/components/sources/ConnectionPicker.tsx +++ b/frontend/editor/src/portal/components/sources/ConnectionPicker.tsx @@ -33,6 +33,13 @@ interface ConnectionPickerProps { * connection, which can point anywhere and so fits any API operation. */ presetId?: string; + /** + * When the picker already sits inside a modal, stacking the shared connection + * modal on top reads badly. A host that can offer its own create surface (the + * source modal swaps to a connection stage) passes this; "New connection..." + * then delegates instead of opening the nested modal. + */ + onCreateNew?: () => void; } export function ConnectionPicker({ @@ -41,6 +48,7 @@ export function ConnectionPicker({ integrationType, createTypeId, presetId, + onCreateNew, }: ConnectionPickerProps) { const { t } = useTranslation(); const [connections, setConnections] = useState( @@ -85,19 +93,25 @@ export function ConnectionPicker({ }))} onChange={(selected) => onChange(selected ?? "")} /> - setModalOpen(true)}> + (onCreateNew ? onCreateNew() : setModalOpen(true))} + > {t("portal.connections.picker.createNew")} {error && } - setModalOpen(false)} - onSaved={(created) => { - setConnections((list) => [...(list ?? []), created]); - onChange(String(created.id)); - }} - /> + {!onCreateNew && ( + setModalOpen(false)} + onSaved={(created) => { + setConnections((list) => [...(list ?? []), created]); + onChange(String(created.id)); + }} + /> + )} ); } diff --git a/frontend/editor/src/portal/components/sources/ConnectionTypePicker.tsx b/frontend/editor/src/portal/components/sources/ConnectionTypePicker.tsx index 7d1ea67fa9..b652626d96 100644 --- a/frontend/editor/src/portal/components/sources/ConnectionTypePicker.tsx +++ b/frontend/editor/src/portal/components/sources/ConnectionTypePicker.tsx @@ -7,6 +7,7 @@ import { type ConnectionCategory, type CreatableConnectionType, } from "@portal/components/sources/connectionTypes"; +import { BrandMark } from "@portal/components/BrandMarks"; /** * Choose what to connect to. @@ -118,15 +119,9 @@ function Grid({ } onClick={() => onPick(type)} > - {/* A monogram rather than a logo: no third-party assets to ship, licence or keep - current, and it still gives each card something to recognise at a glance. Tinted - by category so the colour still carries meaning while searching, when the section - headings that would otherwise group them are gone. */} - - {label.slice(0, 1)} + {/* The vendor's real mark, full colour on the card surface. */} + + {label} diff --git a/frontend/editor/src/portal/components/sources/ConnectionsTab.test.tsx b/frontend/editor/src/portal/components/sources/ConnectionsTab.test.tsx deleted file mode 100644 index 157a674e5c..0000000000 --- a/frontend/editor/src/portal/components/sources/ConnectionsTab.test.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - fireEvent, - render as baseRender, - screen, - waitFor, -} from "@testing-library/react"; -import { MantineProvider } from "@mantine/core"; -import { HttpError } from "@portal/api/http"; -import { ConnectionsTab } from "@portal/components/sources/ConnectionsTab"; -import type { IntegrationConfig } from "@portal/api/integrations"; - -const render = (ui: Parameters[0]) => - baseRender(ui, { wrapper: MantineProvider }); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - i18n: { changeLanguage: vi.fn() }, - }), -})); - -const fetchS3Connections = vi.fn(); -const deleteIntegration = vi.fn(); -vi.mock("@portal/api/integrations", () => ({ - fetchIntegrations: () => fetchS3Connections(), - // Custom-API authoring is a server decision; these tests assert the default view. - fetchIntegrationCapabilities: () => Promise.resolve({ customApi: false }), - fetchS3Connections: () => fetchS3Connections(), - deleteIntegration: (id: number) => deleteIntegration(id), - createIntegration: vi.fn(), - updateIntegration: vi.fn(), -})); - -const CONNECTION = { - id: 5, - integrationType: "S3", - name: "Claims bucket", - config: { bucket: "inbox", region: "us-east-1" }, - canManage: true, -} as unknown as IntegrationConfig; - -describe("ConnectionsTab", () => { - beforeEach(() => { - fetchS3Connections.mockReset(); - deleteIntegration.mockReset(); - deleteIntegration.mockResolvedValue(undefined); - }); - - it("shows the empty state when there are no connections", async () => { - fetchS3Connections.mockResolvedValue([]); - render(); - expect( - await screen.findByText("portal.connections.empty.title"), - ).toBeInTheDocument(); - }); - - it("lists connections and deletes one", async () => { - fetchS3Connections.mockResolvedValueOnce([CONNECTION]); - fetchS3Connections.mockResolvedValueOnce([]); - render(); - - expect(await screen.findByText("Claims bucket")).toBeInTheDocument(); - fireEvent.click(screen.getByText("portal.connections.delete")); - await waitFor(() => expect(deleteIntegration).toHaveBeenCalledWith(5)); - }); - - it("surfaces the 409 when deleting a connection still in use", async () => { - fetchS3Connections.mockResolvedValue([CONNECTION]); - deleteIntegration.mockRejectedValue( - new HttpError(409, "Conflict", { - detail: "Integration is in use by: source 'Claims intake'", - }), - ); - render(); - - await screen.findByText("Claims bucket"); - fireEvent.click(screen.getByText("portal.connections.delete")); - expect( - await screen.findByText( - "Integration is in use by: source 'Claims intake'", - ), - ).toBeInTheDocument(); - }); -}); diff --git a/frontend/editor/src/portal/components/sources/ConnectionsTab.tsx b/frontend/editor/src/portal/components/sources/ConnectionsTab.tsx deleted file mode 100644 index c249afcbae..0000000000 --- a/frontend/editor/src/portal/components/sources/ConnectionsTab.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import AddRoundedIcon from "@mui/icons-material/AddRounded"; -import { - Banner, - Button, - EmptyState, - Skeleton, - Table, - type TableColumn, -} from "@app/ui"; -import { errorMessage } from "@portal/api/http"; -import { - deleteIntegration, - fetchIntegrationCapabilities, - fetchIntegrations, - type IntegrationCapabilities, - type IntegrationConfig, -} from "@portal/api/integrations"; -import { SourcesIcon } from "@portal/components/icons"; -import { ConnectionModal } from "@portal/components/sources/ConnectionModal"; -import { connectionTypeOf } from "@portal/components/sources/connectionTypes"; - -/** - * The Connections tab of the Sources page: the stored connections that sources, pipeline outputs - * and integration steps reference by id — S3, Purview, ConsignO, and admin-authored custom APIs. - * Create/edit go through the shared {@link ConnectionModal}; deleting one the backend still - * references returns a 409, surfaced inline. - */ -export function ConnectionsTab() { - const { t } = useTranslation(); - const [connections, setConnections] = useState( - null, - ); - const [modalOpen, setModalOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [capabilities, setCapabilities] = useState< - IntegrationCapabilities | undefined - >(undefined); - - const refresh = useCallback(async () => { - try { - setConnections(await fetchIntegrations()); - } catch (e) { - setError(errorMessage(e)); - } - }, []); - - useEffect(() => { - void refresh(); - }, [refresh]); - - // What this caller may author. Left undefined on failure, which withholds the custom-API - // option rather than offering something the backend would refuse. - useEffect(() => { - fetchIntegrationCapabilities().then(setCapabilities, () => undefined); - }, []); - - function openCreate() { - setEditing(null); - setModalOpen(true); - } - - function openEdit(connection: IntegrationConfig) { - setEditing(connection); - setModalOpen(true); - } - - async function remove(connection: IntegrationConfig) { - if (busy) return; - setBusy(true); - setError(null); - try { - await deleteIntegration(connection.id); - await refresh(); - } catch (e) { - setError(errorMessage(e)); - } finally { - setBusy(false); - } - } - - const columns = useMemo[]>( - () => [ - { - key: "name", - header: t("portal.connections.table.name"), - render: (c) => {c.name}, - }, - { - key: "type", - header: t("portal.connections.table.type"), - render: (c) => { - const type = connectionTypeOf(c.integrationType, c.config); - return type ? t(type.labelKey) : c.integrationType; - }, - }, - { - key: "detail", - header: t("portal.connections.table.detail"), - render: (c) => ( - - {connectionDetail(c)} - - ), - }, - { - key: "actions", - header: "", - align: "right", - render: (c) => - c.canManage ? ( - - openEdit(c)} - > - {t("portal.connections.edit")} - - void remove(c)} - > - {t("portal.connections.delete")} - - - ) : null, - }, - ], - // remove/openEdit are stable enough for this admin surface; busy gates them. - [t, busy], - ); - - const isLoading = connections === null; - const isEmpty = connections !== null && connections.length === 0; - - return ( - - {/* The empty state carries its own heading and call to action, so the header row would - only double the button and the copy - show it once there are connections to head. */} - {!isEmpty && ( - - - {t("portal.connections.subtitle")} - - } - > - {t("portal.connections.actions.new")} - - - )} - - {error && } - - {isLoading && ( - - {Array.from({ length: 3 }).map((_, i) => ( - - ))} - - )} - - {isEmpty && ( - } - title={t("portal.connections.empty.title")} - description={t("portal.connections.empty.description")} - actions={ - } - > - {t("portal.connections.actions.new")} - - } - /> - )} - - {connections !== null && connections.length > 0 && ( - - className="portal-sources__connections-table" - columns={columns} - rows={connections} - rowKey={(c) => String(c.id)} - /> - )} - - setModalOpen(false)} - onSaved={() => void refresh()} - /> - - ); -} - -/** - * The one line that identifies a connection at a glance. Per type, because "bucket" means nothing - * to a Purview tenant and a base URL means nothing to S3. Never a secret: config arrives masked, - * but only non-secret keys are shown regardless. - */ -function connectionDetail(connection: IntegrationConfig): string { - const config = connection.config ?? {}; - switch (connection.integrationType) { - case "S3": - return String(config.bucket ?? ""); - case "PURVIEW": - return String(config.tenantId ?? ""); - default: - return String(config.baseUrl ?? ""); - } -} diff --git a/frontend/editor/src/portal/components/sources/S3ConnectionPicker.test.tsx b/frontend/editor/src/portal/components/sources/S3ConnectionPicker.test.tsx index 1eb7c64db6..faf253bd5b 100644 --- a/frontend/editor/src/portal/components/sources/S3ConnectionPicker.test.tsx +++ b/frontend/editor/src/portal/components/sources/S3ConnectionPicker.test.tsx @@ -44,10 +44,9 @@ describe("S3ConnectionPicker", () => { fireEvent.click( await screen.findByText("portal.connections.picker.createNew"), ); - fireEvent.change( - screen.getByLabelText(/portal\.connections\.fields\.name/), - { target: { value: "New bucket" } }, - ); + fireEvent.change(screen.getByLabelText(/portal\.integrations\.typedName/), { + target: { value: "New bucket" }, + }); fireEvent.change( screen.getByLabelText( /portal\.connections\.types\.s3\.fields\.bucket\.label/, diff --git a/frontend/editor/src/portal/components/sources/S3ConnectionPicker.tsx b/frontend/editor/src/portal/components/sources/S3ConnectionPicker.tsx index cc36aef29e..e8323e53a7 100644 --- a/frontend/editor/src/portal/components/sources/S3ConnectionPicker.tsx +++ b/frontend/editor/src/portal/components/sources/S3ConnectionPicker.tsx @@ -7,11 +7,14 @@ import { ConnectionPicker } from "@portal/components/sources/ConnectionPicker"; interface S3ConnectionPickerProps { value: string; onChange: (connectionId: string) => void; + /** Forwarded to {@link ConnectionPicker}: hosts inside a modal create in-place. */ + onCreateNew?: () => void; } export function S3ConnectionPicker({ value, onChange, + onCreateNew, }: S3ConnectionPickerProps) { return ( ); } diff --git a/frontend/editor/src/portal/components/sources/SourceModal.css b/frontend/editor/src/portal/components/sources/SourceModal.css new file mode 100644 index 0000000000..94ff215754 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/SourceModal.css @@ -0,0 +1,160 @@ +.portal-source-modal__catalog { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portal-source-modal__hint { + margin: 0; + font-size: 0.8125rem; + color: var(--c-text-subtle); +} + +.portal-source-modal__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(13.5rem, 1fr)); + gap: 0.5rem; +} + +.portal-source-modal__soon-title { + margin: 0.5rem 0 0; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--c-text-muted); +} + +/* Connector tile: real logo on the card surface, no tinted badge behind it. */ +.portal-source-modal__card { + display: flex; + align-items: flex-start; + gap: 0.625rem; + padding: 0.625rem 0.75rem; + text-align: left; + cursor: pointer; + border: 1px solid var(--c-border); + border-radius: var(--radius-md); + background: var(--c-surface); + font: inherit; + color: inherit; + transition: + border-color var(--motion-fast), + background var(--motion-fast); +} + +button.portal-source-modal__card:hover { + border-color: var(--c-border-strong); + background: var(--c-hover); +} + +button.portal-source-modal__card:focus-visible { + border-color: var(--c-primary); + box-shadow: 0 0 0 2px var(--c-primary-tint); + outline: none; +} + +.portal-source-modal__card--soon { + opacity: 0.55; + cursor: not-allowed; +} + +.portal-source-modal__card-icon { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + margin-top: 0.0625rem; +} + +.portal-source-modal__card-text { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.portal-source-modal__card-name { + font-size: 0.8125rem; + font-weight: 600; + color: var(--c-text); +} + +.portal-source-modal__card-desc { + font-size: 0.75rem; + line-height: 1.4; + color: var(--c-text-subtle); +} + +.portal-source-modal__soon-badge { + margin-left: 0.375rem; + padding: 0.0625rem 0.375rem; + border-radius: 999px; + border: 1px solid var(--c-border); + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--c-text-muted); + white-space: nowrap; +} + +.portal-source-modal__form { + display: flex; + flex-direction: column; + gap: 0.875rem; +} + +.portal-source-modal__loading { + display: flex; + justify-content: center; + padding: 2rem 0; +} + +.portal-source-modal__back { + align-self: flex-start; +} + +.portal-source-modal__type-summary { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.625rem 0.75rem; + background: var(--c-surface-sunken); + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-md); +} + +.portal-source-modal__muted { + margin: 0; + font-size: 0.8125rem; + color: var(--c-text-subtle); +} + +.portal-source-modal__copy-row { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.portal-source-modal__copy-row > :first-child { + flex: 1 1 auto; + min-width: 0; +} + +.portal-source-modal__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + width: 100%; +} + +.portal-source-modal__footer-actions { + display: flex; + align-items: center; + gap: 0.5rem; + justify-content: flex-end; +} diff --git a/frontend/editor/src/portal/views/SourceBuilder.test.tsx b/frontend/editor/src/portal/components/sources/SourceModal.test.tsx similarity index 62% rename from frontend/editor/src/portal/views/SourceBuilder.test.tsx rename to frontend/editor/src/portal/components/sources/SourceModal.test.tsx index fab14ff1ae..ca037fde3d 100644 --- a/frontend/editor/src/portal/views/SourceBuilder.test.tsx +++ b/frontend/editor/src/portal/components/sources/SourceModal.test.tsx @@ -6,12 +6,11 @@ import { waitFor, } from "@testing-library/react"; import { PortalTestProviders } from "@portal/test/TestQueryProvider"; -import { MemoryRouter, Route, Routes } from "react-router-dom"; import type { ReactNode } from "react"; -import { SourceBuilder } from "@portal/views/SourceBuilder"; +import { SourceModal } from "@portal/components/sources/SourceModal"; import { UIProvider } from "@portal/contexts/UIContext"; -// SourceBuilder reads useUI() (open settings) and useQueryClient (list +// SourceModal reads useUI() (open settings) and useQueryClient (list // invalidation), so provide the query client + Mantine + the UI context. const Providers = ({ children }: { children: ReactNode }) => ( @@ -41,26 +40,29 @@ vi.mock("@portal/api/sources", () => ({ })); const fetchS3Connections = vi.fn(); +const createIntegration = vi.fn(); vi.mock("@portal/api/integrations", () => ({ fetchIntegrations: () => Promise.resolve([]), fetchIntegrationCapabilities: () => Promise.resolve({ customApi: false }), fetchS3Connections: () => fetchS3Connections(), - createIntegration: vi.fn(), + createIntegration: (body: unknown) => createIntegration(body), })); -function renderBuilder(initial: string) { - return render( - - - sources list} /> - } /> - } /> - - , +function renderModal(sourceId: string | null = null) { + const onClose = vi.fn(); + const onSaved = vi.fn(); + render( + , ); + return { onClose, onSaved }; } -describe("SourceBuilder", () => { +describe("SourceModal", () => { beforeEach(() => { createSource.mockReset(); createSource.mockResolvedValue({ id: "src-1" }); @@ -71,13 +73,16 @@ describe("SourceBuilder", () => { isFolderAccessDeniedError.mockReturnValue(false); fetchS3Connections.mockReset(); fetchS3Connections.mockResolvedValue([]); + createIntegration.mockReset(); + createIntegration.mockResolvedValue({ id: 77, name: "Fresh bucket" }); }); - it("creates a folder source and returns to the list", async () => { - renderBuilder("/processor/sources/new"); + it("creates a folder source through the staged flow and closes", async () => { + const { onClose, onSaved } = renderModal(); - // Folder is the first offered type; fill name + directory. - fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), { + // Stage 1: pick the folder connector, then fill name + directory. + fireEvent.click(screen.getByText("portal.sources.types.folder.label")); + fireEvent.change(screen.getByLabelText(/portal\.integrations\.typedName/), { target: { value: "Claims intake" }, }); fireEvent.change( @@ -97,7 +102,8 @@ describe("SourceBuilder", () => { enabled: true, }), ); - expect(await screen.findByText("sources list")).toBeInTheDocument(); + await waitFor(() => expect(onSaved).toHaveBeenCalled()); + expect(onClose).toHaveBeenCalled(); }); it("offers a Folder Access settings link when the folder is outside allowed roots", async () => { @@ -105,9 +111,10 @@ describe("SourceBuilder", () => { new Error("outside the allowed folder roots"), ); isFolderAccessDeniedError.mockReturnValue(true); - renderBuilder("/processor/sources/new"); + renderModal(); - fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), { + fireEvent.click(screen.getByText("portal.sources.types.folder.label")); + fireEvent.change(screen.getByLabelText(/portal\.integrations\.typedName/), { target: { value: "Claims intake" }, }); fireEvent.change( @@ -129,9 +136,10 @@ describe("SourceBuilder", () => { it("shows a plain error banner (no settings link) for other save failures", async () => { createSource.mockRejectedValue(new Error("boom")); isFolderAccessDeniedError.mockReturnValue(false); - renderBuilder("/processor/sources/new"); + renderModal(); - fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), { + fireEvent.click(screen.getByText("portal.sources.types.folder.label")); + fireEvent.change(screen.getByLabelText(/portal\.integrations\.typedName/), { target: { value: "Claims intake" }, }); fireEvent.change( @@ -149,13 +157,11 @@ describe("SourceBuilder", () => { }); it("gates the s3 type on a chosen connection", async () => { - renderBuilder("/processor/sources/new"); - fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), { + renderModal(); + fireEvent.click(screen.getByText("portal.sources.types.s3.label")); + fireEvent.change(screen.getByLabelText(/portal\.integrations\.typedName/), { target: { value: "Bucket source" }, }); - // Switch to the S3 type: the connection field appears and Create stays - // disabled until a connection is chosen (connectionId is required). - fireEvent.click(screen.getByText("portal.sources.types.s3.label")); expect( await screen.findByText( "portal.sources.types.s3.fields.connection.label", @@ -171,43 +177,87 @@ describe("SourceBuilder", () => { id: "wh-1", options: { webhookId: "whk_abc123", signingSecret: "whsec_topsecret" }, }); - renderBuilder("/processor/sources/new"); + const { onClose } = renderModal(); - fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), { + fireEvent.click(screen.getByText("portal.sources.types.webhook.label")); + fireEvent.change(screen.getByLabelText(/portal\.integrations\.typedName/), { target: { value: "Partner uploads" }, }); - // Webhook's connection is optional (self-hosted local-disk), so a name is enough to create. - fireEvent.click(screen.getByText("portal.sources.types.webhook.label")); fireEvent.click(screen.getByText("portal.sources.builder.create")); await waitFor(() => expect(createSource).toHaveBeenCalledTimes(1)); - expect(createSource).toHaveBeenCalledWith( - expect.objectContaining({ type: "webhook", name: "Partner uploads" }), - ); - expect( await screen.findByDisplayValue("whsec_topsecret"), ).toBeInTheDocument(); expect( screen.getByDisplayValue(/\/api\/v1\/webhooks\/whk_abc123$/), ).toBeInTheDocument(); - expect(screen.queryByText("sources list")).not.toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); fireEvent.click( screen.getByText("portal.sources.types.webhook.reveal.done"), ); - expect(await screen.findByText("sources list")).toBeInTheDocument(); + expect(onClose).toHaveBeenCalled(); }); - it("blocks create until required fields are filled", async () => { - renderBuilder("/processor/sources/new"); - // Name given but directory (required) still blank -> Create disabled. - fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), { - target: { value: "Nameonly" }, + it("creates an S3 connection in-place without stacking a second modal", async () => { + renderModal(); + fireEvent.click(screen.getByText("portal.sources.types.s3.label")); + // "New connection..." swaps the stage instead of opening another modal. + fireEvent.click( + await screen.findByText("portal.connections.picker.createNew"), + ); + expect(document.querySelectorAll('[role="dialog"]').length).toBe(1); + + fireEvent.change(screen.getByLabelText(/portal\.integrations\.typedName/), { + target: { value: "Fresh bucket" }, }); + fireEvent.change( + screen.getByLabelText( + /portal\.connections\.types\.s3\.fields\.bucket\.label/, + ), + { target: { value: "fresh-bucket" } }, + ); + fireEvent.change( + screen.getByLabelText( + /portal\.connections\.types\.s3\.fields\.accessKeyId\.label/, + ), + { target: { value: "AKIA123" } }, + ); + fireEvent.change( + screen.getByLabelText( + /portal\.connections\.types\.s3\.fields\.secretAccessKey\.label/, + ), + { target: { value: "secret" } }, + ); + fireEvent.click(screen.getByText("portal.connections.picker.save")); + + await waitFor(() => expect(createIntegration).toHaveBeenCalledTimes(1)); + expect(createIntegration).toHaveBeenCalledWith( + expect.objectContaining({ + integrationType: "S3", + name: "Fresh bucket", + scope: "TEAM", + }), + ); + // Back on the source form with the new connection selected. expect( - screen.getByText("portal.sources.builder.create").closest("button"), - ).toBeDisabled(); + await screen.findByText( + "portal.sources.types.s3.fields.connection.label", + ), + ).toBeInTheDocument(); + }); + + it("lists coming-soon connectors as inert cards", () => { + renderModal(); + fireEvent.click(screen.getByText("portal.sources.types.sharepoint.label")); + // Still on the type stage: no configure form appeared. + expect( + screen.queryByLabelText(/portal\.integrations\.typedName/), + ).not.toBeInTheDocument(); + expect( + screen.getAllByText("portal.sources.builder.comingSoon").length, + ).toBeGreaterThan(1); }); it("edits an existing source prefilled and saves with its id", async () => { @@ -218,7 +268,7 @@ describe("SourceBuilder", () => { options: { directory: "/old", mode: "consume" }, enabled: true, }); - renderBuilder("/processor/sources/src-9"); + renderModal("src-9"); const directory = await screen.findByLabelText( /portal\.sources\.types\.folder\.fields\.directory\.label/, @@ -236,7 +286,7 @@ describe("SourceBuilder", () => { ); }); - it("deletes an existing source after confirmation", async () => { + it("deletes an existing source after the inline confirm", async () => { fetchSource.mockResolvedValue({ id: "src-9", name: "Existing", @@ -244,12 +294,12 @@ describe("SourceBuilder", () => { options: { directory: "/old" }, enabled: true, }); - renderBuilder("/processor/sources/src-9"); + const { onSaved } = renderModal("src-9"); fireEvent.click(await screen.findByText("portal.sources.builder.delete")); fireEvent.click(await screen.findByText("portal.sources.delete.confirm")); await waitFor(() => expect(deleteSource).toHaveBeenCalledWith("src-9")); - expect(await screen.findByText("sources list")).toBeInTheDocument(); + expect(onSaved).toHaveBeenCalled(); }); }); diff --git a/frontend/editor/src/portal/components/sources/SourceModal.tsx b/frontend/editor/src/portal/components/sources/SourceModal.tsx new file mode 100644 index 0000000000..626c12b33a --- /dev/null +++ b/frontend/editor/src/portal/components/sources/SourceModal.tsx @@ -0,0 +1,659 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; +import { + Banner, + Button, + Checkbox, + FormField, + Input, + Modal, + Select, + Spinner, +} from "@app/ui"; +import { errorMessage } from "@portal/api/http"; +import { + createSource, + deleteSource, + fetchSource, + isFolderAccessDeniedError, + type Source, +} from "@portal/api/sources"; +import { useUI } from "@portal/contexts/UIContext"; +import { useQueryClient } from "@tanstack/react-query"; +import { qk } from "@portal/queries/keys"; +import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes"; +import { + COMING_SOON_SOURCE_TYPES, + CREATABLE_SOURCE_TYPES, + defaultOptions, + WEBHOOK_SOURCE_TYPE, + type CreatableSourceType, +} from "@portal/components/sources/sourceTypes"; +import { BrandMark } from "@portal/components/BrandMarks"; +import { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker"; +import { ConnectionForm } from "@portal/components/sources/ConnectionForm"; +import { + CREATABLE_CONNECTION_TYPES, + buildConnectionConfig, + connectionFormValid, + emptyConnectionValues, +} from "@portal/components/sources/connectionTypes"; +import { createIntegration } from "@portal/api/integrations"; +import "@portal/components/sources/SourceModal.css"; + +function webhookUrl(webhookId: string): string { + return `${window.location.origin}/api/v1/webhooks/${webhookId}`; +} + +const OFFERED_TYPES = creatableSourceTypes(); + +/** A source's stored type resolved to its create-form metadata (edit falls back to any type). */ +function typeFor(type: string | undefined): CreatableSourceType { + return ( + CREATABLE_SOURCE_TYPES.find((t) => t.type === type) ?? + OFFERED_TYPES[0] ?? + CREATABLE_SOURCE_TYPES[0] + ); +} + +/** Stored options coerced to form strings, defaulted from the type's fields. */ +function optionsFor( + type: CreatableSourceType, + options: Record | undefined, +): Record { + const out = defaultOptions(type); + for (const [key, value] of Object.entries(options ?? {})) { + out[key] = value == null ? "" : String(value); + } + return out; +} + +type Stage = "type" | "configure" | "reveal" | "delete" | "connection"; + +/** The S3 catalogue entry, for creating a connection in-place (no stacked modal). */ +const S3_CONNECTION_TYPE = CREATABLE_CONNECTION_TYPES.find( + (entry) => entry.id === "s3", +)!; + +interface SourceModalProps { + open: boolean; + /** When set, edit this source; otherwise create a new one. */ + sourceId?: string | null; + onClose: () => void; + /** + * Fired after a save or delete. The shared sources query is invalidated here + * regardless, so hosts on the query layer need no handler. + */ + onSaved?: () => void; +} + +/** + * Create/edit a source, staged inside one modal: the connector catalogue first + * (including greyed-out coming-soon entries), then the configure form for the + * picked type; webhook creation swaps to a one-time secret reveal, and delete + * swaps to an inline confirm rather than stacking a second modal. + */ +export function SourceModal({ + open, + sourceId, + onClose, + onSaved, +}: SourceModalProps) { + const { t } = useTranslation(); + const { openSettings } = useUI(); + const queryClient = useQueryClient(); + const isEdit = Boolean(sourceId); + + // The list is a shared cache entry (Sources view + Home's ProcessorFlow), so + // a create/delete here must invalidate it before the host re-renders it. + const invalidateSources = () => + queryClient.invalidateQueries({ queryKey: qk.sources() }); + + const [stage, setStage] = useState("type"); + const [type, setType] = useState(OFFERED_TYPES[0]); + const [name, setName] = useState(""); + const [options, setOptions] = useState>(() => + defaultOptions(OFFERED_TYPES[0]), + ); + const [enabled, setEnabled] = useState(true); + const [loaded, setLoaded] = useState(null); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(null); + // A folder-outside-allowed-roots failure: the error banner offers a link to + // the Folder Access settings instead of leaving the admin at a dead end. + const [folderAccessDenied, setFolderAccessDenied] = useState(false); + const [reveal, setReveal] = useState<{ + webhookId: string; + secret: string; + } | null>(null); + // In-place connection create (swaps the stage; never stacks a second modal). + const [connValues, setConnValues] = useState>(() => + emptyConnectionValues(S3_CONNECTION_TYPE), + ); + const [connField, setConnField] = useState(""); + const [connSaving, setConnSaving] = useState(false); + + // Seed on every open: fresh catalogue for create, fetched record for edit. + useEffect(() => { + if (!open) return; + setError(null); + setReveal(null); + setSubmitting(false); + setDeleting(false); + if (!sourceId) { + setStage("type"); + setType(OFFERED_TYPES[0]); + setName(""); + setOptions(defaultOptions(OFFERED_TYPES[0])); + setEnabled(true); + setLoaded(null); + return; + } + setStage("configure"); + setLoading(true); + fetchSource(sourceId) + .then((source) => { + const resolved = typeFor(source.type); + setLoaded(source); + setType(resolved); + setName(source.name ?? ""); + setOptions(optionsFor(resolved, source.options)); + setEnabled(source.enabled ?? true); + }) + .catch((e) => setError(errorMessage(e))) + .finally(() => setLoading(false)); + }, [open, sourceId]); + + function chooseType(next: CreatableSourceType) { + setType(next); + setOptions(defaultOptions(next)); + setStage("configure"); + } + + function setOption(key: string, value: string) { + setOptions((current) => ({ ...current, [key]: value })); + } + + const requiredComplete = type.fields.every( + (field) => !field.required || (options[field.key] ?? "").trim() !== "", + ); + const canSave = name.trim() !== "" && requiredComplete && !submitting; + + const editingWebhookId = + isEdit && loaded?.type === WEBHOOK_SOURCE_TYPE + ? String(loaded.options?.webhookId ?? "") + : ""; + + function finish() { + onSaved?.(); + onClose(); + } + + function openConnectionStage(fieldKey: string) { + setConnValues(emptyConnectionValues(S3_CONNECTION_TYPE)); + setConnField(fieldKey); + setError(null); + setStage("connection"); + } + + async function saveConnection() { + if (connSaving || !connectionFormValid(S3_CONNECTION_TYPE, connValues)) + return; + setConnSaving(true); + setError(null); + try { + const created = await createIntegration({ + integrationType: S3_CONNECTION_TYPE.integrationType, + name: connValues.name.trim(), + scope: "TEAM", + config: buildConnectionConfig(S3_CONNECTION_TYPE, connValues), + }); + // Back to the source form with the fresh connection selected; the picker + // remounts and refetches, so the new name is in its list. + setOption(connField, String(created.id)); + setStage("configure"); + } catch (e) { + setError(errorMessage(e)); + } finally { + setConnSaving(false); + } + } + + async function save() { + if (!canSave) return; + setSubmitting(true); + setError(null); + setFolderAccessDenied(false); + try { + const saved = await createSource({ + id: isEdit ? (sourceId ?? undefined) : undefined, + name: name.trim(), + type: type.type, + options, + enabled, + }); + await invalidateSources(); + if (!isEdit && type.type === WEBHOOK_SOURCE_TYPE) { + const webhookId = String(saved.options?.webhookId ?? ""); + const secret = String(saved.options?.signingSecret ?? ""); + if (webhookId && secret) { + setReveal({ webhookId, secret }); + setStage("reveal"); + setSubmitting(false); + return; + } + } + finish(); + } catch (e) { + setError(errorMessage(e)); + setFolderAccessDenied(isFolderAccessDeniedError(e)); + setSubmitting(false); + } + } + + async function confirmDelete() { + if (!sourceId || deleting) return; + setDeleting(true); + setError(null); + try { + await deleteSource(sourceId); + await invalidateSources(); + finish(); + } catch (e) { + setError(errorMessage(e)); + setDeleting(false); + } + } + + function copy(text: string) { + void navigator.clipboard?.writeText(text); + } + + const title = + stage === "type" + ? t("portal.sources.builder.createTitle") + : stage === "connection" + ? t("portal.connections.createTitleFor", { + name: t(S3_CONNECTION_TYPE.labelKey), + }) + : stage === "reveal" + ? t("portal.sources.types.webhook.reveal.title") + : stage === "delete" + ? t("portal.sources.delete.title") + : isEdit + ? name || t("portal.sources.builder.editTitle") + : t("portal.sources.builder.createTitle"); + + return ( + + setEnabled(e.target.checked)} + label={t("portal.sources.builder.enabled")} + /> + + {isEdit && ( + setStage("delete")} + > + {t("portal.sources.builder.delete")} + + )} + + {t("portal.sources.builder.cancel")} + + void save()} + > + {isEdit + ? t("portal.sources.builder.save") + : t("portal.sources.builder.create")} + + + + ) : stage === "connection" ? ( + + setStage("configure")} + > + {t("portal.connections.picker.cancel")} + + void saveConnection()} + > + {t("portal.connections.picker.save")} + + + ) : stage === "reveal" ? ( + + + {t("portal.sources.types.webhook.reveal.done")} + + + ) : stage === "delete" ? ( + + setStage("configure")} + > + {t("portal.sources.delete.cancel")} + + void confirmDelete()} + > + {t("portal.sources.delete.confirm")} + + + ) : undefined + } + > + {stage === "type" && ( + + + {t("portal.sources.builder.chooseHint")} + + + {OFFERED_TYPES.map((ct) => ( + chooseType(ct)} + > + + + + + + {t(ct.labelKey)} + + + {t(ct.descriptionKey)} + + + + ))} + + + + {t("portal.sources.builder.comingSoonHeading")} + + + {COMING_SOON_SOURCE_TYPES.map((ct) => ( + + + + + + + {t(ct.labelKey)} + + {t("portal.sources.builder.comingSoon")} + + + + {t(ct.descriptionKey)} + + + + ))} + + + )} + + {stage === "configure" && ( + + {loading && ( + + + + )} + + {!loading && ( + <> + {!isEdit && ( + } + onClick={() => setStage("type")} + > + {t("portal.sources.builder.backToTypes")} + + )} + + + + + + {t(type.labelKey)} + + + {t(type.descriptionKey)} + + + + + + setName(e.target.value)} + /> + + + {!isEdit && type.type === WEBHOOK_SOURCE_TYPE && ( + + {t("portal.sources.types.webhook.createNote")} + + )} + + {type.fields.map((field) => ( + + {field.control === "s3Connection" ? ( + + setOption(field.key, connectionId) + } + onCreateNew={() => openConnectionStage(field.key)} + /> + ) : field.control === "select" ? ( + ({ + value: o.value, + label: t(o.labelKey), + }))} + onChange={(value) => setOption(field.key, value ?? "")} + /> + ) : ( + setOption(field.key, e.target.value)} + /> + )} + + ))} + + {editingWebhookId && ( + + + e.currentTarget.select()} + /> + copy(webhookUrl(editingWebhookId))} + > + {t("portal.sources.types.webhook.reveal.copy")} + + + + )} + + {error && + (folderAccessDenied ? ( + openSettings("adminFolderAccess")} + > + {t("portal.sources.builder.folderAccess.openSettings")} + + } + /> + ) : ( + + ))} + > + )} + + )} + + {stage === "connection" && ( + + + {error && } + + )} + + {stage === "reveal" && reveal && ( + + + + + e.currentTarget.select()} + /> + copy(webhookUrl(reveal.webhookId))} + > + {t("portal.sources.types.webhook.reveal.copy")} + + + + + + e.currentTarget.select()} + /> + copy(reveal.secret)} + > + {t("portal.sources.types.webhook.reveal.copy")} + + + + + {t("portal.sources.types.webhook.reveal.usage")} + + + )} + + {stage === "delete" && ( + + {t("portal.sources.delete.body", { name })} + {error && } + + )} + + ); +} diff --git a/frontend/editor/src/portal/components/sources/SourceTypeIcon.tsx b/frontend/editor/src/portal/components/sources/SourceTypeIcon.tsx index 5ccd70b860..b716f7ea59 100644 --- a/frontend/editor/src/portal/components/sources/SourceTypeIcon.tsx +++ b/frontend/editor/src/portal/components/sources/SourceTypeIcon.tsx @@ -1,35 +1,11 @@ +import { BrandMark } from "@portal/components/BrandMarks"; + /** - * Stroke-SVG icons for source types, replacing the black Unicode glyphs - * (⛁ ☁ ✏ ◇) that rendered off-style and mis-centred inside the type tiles and - * table badges. Keyed by the backend source `type`; unknown types fall back to - * a neutral document mark. + * Source-type mark for the sources table and pickers: the connector's real + * brand mark (full colour, no tinted plate), falling back to neutral + * currentColor glyphs for generic types. Keyed by the backend source `type`; + * unknown types get a neutral document/plug mark via BrandMark's fallback. */ - -const PATHS: Record = { - folder: "M3 7h6l2 2h10v9a1 1 0 01-1 1H4a1 1 0 01-1-1V7z", - s3: "M7 18a4 4 0 010-8 5 5 0 019.6-1.3A3.5 3.5 0 0117 18H7z", - webhook: "M13 2L3 14h9l-1 8 10-12h-9l1-8z", - editor: "M4 20h16M14 4l6 6-9 9H5v-6l9-9z", - _default: - "M14 3H7a1 1 0 00-1 1v16a1 1 0 001 1h10a1 1 0 001-1V7l-4-4zM14 3v4h4", -}; - export function SourceTypeIcon({ type }: { type: string }) { - const d = PATHS[type] ?? PATHS._default; - return ( - - - - ); + return ; } diff --git a/frontend/editor/src/portal/components/sources/SourcesTable.tsx b/frontend/editor/src/portal/components/sources/SourcesTable.tsx index ad0a17c81e..23017299a1 100644 --- a/frontend/editor/src/portal/components/sources/SourcesTable.tsx +++ b/frontend/editor/src/portal/components/sources/SourcesTable.tsx @@ -42,10 +42,7 @@ export function SourcesTable({ sources, onRowClick }: SourcesTableProps) { const isEditor = s.type === EDITOR_SOURCE_TYPE; return ( - + diff --git a/frontend/editor/src/portal/components/sources/sourceTypes.ts b/frontend/editor/src/portal/components/sources/sourceTypes.ts index 89d12c945b..e25490a2bf 100644 --- a/frontend/editor/src/portal/components/sources/sourceTypes.ts +++ b/frontend/editor/src/portal/components/sources/sourceTypes.ts @@ -11,7 +11,6 @@ import type { ChipAccent } from "@app/ui"; export interface SourceTypeMeta { labelKey: string; - icon: string; accent: ChipAccent; } @@ -28,29 +27,24 @@ export const WEBHOOK_SOURCE_TYPE = "webhook"; const SOURCE_TYPE_META: Record = { folder: { labelKey: "portal.sources.types.folder.label", - icon: "⛁", accent: "default", }, editor: { labelKey: "portal.sources.types.editor.label", - icon: "✏", accent: "success", }, s3: { labelKey: "portal.sources.types.s3.label", - icon: "☁", accent: "brand", }, webhook: { labelKey: "portal.sources.types.webhook.label", - icon: "↯", accent: "warning", }, }; const UNKNOWN_TYPE_META: SourceTypeMeta = { labelKey: "portal.sources.types.unknown.label", - icon: "◇", accent: "neutral", }; @@ -196,6 +190,33 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [ }, ]; +/** A source type on the roadmap: shown greyed out in the picker, not creatable. */ +export interface ComingSoonSourceType { + type: string; + labelKey: string; + descriptionKey: string; +} + +/** + * Connectors we intend to support, listed so the picker answers "do you + * support X?" honestly instead of hiding the roadmap. Purely presentational - + * nothing here can be created and the backend never sees these type strings. + */ +export const COMING_SOON_SOURCE_TYPES: ComingSoonSourceType[] = [ + "sharepoint", + "onedrive", + "googledrive", + "dropbox", + "box", + "network", + "sftp", + "email", +].map((type) => ({ + type, + labelKey: `portal.sources.types.${type}.label`, + descriptionKey: `portal.sources.types.${type}.description`, +})); + /** Default option values for a type's create form. */ export function defaultOptions( type: CreatableSourceType, diff --git a/frontend/editor/src/portal/contexts/ViewContext.tsx b/frontend/editor/src/portal/contexts/ViewContext.tsx index db3a64ee40..be333f56d3 100644 --- a/frontend/editor/src/portal/contexts/ViewContext.tsx +++ b/frontend/editor/src/portal/contexts/ViewContext.tsx @@ -7,6 +7,7 @@ export type ViewId = | "editor" | "users" | "sources" + | "integrations" | "policies" | "pipelines" | "documents" @@ -21,6 +22,7 @@ export const VIEW_LABELS: Record = { editor: "Editor", users: "Users", sources: "Sources", + integrations: "Integrations", policies: "Policies", pipelines: "Pipelines", documents: "Documents", @@ -36,6 +38,7 @@ export const VIEW_PATHS: Record = { editor: "/editor", users: "/users", sources: "/sources", + integrations: "/integrations", policies: "/policies", pipelines: "/pipelines", documents: "/documents", diff --git a/frontend/editor/src/portal/views/Integrations.css b/frontend/editor/src/portal/views/Integrations.css new file mode 100644 index 0000000000..9d90d70b13 --- /dev/null +++ b/frontend/editor/src/portal/views/Integrations.css @@ -0,0 +1,358 @@ +.portal-integrations { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.5rem; + max-width: 84rem; + margin: 0 auto; +} + +.portal-integrations__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.portal-integrations__title { + margin: 0; + font-size: 1.375rem; + font-weight: 600; + color: var(--c-text); +} + +.portal-integrations__sub { + margin: 0.25rem 0 0; + font-size: 0.875rem; + color: var(--c-text-subtle); +} + +.portal-integrations__toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.portal-integrations__filters { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; + align-items: center; + flex: 1 1 24rem; + min-width: 0; +} + +.portal-integrations__filter { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.3125rem 0.625rem; + border: none; + border-radius: 999px; + background: transparent; + font: inherit; + font-size: 0.8125rem; + font-weight: 500; + color: var(--c-text-muted); + cursor: pointer; +} + +.portal-integrations__filter:hover { + background: var(--c-hover); + color: var(--c-text); +} + +.portal-integrations__filter.is-active { + background: var(--c-primary-tint); + color: var(--c-primary); +} + +.portal-integrations__filter-count { + padding: 0 0.375rem; + border-radius: 999px; + background: var(--c-surface-sunken); + font-size: 0.6875rem; + font-weight: 600; + color: var(--c-text-muted); +} + +.portal-integrations__toolbar-side { + display: flex; + align-items: center; + gap: 0.75rem; + margin-left: auto; +} + +.portal-integrations__search { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.3125rem 0.625rem; + border: 1px solid var(--c-border); + border-radius: var(--radius-md); + background: var(--c-surface); + color: var(--c-text-muted); + font-size: 1rem; +} + +.portal-integrations__search input { + border: none; + outline: none; + background: transparent; + font: inherit; + font-size: 0.8125rem; + color: var(--c-text); + width: clamp(7rem, 14vw, 11rem); +} + +.portal-integrations__skeleton { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* One list, table-shaped: a shared 4-column grid so the connected, available + and coming-soon bands line up under the same headers. */ +.portal-integrations__table { + border: 1px solid var(--c-border); + border-radius: var(--radius-md); + background: var(--c-surface); + overflow: hidden; +} + +.portal-integrations__cols, +.portal-integrations__row { + display: grid; + grid-template-columns: minmax(16rem, 2.5fr) minmax(10rem, 1fr) 11rem; + gap: 0.75rem; + align-items: center; + padding: 0.625rem 1rem; +} + +.portal-integrations__cols { + padding-top: 0.75rem; + padding-bottom: 0.5rem; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--c-text-muted); + border-bottom: 1px solid var(--c-border-subtle); +} + +.portal-integrations__section { + padding: 0.5rem 1rem 0.375rem; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--c-text-muted); + background: var(--c-surface-sunken); + border-top: 1px solid var(--c-border-subtle); + border-bottom: 1px solid var(--c-border-subtle); +} + +.portal-integrations__group + .portal-integrations__group, +.portal-integrations__group + .portal-integrations__row, +.portal-integrations__row + .portal-integrations__row, +.portal-integrations__row + .portal-integrations__group { + border-top: 1px solid var(--c-border-subtle); +} + +.portal-integrations__row { + width: 100%; + border: none; + background: transparent; + font: inherit; + color: inherit; + text-align: left; +} + +button.portal-integrations__row { + cursor: pointer; +} + +button.portal-integrations__row:hover { + background: var(--c-hover); +} + +/* An open group keeps a light wash so its sub-rows visibly belong to it. */ +button.portal-integrations__row[aria-expanded="true"] { + background: var(--c-hover); +} + +.portal-integrations__row--soon { + opacity: 0.55; +} + +.portal-integrations__name { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +} + +.portal-integrations__name svg { + flex: none; +} + +.portal-integrations__name-text { + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; +} + +.portal-integrations__label { + font-size: 0.875rem; + font-weight: 600; + color: var(--c-text); +} + +.portal-integrations__detail { + font-size: 0.75rem; + color: var(--c-text-subtle); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.portal-integrations__chips { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; +} + +.portal-integrations__chip { + padding: 0.125rem 0.5rem; + border: 1px solid var(--c-border); + border-radius: 999px; + font-size: 0.6875rem; + font-weight: 500; + color: var(--c-text-muted); + background: var(--c-surface-sunken); + white-space: nowrap; +} + +.portal-integrations__status { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.375rem; + font-size: 0.8125rem; + font-weight: 500; + color: var(--c-success); +} + +.portal-integrations__status-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 999px; + background: var(--c-success); +} + +.portal-integrations__chevron { + color: var(--c-text-muted); + transition: transform var(--motion-fast); +} + +.portal-integrations__chevron.is-open { + transform: rotate(180deg); +} + +/* Instance sub-rows: same surface as the table (the sunken grey belongs to the + section bands alone), tied to their group by an indented tree rail under the + brand mark rather than a full-width panel. */ +.portal-integrations__instances { + border-top: 1px solid var(--c-border-subtle); + background: var(--c-surface); +} + +.portal-integrations__instance { + display: grid; + grid-template-columns: minmax(10rem, 1fr) minmax(10rem, 1.5fr) auto; + gap: 0.75rem; + align-items: center; + padding: 0.4375rem 1rem 0.4375rem 3.35rem; + position: relative; +} + +.portal-integrations__instance::before { + content: ""; + position: absolute; + left: 1.65rem; + top: 0; + bottom: 0; + width: 2px; + background: var(--c-border); +} + +.portal-integrations__instance + .portal-integrations__instance { + border-top: 1px solid var(--c-border-subtle); +} + +.portal-integrations__instance--add { + grid-template-columns: 1fr; + justify-items: start; + border-top: 1px solid var(--c-border-subtle); +} + +/* The rail stops halfway on the closing row, like the last node of a tree. */ +.portal-integrations__instance--add::before { + bottom: 50%; +} + +.portal-integrations__instance-name { + font-size: 0.8125rem; + font-weight: 600; + color: var(--c-text); +} + +.portal-integrations__instance-detail { + font-size: 0.75rem; + color: var(--c-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.portal-integrations__instance-actions { + display: flex; + gap: 0.375rem; + justify-content: flex-end; +} + +.portal-integrations__soon-badge { + padding: 0.0625rem 0.5rem; + border-radius: 999px; + border: 1px solid var(--c-border); + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--c-text-muted); + white-space: nowrap; +} + +/* Narrow screens: drop the works-with column, keep name + action. */ +@media (max-width: 56rem) { + .portal-integrations__cols, + .portal-integrations__row { + grid-template-columns: minmax(0, 1fr) auto; + } + + .portal-integrations__chips { + display: none; + } + + .portal-integrations__instance { + grid-template-columns: minmax(0, 1fr) auto; + padding-left: 1rem; + } + + .portal-integrations__instance-detail { + display: none; + } +} diff --git a/frontend/editor/src/portal/views/Integrations.test.tsx b/frontend/editor/src/portal/views/Integrations.test.tsx new file mode 100644 index 0000000000..c5e96d8666 --- /dev/null +++ b/frontend/editor/src/portal/views/Integrations.test.tsx @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, + waitFor, +} from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { HttpError } from "@portal/api/http"; +import { Integrations } from "@portal/views/Integrations"; +import type { IntegrationConfig } from "@portal/api/integrations"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: MantineProvider }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const fetchIntegrations = vi.fn(); +const deleteIntegration = vi.fn(); +const capabilities = vi.fn(); +vi.mock("@portal/api/integrations", () => ({ + fetchIntegrations: () => fetchIntegrations(), + fetchIntegrationCapabilities: () => capabilities(), + fetchS3Connections: () => Promise.resolve([]), + deleteIntegration: (id: number) => deleteIntegration(id), + createIntegration: vi.fn(), + updateIntegration: vi.fn(), +})); + +const bucket = (id: number, name: string): IntegrationConfig => + ({ + id, + integrationType: "S3", + name, + config: { bucket: `${name}-bucket`, region: "us-east-1" }, + canManage: true, + }) as unknown as IntegrationConfig; + +describe("Integrations view", () => { + beforeEach(() => { + fetchIntegrations.mockReset(); + deleteIntegration.mockReset(); + deleteIntegration.mockResolvedValue(undefined); + capabilities.mockReset(); + capabilities.mockResolvedValue({ customApi: false }); + }); + + it("lists the available catalogue with Connect actions when nothing is connected", async () => { + fetchIntegrations.mockResolvedValue([]); + render(); + expect( + await screen.findByText("portal.connections.types.s3.label"), + ).toBeInTheDocument(); + expect( + screen.getByText("portal.connections.types.slack.label"), + ).toBeInTheDocument(); + expect( + screen.getAllByText("portal.integrations.connect").length, + ).toBeGreaterThan(5); + // Roadmap source connectors are listed greyed, not connectable. + expect( + screen.getByText("portal.sources.types.sharepoint.label"), + ).toBeInTheDocument(); + }); + + it("groups connections of the same type and expands to the instances", async () => { + fetchIntegrations.mockResolvedValue([ + bucket(1, "Claims"), + bucket(2, "Archive"), + ]); + render(); + + // One connected group row for S3 with the instance count, not two rows. + const group = await screen.findByText( + "portal.integrations.connectionCount", + ); + expect(group).toBeInTheDocument(); + + fireEvent.click(screen.getByText("portal.connections.types.s3.label")); + expect(await screen.findByText("Claims")).toBeInTheDocument(); + expect(screen.getByText("Archive")).toBeInTheDocument(); + expect( + screen.getByText("portal.integrations.addAnother"), + ).toBeInTheDocument(); + // The available band remains for the other, unconnected vendors. + expect( + screen.getByText(/portal\.integrations\.availableHeading/), + ).toBeInTheDocument(); + }); + + it("deletes an instance from the expanded group", async () => { + fetchIntegrations.mockResolvedValueOnce([bucket(5, "Claims")]); + fetchIntegrations.mockResolvedValueOnce([]); + render(); + + fireEvent.click( + await screen.findByText("portal.connections.types.s3.label"), + ); + fireEvent.click(await screen.findByText("portal.connections.delete")); + + await waitFor(() => expect(deleteIntegration).toHaveBeenCalledWith(5)); + }); + + it("surfaces the 409 when deleting a connection still in use", async () => { + fetchIntegrations.mockResolvedValue([bucket(5, "Claims")]); + deleteIntegration.mockRejectedValue( + new HttpError(409, "Conflict", { + detail: "Integration is in use by: source 'Claims intake'", + }), + ); + render(); + + fireEvent.click( + await screen.findByText("portal.connections.types.s3.label"), + ); + fireEvent.click(await screen.findByText("portal.connections.delete")); + + expect( + await screen.findByText( + "Integration is in use by: source 'Claims intake'", + ), + ).toBeInTheDocument(); + }); + + it("offers Custom API as a button and an available row when the server allows it", async () => { + capabilities.mockResolvedValue({ customApi: true }); + fetchIntegrations.mockResolvedValue([]); + render(); + expect( + await screen.findByText("portal.integrations.customApi"), + ).toBeInTheDocument(); + expect( + await screen.findByText("portal.connections.types.api.label"), + ).toBeInTheDocument(); + }); + + it("keeps Custom API out of the catalogue when the server withholds it", async () => { + fetchIntegrations.mockResolvedValue([]); + render(); + await screen.findByText("portal.connections.types.s3.label"); + expect( + screen.queryByText("portal.connections.types.api.label"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/views/Integrations.tsx b/frontend/editor/src/portal/views/Integrations.tsx new file mode 100644 index 0000000000..dad19e750c --- /dev/null +++ b/frontend/editor/src/portal/views/Integrations.tsx @@ -0,0 +1,520 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import SearchRoundedIcon from "@mui/icons-material/SearchRounded"; +import ExpandMoreRoundedIcon from "@mui/icons-material/ExpandMoreRounded"; +import { Banner, Button, Skeleton } from "@app/ui"; +import { errorMessage } from "@portal/api/http"; +import { + deleteIntegration, + fetchIntegrationCapabilities, + fetchIntegrations, + type IntegrationCapabilities, + type IntegrationConfig, +} from "@portal/api/integrations"; +import { BrandMark } from "@portal/components/BrandMarks"; +import { ConnectionModal } from "@portal/components/sources/ConnectionModal"; +import { + CONNECTION_CATEGORIES, + CREATABLE_CONNECTION_TYPES, + connectionTypeOf, + presetConnectionTypes, + type ConnectionCategory, + type CreatableConnectionType, +} from "@portal/components/sources/connectionTypes"; +import { STEP_OPERATIONS } from "@portal/components/policies/stepOperations"; +import { COMING_SOON_SOURCE_TYPES } from "@portal/components/sources/sourceTypes"; +import "@portal/views/Integrations.css"; + +/** + * The integrations catalogue: everything Stirling can talk to, in one place. + * + * Three bands in one list. Connected first — stored connections grouped by + * vendor, expandable when a vendor has several (two S3 buckets is normal, not + * an error), each instance editable and one click from "add another". Then + * Available — the supported vendors, each saying what it works with (sources, + * policies, pipelines) so it's obvious whether a vendor feeds documents in or + * receives them. Coming-soon source connectors close the list greyed out, so + * "do you support X?" is answered honestly instead of hidden. + * + * Setup itself stays in the shared {@link ConnectionModal}; every entry point + * here pins the vendor, so the modal opens straight on the right form. + */ + +type Filter = "all" | "connected" | ConnectionCategory; +type WorksWith = "sources" | "policies" | "pipelines"; + +/** What a vendor plugs into, derived from the catalogues rather than declared. */ +function worksWith(type: CreatableConnectionType): WorksWith[] { + if (type.id === "s3") return ["sources", "pipelines"]; + if (type.kind === "custom") return ["policies", "pipelines"]; + const hasStep = STEP_OPERATIONS.some((op) => op.connectionTypeId === type.id); + // Catalogue operations surface in both the policy step and pipeline pickers. + return hasStep ? ["policies", "pipelines"] : ["policies"]; +} + +/** The one non-secret line that identifies a connection (bucket, tenant, URL). */ +function connectionDetail(connection: IntegrationConfig): string { + const config = connection.config ?? {}; + switch (connection.integrationType) { + case "S3": + return String(config.bucket ?? ""); + case "PURVIEW": + return String(config.tenantId ?? ""); + default: + return String(config.baseUrl ?? ""); + } +} + +interface TypeGroup { + type: CreatableConnectionType; + connections: IntegrationConfig[]; +} + +export function Integrations() { + const { t } = useTranslation(); + const [connections, setConnections] = useState( + null, + ); + const [capabilities, setCapabilities] = useState< + IntegrationCapabilities | undefined + >(undefined); + const [filter, setFilter] = useState("all"); + const [query, setQuery] = useState(""); + const [expanded, setExpanded] = useState>(new Set()); + const [modal, setModal] = useState<{ + open: boolean; + editing: IntegrationConfig | null; + fixedTypeId?: string; + }>({ open: false, editing: null }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + setConnections(await fetchIntegrations()); + } catch (e) { + setError(errorMessage(e)); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + fetchIntegrationCapabilities().then(setCapabilities, () => undefined); + }, []); + + // The supported vendors, plus the free-form Custom API entry when the server + // allows this caller to author one (same gate as the header button). + const catalogue = useMemo(() => { + const presets = presetConnectionTypes(); + if (!capabilities?.customApi) return presets; + const custom = CREATABLE_CONNECTION_TYPES.find((t) => t.kind === "custom"); + return custom ? [...presets, custom] : presets; + }, [capabilities]); + + // Stored connections grouped under their vendor. Unknown types (an MCP row, + // or a vendor this build no longer ships) group under the custom entry via + // connectionTypeOf's fallback so nothing stored ever disappears from view. + const groups = useMemo(() => { + const byType = new Map(); + for (const connection of connections ?? []) { + const type = connectionTypeOf( + connection.integrationType, + connection.config, + ); + if (!type) continue; + const group = byType.get(type.id) ?? { type, connections: [] }; + group.connections.push(connection); + byType.set(type.id, group); + } + return byType; + }, [connections]); + + const q = query.trim().toLowerCase(); + const matches = useCallback( + (label: string, extra: string[] = []) => + q === "" || + [label, ...extra].join(" ").toLowerCase().includes(q) || + q + .split(/\s+/) + .every((word) => + [label, ...extra].join(" ").toLowerCase().includes(word), + ), + [q], + ); + + const connectedGroups = useMemo( + () => + [...groups.values()].filter( + (group) => + (filter === "all" || + filter === "connected" || + group.type.category === filter) && + matches(t(group.type.labelKey), [ + group.type.id, + ...group.connections.map((c) => c.name), + ]), + ), + [groups, filter, matches, t], + ); + + const availableTypes = useMemo( + () => + catalogue.filter( + (type) => + !groups.has(type.id) && + filter !== "connected" && + (filter === "all" || type.category === filter) && + matches(t(type.labelKey), [type.id, ...(type.searchTerms ?? [])]), + ), + [catalogue, groups, filter, matches, t], + ); + + const comingSoon = useMemo( + () => + (filter === "all" || filter === "storage" + ? COMING_SOON_SOURCE_TYPES + : [] + ).filter((entry) => matches(t(entry.labelKey), [entry.type])), + [filter, matches, t], + ); + + // Filter chips: only categories that actually contain something. + const categoryCounts = useMemo(() => { + const counts = new Map(); + for (const type of catalogue) { + counts.set(type.category, (counts.get(type.category) ?? 0) + 1); + } + return counts; + }, [catalogue]); + + function toggleExpand(typeId: string) { + setExpanded((current) => { + const next = new Set(current); + if (next.has(typeId)) next.delete(typeId); + else next.add(typeId); + return next; + }); + } + + function openCreate(typeId: string) { + setModal({ open: true, editing: null, fixedTypeId: typeId }); + } + + function openEdit(connection: IntegrationConfig) { + setModal({ open: true, editing: connection }); + } + + async function remove(connection: IntegrationConfig) { + if (busy) return; + setBusy(true); + setError(null); + try { + await deleteIntegration(connection.id); + await refresh(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setBusy(false); + } + } + + const isLoading = connections === null; + + const chip = (kind: WorksWith) => ( + + {t(`portal.integrations.worksWith.${kind}`)} + + ); + + return ( + + + + + {t("portal.integrations.title")} + + + {t("portal.integrations.subtitle")} + + + {capabilities?.customApi && ( + openCreate("api")} + leftSection={} + > + {t("portal.integrations.customApi")} + + )} + + + + + setFilter("all")} + /> + setFilter("connected")} + /> + {CONNECTION_CATEGORIES.filter( + (category) => + category !== "advanced" && + (categoryCounts.get(category) ?? 0) > 0, + ).map((category) => ( + setFilter(category)} + /> + ))} + + + + + setQuery(e.target.value)} + placeholder={t("portal.integrations.searchPlaceholder")} + aria-label={t("portal.integrations.searchPlaceholder")} + /> + + + + + {error && } + + {isLoading ? ( + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + ) : ( + + + {t("portal.integrations.table.integration")} + {t("portal.integrations.table.worksWith")} + + + + {connectedGroups.length > 0 && ( + + {t("portal.integrations.connectedHeading")} ·{" "} + {connectedGroups.length} + + )} + {connectedGroups.map(({ type, connections: list }) => { + const open = expanded.has(type.id); + return ( + + toggleExpand(type.id)} + > + + + + + {t(type.labelKey)} + + + {list.length === 1 + ? list[0].name + : t("portal.integrations.connectionCount", { + count: list.length, + })} + + + + + {worksWith(type).map(chip)} + + + + {t("portal.integrations.status.connected")} + + + + {open && ( + + {list.map((connection) => ( + + + {connection.name} + + + {connectionDetail(connection)} + + {connection.canManage && ( + + openEdit(connection)} + > + {t("portal.connections.edit")} + + void remove(connection)} + > + {t("portal.connections.delete")} + + + )} + + ))} + + openCreate(type.id)} + leftSection={ + + } + > + {t("portal.integrations.addAnother")} + + + + )} + + ); + })} + + {availableTypes.length > 0 && ( + + {t("portal.integrations.availableHeading")} ·{" "} + {availableTypes.length} + + )} + {availableTypes.map((type) => ( + + + + + + {t(type.labelKey)} + + + {t(type.descriptionKey)} + + + + + {worksWith(type).map(chip)} + + + openCreate(type.id)} + > + {t("portal.integrations.connect")} + + + + ))} + + {comingSoon.length > 0 && ( + + {t("portal.integrations.comingSoonHeading")} · {comingSoon.length} + + )} + {comingSoon.map((entry) => ( + + + + + + {t(entry.labelKey)} + + + {t(entry.descriptionKey)} + + + + + {chip("sources")} + + + + {t("portal.sources.builder.comingSoon")} + + + + ))} + + )} + + setModal({ open: false, editing: null })} + onSaved={() => void refresh()} + /> + + ); +} + +function FilterChip({ + active, + label, + count, + onClick, +}: { + active: boolean; + label: string; + count: number; + onClick: () => void; +}) { + return ( + + {label} + {count} + + ); +} diff --git a/frontend/editor/src/portal/views/PipelineBuilder.css b/frontend/editor/src/portal/views/PipelineBuilder.css index b56b199676..f96228d042 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.css +++ b/frontend/editor/src/portal/views/PipelineBuilder.css @@ -328,6 +328,10 @@ font-size: 1.125rem; } +.portal-pipelines__picker-icon svg { + display: block; +} + .portal-pipelines__picker-name { font-size: 0.8125rem; } diff --git a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx index 2f7300422f..fe88ad7917 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx @@ -211,10 +211,9 @@ describe("PipelineBuilder", () => { fireEvent.click( await screen.findByText("portal.connections.picker.createNew"), ); - fireEvent.change( - screen.getByLabelText(/portal\.connections\.fields\.name/), - { target: { value: "Claims bucket" } }, - ); + fireEvent.change(screen.getByLabelText(/portal\.integrations\.typedName/), { + target: { value: "Claims bucket" }, + }); fireEvent.change( screen.getByLabelText( /portal\.connections\.types\.s3\.fields\.bucket\.label/, diff --git a/frontend/editor/src/portal/views/SourceBuilder.css b/frontend/editor/src/portal/views/SourceBuilder.css deleted file mode 100644 index ec6b9fe4a4..0000000000 --- a/frontend/editor/src/portal/views/SourceBuilder.css +++ /dev/null @@ -1,116 +0,0 @@ -.portal-source-builder { - display: flex; - flex-direction: column; - gap: 1.25rem; - padding: 1.5rem; - max-width: 84rem; - margin: 0 auto; -} - -.portal-source-builder__loading { - display: flex; - justify-content: center; - padding: 4rem 0; -} - -.portal-source-builder__head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; - flex-wrap: wrap; -} - -.portal-source-builder__head-main { - display: flex; - flex-direction: column; - gap: 0.5rem; - align-items: flex-start; -} - -.portal-source-builder__title { - font-size: 1.375rem; - font-weight: 600; - color: var(--c-text); - margin: 0; -} - -.portal-source-builder__head-actions { - display: flex; - align-items: center; - gap: 0.625rem; -} - -.portal-source-builder__body { - display: flex; - flex-direction: column; - gap: 1rem; - max-width: 32rem; -} - -.portal-source-builder__type-grid { - display: flex; - gap: 0.625rem; - flex-wrap: wrap; -} - -.portal-source-builder__type-card { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.375rem; - min-width: 6rem; - padding: 0.875rem 1rem; - border: 1px solid var(--color-border-2); - border-radius: 0.5rem; -} - -.portal-source-builder__type-card.is-selected { - border-color: var(--color-accent, var(--color-brand)); - background: var(--c-hover); -} - -.portal-source-builder__type-icon { - font-size: 1.5rem; - line-height: 1; -} - -.portal-source-builder__type-name { - font-size: 0.8125rem; - font-weight: 500; -} - -.portal-source-builder__delete-actions { - display: flex; - justify-content: flex-end; - gap: 0.5rem; -} - -.portal-source-builder__copy-row { - display: flex; - gap: 0.5rem; - align-items: center; -} - -.portal-source-builder__copy-row > :first-child { - flex: 1 1 auto; - min-width: 0; -} - -.portal-source-builder__reveal { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.portal-source-builder__muted { - margin: 0; - font-size: 0.8125rem; - color: var(--color-text-4); -} - -.portal-source-builder__type-description { - margin: 0; - font-size: 0.875rem; - color: var(--color-text-3); -} diff --git a/frontend/editor/src/portal/views/SourceBuilder.tsx b/frontend/editor/src/portal/views/SourceBuilder.tsx deleted file mode 100644 index c38eb9f48d..0000000000 --- a/frontend/editor/src/portal/views/SourceBuilder.tsx +++ /dev/null @@ -1,501 +0,0 @@ -import { useEffect, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; -import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; -import { - Banner, - Button, - Checkbox, - FormField, - Input, - Modal, - Select, - Spinner, -} from "@app/ui"; -import { errorMessage } from "@portal/api/http"; -import { - createSource, - deleteSource, - fetchSource, - isFolderAccessDeniedError, - type Source, -} from "@portal/api/sources"; -import { useUI } from "@portal/contexts/UIContext"; -import { useAsync } from "@portal/hooks/useAsync"; -import { useQueryClient } from "@tanstack/react-query"; -import { qk } from "@portal/queries/keys"; -import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; -import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes"; -import { - CREATABLE_SOURCE_TYPES, - defaultOptions, - sourceTypeMeta, - WEBHOOK_SOURCE_TYPE, - type CreatableSourceType, -} from "@portal/components/sources/sourceTypes"; -import { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker"; -import "@portal/views/SourceBuilder.css"; - -function webhookUrl(webhookId: string): string { - return `${window.location.origin}/api/v1/webhooks/${webhookId}`; -} - -const OFFERED_TYPES = creatableSourceTypes(); - -/** A source's stored type resolved to its create-form metadata (edit falls back to any type). */ -function typeFor(type: string | undefined): CreatableSourceType { - return ( - CREATABLE_SOURCE_TYPES.find((t) => t.type === type) ?? - OFFERED_TYPES[0] ?? - CREATABLE_SOURCE_TYPES[0] - ); -} - -/** Stored options coerced to form strings, defaulted from the type's fields. */ -function optionsFor( - type: CreatableSourceType, - options: Record | undefined, -): Record { - const out = defaultOptions(type); - for (const [key, value] of Object.entries(options ?? {})) { - out[key] = value == null ? "" : String(value); - } - return out; -} - -/** - * Full-page create/edit for a source, mirroring the pipeline builder: new lands - * on /sources/new (with a type picker), a row opens /sources/:id prefilled. - * Save and delete navigate back to the Sources list. The virtual editor source - * is never routed here (the list row is not a link). - */ -export function SourceBuilder() { - const { t } = useTranslation(); - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const { openSettings } = useUI(); - const { id } = useParams(); - const isEdit = Boolean(id); - const listPath = toPortalPath(VIEW_PATHS.sources); - - // The list is a shared cache entry (Sources view + Home's ProcessorFlow), so - // a create/delete here must invalidate it before we navigate back to it. - const invalidateSources = () => - queryClient.invalidateQueries({ queryKey: qk.sources() }); - - const sourceState = useAsync( - async () => (id ? await fetchSource(id) : null), - [id], - ); - - const [type, setType] = useState(OFFERED_TYPES[0]); - const [name, setName] = useState(""); - const [options, setOptions] = useState>(() => - defaultOptions(OFFERED_TYPES[0]), - ); - const [enabled, setEnabled] = useState(true); - const [seeded, setSeeded] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - // A folder-outside-allowed-roots failure: the error banner offers a link to - // the Folder Access settings instead of leaving the admin at a dead end. - const [folderAccessDenied, setFolderAccessDenied] = useState(false); - const [pendingDelete, setPendingDelete] = useState(false); - const [deleting, setDeleting] = useState(false); - const [reveal, setReveal] = useState<{ - webhookId: string; - secret: string; - } | null>(null); - - // Seed once: immediately for a new source, or after the record loads for edit. - useEffect(() => { - if (seeded) return; - if (isEdit && !sourceState.data) return; - const source = sourceState.data ?? undefined; - const resolved = typeFor(source?.type); - setType(resolved); - setName(source?.name ?? ""); - setOptions(optionsFor(resolved, source?.options)); - setEnabled(source?.enabled ?? true); - setSeeded(true); - }, [isEdit, sourceState.data, seeded]); - - function chooseType(next: CreatableSourceType) { - setType(next); - setOptions(defaultOptions(next)); - } - - function setOption(key: string, value: string) { - setOptions((current) => ({ ...current, [key]: value })); - } - - const requiredComplete = type.fields.every( - (field) => !field.required || (options[field.key] ?? "").trim() !== "", - ); - const canSave = name.trim() !== "" && requiredComplete && !submitting; - - const editingWebhookId = - isEdit && sourceState.data?.type === WEBHOOK_SOURCE_TYPE - ? String(sourceState.data.options?.webhookId ?? "") - : ""; - const revealUrl = reveal ? webhookUrl(reveal.webhookId) : ""; - const revealSecret = reveal ? reveal.secret : ""; - - function dismissReveal() { - setReveal(null); - navigate(listPath); - } - - async function save() { - if (!canSave) return; - setSubmitting(true); - setError(null); - setFolderAccessDenied(false); - try { - const saved = await createSource({ - id: isEdit ? id : undefined, - name: name.trim(), - type: type.type, - options, - enabled, - }); - await invalidateSources(); - if (!isEdit && type.type === WEBHOOK_SOURCE_TYPE) { - const webhookId = String(saved.options?.webhookId ?? ""); - const secret = String(saved.options?.signingSecret ?? ""); - if (webhookId && secret) { - setReveal({ webhookId, secret }); - return; - } - } - navigate(listPath); - } catch (e) { - setError(errorMessage(e)); - setFolderAccessDenied(isFolderAccessDeniedError(e)); - setSubmitting(false); - } - } - - function copy(text: string) { - void navigator.clipboard?.writeText(text); - } - - async function confirmDelete() { - if (!id || deleting) return; - setDeleting(true); - try { - await deleteSource(id); - await invalidateSources(); - navigate(listPath); - } catch (e) { - setError(errorMessage(e)); - setDeleting(false); - setPendingDelete(false); - } - } - - if (isEdit && sourceState.error) { - return ( - - - navigate(listPath)}> - {t("portal.sources.builder.back")} - - - ); - } - - if (isEdit && !seeded) { - return ( - - - - ); - } - - return ( - - - - navigate(listPath)} - leftSection={ - - } - > - {t("portal.sources.builder.back")} - - - {isEdit - ? name || t("portal.sources.builder.editTitle") - : t("portal.sources.builder.createTitle")} - - - - setEnabled(e.target.checked)} - label={t("portal.sources.builder.enabled")} - /> - {isEdit && ( - setPendingDelete(true)} - leftSection={ - - } - > - {t("portal.sources.builder.delete")} - - )} - navigate(listPath)} - > - {t("portal.sources.builder.cancel")} - - void save()}> - {isEdit - ? t("portal.sources.builder.save") - : t("portal.sources.builder.create")} - - - - - - - setName(e.target.value)} - /> - - - {!isEdit && OFFERED_TYPES.length > 1 && ( - - - {OFFERED_TYPES.map((ct) => ( - chooseType(ct)} - > - - {sourceTypeMeta(ct.type).icon} - - - {t(ct.labelKey)} - - - ))} - - - )} - - {!isEdit && ( - - {t(type.descriptionKey)} - - )} - - {!isEdit && type.type === WEBHOOK_SOURCE_TYPE && ( - - {t("portal.sources.types.webhook.createNote")} - - )} - - {type.fields.map((field) => ( - - {field.control === "s3Connection" ? ( - setOption(field.key, connectionId)} - /> - ) : field.control === "select" ? ( - ({ - value: o.value, - label: t(o.labelKey), - }))} - onChange={(value) => setOption(field.key, value ?? "")} - /> - ) : ( - setOption(field.key, e.target.value)} - /> - )} - - ))} - - {editingWebhookId && ( - - - e.currentTarget.select()} - /> - copy(webhookUrl(editingWebhookId))} - > - {t("portal.sources.types.webhook.reveal.copy")} - - - - )} - - {error && - (folderAccessDenied ? ( - openSettings("adminFolderAccess")} - > - {t( - "portal.sources.builder.folderAccess.openSettings", - "Folder Access settings", - )} - - } - /> - ) : ( - - ))} - - - !deleting && setPendingDelete(false)} - width="sm" - title={t("portal.sources.delete.title")} - footer={ - - setPendingDelete(false)} - > - {t("portal.sources.delete.cancel")} - - void confirmDelete()} - > - {t("portal.sources.delete.confirm")} - - - } - > - {t("portal.sources.delete.body", { name })} - - - - - {t("portal.sources.types.webhook.reveal.done")} - - - } - > - {reveal && ( - - - - - e.currentTarget.select()} - /> - copy(revealUrl)} - > - {t("portal.sources.types.webhook.reveal.copy")} - - - - - - e.currentTarget.select()} - /> - copy(revealSecret)} - > - {t("portal.sources.types.webhook.reveal.copy")} - - - - - {t("portal.sources.types.webhook.reveal.usage")} - - - )} - - - ); -} diff --git a/frontend/editor/src/portal/views/Sources.css b/frontend/editor/src/portal/views/Sources.css index 0fd4646f17..5ca4064e65 100644 --- a/frontend/editor/src/portal/views/Sources.css +++ b/frontend/editor/src/portal/views/Sources.css @@ -61,6 +61,8 @@ color: var(--c-text); } +/* The connector's real mark, no tinted plate: the box only reserves space so + rows align; neutral (currentColor) marks read in the muted text tone. */ .portal-sources__type-dot { display: inline-flex; align-items: center; @@ -68,41 +70,8 @@ width: 1.875rem; height: 1.875rem; flex-shrink: 0; - border-radius: var(--radius-md); - font-size: 0.875rem; - color: var(--dot-c, var(--c-text-subtle)); - background: color-mix( - in srgb, - var(--dot-c, var(--c-text-subtle)) 14%, - transparent - ); -} - -/* Neutral keeps a plain muted surface rather than an accent tint. */ -.portal-sources__type-dot--neutral { - background: var(--c-surface-sunken); color: var(--c-text-subtle); } -.portal-sources__type-dot--default { - --dot-c: var(--c-primary-hover); -} -.portal-sources__type-dot--premium { - --dot-c: var(--color-purple-dark); -} -.portal-sources__type-dot--success { - --dot-c: var(--color-green-dark); -} -.portal-sources__type-dot--warning { - --dot-c: var(--color-amber-dark); -} -/* Dark: subtle tint + bright icon; amber-light/amber-dark are too close to read. */ -[data-theme="dark"] .portal-sources__type-dot--warning { - background: color-mix(in srgb, var(--color-amber) 20%, transparent); - color: var(--color-amber); -} -.portal-sources__type-dot--danger { - --dot-c: var(--color-red-dark); -} .portal-sources__muted { font-size: 0.8125rem; @@ -453,10 +422,10 @@ height: 1.6rem; } -/* Sized to sit centred inside the 1.875rem table badge / picker dot. */ +/* Sized to sit centred inside the 1.875rem table cell / picker dot. */ .portal-sources__type-svg { - width: 1.05rem; - height: 1.05rem; + width: 1.25rem; + height: 1.25rem; display: block; } @@ -750,44 +719,19 @@ border-style: dashed; } +/* The vendor's real mark: full colour, no tinted plate behind it. Neutral + (currentColor) marks pick up the muted text tone instead. */ .portal-conn-picker__mark { flex: none; display: grid; place-items: center; - width: 1.75rem; - height: 1.75rem; - border-radius: var(--radius-sm, 0.375rem); - background: color-mix(in srgb, var(--c-conn-accent) 14%, transparent); - color: var(--c-conn-accent); - font-size: 0.875rem; - font-weight: 600; + width: 1.5rem; + height: 1.5rem; + color: var(--c-text-subtle); } -/* One hue per category. Muted on purpose - these sit 60-odd to a page, and saturated marks would - turn the grid into confetti. */ -.portal-conn-picker__mark--storage { - background: color-mix(in srgb, var(--c-conn-storage) 16%, transparent); - color: var(--c-conn-storage); -} - -.portal-conn-picker__mark--signing { - background: color-mix(in srgb, var(--c-conn-signing) 18%, transparent); - color: var(--c-conn-signing); -} - -.portal-conn-picker__mark--security { - background: color-mix(in srgb, var(--c-conn-accent) 16%, transparent); - color: var(--c-conn-accent); -} - -.portal-conn-picker__mark--notify { - background: color-mix(in srgb, var(--c-conn-notify) 18%, transparent); - color: var(--c-conn-notify); -} - -.portal-conn-picker__mark--advanced { - background: color-mix(in srgb, var(--c-conn-neutral) 16%, transparent); - color: var(--c-text-muted); +.portal-conn-picker__mark svg { + display: block; } .portal-conn-picker__card-text { diff --git a/frontend/editor/src/portal/views/Sources.test.tsx b/frontend/editor/src/portal/views/Sources.test.tsx index f4a52b5665..d4d4b0137f 100644 --- a/frontend/editor/src/portal/views/Sources.test.tsx +++ b/frontend/editor/src/portal/views/Sources.test.tsx @@ -6,11 +6,21 @@ import { } from "@testing-library/react"; import { PortalTestProviders } from "@portal/test/TestQueryProvider"; import { MemoryRouter, Route, Routes } from "react-router-dom"; +import type { ReactNode } from "react"; import type { SourcesResponse } from "@portal/api/sources"; import { Sources } from "@portal/views/Sources"; +import { UIProvider } from "@portal/contexts/UIContext"; + +// The view uses the shared query hooks and the embedded SourceModal reads +// useUI(), so wrap the query client + Mantine + the UI context. +const Providers = ({ children }: { children: ReactNode }) => ( + + {children} + +); const render = (ui: Parameters[0]) => - baseRender(ui, { wrapper: PortalTestProviders }); + baseRender(ui, { wrapper: Providers }); // Deterministic i18n: keys returned verbatim. vi.mock("react-i18next", () => ({ @@ -21,8 +31,13 @@ vi.mock("react-i18next", () => ({ })); const fetchSources = vi.fn(); +const fetchSource = vi.fn(); vi.mock("@portal/api/sources", () => ({ fetchSources: () => fetchSources(), + fetchSource: (id: string) => fetchSource(id), + createSource: vi.fn(), + deleteSource: vi.fn(), + isFolderAccessDeniedError: () => false, })); const fetchS3Connections = vi.fn(); @@ -74,12 +89,8 @@ function renderView(initial = "/processor/sources") { } /> source builder: new} - /> - source builder: edit} + path="/processor/integrations" + element={integrations view} /> , @@ -90,42 +101,61 @@ describe("Sources view", () => { beforeEach(() => { fetchSources.mockReset(); fetchSources.mockResolvedValue(RESPONSE); + fetchSource.mockReset(); + fetchSource.mockResolvedValue({ + id: "src-1", + name: "Claims intake", + type: "folder", + options: { directory: "/in" }, + enabled: true, + }); fetchS3Connections.mockReset(); fetchS3Connections.mockResolvedValue([]); }); - it("opens a source's own page on row click", async () => { + it("opens the edit modal on row click", async () => { renderView(); fireEvent.click(await screen.findByText("Claims intake")); - expect(await screen.findByText("source builder: edit")).toBeInTheDocument(); + // The modal fetches the record and shows the configure form. + expect( + await screen.findByText("portal.sources.builder.save"), + ).toBeInTheDocument(); + expect(fetchSource).toHaveBeenCalledWith("src-1"); }); - it("navigates to the create page from the connect button", async () => { + it("opens the create modal from the connect button", async () => { renderView(); await screen.findByText("Claims intake"); - fireEvent.click(screen.getByText("portal.sources.actions.connectSource")); - expect(await screen.findByText("source builder: new")).toBeInTheDocument(); + fireEvent.click( + screen.getAllByText("portal.sources.actions.connectSource")[0], + ); + // Stage 1 of the modal: the connector catalogue with coming-soon entries. + expect( + await screen.findByText("portal.sources.types.sharepoint.label"), + ).toBeInTheDocument(); }); - it("does not navigate when the virtual editor row is clicked", async () => { + it("opens the create modal on arrival with ?new=1", async () => { + renderView("/processor/sources?new=1"); + expect( + await screen.findByText("portal.sources.types.sharepoint.label"), + ).toBeInTheDocument(); + }); + + it("does not open the modal when the virtual editor row is clicked", async () => { renderView(); fireEvent.click( await screen.findByText("portal.sources.types.editor.label"), ); - // Still on the list: the builder stub never rendered. - expect(screen.queryByText("source builder: edit")).not.toBeInTheDocument(); - expect(screen.getByText("Claims intake")).toBeInTheDocument(); + expect(fetchSource).not.toHaveBeenCalled(); + expect( + screen.queryByText("portal.sources.builder.save"), + ).not.toBeInTheDocument(); }); - it("shows the connections surface on the Connections tab", async () => { - renderView(); - await screen.findByText("Claims intake"); - fireEvent.click(screen.getByText("portal.sources.tabs.connections")); - // Empty connections list -> the connections empty state. - expect( - await screen.findByText("portal.connections.empty.title"), - ).toBeInTheDocument(); - expect(fetchS3Connections).toHaveBeenCalled(); + it("redirects the old connections tab to the integrations view", async () => { + renderView("/processor/sources?tab=connections"); + expect(await screen.findByText("integrations view")).toBeInTheDocument(); }); it("hides the KPI strip and shows the empty state when only the editor exists", async () => { @@ -137,8 +167,6 @@ describe("Sources view", () => { expect( await screen.findByText("portal.sources.empty.title"), ).toBeInTheDocument(); - expect( - screen.queryByText("portal.sources.kpi.total"), - ).not.toBeInTheDocument(); + expect(screen.queryByText("portal.sources.kpi.total")).toBeNull(); }); }); diff --git a/frontend/editor/src/portal/views/Sources.tsx b/frontend/editor/src/portal/views/Sources.tsx index 6013e00d79..dd449e71c8 100644 --- a/frontend/editor/src/portal/views/Sources.tsx +++ b/frontend/editor/src/portal/views/Sources.tsx @@ -1,7 +1,8 @@ +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { Navigate, useSearchParams } from "react-router-dom"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; -import { Button, EmptyState, Skeleton, Tabs } from "@app/ui"; +import { Button, EmptyState, Skeleton } from "@app/ui"; import { useSectionFlags } from "@portal/hooks/useAsync"; import { useSources } from "@portal/queries/sources"; import { SourcesIcon } from "@portal/components/icons"; @@ -9,37 +10,46 @@ import { type SourceView } from "@portal/api/sources"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { KpiStrip } from "@portal/components/sources/KpiStrip"; import { SourcesTable } from "@portal/components/sources/SourcesTable"; -import { ConnectionsTab } from "@portal/components/sources/ConnectionsTab"; +import { SourceModal } from "@portal/components/sources/SourceModal"; import "@portal/views/Sources.css"; -type SourcesTab = "sources" | "connections"; - export function Sources() { const { t } = useTranslation(); - const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); - const activeTab: SourcesTab = - searchParams.get("tab") === "connections" ? "connections" : "sources"; const state = useSources(); const { data, loading } = state; const { isLoading } = useSectionFlags(state); + // Create/edit live in a modal on this list; `?new=1` (old /sources/new deep + // links redirect here with it) opens the create flow on arrival. + const [modal, setModal] = useState<{ + open: boolean; + sourceId: string | null; + }>({ open: false, sourceId: null }); + + useEffect(() => { + if (searchParams.get("new") !== "1") return; + setModal({ open: true, sourceId: null }); + const next = new URLSearchParams(searchParams); + next.delete("new"); + setSearchParams(next, { replace: true }); + }, [searchParams, setSearchParams]); + const sources = data?.sources ?? []; + // The editor is a virtual row that's always present, so "empty" means no // configured sources beyond it. Gates the KPI strip and empty panel. const configuredCount = sources.filter((s) => s.type !== "editor").length; const showEmpty = !isLoading && configuredCount === 0; - const openCreate = () => navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`); + const openCreate = () => setModal({ open: true, sourceId: null }); const openSource = (source: SourceView) => - navigate(`${toPortalPath(VIEW_PATHS.sources)}/${source.id}`); + setModal({ open: true, sourceId: source.id }); - function selectTab(tab: SourcesTab) { - const next = new URLSearchParams(searchParams); - if (tab === "sources") next.delete("tab"); - else next.set("tab", tab); - setSearchParams(next, { replace: true }); + // The Connections tab moved to its own Integrations view. + if (searchParams.get("tab") === "connections") { + return ; } return ( @@ -49,66 +59,51 @@ export function Sources() { {t("portal.sources.title")} {t("portal.sources.subtitle")} - {activeTab === "sources" && ( - + + } + > + {t("portal.sources.actions.connectSource")} + + + + + {!showEmpty && } + + {isLoading && ( + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + )} + + {showEmpty && ( + } + title={t("portal.sources.empty.title")} + description={t("portal.sources.empty.description")} + actions={ } > {t("portal.sources.actions.connectSource")} - - )} - - - - variant="underline" - ariaLabel={t("portal.sources.title")} - activeKey={activeTab} - onChange={selectTab} - items={[ - { key: "sources", label: t("portal.sources.tabs.sources") }, - { key: "connections", label: t("portal.sources.tabs.connections") }, - ]} - /> - - {activeTab === "connections" ? ( - - ) : ( - <> - {!showEmpty && } - - {isLoading && ( - - {Array.from({ length: 5 }).map((_, i) => ( - - ))} - - )} - - {showEmpty && ( - } - title={t("portal.sources.empty.title")} - description={t("portal.sources.empty.description")} - actions={ - - } - > - {t("portal.sources.actions.connectSource")} - - } - /> - )} - - {!isLoading && sources.length > 0 && ( - - )} - > + } + /> )} + + {!isLoading && sources.length > 0 && ( + + )} + + setModal({ open: false, sourceId: null })} + /> ); } diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 76daf03315..d2853d8f1e 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -269,11 +269,15 @@ export default defineConfig( }, }, // TEMPORARY (same rationale as procurement above): the connection/operation catalogues render - // bespoke preset tiles (tinted monogram + two-line text). Raw- rule only; migrate later. + // bespoke preset tiles (brand mark + two-line text), and the integrations + // page adds the same tiles as full-width expandable rows plus filter chips. + // Raw- rule only; migrate later. { files: [ "editor/src/portal/components/sources/ConnectionTypePicker.tsx", + "editor/src/portal/components/sources/SourceModal.tsx", "editor/src/portal/components/policies/PolicyExternalApiConfig.tsx", + "editor/src/portal/views/Integrations.tsx", ], rules: { "no-restricted-syntax": ["error", ...mantineComponentImportRestrictions],
- {t("portal.connections.subtitle")} -
+ {t("portal.sources.builder.chooseHint")} +
+ {t("portal.sources.types.webhook.createNote")} +
+ {t("portal.sources.types.webhook.reveal.usage")} +
{t("portal.sources.delete.body", { name })}
+ {t("portal.integrations.subtitle")} +
- {t(type.descriptionKey)} -
- {t("portal.sources.types.webhook.createNote")} -
- {t("portal.sources.types.webhook.reveal.usage")} -
{t("portal.sources.subtitle")}