mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Source and connections changes to integrations (#7068)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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={<PipelineBuilder />}
|
||||
/>
|
||||
<Route path={rel(VIEW_PATHS.sources)} element={<Sources />} />
|
||||
{/* Source create/edit is a modal on the list now; old deep links land there. */}
|
||||
<Route
|
||||
path={`${rel(VIEW_PATHS.sources)}/new`}
|
||||
element={<SourceBuilder />}
|
||||
element={
|
||||
<Navigate to={`${toPortalPath(VIEW_PATHS.sources)}?new=1`} replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={`${rel(VIEW_PATHS.sources)}/:id`}
|
||||
element={<SourceBuilder />}
|
||||
element={<Navigate to={toPortalPath(VIEW_PATHS.sources)} replace />}
|
||||
/>
|
||||
<Route path={rel(VIEW_PATHS.integrations)} element={<Integrations />} />
|
||||
<Route path={rel(VIEW_PATHS.policies)} element={<Policies />} />
|
||||
<Route path={rel(VIEW_PATHS.documents)} element={<Documents />} />
|
||||
<Route path={rel(VIEW_PATHS.editor)} element={<EditorAdmin />} />
|
||||
|
||||
@@ -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 (
|
||||
<svg
|
||||
viewBox={viewBox}
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
style={style}
|
||||
aria-hidden
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Stroke({
|
||||
size = 20,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
}: MarkProps & { children: ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
style={style}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Brand-coloured marks, keyed by connection-type/source-type id. */
|
||||
const BRAND: Record<string, (p: MarkProps) => ReactNode> = {
|
||||
s3: (p) => (
|
||||
<Fill {...p}>
|
||||
<path
|
||||
d="M5.2 4.5c0-1.2 3-2.1 6.8-2.1s6.8.9 6.8 2.1l-1.5 14.6c-.1 1.5-2.4 2.7-5.3 2.7s-5.2-1.2-5.3-2.7z"
|
||||
fill="#E25444"
|
||||
/>
|
||||
<ellipse cx="12" cy="4.5" rx="6.8" ry="2.1" fill="#F2B0AA" />
|
||||
<ellipse cx="12" cy="4.7" rx="4.6" ry="1.2" fill="#B8352A" />
|
||||
</Fill>
|
||||
),
|
||||
sharepoint: (p) => (
|
||||
<Fill {...p}>
|
||||
<circle cx="10" cy="8.6" r="5.6" fill="#036C70" />
|
||||
<circle cx="15.4" cy="13.4" r="4.9" fill="#1A9BA1" />
|
||||
<circle cx="9.8" cy="17.2" r="4" fill="#37C6D0" />
|
||||
</Fill>
|
||||
),
|
||||
purview: (p) => (
|
||||
<Fill {...p}>
|
||||
<path d="M12 2.6 21.4 12 12 21.4 2.6 12z" fill="#1490DF" />
|
||||
<path d="M12 2.6 21.4 12H12z" fill="#28A8EA" />
|
||||
<path d="M12 12v9.4L2.6 12z" fill="#0F6CBD" />
|
||||
</Fill>
|
||||
),
|
||||
box: (p) => (
|
||||
<Fill {...p}>
|
||||
<rect x="2" y="2" width="20" height="20" rx="4.5" fill="#0061D5" />
|
||||
<path
|
||||
d="M8 6.5v11"
|
||||
stroke="#fff"
|
||||
strokeWidth="2.1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle
|
||||
cx="12.6"
|
||||
cy="13.3"
|
||||
r="4.1"
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeWidth="2.1"
|
||||
/>
|
||||
</Fill>
|
||||
),
|
||||
slack: (p) => (
|
||||
<Fill {...p}>
|
||||
<rect x="9.6" y="2.2" width="3.4" height="8" rx="1.7" fill="#36C5F0" />
|
||||
<circle cx="5.8" cy="8.5" r="1.7" fill="#36C5F0" />
|
||||
<rect x="13.8" y="9.6" width="8" height="3.4" rx="1.7" fill="#2EB67D" />
|
||||
<circle cx="15.5" cy="5.8" r="1.7" fill="#2EB67D" />
|
||||
<rect x="11" y="13.8" width="3.4" height="8" rx="1.7" fill="#ECB22E" />
|
||||
<circle cx="18.2" cy="15.5" r="1.7" fill="#ECB22E" />
|
||||
<rect x="2.2" y="11" width="8" height="3.4" rx="1.7" fill="#E01E5A" />
|
||||
<circle cx="8.5" cy="18.2" r="1.7" fill="#E01E5A" />
|
||||
</Fill>
|
||||
),
|
||||
teams: (p) => (
|
||||
<Fill {...p}>
|
||||
<rect x="2.5" y="2.5" width="19" height="19" rx="4.5" fill="#6264A7" />
|
||||
<path
|
||||
d="M7.2 9h7v2.2h-2.3v6.6h-2.4v-6.6H7.2z"
|
||||
fill="#fff"
|
||||
transform="translate(1.3 -0.4)"
|
||||
/>
|
||||
</Fill>
|
||||
),
|
||||
discord: (p) => (
|
||||
<Fill {...p}>
|
||||
<path
|
||||
d="M5.6 5.6C7.6 4.6 9.8 4.1 12 4.1s4.4.5 6.4 1.5c1.5 3 2 6.1 1.6 9.4-1.5 1.6-3.4 2.7-5.3 3.2l-1.1-2.2h-3.2l-1.1 2.2c-1.9-.5-3.8-1.6-5.3-3.2-.4-3.3.1-6.4 1.6-9.4z"
|
||||
fill="#5865F2"
|
||||
/>
|
||||
<ellipse cx="9.2" cy="11.6" rx="1.35" ry="1.5" fill="#fff" />
|
||||
<ellipse cx="14.8" cy="11.6" rx="1.35" ry="1.5" fill="#fff" />
|
||||
</Fill>
|
||||
),
|
||||
googlechat: (p) => (
|
||||
<Fill {...p}>
|
||||
<path
|
||||
d="M4.5 2.5h13a2.5 2.5 0 0 1 2.5 2.5v16.5l-4.4-4H4.5A2.5 2.5 0 0 1 2 15V5a2.5 2.5 0 0 1 2.5-2.5z"
|
||||
fill="#00AC47"
|
||||
/>
|
||||
<path
|
||||
d="M7 8.4h10M7 12.1h6.5"
|
||||
stroke="#fff"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Fill>
|
||||
),
|
||||
zapier: (p) => (
|
||||
<Fill {...p}>
|
||||
<g fill="#FF4F00">
|
||||
<rect x="10.6" y="2.5" width="2.8" height="19" rx="1.4" />
|
||||
<rect
|
||||
x="10.6"
|
||||
y="2.5"
|
||||
width="2.8"
|
||||
height="19"
|
||||
rx="1.4"
|
||||
transform="rotate(90 12 12)"
|
||||
/>
|
||||
<rect
|
||||
x="10.6"
|
||||
y="2.5"
|
||||
width="2.8"
|
||||
height="19"
|
||||
rx="1.4"
|
||||
transform="rotate(45 12 12)"
|
||||
/>
|
||||
<rect
|
||||
x="10.6"
|
||||
y="2.5"
|
||||
width="2.8"
|
||||
height="19"
|
||||
rx="1.4"
|
||||
transform="rotate(135 12 12)"
|
||||
/>
|
||||
</g>
|
||||
<circle cx="12" cy="12" r="2.6" fill="#FF4F00" />
|
||||
</Fill>
|
||||
),
|
||||
jira: (p) => (
|
||||
<Fill {...p}>
|
||||
<path
|
||||
d="M12 2.4 17 7.4a1.6 1.6 0 0 1 0 2.3L12 14.6 7 9.7a1.6 1.6 0 0 1 0-2.3z"
|
||||
fill="#2684FF"
|
||||
/>
|
||||
<path
|
||||
d="M12 9.4 17 14.3a1.6 1.6 0 0 1 0 2.3L12 21.6 7 16.6a1.6 1.6 0 0 1 0-2.3z"
|
||||
fill="#0052CC"
|
||||
/>
|
||||
</Fill>
|
||||
),
|
||||
confluence: (p) => (
|
||||
<Fill {...p}>
|
||||
<path
|
||||
d="M3 16.2c2.7-4.3 5.5-5.9 8.7-4.8 2.1.7 3.8 2.1 7.2 2.7l-1.6 4.4c-3.9-.7-5.9-2.2-7.6-2.8-1.7-.5-3.1.1-4.8 2.6z"
|
||||
fill="#2684FF"
|
||||
/>
|
||||
<path
|
||||
d="M21 7.8c-2.7 4.3-5.5 5.9-8.7 4.8-2.1-.7-3.8-2.1-7.2-2.7l1.6-4.4c3.9.7 5.9 2.2 7.6 2.8 1.7.5 3.1-.1 4.8-2.6z"
|
||||
fill="#0052CC"
|
||||
/>
|
||||
</Fill>
|
||||
),
|
||||
nextcloud: (p) => (
|
||||
<Fill {...p}>
|
||||
<g fill="none" stroke="#0082C9" strokeWidth="2">
|
||||
<circle cx="4.9" cy="12" r="2.1" />
|
||||
<circle cx="12" cy="12" r="3.4" />
|
||||
<circle cx="19.1" cy="12" r="2.1" />
|
||||
</g>
|
||||
</Fill>
|
||||
),
|
||||
splunk: (p) => (
|
||||
<Fill {...p}>
|
||||
<path d="M6 4.8 18.4 12 6 19.2v-3.4l7-3.8-7-3.8z" fill="#65A637" />
|
||||
</Fill>
|
||||
),
|
||||
elastic: (p) => (
|
||||
<Fill {...p}>
|
||||
<path
|
||||
d="M4.6 9.3C6.8 5.6 10.9 3.7 15 4.5c2.9.6 5 2.4 5.8 4.8H4.6z"
|
||||
fill="#FEC514"
|
||||
/>
|
||||
<rect
|
||||
x="3.4"
|
||||
y="10.4"
|
||||
width="17.2"
|
||||
height="3.2"
|
||||
rx="1.6"
|
||||
fill="#00BFB3"
|
||||
/>
|
||||
<path
|
||||
d="M4.6 14.7h16.2c-.8 2.4-2.9 4.2-5.8 4.8-4.1.8-8.2-1.1-10.4-4.8z"
|
||||
fill="#1BA9F5"
|
||||
/>
|
||||
</Fill>
|
||||
),
|
||||
sumologic: (p) => (
|
||||
<Fill {...p}>
|
||||
<rect x="2.5" y="2.5" width="19" height="19" rx="4.5" fill="#000099" />
|
||||
<path
|
||||
d="M6.5 9.5c1.8-1.4 3.7-1.4 5.5 0s3.7 1.4 5.5 0M6.5 14.5c1.8-1.4 3.7-1.4 5.5 0s3.7 1.4 5.5 0"
|
||||
stroke="#fff"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
</Fill>
|
||||
),
|
||||
sendgrid: (p) => (
|
||||
<Fill {...p}>
|
||||
<rect x="3" y="3" width="6" height="6" fill="#51A9E3" />
|
||||
<rect x="9" y="3" width="6" height="6" fill="#1A82E2" />
|
||||
<rect x="15" y="3" width="6" height="6" fill="#51A9E3" />
|
||||
<rect x="3" y="9" width="6" height="6" fill="#1A82E2" />
|
||||
<rect x="9" y="9" width="6" height="6" fill="#51A9E3" />
|
||||
<rect x="15" y="9" width="6" height="6" fill="#1A82E2" />
|
||||
<rect x="3" y="15" width="6" height="6" fill="#51A9E3" />
|
||||
<rect x="9" y="15" width="6" height="6" fill="#1A82E2" />
|
||||
</Fill>
|
||||
),
|
||||
mailgun: (p) => (
|
||||
<Fill {...p}>
|
||||
<circle cx="12" cy="12" r="9.5" fill="#ED413E" />
|
||||
<circle cx="12" cy="12" r="5.2" fill="#fff" />
|
||||
<circle cx="12" cy="12" r="2.2" fill="#ED413E" />
|
||||
<circle cx="17.8" cy="12" r="1.3" fill="#ED413E" />
|
||||
</Fill>
|
||||
),
|
||||
cloudmersive: (p) => (
|
||||
<Fill {...p}>
|
||||
<rect x="2.5" y="2.5" width="19" height="19" rx="4.5" fill="#D64541" />
|
||||
<path
|
||||
d="M15.8 8.6a4.9 4.9 0 1 0 0 6.8"
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Fill>
|
||||
),
|
||||
cloudmersiveadvanced: (p) => (
|
||||
<Fill {...p}>
|
||||
<rect x="2.5" y="2.5" width="19" height="19" rx="4.5" fill="#B03A37" />
|
||||
<path
|
||||
d="M15.8 8.6a4.9 4.9 0 1 0 0 6.8"
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="16.9" cy="12" r="1.4" fill="#fff" />
|
||||
</Fill>
|
||||
),
|
||||
presidio: (p) => (
|
||||
<Fill {...p}>
|
||||
<rect x="2.5" y="2.5" width="19" height="19" rx="4.5" fill="#6B4EE6" />
|
||||
<path
|
||||
d="M12 6.2l4.6 2v3.2c0 3-1.9 5-4.6 6.1-2.7-1.1-4.6-3.1-4.6-6.1V8.2z"
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeWidth="1.7"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="11.2" r="1.1" fill="#fff" />
|
||||
</Fill>
|
||||
),
|
||||
clamav: (p) => (
|
||||
<Fill {...p}>
|
||||
<path
|
||||
d="M12 2.8l7.2 3v5.1c0 4.5-3 7.6-7.2 9.3-4.2-1.7-7.2-4.8-7.2-9.3V5.8z"
|
||||
fill="#3E8E9E"
|
||||
/>
|
||||
<path
|
||||
d="m8.9 11.7 2.2 2.2 4-4"
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Fill>
|
||||
),
|
||||
consigno: (p) => (
|
||||
<Fill {...p}>
|
||||
<path
|
||||
d="M16.1 3.3a2.35 2.35 0 0 1 3.3 3.3L8.2 17.8 3.6 19l1.2-4.6z"
|
||||
fill="#1B2A4A"
|
||||
/>
|
||||
<path
|
||||
d="M12.5 20.4c2.4-1.3 4.2-1.3 6.6 0"
|
||||
fill="none"
|
||||
stroke="#C33E37"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Fill>
|
||||
),
|
||||
};
|
||||
|
||||
/** Neutral currentColor strokes for generic / self-hosted / roadmap entries. */
|
||||
const NEUTRAL: Record<string, ReactNode> = {
|
||||
folder: (
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
),
|
||||
webhook: <polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />,
|
||||
editor: (
|
||||
<>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z" />
|
||||
</>
|
||||
),
|
||||
api: (
|
||||
<>
|
||||
<path d="M8.5 4C6.5 4 6 5 6 6.5v2.3C6 10.4 5.4 11.2 4 11.5v1c1.4.3 2 1.1 2 2.7v2.3C6 19 6.5 20 8.5 20" />
|
||||
<path d="M15.5 4c2 0 2.5 1 2.5 2.5v2.3c0 1.6.6 2.4 2 2.7v1c-1.4.3-2 1.1-2 2.7v2.3c0 1.5-.5 2.5-2.5 2.5" />
|
||||
</>
|
||||
),
|
||||
network: (
|
||||
<>
|
||||
<rect x="2.5" y="4.5" width="9" height="7" rx="1" />
|
||||
<rect x="12.5" y="12.5" width="9" height="7" rx="1" />
|
||||
<path d="M7 11.5V16h5.5" />
|
||||
</>
|
||||
),
|
||||
sftp: (
|
||||
<>
|
||||
<rect x="4" y="13" width="16" height="7" rx="1.5" />
|
||||
<path d="M7.5 16.5h.01" />
|
||||
<path d="M11 16.5h.01" />
|
||||
<path d="M12 10V3.5" />
|
||||
<path d="m8.5 6.5 3.5-3 3.5 3" />
|
||||
</>
|
||||
),
|
||||
email: (
|
||||
<>
|
||||
<path d="M21 12.5V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-5.5" />
|
||||
<path d="M3 12.5h5l2 3h4l2-3h5" />
|
||||
<path d="M12 3v6.5" />
|
||||
<path d="m9 7 3 3 3-3" />
|
||||
</>
|
||||
),
|
||||
_default: (
|
||||
<>
|
||||
<path d="M9 7V3.5" />
|
||||
<path d="M15 7V3.5" />
|
||||
<path d="M6.5 7h11v3.5a5.5 5.5 0 0 1-11 0z" />
|
||||
<path d="M12 16v4.5" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
/** 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 (
|
||||
<GoogleDriveIcon
|
||||
colored
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (id === "onedrive") {
|
||||
return (
|
||||
<OneDriveIcon
|
||||
colored
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (id === "dropbox") {
|
||||
return (
|
||||
<DropboxIcon
|
||||
colored
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const brand = BRAND[id];
|
||||
if (brand) return <>{brand({ size, className, style })}</>;
|
||||
return (
|
||||
<Stroke size={size} className={className} style={style}>
|
||||
{NEUTRAL[id] ?? NEUTRAL._default}
|
||||
</Stroke>
|
||||
);
|
||||
}
|
||||
@@ -289,3 +289,14 @@ export function ExternalLinkIcon(props: IconProps) {
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IntegrationsIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M9 7V2.5" />
|
||||
<path d="M15 7V2.5" />
|
||||
<path d="M6.5 7h11v3.5a5.5 5.5 0 0 1-11 0z" />
|
||||
<path d="M12 16v2a3 3 0 0 1-3 3H7" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
<BrandMark
|
||||
id={op.custom ? "api" : op.connectionTypeId}
|
||||
size={17}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<span
|
||||
className={`portal-conn-picker__mark portal-conn-picker__mark--${op.category}`}
|
||||
aria-hidden
|
||||
>
|
||||
{label.slice(0, 1)}
|
||||
<span className="portal-conn-picker__mark" aria-hidden>
|
||||
<BrandMark
|
||||
id={op.custom ? "api" : op.connectionTypeId}
|
||||
size={20}
|
||||
/>
|
||||
</span>
|
||||
<span className="portal-conn-picker__card-text">
|
||||
<span className="portal-conn-picker__card-name">{label}</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
HomeIcon,
|
||||
UsersIcon,
|
||||
SourcesIcon,
|
||||
IntegrationsIcon,
|
||||
PoliciesIcon,
|
||||
PipelinesIcon,
|
||||
DocumentsIcon,
|
||||
@@ -37,9 +38,10 @@ export const GROUP_PROCESSOR: NavEntry[] = [
|
||||
{ id: "documents", icon: <DocumentsIcon /> },
|
||||
];
|
||||
|
||||
// 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: <UsersIcon /> },
|
||||
{ id: "integrations", icon: <IntegrationsIcon /> },
|
||||
{ id: "infrastructure", icon: <InfrastructureIcon /> },
|
||||
{ id: "usage", icon: <UsageIcon /> },
|
||||
{ id: "docs", icon: <DocsIcon /> },
|
||||
|
||||
@@ -31,7 +31,14 @@ export function ConnectionForm({
|
||||
|
||||
return (
|
||||
<div className="portal-sources__connection-form">
|
||||
<FormField label={t("portal.connections.fields.name")} required>
|
||||
{/* "S3 storage name", "Jira name": the vendor is in the label, so the
|
||||
field explains itself and the placeholder stays a neutral example. */}
|
||||
<FormField
|
||||
label={t("portal.integrations.typedName", {
|
||||
tool: t(type.labelKey),
|
||||
})}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
value={values.name ?? ""}
|
||||
placeholder={t("portal.connections.fields.namePlaceholder")}
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("ConnectionModal", () => {
|
||||
),
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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<IntegrationConfig[] | null>(
|
||||
@@ -85,19 +93,25 @@ export function ConnectionPicker({
|
||||
}))}
|
||||
onChange={(selected) => onChange(selected ?? "")}
|
||||
/>
|
||||
<Button variant="tertiary" size="sm" onClick={() => setModalOpen(true)}>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => (onCreateNew ? onCreateNew() : setModalOpen(true))}
|
||||
>
|
||||
{t("portal.connections.picker.createNew")}
|
||||
</Button>
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
<ConnectionModal
|
||||
open={modalOpen}
|
||||
fixedTypeId={createTypeId}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={(created) => {
|
||||
setConnections((list) => [...(list ?? []), created]);
|
||||
onChange(String(created.id));
|
||||
}}
|
||||
/>
|
||||
{!onCreateNew && (
|
||||
<ConnectionModal
|
||||
open={modalOpen}
|
||||
fixedTypeId={createTypeId}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={(created) => {
|
||||
setConnections((list) => [...(list ?? []), created]);
|
||||
onChange(String(created.id));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
<span
|
||||
className={`portal-conn-picker__mark portal-conn-picker__mark--${type.category}`}
|
||||
aria-hidden
|
||||
>
|
||||
{label.slice(0, 1)}
|
||||
{/* The vendor's real mark, full colour on the card surface. */}
|
||||
<span className="portal-conn-picker__mark" aria-hidden>
|
||||
<BrandMark id={type.id} size={20} />
|
||||
</span>
|
||||
<span className="portal-conn-picker__card-text">
|
||||
<span className="portal-conn-picker__card-name">{label}</span>
|
||||
|
||||
@@ -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<typeof baseRender>[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(<ConnectionsTab />);
|
||||
expect(
|
||||
await screen.findByText("portal.connections.empty.title"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists connections and deletes one", async () => {
|
||||
fetchS3Connections.mockResolvedValueOnce([CONNECTION]);
|
||||
fetchS3Connections.mockResolvedValueOnce([]);
|
||||
render(<ConnectionsTab />);
|
||||
|
||||
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(<ConnectionsTab />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<IntegrationConfig[] | null>(
|
||||
null,
|
||||
);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<IntegrationConfig | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<TableColumn<IntegrationConfig>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: t("portal.connections.table.name"),
|
||||
render: (c) => <strong>{c.name}</strong>,
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<span className="portal-sources__connections-bucket">
|
||||
{connectionDetail(c)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
render: (c) =>
|
||||
c.canManage ? (
|
||||
<span className="portal-sources__connections-actions">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => openEdit(c)}
|
||||
>
|
||||
{t("portal.connections.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
accent="danger"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(c)}
|
||||
>
|
||||
{t("portal.connections.delete")}
|
||||
</Button>
|
||||
</span>
|
||||
) : 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 (
|
||||
<section className="portal-sources__connections">
|
||||
{/* 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 && (
|
||||
<div className="portal-sources__connections-head">
|
||||
<p className="portal-sources__connections-sub">
|
||||
{t("portal.connections.subtitle")}
|
||||
</p>
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
|
||||
>
|
||||
{t("portal.connections.actions.new")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-sources__table-skeleton" aria-hidden>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} height="3rem" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
icon={<SourcesIcon size={28} />}
|
||||
title={t("portal.connections.empty.title")}
|
||||
description={t("portal.connections.empty.description")}
|
||||
actions={
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
|
||||
>
|
||||
{t("portal.connections.actions.new")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{connections !== null && connections.length > 0 && (
|
||||
<Table<IntegrationConfig>
|
||||
className="portal-sources__connections-table"
|
||||
columns={columns}
|
||||
rows={connections}
|
||||
rowKey={(c) => String(c.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConnectionModal
|
||||
open={modalOpen}
|
||||
connection={editing}
|
||||
capabilities={capabilities}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={() => void refresh()}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ?? "");
|
||||
}
|
||||
}
|
||||
@@ -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/,
|
||||
|
||||
@@ -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 (
|
||||
<ConnectionPicker
|
||||
@@ -19,6 +22,7 @@ export function S3ConnectionPicker({
|
||||
onChange={onChange}
|
||||
integrationType="S3"
|
||||
createTypeId="s3"
|
||||
onCreateNew={onCreateNew}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+99
-49
@@ -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 }) => (
|
||||
<PortalTestProviders>
|
||||
@@ -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(
|
||||
<MemoryRouter initialEntries={[initial]}>
|
||||
<Routes>
|
||||
<Route path="/processor/sources" element={<div>sources list</div>} />
|
||||
<Route path="/processor/sources/new" element={<SourceBuilder />} />
|
||||
<Route path="/processor/sources/:id" element={<SourceBuilder />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
function renderModal(sourceId: string | null = null) {
|
||||
const onClose = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<SourceModal
|
||||
open
|
||||
sourceId={sourceId}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | undefined,
|
||||
): Record<string, string> {
|
||||
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<Stage>("type");
|
||||
const [type, setType] = useState<CreatableSourceType>(OFFERED_TYPES[0]);
|
||||
const [name, setName] = useState("");
|
||||
const [options, setOptions] = useState<Record<string, string>>(() =>
|
||||
defaultOptions(OFFERED_TYPES[0]),
|
||||
);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [loaded, setLoaded] = useState<Source | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<Record<string, string>>(() =>
|
||||
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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={stage === "reveal" ? finish : onClose}
|
||||
width={stage === "type" ? "lg" : stage === "delete" ? "sm" : "md"}
|
||||
title={title}
|
||||
footer={
|
||||
stage === "configure" ? (
|
||||
<div className="portal-source-modal__footer">
|
||||
<Checkbox
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
label={t("portal.sources.builder.enabled")}
|
||||
/>
|
||||
<span className="portal-source-modal__footer-actions">
|
||||
{isEdit && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
accent="danger"
|
||||
disabled={submitting}
|
||||
onClick={() => setStage("delete")}
|
||||
>
|
||||
{t("portal.sources.builder.delete")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={submitting}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t("portal.sources.builder.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
loading={submitting}
|
||||
disabled={!canSave}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{isEdit
|
||||
? t("portal.sources.builder.save")
|
||||
: t("portal.sources.builder.create")}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
) : stage === "connection" ? (
|
||||
<div className="portal-source-modal__footer-actions">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={connSaving}
|
||||
onClick={() => setStage("configure")}
|
||||
>
|
||||
{t("portal.connections.picker.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
loading={connSaving}
|
||||
disabled={!connectionFormValid(S3_CONNECTION_TYPE, connValues)}
|
||||
onClick={() => void saveConnection()}
|
||||
>
|
||||
{t("portal.connections.picker.save")}
|
||||
</Button>
|
||||
</div>
|
||||
) : stage === "reveal" ? (
|
||||
<div className="portal-source-modal__footer-actions">
|
||||
<Button size="sm" onClick={finish}>
|
||||
{t("portal.sources.types.webhook.reveal.done")}
|
||||
</Button>
|
||||
</div>
|
||||
) : stage === "delete" ? (
|
||||
<div className="portal-source-modal__footer-actions">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
onClick={() => setStage("configure")}
|
||||
>
|
||||
{t("portal.sources.delete.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
accent="danger"
|
||||
loading={deleting}
|
||||
onClick={() => void confirmDelete()}
|
||||
>
|
||||
{t("portal.sources.delete.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{stage === "type" && (
|
||||
<div className="portal-source-modal__catalog">
|
||||
<p className="portal-source-modal__hint">
|
||||
{t("portal.sources.builder.chooseHint")}
|
||||
</p>
|
||||
<div
|
||||
className="portal-source-modal__grid"
|
||||
role="listbox"
|
||||
aria-label={t("portal.sources.wizard.type")}
|
||||
>
|
||||
{OFFERED_TYPES.map((ct) => (
|
||||
<button
|
||||
key={ct.type}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
className="portal-source-modal__card"
|
||||
onClick={() => chooseType(ct)}
|
||||
>
|
||||
<span className="portal-source-modal__card-icon" aria-hidden>
|
||||
<BrandMark id={ct.type} size={22} />
|
||||
</span>
|
||||
<span className="portal-source-modal__card-text">
|
||||
<span className="portal-source-modal__card-name">
|
||||
{t(ct.labelKey)}
|
||||
</span>
|
||||
<span className="portal-source-modal__card-desc">
|
||||
{t(ct.descriptionKey)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 className="portal-source-modal__soon-title">
|
||||
{t("portal.sources.builder.comingSoonHeading")}
|
||||
</h3>
|
||||
<div className="portal-source-modal__grid">
|
||||
{COMING_SOON_SOURCE_TYPES.map((ct) => (
|
||||
<div
|
||||
key={ct.type}
|
||||
className="portal-source-modal__card portal-source-modal__card--soon"
|
||||
aria-disabled
|
||||
>
|
||||
<span className="portal-source-modal__card-icon" aria-hidden>
|
||||
<BrandMark id={ct.type} size={22} />
|
||||
</span>
|
||||
<span className="portal-source-modal__card-text">
|
||||
<span className="portal-source-modal__card-name">
|
||||
{t(ct.labelKey)}
|
||||
<span className="portal-source-modal__soon-badge">
|
||||
{t("portal.sources.builder.comingSoon")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="portal-source-modal__card-desc">
|
||||
{t(ct.descriptionKey)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stage === "configure" && (
|
||||
<div className="portal-source-modal__form">
|
||||
{loading && (
|
||||
<div className="portal-source-modal__loading">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{!isEdit && (
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
className="portal-source-modal__back"
|
||||
leftSection={<ArrowBackRoundedIcon fontSize="inherit" />}
|
||||
onClick={() => setStage("type")}
|
||||
>
|
||||
{t("portal.sources.builder.backToTypes")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="portal-source-modal__type-summary">
|
||||
<BrandMark id={type.type} size={22} />
|
||||
<span className="portal-source-modal__card-text">
|
||||
<span className="portal-source-modal__card-name">
|
||||
{t(type.labelKey)}
|
||||
</span>
|
||||
<span className="portal-source-modal__card-desc">
|
||||
{t(type.descriptionKey)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label={t("portal.integrations.typedName", {
|
||||
tool: t(type.labelKey),
|
||||
})}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder={t("portal.sources.wizard.namePlaceholder")}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{!isEdit && type.type === WEBHOOK_SOURCE_TYPE && (
|
||||
<p className="portal-source-modal__muted">
|
||||
{t("portal.sources.types.webhook.createNote")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{type.fields.map((field) => (
|
||||
<FormField
|
||||
key={field.key}
|
||||
label={t(field.labelKey)}
|
||||
helperText={
|
||||
field.helperTextKey ? t(field.helperTextKey) : undefined
|
||||
}
|
||||
required={field.required}
|
||||
>
|
||||
{field.control === "s3Connection" ? (
|
||||
<S3ConnectionPicker
|
||||
value={options[field.key] ?? ""}
|
||||
onChange={(connectionId) =>
|
||||
setOption(field.key, connectionId)
|
||||
}
|
||||
onCreateNew={() => openConnectionStage(field.key)}
|
||||
/>
|
||||
) : field.control === "select" ? (
|
||||
<Select
|
||||
value={options[field.key] ?? ""}
|
||||
options={(field.options ?? []).map((o) => ({
|
||||
value: o.value,
|
||||
label: t(o.labelKey),
|
||||
}))}
|
||||
onChange={(value) => setOption(field.key, value ?? "")}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={
|
||||
field.control === "password" ? "password" : undefined
|
||||
}
|
||||
value={options[field.key] ?? ""}
|
||||
placeholder={
|
||||
field.placeholderKey
|
||||
? t(field.placeholderKey)
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) => setOption(field.key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
))}
|
||||
|
||||
{editingWebhookId && (
|
||||
<FormField
|
||||
label={t("portal.sources.types.webhook.detail.deliveryUrl")}
|
||||
helperText={t(
|
||||
"portal.sources.types.webhook.detail.secretNote",
|
||||
)}
|
||||
>
|
||||
<div className="portal-source-modal__copy-row">
|
||||
<Input
|
||||
value={webhookUrl(editingWebhookId)}
|
||||
readOnly
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => copy(webhookUrl(editingWebhookId))}
|
||||
>
|
||||
{t("portal.sources.types.webhook.reveal.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{error &&
|
||||
(folderAccessDenied ? (
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t("portal.sources.builder.folderAccess.title")}
|
||||
description={t(
|
||||
"portal.sources.builder.folderAccess.description",
|
||||
)}
|
||||
action={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => openSettings("adminFolderAccess")}
|
||||
>
|
||||
{t("portal.sources.builder.folderAccess.openSettings")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Banner tone="danger" description={error} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stage === "connection" && (
|
||||
<div className="portal-source-modal__form">
|
||||
<ConnectionForm
|
||||
type={S3_CONNECTION_TYPE}
|
||||
values={connValues}
|
||||
onChange={setConnValues}
|
||||
/>
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stage === "reveal" && reveal && (
|
||||
<div className="portal-source-modal__form">
|
||||
<Banner
|
||||
tone="warning"
|
||||
description={t("portal.sources.types.webhook.reveal.secretWarning")}
|
||||
/>
|
||||
<FormField label={t("portal.sources.types.webhook.reveal.url")}>
|
||||
<div className="portal-source-modal__copy-row">
|
||||
<Input
|
||||
value={webhookUrl(reveal.webhookId)}
|
||||
readOnly
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => copy(webhookUrl(reveal.webhookId))}
|
||||
>
|
||||
{t("portal.sources.types.webhook.reveal.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.webhook.reveal.secret")}
|
||||
helperText={t("portal.sources.types.webhook.reveal.secretHelp")}
|
||||
>
|
||||
<div className="portal-source-modal__copy-row">
|
||||
<Input
|
||||
value={reveal.secret}
|
||||
readOnly
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => copy(reveal.secret)}
|
||||
>
|
||||
{t("portal.sources.types.webhook.reveal.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
<p className="portal-source-modal__muted">
|
||||
{t("portal.sources.types.webhook.reveal.usage")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stage === "delete" && (
|
||||
<div className="portal-source-modal__form">
|
||||
<p>{t("portal.sources.delete.body", { name })}</p>
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<svg
|
||||
className="portal-sources__type-svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d={d}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return <BrandMark id={type} className="portal-sources__type-svg" />;
|
||||
}
|
||||
|
||||
@@ -42,10 +42,7 @@ export function SourcesTable({ sources, onRowClick }: SourcesTableProps) {
|
||||
const isEditor = s.type === EDITOR_SOURCE_TYPE;
|
||||
return (
|
||||
<div className="portal-sources__name-cell">
|
||||
<span
|
||||
className={`portal-sources__type-dot portal-sources__type-dot--${meta.accent}`}
|
||||
aria-hidden
|
||||
>
|
||||
<span className="portal-sources__type-dot" aria-hidden>
|
||||
<SourceTypeIcon type={s.type} />
|
||||
</span>
|
||||
<div className="portal-sources__name-text">
|
||||
|
||||
@@ -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<string, SourceTypeMeta> = {
|
||||
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,
|
||||
|
||||
@@ -7,6 +7,7 @@ export type ViewId =
|
||||
| "editor"
|
||||
| "users"
|
||||
| "sources"
|
||||
| "integrations"
|
||||
| "policies"
|
||||
| "pipelines"
|
||||
| "documents"
|
||||
@@ -21,6 +22,7 @@ export const VIEW_LABELS: Record<ViewId, string> = {
|
||||
editor: "Editor",
|
||||
users: "Users",
|
||||
sources: "Sources",
|
||||
integrations: "Integrations",
|
||||
policies: "Policies",
|
||||
pipelines: "Pipelines",
|
||||
documents: "Documents",
|
||||
@@ -36,6 +38,7 @@ export const VIEW_PATHS: Record<ViewId, string> = {
|
||||
editor: "/editor",
|
||||
users: "/users",
|
||||
sources: "/sources",
|
||||
integrations: "/integrations",
|
||||
policies: "/policies",
|
||||
pipelines: "/pipelines",
|
||||
documents: "/documents",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof baseRender>[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(<Integrations />);
|
||||
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(<Integrations />);
|
||||
|
||||
// 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(<Integrations />);
|
||||
|
||||
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(<Integrations />);
|
||||
|
||||
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(<Integrations />);
|
||||
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(<Integrations />);
|
||||
await screen.findByText("portal.connections.types.s3.label");
|
||||
expect(
|
||||
screen.queryByText("portal.connections.types.api.label"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<IntegrationConfig[] | null>(
|
||||
null,
|
||||
);
|
||||
const [capabilities, setCapabilities] = useState<
|
||||
IntegrationCapabilities | undefined
|
||||
>(undefined);
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [expanded, setExpanded] = useState<Set<string>>(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<string | null>(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<string, TypeGroup>();
|
||||
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<ConnectionCategory, number>();
|
||||
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) => (
|
||||
<span key={kind} className="portal-integrations__chip">
|
||||
{t(`portal.integrations.worksWith.${kind}`)}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="portal-integrations">
|
||||
<header className="portal-integrations__head">
|
||||
<div>
|
||||
<h1 className="portal-integrations__title">
|
||||
{t("portal.integrations.title")}
|
||||
</h1>
|
||||
<p className="portal-integrations__sub">
|
||||
{t("portal.integrations.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
{capabilities?.customApi && (
|
||||
<Button
|
||||
onClick={() => openCreate("api")}
|
||||
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
|
||||
>
|
||||
{t("portal.integrations.customApi")}
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="portal-integrations__toolbar">
|
||||
<div
|
||||
className="portal-integrations__filters"
|
||||
role="tablist"
|
||||
aria-label={t("portal.integrations.title")}
|
||||
>
|
||||
<FilterChip
|
||||
active={filter === "all"}
|
||||
label={t("portal.integrations.filters.all")}
|
||||
count={catalogue.length}
|
||||
onClick={() => setFilter("all")}
|
||||
/>
|
||||
<FilterChip
|
||||
active={filter === "connected"}
|
||||
label={t("portal.integrations.filters.connected")}
|
||||
count={groups.size}
|
||||
onClick={() => setFilter("connected")}
|
||||
/>
|
||||
{CONNECTION_CATEGORIES.filter(
|
||||
(category) =>
|
||||
category !== "advanced" &&
|
||||
(categoryCounts.get(category) ?? 0) > 0,
|
||||
).map((category) => (
|
||||
<FilterChip
|
||||
key={category}
|
||||
active={filter === category}
|
||||
label={t(`portal.integrations.filters.${category}`)}
|
||||
count={categoryCounts.get(category) ?? 0}
|
||||
onClick={() => setFilter(category)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="portal-integrations__toolbar-side">
|
||||
<label className="portal-integrations__search">
|
||||
<SearchRoundedIcon fontSize="inherit" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("portal.integrations.searchPlaceholder")}
|
||||
aria-label={t("portal.integrations.searchPlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="portal-integrations__skeleton" aria-hidden>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} height="3.25rem" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="portal-integrations__table">
|
||||
<div className="portal-integrations__cols" aria-hidden>
|
||||
<span>{t("portal.integrations.table.integration")}</span>
|
||||
<span>{t("portal.integrations.table.worksWith")}</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{connectedGroups.length > 0 && (
|
||||
<div className="portal-integrations__section">
|
||||
{t("portal.integrations.connectedHeading")} ·{" "}
|
||||
{connectedGroups.length}
|
||||
</div>
|
||||
)}
|
||||
{connectedGroups.map(({ type, connections: list }) => {
|
||||
const open = expanded.has(type.id);
|
||||
return (
|
||||
<div key={type.id} className="portal-integrations__group">
|
||||
<button
|
||||
type="button"
|
||||
className="portal-integrations__row portal-integrations__row--connected"
|
||||
aria-expanded={open}
|
||||
onClick={() => toggleExpand(type.id)}
|
||||
>
|
||||
<span className="portal-integrations__name">
|
||||
<BrandMark id={type.id} size={22} />
|
||||
<span className="portal-integrations__name-text">
|
||||
<span className="portal-integrations__label">
|
||||
{t(type.labelKey)}
|
||||
</span>
|
||||
<span className="portal-integrations__detail">
|
||||
{list.length === 1
|
||||
? list[0].name
|
||||
: t("portal.integrations.connectionCount", {
|
||||
count: list.length,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="portal-integrations__chips">
|
||||
{worksWith(type).map(chip)}
|
||||
</span>
|
||||
<span className="portal-integrations__status">
|
||||
<span className="portal-integrations__status-dot" />
|
||||
{t("portal.integrations.status.connected")}
|
||||
<ExpandMoreRoundedIcon
|
||||
fontSize="inherit"
|
||||
className={
|
||||
"portal-integrations__chevron" +
|
||||
(open ? " is-open" : "")
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="portal-integrations__instances">
|
||||
{list.map((connection) => (
|
||||
<div
|
||||
key={connection.id}
|
||||
className="portal-integrations__instance"
|
||||
>
|
||||
<span className="portal-integrations__instance-name">
|
||||
{connection.name}
|
||||
</span>
|
||||
<span className="portal-integrations__instance-detail">
|
||||
{connectionDetail(connection)}
|
||||
</span>
|
||||
{connection.canManage && (
|
||||
<span className="portal-integrations__instance-actions">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => openEdit(connection)}
|
||||
>
|
||||
{t("portal.connections.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
accent="danger"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(connection)}
|
||||
>
|
||||
{t("portal.connections.delete")}
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="portal-integrations__instance portal-integrations__instance--add">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => openCreate(type.id)}
|
||||
leftSection={
|
||||
<AddRoundedIcon style={{ fontSize: "1rem" }} />
|
||||
}
|
||||
>
|
||||
{t("portal.integrations.addAnother")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{availableTypes.length > 0 && (
|
||||
<div className="portal-integrations__section">
|
||||
{t("portal.integrations.availableHeading")} ·{" "}
|
||||
{availableTypes.length}
|
||||
</div>
|
||||
)}
|
||||
{availableTypes.map((type) => (
|
||||
<div key={type.id} className="portal-integrations__row">
|
||||
<span className="portal-integrations__name">
|
||||
<BrandMark id={type.id} size={22} />
|
||||
<span className="portal-integrations__name-text">
|
||||
<span className="portal-integrations__label">
|
||||
{t(type.labelKey)}
|
||||
</span>
|
||||
<span className="portal-integrations__detail">
|
||||
{t(type.descriptionKey)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="portal-integrations__chips">
|
||||
{worksWith(type).map(chip)}
|
||||
</span>
|
||||
<span className="portal-integrations__status">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => openCreate(type.id)}
|
||||
>
|
||||
{t("portal.integrations.connect")}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{comingSoon.length > 0 && (
|
||||
<div className="portal-integrations__section">
|
||||
{t("portal.integrations.comingSoonHeading")} · {comingSoon.length}
|
||||
</div>
|
||||
)}
|
||||
{comingSoon.map((entry) => (
|
||||
<div
|
||||
key={entry.type}
|
||||
className="portal-integrations__row portal-integrations__row--soon"
|
||||
aria-disabled
|
||||
>
|
||||
<span className="portal-integrations__name">
|
||||
<BrandMark id={entry.type} size={22} />
|
||||
<span className="portal-integrations__name-text">
|
||||
<span className="portal-integrations__label">
|
||||
{t(entry.labelKey)}
|
||||
</span>
|
||||
<span className="portal-integrations__detail">
|
||||
{t(entry.descriptionKey)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="portal-integrations__chips">
|
||||
{chip("sources")}
|
||||
</span>
|
||||
<span className="portal-integrations__status">
|
||||
<span className="portal-integrations__soon-badge">
|
||||
{t("portal.sources.builder.comingSoon")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConnectionModal
|
||||
open={modal.open}
|
||||
connection={modal.editing}
|
||||
fixedTypeId={modal.fixedTypeId}
|
||||
capabilities={capabilities}
|
||||
onClose={() => setModal({ open: false, editing: null })}
|
||||
onSaved={() => void refresh()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
active,
|
||||
label,
|
||||
count,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
count: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={"portal-integrations__filter" + (active ? " is-active" : "")}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
<span className="portal-integrations__filter-count">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -328,6 +328,10 @@
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.portal-pipelines__picker-icon svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.portal-pipelines__picker-name {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string, unknown> | undefined,
|
||||
): Record<string, string> {
|
||||
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<Source | null>(
|
||||
async () => (id ? await fetchSource(id) : null),
|
||||
[id],
|
||||
);
|
||||
|
||||
const [type, setType] = useState<CreatableSourceType>(OFFERED_TYPES[0]);
|
||||
const [name, setName] = useState("");
|
||||
const [options, setOptions] = useState<Record<string, string>>(() =>
|
||||
defaultOptions(OFFERED_TYPES[0]),
|
||||
);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [seeded, setSeeded] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="portal-source-builder">
|
||||
<Banner tone="danger" description={errorMessage(sourceState.error)} />
|
||||
<Button variant="tertiary" onClick={() => navigate(listPath)}>
|
||||
{t("portal.sources.builder.back")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEdit && !seeded) {
|
||||
return (
|
||||
<div className="portal-source-builder__loading">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-source-builder">
|
||||
<header className="portal-source-builder__head">
|
||||
<div className="portal-source-builder__head-main">
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
onClick={() => navigate(listPath)}
|
||||
leftSection={
|
||||
<ArrowBackRoundedIcon style={{ fontSize: "1.125rem" }} />
|
||||
}
|
||||
>
|
||||
{t("portal.sources.builder.back")}
|
||||
</Button>
|
||||
<h1 className="portal-source-builder__title">
|
||||
{isEdit
|
||||
? name || t("portal.sources.builder.editTitle")
|
||||
: t("portal.sources.builder.createTitle")}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="portal-source-builder__head-actions">
|
||||
<Checkbox
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
label={t("portal.sources.builder.enabled")}
|
||||
/>
|
||||
{isEdit && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
accent="danger"
|
||||
onClick={() => setPendingDelete(true)}
|
||||
leftSection={
|
||||
<DeleteOutlineRoundedIcon style={{ fontSize: "1.125rem" }} />
|
||||
}
|
||||
>
|
||||
{t("portal.sources.builder.delete")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => navigate(listPath)}
|
||||
>
|
||||
{t("portal.sources.builder.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" disabled={!canSave} onClick={() => void save()}>
|
||||
{isEdit
|
||||
? t("portal.sources.builder.save")
|
||||
: t("portal.sources.builder.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="portal-source-builder__body">
|
||||
<FormField label={t("portal.sources.wizard.name")} required>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder={t("portal.sources.wizard.namePlaceholder")}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{!isEdit && OFFERED_TYPES.length > 1 && (
|
||||
<FormField label={t("portal.sources.wizard.type")}>
|
||||
<div className="portal-source-builder__type-grid">
|
||||
{OFFERED_TYPES.map((ct) => (
|
||||
<Button
|
||||
key={ct.type}
|
||||
variant="tertiary"
|
||||
className={
|
||||
"portal-source-builder__type-card" +
|
||||
(type.type === ct.type ? " is-selected" : "")
|
||||
}
|
||||
onClick={() => chooseType(ct)}
|
||||
>
|
||||
<span
|
||||
className="portal-source-builder__type-icon"
|
||||
aria-hidden
|
||||
>
|
||||
{sourceTypeMeta(ct.type).icon}
|
||||
</span>
|
||||
<span className="portal-source-builder__type-name">
|
||||
{t(ct.labelKey)}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{!isEdit && (
|
||||
<p className="portal-source-builder__type-description">
|
||||
{t(type.descriptionKey)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isEdit && type.type === WEBHOOK_SOURCE_TYPE && (
|
||||
<p className="portal-source-builder__muted">
|
||||
{t("portal.sources.types.webhook.createNote")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{type.fields.map((field) => (
|
||||
<FormField
|
||||
key={field.key}
|
||||
label={t(field.labelKey)}
|
||||
helperText={
|
||||
field.helperTextKey ? t(field.helperTextKey) : undefined
|
||||
}
|
||||
required={field.required}
|
||||
>
|
||||
{field.control === "s3Connection" ? (
|
||||
<S3ConnectionPicker
|
||||
value={options[field.key] ?? ""}
|
||||
onChange={(connectionId) => setOption(field.key, connectionId)}
|
||||
/>
|
||||
) : field.control === "select" ? (
|
||||
<Select
|
||||
value={options[field.key] ?? ""}
|
||||
options={(field.options ?? []).map((o) => ({
|
||||
value: o.value,
|
||||
label: t(o.labelKey),
|
||||
}))}
|
||||
onChange={(value) => setOption(field.key, value ?? "")}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.control === "password" ? "password" : undefined}
|
||||
value={options[field.key] ?? ""}
|
||||
placeholder={
|
||||
field.placeholderKey ? t(field.placeholderKey) : undefined
|
||||
}
|
||||
onChange={(e) => setOption(field.key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
))}
|
||||
|
||||
{editingWebhookId && (
|
||||
<FormField
|
||||
label={t("portal.sources.types.webhook.detail.deliveryUrl")}
|
||||
helperText={t("portal.sources.types.webhook.detail.secretNote")}
|
||||
>
|
||||
<div className="portal-source-builder__copy-row">
|
||||
<Input
|
||||
value={webhookUrl(editingWebhookId)}
|
||||
readOnly
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => copy(webhookUrl(editingWebhookId))}
|
||||
>
|
||||
{t("portal.sources.types.webhook.reveal.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{error &&
|
||||
(folderAccessDenied ? (
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t(
|
||||
"portal.sources.builder.folderAccess.title",
|
||||
"This folder isn't allowed",
|
||||
)}
|
||||
description={t(
|
||||
"portal.sources.builder.folderAccess.description",
|
||||
"Folder automations can only use folders an administrator has allowed. Add it under Folder Access settings, then try again.",
|
||||
)}
|
||||
action={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => openSettings("adminFolderAccess")}
|
||||
>
|
||||
{t(
|
||||
"portal.sources.builder.folderAccess.openSettings",
|
||||
"Folder Access settings",
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Banner tone="danger" description={error} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={pendingDelete}
|
||||
onClose={() => !deleting && setPendingDelete(false)}
|
||||
width="sm"
|
||||
title={t("portal.sources.delete.title")}
|
||||
footer={
|
||||
<div className="portal-source-builder__delete-actions">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
onClick={() => setPendingDelete(false)}
|
||||
>
|
||||
{t("portal.sources.delete.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
accent="danger"
|
||||
loading={deleting}
|
||||
onClick={() => void confirmDelete()}
|
||||
>
|
||||
{t("portal.sources.delete.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p>{t("portal.sources.delete.body", { name })}</p>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={reveal !== null}
|
||||
onClose={dismissReveal}
|
||||
width="md"
|
||||
title={t("portal.sources.types.webhook.reveal.title")}
|
||||
footer={
|
||||
<div className="portal-source-builder__delete-actions">
|
||||
<Button size="sm" onClick={dismissReveal}>
|
||||
{t("portal.sources.types.webhook.reveal.done")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{reveal && (
|
||||
<div className="portal-source-builder__reveal">
|
||||
<Banner
|
||||
tone="warning"
|
||||
description={t(
|
||||
"portal.sources.types.webhook.reveal.secretWarning",
|
||||
)}
|
||||
/>
|
||||
<FormField label={t("portal.sources.types.webhook.reveal.url")}>
|
||||
<div className="portal-source-builder__copy-row">
|
||||
<Input
|
||||
value={revealUrl}
|
||||
readOnly
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => copy(revealUrl)}
|
||||
>
|
||||
{t("portal.sources.types.webhook.reveal.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.webhook.reveal.secret")}
|
||||
helperText={t("portal.sources.types.webhook.reveal.secretHelp")}
|
||||
>
|
||||
<div className="portal-source-builder__copy-row">
|
||||
<Input
|
||||
value={revealSecret}
|
||||
readOnly
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => copy(revealSecret)}
|
||||
>
|
||||
{t("portal.sources.types.webhook.reveal.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
<p className="portal-source-builder__muted">
|
||||
{t("portal.sources.types.webhook.reveal.usage")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }) => (
|
||||
<PortalTestProviders>
|
||||
<UIProvider>{children}</UIProvider>
|
||||
</PortalTestProviders>
|
||||
);
|
||||
|
||||
const render = (ui: Parameters<typeof baseRender>[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") {
|
||||
<Routes>
|
||||
<Route path="/processor/sources" element={<Sources />} />
|
||||
<Route
|
||||
path="/processor/sources/new"
|
||||
element={<div>source builder: new</div>}
|
||||
/>
|
||||
<Route
|
||||
path="/processor/sources/:id"
|
||||
element={<div>source builder: edit</div>}
|
||||
path="/processor/integrations"
|
||||
element={<div>integrations view</div>}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <Navigate to={toPortalPath(VIEW_PATHS.integrations)} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -49,66 +59,51 @@ export function Sources() {
|
||||
<h1 className="portal-sources__title">{t("portal.sources.title")}</h1>
|
||||
<p className="portal-sources__sub">{t("portal.sources.subtitle")}</p>
|
||||
</div>
|
||||
{activeTab === "sources" && (
|
||||
<div className="portal-sources__actions">
|
||||
<div className="portal-sources__actions">
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
|
||||
>
|
||||
{t("portal.sources.actions.connectSource")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!showEmpty && <KpiStrip data={data} loading={loading} />}
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-sources__table-skeleton" aria-hidden>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} height="3rem" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEmpty && (
|
||||
<EmptyState
|
||||
icon={<SourcesIcon size={28} />}
|
||||
title={t("portal.sources.empty.title")}
|
||||
description={t("portal.sources.empty.description")}
|
||||
actions={
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
|
||||
>
|
||||
{t("portal.sources.actions.connectSource")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<Tabs<SourcesTab>
|
||||
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" ? (
|
||||
<ConnectionsTab />
|
||||
) : (
|
||||
<>
|
||||
{!showEmpty && <KpiStrip data={data} loading={loading} />}
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-sources__table-skeleton" aria-hidden>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} height="3rem" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEmpty && (
|
||||
<EmptyState
|
||||
icon={<SourcesIcon size={28} />}
|
||||
title={t("portal.sources.empty.title")}
|
||||
description={t("portal.sources.empty.description")}
|
||||
actions={
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
leftSection={
|
||||
<AddRoundedIcon style={{ fontSize: "1.125rem" }} />
|
||||
}
|
||||
>
|
||||
{t("portal.sources.actions.connectSource")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && sources.length > 0 && (
|
||||
<SourcesTable sources={sources} onRowClick={openSource} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && sources.length > 0 && (
|
||||
<SourcesTable sources={sources} onRowClick={openSource} />
|
||||
)}
|
||||
|
||||
<SourceModal
|
||||
open={modal.open}
|
||||
sourceId={modal.sourceId}
|
||||
onClose={() => setModal({ open: false, sourceId: null })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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-<button> 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-<button> 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],
|
||||
|
||||
Reference in New Issue
Block a user