Compare commits

...
Author SHA1 Message Date
Anthony Stirling 10faaee499 Rework source creation into a stepped connection wizard 2026-07-25 00:06:12 +01:00
7 changed files with 650 additions and 159 deletions
@@ -8679,24 +8679,35 @@ title = "Sources"
connectSource = "Connect source"
[portal.sources.builder]
back = "Back to sources"
backToTypes = "All source types"
backStep = "Back"
cancel = "Cancel"
chooseHint = "Choose where documents come from. Greyed-out connectors are on the way."
comingSoon = "Coming soon"
comingSoonHeading = "Coming soon"
connectionEmpty = "No {{tool}} connections yet. Enter the details once and every source, policy and pipeline can reuse them."
connectionHint = "Pick the {{tool}} connection this source reads from, or add a new one."
create = "Create source"
createTitle = "Connect a source"
delete = "Delete"
editTitle = "Edit source"
enabled = "Enabled"
manageIntegrations = "View all in Integrations"
next = "Next"
save = "Save changes"
saveConnection = "Save and continue"
sourceName = "Source name"
[portal.sources.builder.folderAccess]
description = "Folder automations can only use folders an administrator has allowed. Add it under Folder Access settings, then try again."
openSettings = "Folder Access settings"
title = "This folder isn't allowed"
[portal.sources.builder.steps]
aria = "Setup steps"
configure = "Set up source"
connection = "Connection"
type = "Source type"
[portal.sources.delete]
body = "Delete \"{{name}}\"? This can't be undone. Policies that reference it would need to be updated."
cancel = "Cancel"
@@ -8709,7 +8720,7 @@ title = "No sources connected yet"
[portal.sources.kpi]
inUse = "In use"
total = "Connections"
total = "Sources"
unused = "Unused"
[portal.sources.status]
+6
View File
@@ -67,6 +67,12 @@
min-width: 0;
}
/* Leading slot (back button): pull up to optically align with the title line. */
.sui-modal__header-start {
flex: 0 0 auto;
margin: -0.25rem 0 0 -0.25rem;
}
.sui-modal__title {
font-size: 0.9375rem;
font-weight: 600;
+7
View File
@@ -11,6 +11,9 @@ export interface ModalProps {
onClose: () => void;
title?: ReactNode;
subtitle?: ReactNode;
/** Rendered in the header before the title, e.g. a back button. Kept outside
* the title node so it never joins the dialog's accessible name. */
headerStart?: ReactNode;
footer?: ReactNode;
/** sm=24rem, md=32rem, lg=48rem, xl=64rem. */
width?: ModalWidth;
@@ -28,6 +31,7 @@ export function Modal({
onClose,
title,
subtitle,
headerStart,
footer,
width = "md",
disableBackdropClose = false,
@@ -84,6 +88,9 @@ export function Modal({
>
{(title || subtitle) && (
<header className="sui-modal__header">
{headerStart && (
<div className="sui-modal__header-start">{headerStart}</div>
)}
<div className="sui-modal__header-text">
{title && (
<div id={titleId} className="sui-modal__title">
@@ -101,12 +101,79 @@ button.portal-source-modal__card:focus-visible {
white-space: nowrap;
}
/* Step rail in the modal header: numbered dots joined by short connectors. */
.portal-source-modal__steps {
display: flex;
align-items: center;
gap: 0.375rem;
margin: 0.375rem 0 0;
padding: 0;
list-style: none;
}
.portal-source-modal__step {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--c-text-muted);
white-space: nowrap;
}
/* Connector line between steps. */
.portal-source-modal__step + .portal-source-modal__step::before {
content: "";
width: 1.25rem;
height: 1px;
margin-right: 0.375rem;
background: var(--c-border);
}
.portal-source-modal__step-dot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.125rem;
height: 1.125rem;
border-radius: 999px;
border: 1px solid var(--c-border);
background: var(--c-surface);
font-size: 0.625rem;
font-weight: 600;
line-height: 1;
}
.portal-source-modal__step.is-active {
color: var(--c-text);
font-weight: 600;
}
.portal-source-modal__step.is-active .portal-source-modal__step-dot {
border-color: var(--c-primary);
background: var(--c-primary);
color: var(--c-text-on-primary);
}
.portal-source-modal__step.is-done .portal-source-modal__step-dot {
border-color: var(--c-primary);
color: var(--c-primary);
}
.portal-source-modal__form {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
/* "New connection..." on the left, the view-all escape hatch on the right. */
.portal-source-modal__connection-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.portal-source-modal__loading {
display: flex;
justify-content: center;
@@ -0,0 +1,49 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { SourceModal } from "@portal/components/sources/SourceModal";
/**
* The create wizard: type catalogue, then (for connection-backed types) the
* connection step, then the source-only setup. Driven by the shared MSW
* handlers, so the full flow works in-story including inline connection
* creation.
*/
const meta: Meta<typeof SourceModal> = {
title: "Portal/Sources/SourceModal",
component: SourceModal,
parameters: { layout: "fullscreen" },
decorators: [
(Story) => {
// The modal reads the shared sources cache; a per-story client mirrors
// PortalApp's provider (retries off so mock errors surface immediately).
const [client] = useState(
() =>
new QueryClient({ defaultOptions: { queries: { retry: false } } }),
);
return (
<QueryClientProvider client={client}>
<Story />
</QueryClientProvider>
);
},
],
args: { open: true, onClose: () => {}, sourceId: null },
};
export default meta;
type Story = StoryObj<typeof SourceModal>;
/** Step 1: the connector catalogue with coming-soon entries. */
export const Create: Story = {};
/** The connection step with no stored connections: opens on the inline form. */
export const CreateNoConnections: Story = {
parameters: {
msw: {
handlers: [
http.get("*/api/v1/integrations", () => HttpResponse.json([])),
],
},
},
};
@@ -5,17 +5,21 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
import type { ReactNode } from "react";
import { SourceModal } from "@portal/components/sources/SourceModal";
import { UIProvider } from "@portal/contexts/UIContext";
// SourceModal reads useUI() (open settings) and useQueryClient (list
// invalidation), so provide the query client + Mantine + the UI context.
// SourceModal reads useUI() (open settings), useQueryClient (list
// invalidation) and useNavigate (View all in Integrations), so provide the
// query client + Mantine + the UI context + a router.
const Providers = ({ children }: { children: ReactNode }) => (
<PortalTestProviders>
<UIProvider>{children}</UIProvider>
</PortalTestProviders>
<MemoryRouter>
<PortalTestProviders>
<UIProvider>{children}</UIProvider>
</PortalTestProviders>
</MemoryRouter>
);
const render = (ui: Parameters<typeof baseRender>[0]) =>
@@ -80,11 +84,12 @@ describe("SourceModal", () => {
it("creates a folder source through the staged flow and closes", async () => {
const { onClose, onSaved } = renderModal();
// Stage 1: pick the folder connector, then fill name + directory.
// A folder has no integration step: the catalogue goes straight to setup.
fireEvent.click(screen.getByText("portal.sources.types.folder.label"));
fireEvent.change(screen.getByLabelText(/portal\.integrations\.typedName/), {
target: { value: "Claims intake" },
});
fireEvent.change(
screen.getByLabelText(/portal\.sources\.builder\.sourceName/),
{ target: { value: "Claims intake" } },
);
fireEvent.change(
screen.getByLabelText(
/portal\.sources\.types\.folder\.fields\.directory\.label/,
@@ -114,9 +119,10 @@ describe("SourceModal", () => {
renderModal();
fireEvent.click(screen.getByText("portal.sources.types.folder.label"));
fireEvent.change(screen.getByLabelText(/portal\.integrations\.typedName/), {
target: { value: "Claims intake" },
});
fireEvent.change(
screen.getByLabelText(/portal\.sources\.builder\.sourceName/),
{ target: { value: "Claims intake" } },
);
fireEvent.change(
screen.getByLabelText(
/portal\.sources\.types\.folder\.fields\.directory\.label/,
@@ -139,9 +145,10 @@ describe("SourceModal", () => {
renderModal();
fireEvent.click(screen.getByText("portal.sources.types.folder.label"));
fireEvent.change(screen.getByLabelText(/portal\.integrations\.typedName/), {
target: { value: "Claims intake" },
});
fireEvent.change(
screen.getByLabelText(/portal\.sources\.builder\.sourceName/),
{ target: { value: "Claims intake" } },
);
fireEvent.change(
screen.getByLabelText(
/portal\.sources\.types\.folder\.fields\.directory\.label/,
@@ -156,57 +163,15 @@ describe("SourceModal", () => {
).not.toBeInTheDocument();
});
it("gates the s3 type on a chosen connection", async () => {
it("walks s3 through the connection step, creating one inline when none exist", async () => {
renderModal();
// No stored connections: the step opens straight on the inline form,
// still inside the single modal.
fireEvent.click(screen.getByText("portal.sources.types.s3.label"));
fireEvent.change(screen.getByLabelText(/portal\.integrations\.typedName/), {
target: { value: "Bucket source" },
});
expect(
await screen.findByText(
"portal.sources.types.s3.fields.connection.label",
),
await screen.findByLabelText(/portal\.integrations\.typedName/),
).toBeInTheDocument();
expect(
screen.getByText("portal.sources.builder.create").closest("button"),
).toBeDisabled();
});
it("reveals the delivery URL and signing secret once after creating a webhook", async () => {
createSource.mockResolvedValue({
id: "wh-1",
options: { webhookId: "whk_abc123", signingSecret: "whsec_topsecret" },
});
const { onClose } = renderModal();
fireEvent.click(screen.getByText("portal.sources.types.webhook.label"));
fireEvent.change(screen.getByLabelText(/portal\.integrations\.typedName/), {
target: { value: "Partner uploads" },
});
fireEvent.click(screen.getByText("portal.sources.builder.create"));
await waitFor(() => expect(createSource).toHaveBeenCalledTimes(1));
expect(
await screen.findByDisplayValue("whsec_topsecret"),
).toBeInTheDocument();
expect(
screen.getByDisplayValue(/\/api\/v1\/webhooks\/whk_abc123$/),
).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
fireEvent.click(
screen.getByText("portal.sources.types.webhook.reveal.done"),
);
expect(onClose).toHaveBeenCalled();
});
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/), {
@@ -230,7 +195,7 @@ describe("SourceModal", () => {
),
{ target: { value: "secret" } },
);
fireEvent.click(screen.getByText("portal.connections.picker.save"));
fireEvent.click(screen.getByText("portal.sources.builder.saveConnection"));
await waitFor(() => expect(createIntegration).toHaveBeenCalledTimes(1));
expect(createIntegration).toHaveBeenCalledWith(
@@ -240,12 +205,89 @@ describe("SourceModal", () => {
scope: "TEAM",
}),
);
// Back on the source form with the new connection selected.
// On the setup step with the fresh connection selected: name it and save.
fireEvent.change(
await screen.findByLabelText(/portal\.sources\.builder\.sourceName/),
{ target: { value: "Bucket source" } },
);
fireEvent.click(screen.getByText("portal.sources.builder.create"));
await waitFor(() => expect(createSource).toHaveBeenCalledTimes(1));
expect(createSource).toHaveBeenCalledWith(
expect.objectContaining({
name: "Bucket source",
type: "s3",
options: expect.objectContaining({ connectionId: "77" }),
}),
);
});
it("gates the connection step's Next on a selection when connections exist", async () => {
fetchS3Connections.mockResolvedValue([
{ id: 5, name: "Prod bucket", integrationType: "S3" },
]);
renderModal();
fireEvent.click(screen.getByText("portal.sources.types.s3.label"));
expect(
await screen.findByText(
"portal.sources.types.s3.fields.connection.label",
await screen.findByText("portal.connections.picker.createNew"),
).toBeInTheDocument();
expect(
screen.getByText("portal.sources.builder.next").closest("button"),
).toBeDisabled();
// "New connection..." swaps to the inline form; still one modal.
fireEvent.click(screen.getByText("portal.connections.picker.createNew"));
expect(
await screen.findByLabelText(
/portal\.connections\.types\.s3\.fields\.bucket\.label/,
),
).toBeInTheDocument();
expect(document.querySelectorAll('[role="dialog"]').length).toBe(1);
});
it("returns from the connection step to the catalogue via the header back arrow", async () => {
renderModal();
fireEvent.click(screen.getByText("portal.sources.types.s3.label"));
expect(
await screen.findByLabelText(/portal\.integrations\.typedName/),
).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("portal.sources.builder.backStep"));
expect(
screen.getByText("portal.sources.types.folder.label"),
).toBeInTheDocument();
});
it("reveals the delivery URL and signing secret once after creating a webhook", async () => {
createSource.mockResolvedValue({
id: "wh-1",
options: { webhookId: "whk_abc123", signingSecret: "whsec_topsecret" },
});
const { onClose } = renderModal();
fireEvent.click(screen.getByText("portal.sources.types.webhook.label"));
fireEvent.change(
screen.getByLabelText(/portal\.sources\.builder\.sourceName/),
{ target: { value: "Partner uploads" } },
);
fireEvent.click(screen.getByText("portal.sources.builder.create"));
await waitFor(() => expect(createSource).toHaveBeenCalledTimes(1));
expect(
await screen.findByDisplayValue("whsec_topsecret"),
).toBeInTheDocument();
expect(
screen.getByDisplayValue(/\/api\/v1\/webhooks\/whk_abc123$/),
).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
fireEvent.click(
screen.getByText("portal.sources.types.webhook.reveal.done"),
);
expect(onClose).toHaveBeenCalled();
});
it("lists coming-soon connectors as inert cards", () => {
@@ -253,7 +295,7 @@ describe("SourceModal", () => {
fireEvent.click(screen.getByText("portal.sources.types.sharepoint.label"));
// Still on the type stage: no configure form appeared.
expect(
screen.queryByLabelText(/portal\.integrations\.typedName/),
screen.queryByLabelText(/portal\.sources\.builder\.sourceName/),
).not.toBeInTheDocument();
expect(
screen.getAllByText("portal.sources.builder.comingSoon").length,
@@ -1,6 +1,9 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import ArrowForwardRoundedIcon from "@mui/icons-material/ArrowForwardRounded";
import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
import {
Banner,
Button,
@@ -22,6 +25,7 @@ import {
import { useUI } from "@portal/contexts/UIContext";
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 {
COMING_SOON_SOURCE_TYPES,
@@ -29,6 +33,7 @@ import {
defaultOptions,
WEBHOOK_SOURCE_TYPE,
type CreatableSourceType,
type SourceFieldDef,
} from "@portal/components/sources/sourceTypes";
import { BrandMark } from "@portal/components/BrandMarks";
import { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker";
@@ -39,7 +44,11 @@ import {
connectionFormValid,
emptyConnectionValues,
} from "@portal/components/sources/connectionTypes";
import { createIntegration } from "@portal/api/integrations";
import {
createIntegration,
fetchS3Connections,
type IntegrationConfig,
} from "@portal/api/integrations";
import "@portal/components/sources/SourceModal.css";
function webhookUrl(webhookId: string): string {
@@ -69,13 +78,75 @@ function optionsFor(
return out;
}
type Stage = "type" | "configure" | "reveal" | "delete" | "connection";
/** The slot on a type that references a stored connection, if it has one. */
function connectionFieldOf(
type: CreatableSourceType,
): SourceFieldDef | undefined {
return type.fields.find((field) => field.control === "s3Connection");
}
type Stage =
| "type"
| "connection"
| "configure"
| "reveal"
| "delete"
| "connCreate";
/** 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",
)!;
const STEP_LABEL_KEYS: Record<string, string> = {
type: "portal.sources.builder.steps.type",
connection: "portal.sources.builder.steps.connection",
configure: "portal.sources.builder.steps.configure",
};
/** The create flow's step rail, shown in the modal header. */
function WizardSteps({
stage,
stepKeys,
}: {
stage: Stage;
stepKeys: string[];
}) {
const { t } = useTranslation();
const activeIndex = stepKeys.indexOf(stage);
return (
<ol
className="portal-source-modal__steps"
aria-label={t("portal.sources.builder.steps.aria")}
>
{stepKeys.map((key, index) => {
const state =
index === activeIndex
? "active"
: index < activeIndex
? "done"
: "todo";
return (
<li
key={key}
className={`portal-source-modal__step is-${state}`}
aria-current={state === "active" ? "step" : undefined}
>
<span className="portal-source-modal__step-dot" aria-hidden>
{state === "done" ? (
<CheckRoundedIcon style={{ fontSize: "0.75rem" }} />
) : (
index + 1
)}
</span>
{t(STEP_LABEL_KEYS[key])}
</li>
);
})}
</ol>
);
}
interface SourceModalProps {
open: boolean;
/** When set, edit this source; otherwise create a new one. */
@@ -89,10 +160,12 @@ interface SourceModalProps {
}
/**
* 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.
* Create/edit a source as a stepped wizard in one modal: pick the connector
* type, then (for connection-backed types) select or create the connection
* it reads from, then the source-only setup (name, prefix, mode). Webhook
* creation swaps to a one-time secret reveal, and delete swaps to an inline
* confirm rather than stacking a second modal. Edit skips the wizard and goes
* straight to the setup form.
*/
export function SourceModal({
open,
@@ -102,6 +175,7 @@ export function SourceModal({
}: SourceModalProps) {
const { t } = useTranslation();
const { openSettings } = useUI();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isEdit = Boolean(sourceId);
@@ -129,13 +203,20 @@ export function SourceModal({
webhookId: string;
secret: string;
} | null>(null);
// In-place connection create (swaps the stage; never stacks a second modal).
// The connection step: the stored connections of the type's kind, and
// whether the step is picking one or creating one inline (never a 2nd modal).
const [connections, setConnections] = useState<IntegrationConfig[] | null>(
null,
);
const [connMode, setConnMode] = useState<"select" | "create">("select");
const [connValues, setConnValues] = useState<Record<string, string>>(() =>
emptyConnectionValues(S3_CONNECTION_TYPE),
);
const [connField, setConnField] = useState("");
const [connSaving, setConnSaving] = useState(false);
const connectionField = connectionFieldOf(type);
// Seed on every open: fresh catalogue for create, fetched record for edit.
useEffect(() => {
if (!open) return;
@@ -143,6 +224,8 @@ export function SourceModal({
setReveal(null);
setSubmitting(false);
setDeleting(false);
setConnections(null);
setConnMode("select");
if (!sourceId) {
setStage("type");
setType(OFFERED_TYPES[0]);
@@ -167,10 +250,33 @@ export function SourceModal({
.finally(() => setLoading(false));
}, [open, sourceId]);
// The connection step's list, fetched once per open when the step is
// reached; an empty account has nothing to pick, so it opens on the form.
useEffect(() => {
if (!open || stage !== "connection" || connections !== null) return;
let cancelled = false;
fetchS3Connections()
.then((list) => {
if (cancelled) return;
setConnections(list);
if (list.length === 0) {
setConnValues(emptyConnectionValues(S3_CONNECTION_TYPE));
setConnMode("create");
}
})
.catch((e) => {
if (!cancelled) setError(errorMessage(e));
});
return () => {
cancelled = true;
};
}, [open, stage, connections]);
function chooseType(next: CreatableSourceType) {
setType(next);
setOptions(defaultOptions(next));
setStage("configure");
setError(null);
setStage(connectionFieldOf(next) ? "connection" : "configure");
}
function setOption(key: string, value: string) {
@@ -182,6 +288,13 @@ export function SourceModal({
);
const canSave = name.trim() !== "" && requiredComplete && !submitting;
const selectedConnectionId = connectionField
? (options[connectionField.key] ?? "").trim()
: "";
const selectedConnection = connections?.find(
(connection) => String(connection.id) === selectedConnectionId,
);
const editingWebhookId =
isEdit && loaded?.type === WEBHOOK_SOURCE_TYPE
? String(loaded.options?.webhookId ?? "")
@@ -192,36 +305,65 @@ export function SourceModal({
onClose();
}
function goToIntegrations() {
onClose();
navigate(toPortalPath(VIEW_PATHS.integrations));
}
function startInlineCreate() {
setConnValues(emptyConnectionValues(S3_CONNECTION_TYPE));
setError(null);
setConnMode("create");
}
/** Edit flow only: the configure form's picker swaps the stage in-place. */
function openConnectionStage(fieldKey: string) {
setConnValues(emptyConnectionValues(S3_CONNECTION_TYPE));
setConnField(fieldKey);
setError(null);
setStage("connection");
setStage("connCreate");
}
async function saveConnection() {
async function createConnection(): Promise<IntegrationConfig | null> {
if (connSaving || !connectionFormValid(S3_CONNECTION_TYPE, connValues))
return;
return null;
setConnSaving(true);
setError(null);
try {
const created = await createIntegration({
return 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));
return null;
} finally {
setConnSaving(false);
}
}
/** Connection step's inline create: select the new one and move on. */
async function saveConnectionAndContinue() {
if (!connectionField) return;
const created = await createConnection();
if (!created) return;
setConnections((list) => [...(list ?? []), created]);
setOption(connectionField.key, String(created.id));
setConnMode("select");
setStage("configure");
}
/** Edit flow's in-place create: back to the form with the new id selected. */
async function saveConnectionForEdit() {
const created = await createConnection();
if (!created) return;
// The picker remounts and refetches, so the new name is in its list.
setOption(connField, String(created.id));
setStage("configure");
}
async function save() {
if (!canSave) return;
setSubmitting(true);
@@ -273,19 +415,53 @@ export function SourceModal({
}
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");
stage === "connCreate"
? 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");
// The wizard chrome (step rail + header back arrow) belongs to create only;
// edit opens directly on the form and reveal/delete are terminal swaps.
const wizardStage =
!isEdit &&
(stage === "type" || stage === "connection" || stage === "configure");
const stepKeys =
stage === "type" || connectionField
? ["type", "connection", "configure"]
: ["type", "configure"];
const headerBack =
!isEdit &&
stage === "connection" &&
connMode === "create" &&
(connections?.length ?? 0) > 0
? () => {
setError(null);
setConnMode("select");
}
: !isEdit && stage === "connection"
? () => {
setError(null);
setStage("type");
}
: !isEdit && stage === "configure"
? () => {
setError(null);
setStage(connectionField ? "connection" : "type");
}
: stage === "connCreate"
? () => {
setError(null);
setStage("configure");
}
: null;
return (
<Modal
@@ -293,6 +469,26 @@ export function SourceModal({
onClose={stage === "reveal" ? finish : onClose}
width={stage === "type" ? "lg" : stage === "delete" ? "sm" : "md"}
title={title}
subtitle={
wizardStage ? (
<WizardSteps stage={stage} stepKeys={stepKeys} />
) : undefined
}
headerStart={
headerBack ? (
<Button
variant="tertiary"
accent="neutral"
size="sm"
shape="circle"
aria-label={t("portal.sources.builder.backStep")}
onClick={headerBack}
leftSection={
<ArrowBackRoundedIcon style={{ fontSize: "1.125rem" }} />
}
/>
) : undefined
}
footer={
stage === "configure" ? (
<div className="portal-source-modal__footer">
@@ -334,6 +530,38 @@ export function SourceModal({
</span>
</div>
) : stage === "connection" ? (
<div className="portal-source-modal__footer-actions">
<Button
variant="tertiary"
size="sm"
disabled={connSaving}
onClick={onClose}
>
{t("portal.sources.builder.cancel")}
</Button>
{connMode === "create" ? (
<Button
size="sm"
loading={connSaving}
disabled={!connectionFormValid(S3_CONNECTION_TYPE, connValues)}
onClick={() => void saveConnectionAndContinue()}
>
{t("portal.sources.builder.saveConnection")}
</Button>
) : (
<Button
size="sm"
disabled={selectedConnectionId === ""}
onClick={() => {
setError(null);
setStage("configure");
}}
>
{t("portal.sources.builder.next")}
</Button>
)}
</div>
) : stage === "connCreate" ? (
<div className="portal-source-modal__footer-actions">
<Button
variant="tertiary"
@@ -347,7 +575,7 @@ export function SourceModal({
size="sm"
loading={connSaving}
disabled={!connectionFormValid(S3_CONNECTION_TYPE, connValues)}
onClick={() => void saveConnection()}
onClick={() => void saveConnectionForEdit()}
>
{t("portal.connections.picker.save")}
</Button>
@@ -444,6 +672,97 @@ export function SourceModal({
</div>
)}
{stage === "connection" && connectionField && (
<div className="portal-source-modal__form">
<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>
{connections === null ? (
error ? (
<Banner tone="danger" description={error} />
) : (
<div className="portal-source-modal__loading">
<Spinner />
</div>
)
) : connMode === "create" ? (
<>
{connections.length === 0 && (
<p className="portal-source-modal__muted">
{t("portal.sources.builder.connectionEmpty", {
tool: t(type.labelKey),
})}
</p>
)}
<ConnectionForm
type={S3_CONNECTION_TYPE}
values={connValues}
onChange={setConnValues}
/>
{error && <Banner tone="danger" description={error} />}
</>
) : (
<>
<p className="portal-source-modal__muted">
{t("portal.sources.builder.connectionHint", {
tool: t(type.labelKey),
})}
</p>
<FormField
label={t(connectionField.labelKey)}
helperText={
connectionField.helperTextKey
? t(connectionField.helperTextKey)
: undefined
}
required
>
<Select
value={selectedConnectionId || null}
placeholder={t("portal.connections.picker.placeholder")}
options={connections.map((connection) => ({
value: String(connection.id),
label: connection.name,
}))}
onChange={(selected) =>
setOption(connectionField.key, selected ?? "")
}
/>
</FormField>
<div className="portal-source-modal__connection-actions">
<Button
variant="tertiary"
size="sm"
onClick={startInlineCreate}
>
{t("portal.connections.picker.createNew")}
</Button>
<Button
variant="quiet"
size="sm"
onClick={goToIntegrations}
rightSection={
<ArrowForwardRoundedIcon style={{ fontSize: "1rem" }} />
}
>
{t("portal.sources.builder.manageIntegrations")}
</Button>
</div>
{error && <Banner tone="danger" description={error} />}
</>
)}
</div>
)}
{stage === "configure" && (
<div className="portal-source-modal__form">
{loading && (
@@ -454,18 +773,6 @@ export function SourceModal({
{!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">
@@ -473,15 +780,13 @@ export function SourceModal({
{t(type.labelKey)}
</span>
<span className="portal-source-modal__card-desc">
{t(type.descriptionKey)}
{selectedConnection?.name ?? t(type.descriptionKey)}
</span>
</span>
</div>
<FormField
label={t("portal.integrations.typedName", {
tool: t(type.labelKey),
})}
label={t("portal.sources.builder.sourceName")}
required
>
<Input
@@ -497,48 +802,52 @@ export function SourceModal({
</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>
))}
{type.fields
// Create picks the connection on its own step; edit keeps the
// picker here so an existing source can be repointed.
.filter((field) => isEdit || field.control !== "s3Connection")
.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
@@ -590,7 +899,7 @@ export function SourceModal({
</div>
)}
{stage === "connection" && (
{stage === "connCreate" && (
<div className="portal-source-modal__form">
<ConnectionForm
type={S3_CONNECTION_TYPE}