Improve modals in Sources page in Processor (#7664)

# Description of Changes

Various changes throughout to try and convert the bulk of the dev UI
sources modals to production quality. Changes include:

- Fixing inconsistencies between different modals
- Hide things users will rarely need to change behind advanced
- Removed clutter in the UI
- Renaming settings in terms that the user will understand and care
about

<img width="2360" height="3068" alt="image"
src="https://github.com/user-attachments/assets/2c637e8f-bc1b-4c7e-98cb-d836ae626ba5"
/>

<img width="2360" height="3008" alt="image"
src="https://github.com/user-attachments/assets/d894aadc-7c83-41d1-b614-881637a6bd34"
/>
This commit is contained in:
James Brunton
2026-08-25 15:59:16 +00:00
committed by GitHub
parent 0d75715af2
commit 353df7a647
25 changed files with 512 additions and 354 deletions
@@ -8833,7 +8833,9 @@ title = "Sources"
connectSource = "Connect source"
[portal.sources.builder]
advanced = "Advanced"
back = "Back to sources"
backToSource = "Back to source setup"
backToTypes = "All source types"
cancel = "Cancel"
chooseHint = "Choose where documents come from. Greyed-out connectors are on the way."
@@ -8843,7 +8845,6 @@ create = "Create source"
createTitle = "Connect a source"
delete = "Delete"
editTitle = "Edit source"
enabled = "Enabled"
save = "Save changes"
[portal.sources.builder.folderAccess]
@@ -8863,23 +8864,24 @@ total = "Connections"
unused = "Unused"
[portal.sources.networkFields.connection]
helperText = "The stored connection with the server address and credentials. Reused by every source that references it."
helperText = "The saved connection with the server address and credentials. Shared by every source that uses it."
label = "Connection"
[portal.sources.networkFields.directory]
helperText = "Folder on the server to poll, relative to the login home or share root. Leave blank for the root."
helperText = "The folder on the server to watch, relative to the login home or share root."
label = "Folder"
placeholder = "incoming/"
placeholder = "Login home or share root"
[portal.sources.networkFields.mode]
helperText = "Consume removes each file from the server once every policy has processed it."
label = "Read mode"
helperText = "Whether to leave the original file in place after it has been processed. If the original file is left in place, it will be re-processed next time the pipeline scans the source."
label = "After processing"
[portal.sources.networkFields.mode.options]
consume = "Consume: process each file once"
snapshot = "Snapshot: re-read the folder every run"
consume = "Delete the file"
snapshot = "Leave it in place"
[portal.sources.networkFields.recursive]
helperText = "Include subfolders if your files are organised into nested folders, or watch only the top level."
label = "Folder depth"
[portal.sources.networkFields.recursive.options]
@@ -8920,26 +8922,28 @@ description = "Watch a directory on the server for new documents."
label = "Folder"
[portal.sources.types.folder.fields.directory]
helperText = "Absolute path Stirling watches for files to process."
label = "Directory path"
helperText = "The absolute path to the folder to watch for new files."
label = "Folder"
placeholder = "/data/incoming"
[portal.sources.types.folder.fields.identity]
helperText = "Content check reads each changed file, so renames and touches that don't alter content are not reprocessed."
helperText = "Using the file contents for change detection is slower but more accurate."
label = "Change detection"
[portal.sources.types.folder.fields.identity.options]
hash = "Size, date and content check"
stat = "Size and date modified"
hash = "File Metadata & Content"
stat = "File Metadata"
[portal.sources.types.folder.fields.mode]
label = "Read mode"
helperText = "Whether to leave the original file in place after it has been processed. If the original file is left in place, it will be re-processed next time the pipeline scans the source."
label = "After processing"
[portal.sources.types.folder.fields.mode.options]
consume = "Consume: process each file once"
snapshot = "Snapshot: re-read the folder every run"
consume = "Delete the file"
snapshot = "Leave it in place"
[portal.sources.types.folder.fields.recursive]
helperText = "Include subfolders if your files are organised into nested folders, or watch only the top level."
label = "Folder depth"
[portal.sources.types.folder.fields.recursive.options]
@@ -8967,21 +8971,21 @@ description = "Pull documents from an Amazon S3 or S3-compatible bucket."
label = "Amazon S3"
[portal.sources.types.s3.fields.connection]
helperText = "The stored connection holding the bucket and credentials. Reused by every source and pipeline output that references it."
helperText = "The saved connection with the bucket and credentials. Shared by every source and pipeline output that uses it."
label = "Connection"
[portal.sources.types.s3.fields.mode]
helperText = "Consume removes each object from the bucket once every policy has processed it."
label = "Read mode"
helperText = "Whether to leave the original object in place after it has been processed. If the original object is left in place, it will be re-processed next time the pipeline scans the source."
label = "After processing"
[portal.sources.types.s3.fields.mode.options]
consume = "Consume: process each object once"
snapshot = "Snapshot: re-read the bucket every run"
consume = "Delete the object"
snapshot = "Leave it in place"
[portal.sources.types.s3.fields.prefix]
helperText = "Only objects whose keys start with this prefix are processed."
label = "Key prefix"
placeholder = "incoming/"
helperText = "The folder (key prefix) within the bucket to watch."
label = "Folder"
placeholder = "Whole bucket"
[portal.sources.types.sftp]
description = "Poll an SFTP drop folder for new documents."
+7 -5
View File
@@ -1,5 +1,5 @@
/* COLORS — canonical semantic core (~21 --c-* tokens) mapped from primitives.css per theme; compat.css aliases legacy names onto these. Editor: always data-app-theme="custom" + data-mantine-color-scheme=light|dark. Portal/Storybook: data-theme=light|dark → LIGHT/MIDNIGHT. */
/* Surface elevation: --c-bg (canvas) < --c-bg-raised (sidebars) < --c-surface (cards) < --c-surface-raised < --c-surface-sunken; plus --c-input-bg, --c-hover, --c-active, --c-overlay. */
/* Surface elevation: --c-bg (canvas) < --c-bg-raised (sidebars) < --c-surface (cards) < --c-surface-raised < --c-surface-sunken; plus --c-modal-surface (modal panels), --c-input-bg, --c-hover, --c-active, --c-overlay. */
/* ── LIGHT ───────────────────────────────────────────────────────────────── */
:root,
@@ -11,6 +11,7 @@ html[data-app-theme="light"] {
--c-surface-raised: var(--p-white);
--c-surface-sunken: var(--p-gray-100);
--c-input-bg: var(--p-white);
--c-modal-surface: var(--p-white);
--c-hover: var(--p-gray-50);
--c-active: var(--p-gray-100);
--c-overlay: rgba(0, 0, 0, 0.5);
@@ -22,7 +23,7 @@ html[data-app-theme="light"] {
--c-btn-solid: var(--c-text);
--c-btn-inverse: var(--p-snow);
--c-btn-secondary: var(--c-btn-inverse);
--c-btn-secondary: var(--c-surface-raised);
--c-btn-secondary-border: var(--c-border);
--c-border: var(--p-c-f0f0f0);
@@ -144,7 +145,8 @@ html[data-app-theme="midnight"] {
--c-surface: var(--p-c-1a1a1d);
--c-surface-raised: var(--p-zinc-650);
--c-surface-sunken: var(--p-zinc-850);
--c-input-bg: var(--p-zinc-650);
--c-input-bg: var(--c-surface);
--c-modal-surface: var(--c-surface);
--c-hover: var(--p-gray-800);
--c-active: var(--p-gray-800);
--c-overlay: rgba(0, 0, 0, 0.6);
@@ -360,7 +362,7 @@ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] {
var(--c-primary) 8%,
var(--p-zinc-900)
);
--c-input-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-zinc-900));
--c-input-bg: var(--c-surface);
--c-hover: color-mix(in srgb, var(--c-primary) 12%, var(--p-zinc-750));
--c-active: color-mix(in srgb, var(--c-primary) 15%, var(--p-zinc-700));
--c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-c-28282d));
@@ -424,7 +426,7 @@ html[data-app-theme="custom"][data-accent="default"][data-mantine-color-scheme="
--c-surface: var(--p-c-1a1a1d);
--c-surface-raised: var(--p-zinc-775);
--c-surface-sunken: var(--p-zinc-900);
--c-input-bg: var(--p-zinc-900);
--c-input-bg: var(--c-surface);
--c-hover: var(--p-zinc-750);
--c-active: var(--p-zinc-700);
--c-border: var(--p-c-28282d);
+19 -27
View File
@@ -1,51 +1,41 @@
.sui-collapsible {
display: flex;
flex-direction: column;
overflow: hidden;
}
/* A bare chevron-led toggle: no box, sized to its label, left-aligned. */
.sui-collapsible__head {
display: flex;
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 0.875rem;
background: var(--c-surface);
gap: 0.375rem;
padding: 0;
border: none;
background: none;
cursor: pointer;
text-align: left;
transition: background-color 0.15s ease;
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
color: var(--c-text-muted);
}
.sui-collapsible__head:hover {
background: var(--c-hover);
}
.sui-collapsible__head-main {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
flex-wrap: wrap;
}
.sui-collapsible__head-end {
display: inline-flex;
align-items: center;
gap: 0.625rem;
margin-left: auto;
flex-shrink: 0;
color: var(--c-text);
}
/* Points right when closed, rotates down when open. */
.sui-collapsible__chevron {
color: var(--c-text-subtle);
transform: rotate(-90deg);
transition: transform 0.2s ease;
}
.sui-collapsible__chevron[data-open="true"] {
transform: rotate(180deg);
transform: rotate(0deg);
}
/* Grid-rows disclosure: 0fr → 1fr, no magic max-height */
/* Grid-rows disclosure: 0fr → 1fr, no magic max-height. */
.sui-collapsible__body {
display: grid;
grid-template-rows: 0fr;
@@ -56,6 +46,8 @@
grid-template-rows: 1fr;
}
/* Gap between the toggle and the revealed content, no side indent. */
.sui-collapsible__body-inner {
overflow: hidden;
padding-top: 0.875rem;
}
@@ -19,25 +19,17 @@ export const Default: Story = {
<Collapsible
open={open}
onToggle={() => setOpen((o) => !o)}
header={<strong>Section title</strong>}
aside={<span style={{ fontSize: "0.75rem" }}>3 items</span>}
header="Advanced"
>
<div
style={{
padding: "0.875rem",
borderTop: "1px solid var(--c-border)",
}}
>
Body content revealed when the section is open.
</div>
Body content revealed when the section is open.
</Collapsible>
</div>
);
},
};
// Stacked instances form an accordion; each section toggles independently.
export const Accordion: Story = {
// Several independent disclosures stacked in a form-like column.
export const Stacked: Story = {
render: () => {
const [open, setOpen] = useState<number | null>(0);
const sections = ["Trial", "Quote", "Agreement"];
@@ -47,7 +39,7 @@ export const Accordion: Story = {
maxWidth: "40rem",
display: "flex",
flexDirection: "column",
gap: "0.75rem",
gap: "0.875rem",
}}
>
{sections.map((label, i) => (
@@ -55,16 +47,9 @@ export const Accordion: Story = {
key={label}
open={open === i}
onToggle={() => setOpen(open === i ? null : i)}
header={<strong>{label}</strong>}
header={label}
>
<div
style={{
padding: "0.875rem",
borderTop: "1px solid var(--c-border)",
}}
>
{label} details.
</div>
{label} details.
</Collapsible>
))}
</div>
+21 -31
View File
@@ -1,38 +1,31 @@
import type { ReactNode } from "react";
import "@app/ui/Surface.css";
import "@app/ui/Collapsible.css";
export interface CollapsibleProps {
/** Whether the section is expanded. Controlled — pair with `onToggle`. */
open: boolean;
onToggle: () => void;
/** Header content (left/main side); the chevron is appended automatically. */
/** The toggle's label, shown after the chevron. */
header: ReactNode;
/** Right-aligned header content shown before the chevron (a count, a label). */
aside?: ReactNode;
children: ReactNode;
className?: string;
}
/**
* A single bordered disclosure section: an always-visible header that toggles,
* and a body that animates open/closed via a grid-rows transition (no magic
* max-height). Stack several to build an accordion. Header content is fully
* caller-supplied, so it suits both terse and rich (icon + chips + count) rows.
* A lightweight inline disclosure: a chevron-led label that toggles, and a body
* that animates open/closed via a grid-rows transition (no magic max-height).
* No surface or box - it sits inline within a form or panel.
*/
export function Collapsible({
open,
onToggle,
header,
aside,
children,
className,
}: CollapsibleProps) {
return (
<div
className={["sui-surface", "sui-collapsible", className ?? ""]
.filter(Boolean)
.join(" ")}
className={["sui-collapsible", className ?? ""].filter(Boolean).join(" ")}
>
<button
type="button"
@@ -40,25 +33,22 @@ export function Collapsible({
onClick={onToggle}
aria-expanded={open}
>
<span className="sui-collapsible__head-main">{header}</span>
<span className="sui-collapsible__head-end">
{aside}
<svg
className="sui-collapsible__chevron"
data-open={open}
viewBox="0 0 24 24"
width={16}
height={16}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
<svg
className="sui-collapsible__chevron"
data-open={open}
viewBox="0 0 24 24"
width={16}
height={16}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<polyline points="6 9 12 15 18 9" />
</svg>
{header}
</button>
<div className="sui-collapsible__body" data-open={open}>
<div className="sui-collapsible__body-inner">{children}</div>
+1 -1
View File
@@ -12,7 +12,7 @@ import { useInputAria } from "@app/ui/ariaForwarding";
import "@app/ui/MantineForms.css";
const SUI_INPUT_VARS = {
"--input-bg": "var(--c-surface)",
"--input-bg": "var(--c-input-bg)",
"--input-bd": "var(--c-border)",
"--input-bd-focus": "var(--c-primary)",
"--input-radius": "var(--radius-md)",
+30
View File
@@ -5,6 +5,12 @@
min-width: 0;
}
.sui-field__label-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
.sui-field__label {
font-size: 0.75rem;
font-weight: 600;
@@ -13,6 +19,30 @@
color: var(--color-section-label);
}
/* An (i) affordance beside the label: the explanation lives in its tooltip
rather than as permanent subtext under the control. */
.sui-field__info {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: none;
color: var(--c-text-subtle);
cursor: pointer;
line-height: 0;
}
.sui-field__info:hover {
color: var(--c-text);
}
.sui-field__info:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: 2px;
border-radius: 999px;
}
.sui-field__required {
/* The base red is a fill colour; as text on the form background it only
reaches 3.4:1. */
+54 -9
View File
@@ -5,12 +5,18 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import { Tooltip } from "@mantine/core";
import "@app/ui/FormField.css";
export interface FormFieldProps {
label?: ReactNode;
/** Helper text shown under the control. Replaced by `error` when present. */
helperText?: ReactNode;
/**
* Supplementary explanation shown behind an (i) affordance on the label,
* instead of taking up permanent space under the control like `helperText`.
*/
info?: ReactNode;
/** Error string. Causes the control + helper region to swap to the error tone. */
error?: ReactNode;
required?: boolean;
@@ -32,6 +38,7 @@ export interface FormFieldProps {
export function FormField({
label,
helperText,
info,
error,
required,
children,
@@ -57,16 +64,54 @@ export function FormField({
.filter(Boolean)
.join(" ")}
>
{label && (
<label htmlFor={controlId} className="sui-field__label">
{label}
{required && (
<span className="sui-field__required" aria-hidden>
{" "}
*
</span>
{(label || info) && (
<div className="sui-field__label-row">
{label && (
<label htmlFor={controlId} className="sui-field__label">
{label}
{required && (
<span className="sui-field__required" aria-hidden>
{" "}
*
</span>
)}
</label>
)}
</label>
{info && (
<Tooltip
label={info}
multiline
w={260}
withArrow
position="top"
events={{ hover: true, focus: true, touch: true }}
>
<button
type="button"
className="sui-field__info"
aria-label={
typeof info === "string" ? info : "More information"
}
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</button>
</Tooltip>
)}
</div>
)}
<div className="sui-field__control">{child}</div>
{(error || helperText) && (
+1 -1
View File
@@ -3,7 +3,7 @@
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-2);
background: var(--c-surface);
background: var(--c-input-bg);
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
color: var(--c-text);
+6 -2
View File
@@ -13,7 +13,7 @@
* here act as a typed reference and as a fallback for any slot Mantine reads
* before the inline vars are applied. */
.sui-mantine-wrapper {
--input-bg: var(--c-surface);
--input-bg: var(--c-input-bg);
--input-bd: var(--c-border);
--input-bd-focus: var(--c-primary);
--input-radius: var(--radius-md);
@@ -28,6 +28,10 @@
font-size: 0.875rem;
}
.sui-mantine-dropdown [role="option"] {
font-size: 0.875rem;
}
/* SUI focus ring — matches .sui-input:focus-within */
.sui-mantine-wrapper[data-focused],
.sui-mantine-wrapper:focus-within {
@@ -112,7 +116,7 @@
* SuiProvider syncs forceColorScheme to SUI theme, so
* [data-mantine-color-scheme="dark"] === [data-theme="dark"] in practice. ---- */
[data-mantine-color-scheme="dark"] .sui-mantine-wrapper {
--input-bg: var(--c-surface);
--input-bg: var(--c-input-bg);
--input-bd: var(--c-border);
--input-color: var(--c-text);
}
+13 -5
View File
@@ -15,7 +15,7 @@
/* Set our own font: the modal portals to <body>, so it can't inherit the
app's font through the DOM (the portal's .portal-scope isn't an ancestor). */
font-family: var(--font-sans);
background: var(--c-surface);
background: var(--c-modal-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-xl);
box-shadow:
@@ -56,12 +56,19 @@
.sui-modal__header {
display: flex;
align-items: flex-start;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.125rem 0.75rem;
border-bottom: 1px solid var(--c-border-subtle);
}
/* A lone title centres against the back/close controls. With a subtitle the
header text is two lines, so top-align it and let the controls sit on the
title line rather than the middle of the taller block. */
.sui-modal__header:has(.sui-modal__sub) {
align-items: flex-start;
}
.sui-modal__header-text {
flex: 1 1 auto;
min-width: 0;
@@ -79,9 +86,10 @@
color: var(--c-text-subtle);
}
/* Sizing/background/hover are owned by the Button; this just keeps it from
* stretching. */
.sui-modal__close {
/* Sizing/background/hover are owned by the Button; this just keeps them from
* stretching. The back arrow leads the header; the close button trails it. */
.sui-modal__close,
.sui-modal__back {
flex: 0 0 auto;
}
+34 -1
View File
@@ -12,6 +12,10 @@ export interface ModalProps {
title?: ReactNode;
subtitle?: ReactNode;
footer?: ReactNode;
/** When set, a back arrow renders at the start of the header (e.g. to step back in a staged modal). */
onBack?: () => void;
/** Accessible label for the back arrow. */
backLabel?: string;
/** sm=24rem, md=32rem, lg=48rem, xl=64rem. */
width?: ModalWidth;
disableBackdropClose?: boolean;
@@ -29,6 +33,8 @@ export function Modal({
title,
subtitle,
footer,
onBack,
backLabel,
width = "md",
disableBackdropClose = false,
disableEscapeClose = false,
@@ -82,8 +88,35 @@ export function Modal({
aria-label={!hasTitle ? ariaLabel : undefined}
tabIndex={-1}
>
{(title || subtitle) && (
{(title || subtitle || onBack) && (
<header className="sui-modal__header">
{onBack && (
<Button
variant="tertiary"
accent="neutral"
size="sm"
shape="circle"
className="sui-modal__back"
onClick={onBack}
aria-label={backLabel ?? "Back"}
leftSection={
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
}
/>
)}
<div className="sui-modal__header-text">
{title && (
<div id={titleId} className="sui-modal__title">
+2 -1
View File
@@ -8,7 +8,7 @@ import { useInputAria } from "@app/ui/ariaForwarding";
import "@app/ui/MantineForms.css";
const SUI_INPUT_VARS = {
"--input-bg": "var(--c-surface)",
"--input-bg": "var(--c-input-bg)",
"--input-bd": "var(--c-border)",
"--input-bd-focus": "var(--c-primary)",
"--input-radius": "var(--radius-md)",
@@ -174,6 +174,7 @@ export function MultiSelect({
ref={inputRef}
classNames={{
wrapper: "sui-mantine-wrapper",
dropdown: "sui-mantine-dropdown",
pill: "sui-mantine-pill",
pillsList: "sui-mantine-pills-list",
}}
+1 -1
View File
@@ -7,7 +7,7 @@ import { useInputAria } from "@app/ui/ariaForwarding";
import "@app/ui/MantineForms.css";
const SUI_INPUT_VARS = {
"--input-bg": "var(--c-surface)",
"--input-bg": "var(--c-input-bg)",
"--input-bd": "var(--c-border)",
"--input-bd-focus": "var(--c-primary)",
"--input-radius": "var(--radius-md)",
+1 -1
View File
@@ -2,7 +2,7 @@
position: relative;
display: inline-flex;
align-items: center;
background: var(--c-surface);
background: var(--c-input-bg);
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
color: var(--c-text);
+5 -2
View File
@@ -7,7 +7,7 @@ import { useInputAria } from "@app/ui/ariaForwarding";
import "@app/ui/MantineForms.css";
const SUI_INPUT_VARS = {
"--input-bg": "var(--c-surface)",
"--input-bg": "var(--c-input-bg)",
"--input-bd": "var(--c-border)",
"--input-bd-focus": "var(--c-primary)",
"--input-radius": "var(--radius-md)",
@@ -150,7 +150,10 @@ export function Select({
// required sets the input attribute only; FormField renders the asterisk.
withAsterisk={false}
ref={inputRef}
classNames={{ wrapper: "sui-mantine-wrapper" }}
classNames={{
wrapper: "sui-mantine-wrapper",
dropdown: "sui-mantine-dropdown",
}}
styles={{ wrapper: SUI_INPUT_VARS }}
{...passthroughProps}
/>
@@ -262,7 +262,7 @@ describe("ConnectionModal", () => {
expect(screen.queryByText("portal.connections.types.s3.label")).toBeNull();
});
it("picking a vendor opens its form, and the back link returns to the grid", async () => {
it("picking a vendor opens its form, and the header back arrow returns to the grid", async () => {
render(wrap(<ConnectionModal open onClose={vi.fn()} onSaved={vi.fn()} />));
await userEvent.click(
@@ -280,7 +280,9 @@ describe("ConnectionModal", () => {
).toBeInTheDocument(),
);
await userEvent.click(screen.getByText("portal.connections.picker2.back"));
await userEvent.click(
screen.getByRole("button", { name: "portal.connections.picker2.back" }),
);
await waitFor(() =>
expect(
screen.getByText("portal.connections.types.s3.label"),
@@ -1,4 +1,3 @@
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Banner, Button, Modal } from "@app/ui";
@@ -152,6 +151,11 @@ export function ConnectionModal({
onClose={onClose}
// The grid wants room to breathe; a form is easier to read narrow.
width={mode === "pick" ? "lg" : "md"}
// Once a type is chosen the header owns the step-back to the picker; the form
// body is just the fields. Only when a choice was actually offered (not a
// pinned slot or an edit, where the type is fixed).
onBack={mode === "form" && canPick ? () => setMode("pick") : undefined}
backLabel={t("portal.connections.picker2.back")}
title={
isEdit
? t("portal.connections.editTitle")
@@ -190,20 +194,6 @@ export function ConnectionModal({
<ConnectionTypePicker types={available} onPick={pickType} />
) : (
<>
{canPick && (
// The choice is made in the picker now; this just gets them back to it, so the
// selection stays visible without a dropdown that duplicates the grid.
<Button
variant="quiet"
size="sm"
className="portal-sources__connection-back"
leftSection={<ArrowBackRoundedIcon fontSize="inherit" />}
onClick={() => setMode("pick")}
>
{t("portal.connections.picker2.back")}
</Button>
)}
{type ? (
<ConnectionForm type={type} values={values} onChange={setValues} />
) : (
@@ -1,3 +1,4 @@
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Banner, Button, Select } from "@app/ui";
@@ -83,21 +84,26 @@ export function ConnectionPicker({
};
}, [integrationType, presetId]);
const openCreate = () => (onCreateNew ? onCreateNew() : setModalOpen(true));
const showSelect = (connections?.length ?? 0) > 0 || Boolean(value);
return (
<div className="portal-sources__connection-picker">
<Select
value={value || null}
placeholder={t("portal.connections.picker.placeholder")}
options={(connections ?? []).map((connection) => ({
value: String(connection.id),
label: connection.name,
}))}
onChange={(selected) => onChange(selected ?? "")}
/>
{showSelect && (
<Select
value={value || null}
placeholder={t("portal.connections.picker.placeholder")}
options={(connections ?? []).map((connection) => ({
value: String(connection.id),
label: connection.name,
}))}
onChange={(selected) => onChange(selected ?? "")}
/>
)}
<Button
variant="tertiary"
size="sm"
onClick={() => (onCreateNew ? onCreateNew() : setModalOpen(true))}
variant="secondary"
leftSection={<AddRoundedIcon fontSize="inherit" />}
onClick={openCreate}
>
{t("portal.connections.picker.createNew")}
</Button>
@@ -35,7 +35,7 @@
cursor: pointer;
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
background: var(--c-surface);
background: var(--c-surface-raised);
font: inherit;
color: inherit;
transition:
@@ -107,24 +107,24 @@ button.portal-source-modal__card:focus-visible {
gap: 0.875rem;
}
/* The Advanced disclosure's revealed fields stack like the rest of the form. */
.portal-source-modal__advanced-fields {
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;
/* Type identity in the modal header: brand icon beside the connector name. */
.portal-source-modal__title {
display: inline-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);
gap: 0.5rem;
}
.portal-source-modal__muted {
@@ -144,14 +144,6 @@ button.portal-source-modal__card:focus-visible {
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;
@@ -82,7 +82,7 @@ describe("SourceModal", () => {
// 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/), {
fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), {
target: { value: "Claims intake" },
});
fireEvent.change(
@@ -114,7 +114,7 @@ describe("SourceModal", () => {
renderModal();
fireEvent.click(screen.getByText("portal.sources.types.folder.label"));
fireEvent.change(screen.getByLabelText(/portal\.integrations\.typedName/), {
fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), {
target: { value: "Claims intake" },
});
fireEvent.change(
@@ -139,7 +139,7 @@ describe("SourceModal", () => {
renderModal();
fireEvent.click(screen.getByText("portal.sources.types.folder.label"));
fireEvent.change(screen.getByLabelText(/portal\.integrations\.typedName/), {
fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), {
target: { value: "Claims intake" },
});
fireEvent.change(
@@ -159,7 +159,7 @@ describe("SourceModal", () => {
it("gates the s3 type on a chosen connection", async () => {
renderModal();
fireEvent.click(screen.getByText("portal.sources.types.s3.label"));
fireEvent.change(screen.getByLabelText(/portal\.integrations\.typedName/), {
fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), {
target: { value: "Bucket source" },
});
expect(
@@ -180,7 +180,7 @@ describe("SourceModal", () => {
const { onClose } = renderModal();
fireEvent.click(screen.getByText("portal.sources.types.webhook.label"));
fireEvent.change(screen.getByLabelText(/portal\.integrations\.typedName/), {
fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), {
target: { value: "Partner uploads" },
});
fireEvent.click(screen.getByText("portal.sources.builder.create"));
@@ -253,7 +253,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\.wizard\.name/),
).not.toBeInTheDocument();
expect(
screen.getAllByText("portal.sources.builder.comingSoon").length,
@@ -1,13 +1,13 @@
import { useEffect, useState } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import {
Banner,
Button,
Checkbox,
Collapsible,
FormField,
Input,
Modal,
type ModalWidth,
Select,
Spinner,
} from "@app/ui";
@@ -29,6 +29,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";
@@ -73,6 +74,13 @@ function optionsFor(
type Stage = "type" | "configure" | "reveal" | "delete" | "connection";
/** The picker grid wants room; a delete confirm wants little; forms sit between. */
function stageWidth(stage: Stage): ModalWidth {
if (stage === "type") return "lg";
if (stage === "delete") return "sm";
return "md";
}
/** 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",
@@ -148,6 +156,9 @@ export function SourceModal({
);
const [connField, setConnField] = useState("");
const [connSaving, setConnSaving] = useState(false);
// The "Advanced" disclosure starts closed; on edit it opens if a stored value
// there differs from its default, so nothing non-standard hides behind it.
const [showAdvanced, setShowAdvanced] = useState(false);
// Seed on every open: fresh catalogue for create, fetched record for edit.
useEffect(() => {
@@ -163,6 +174,7 @@ export function SourceModal({
setOptions(defaultOptions(OFFERED_TYPES[0]));
setEnabled(true);
setLoaded(null);
setShowAdvanced(false);
return;
}
setStage("configure");
@@ -170,11 +182,17 @@ export function SourceModal({
fetchSource(sourceId)
.then((source) => {
const resolved = typeFor(source.type);
const opts = optionsFor(resolved, source.options);
setLoaded(source);
setType(resolved);
setName(source.name ?? "");
setOptions(optionsFor(resolved, source.options));
setOptions(opts);
setEnabled(source.enabled ?? true);
setShowAdvanced(
resolved.fields.some(
(f) => f.advanced && (opts[f.key] ?? "") !== (f.defaultValue ?? ""),
),
);
})
.catch((e) => setError(errorMessage(e)))
.finally(() => setLoading(false));
@@ -183,6 +201,7 @@ export function SourceModal({
function chooseType(next: CreatableSourceType) {
setType(next);
setOptions(defaultOptions(next));
setShowAdvanced(false);
setStage("configure");
}
@@ -288,77 +307,96 @@ export function SourceModal({
void navigator.clipboard?.writeText(text);
}
const title =
stage === "type"
? t("portal.sources.builder.createTitle")
: stage === "connection"
? t("portal.connections.createTitleFor", {
name: t(connType.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");
// Once a type is chosen the header carries its identity (icon + name), so the
// configure body is just the form - no title/summary block in it.
const showTypeHeader = stage === "configure" && !loading;
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>
)}
function renderTitle(): ReactNode {
if (showTypeHeader) {
return (
<span className="portal-source-modal__title">
<BrandMark id={type.type} size={18} />
{t(type.labelKey)}
</span>
);
}
switch (stage) {
case "connection":
return t("portal.connections.createTitleFor", {
name: t(connType.labelKey),
});
case "reveal":
return t("portal.sources.types.webhook.reveal.title");
case "delete":
return t("portal.sources.delete.title");
default:
// The picker, or an edit whose record is still loading.
return t(
isEdit
? "portal.sources.builder.editTitle"
: "portal.sources.builder.createTitle",
);
}
}
// The header back arrow only has somewhere to step to from the two staged
// steps: the connection sub-form returns to the source form, and a fresh
// source's form returns to the type picker.
function stageBack(): { onBack?: () => void; backLabel: string } {
if (stage === "connection") {
return {
onBack: () => setStage("configure"),
backLabel: t("portal.sources.builder.backToSource"),
};
}
if (stage === "configure" && !isEdit) {
return {
onBack: () => setStage("type"),
backLabel: t("portal.sources.builder.backToTypes"),
};
}
return { backLabel: t("portal.sources.builder.backToTypes") };
}
function renderFooter(): ReactNode {
switch (stage) {
case "configure":
return (
<div className="portal-source-modal__footer-actions">
{isEdit && (
<Button
variant="tertiary"
size="sm"
accent="danger"
disabled={submitting}
onClick={onClose}
onClick={() => setStage("delete")}
>
{t("portal.sources.builder.cancel")}
{t("portal.sources.builder.delete")}
</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")}
disabled={submitting}
onClick={onClose}
>
{t("portal.connections.picker.cancel")}
{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>
</div>
);
case "connection":
return (
<div className="portal-source-modal__footer-actions">
<Button
size="sm"
loading={connSaving}
@@ -368,13 +406,17 @@ export function SourceModal({
{t("portal.connections.picker.save")}
</Button>
</div>
) : stage === "reveal" ? (
);
case "reveal":
return (
<div className="portal-source-modal__footer-actions">
<Button size="sm" onClick={finish}>
{t("portal.sources.types.webhook.reveal.done")}
</Button>
</div>
) : stage === "delete" ? (
);
case "delete":
return (
<div className="portal-source-modal__footer-actions">
<Button
variant="tertiary"
@@ -393,8 +435,107 @@ export function SourceModal({
{t("portal.sources.delete.confirm")}
</Button>
</div>
) : undefined
}
);
default:
return undefined; // "type" - the picker commits on click, so no footer.
}
}
function renderS3ConnectionControl(field: SourceFieldDef) {
return (
<S3ConnectionPicker
value={options[field.key] ?? ""}
onChange={(id) => setOption(field.key, id)}
onCreateNew={() => openConnectionStage(field.key, S3_CONNECTION_TYPE)}
/>
);
}
function renderConnectionControl(field: SourceFieldDef) {
const connectionType = connectionTypeById(field.connectionTypeId ?? "");
return (
<ConnectionPicker
value={options[field.key] ?? ""}
onChange={(id) => setOption(field.key, id)}
integrationType={connectionType.integrationType}
createTypeId={field.connectionTypeId ?? ""}
presetId={field.connectionTypeId}
onCreateNew={() => openConnectionStage(field.key, connectionType)}
/>
);
}
function renderSelectControl(field: SourceFieldDef) {
return (
<Select
value={options[field.key] ?? ""}
options={(field.options ?? []).map((o) => ({
value: o.value,
label: t(o.labelKey),
}))}
onChange={(value) => setOption(field.key, value ?? "")}
/>
);
}
function renderInputControl(field: SourceFieldDef) {
return (
<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)}
/>
);
}
function renderControl(field: SourceFieldDef) {
switch (field.control) {
case "s3Connection":
return renderS3ConnectionControl(field);
case "connection":
return renderConnectionControl(field);
case "select":
return renderSelectControl(field);
default:
return renderInputControl(field);
}
}
function renderField(field: SourceFieldDef) {
return (
<FormField
key={field.key}
label={t(field.labelKey)}
info={field.helperTextKey ? t(field.helperTextKey) : undefined}
required={field.required}
>
{renderControl(field)}
</FormField>
);
}
// A field can gate itself on another's current value (e.g. change detection
// only applies in consume mode), so a knob that does nothing never shows.
function fieldVisible(field: SourceFieldDef): boolean {
const cond = field.visibleWhen;
return !cond || (options[cond.key] ?? "") === cond.equals;
}
const visibleFields = type.fields.filter(fieldVisible);
const primaryFields = visibleFields.filter((field) => !field.advanced);
const advancedFields = visibleFields.filter((field) => field.advanced);
const back = stageBack();
return (
<Modal
open={open}
onClose={stage === "reveal" ? finish : onClose}
width={stageWidth(stage)}
title={renderTitle()}
onBack={back.onBack}
backLabel={back.backLabel}
footer={renderFooter()}
>
{stage === "type" && (
<div className="portal-source-modal__catalog">
@@ -470,36 +611,7 @@ 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">
<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
>
<FormField label={t("portal.sources.wizard.name")} required>
<Input
value={name}
placeholder={t("portal.sources.wizard.namePlaceholder")}
@@ -513,69 +625,19 @@ 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}
{primaryFields.map((field) => renderField(field))}
{advancedFields.length > 0 && (
<Collapsible
open={showAdvanced}
onToggle={() => setShowAdvanced((v) => !v)}
header={t("portal.sources.builder.advanced")}
>
{field.control === "s3Connection" ? (
<S3ConnectionPicker
value={options[field.key] ?? ""}
onChange={(connectionId) =>
setOption(field.key, connectionId)
}
onCreateNew={() =>
openConnectionStage(field.key, S3_CONNECTION_TYPE)
}
/>
) : field.control === "connection" ? (
<ConnectionPicker
value={options[field.key] ?? ""}
onChange={(connectionId) =>
setOption(field.key, connectionId)
}
integrationType={
connectionTypeById(field.connectionTypeId ?? "")
.integrationType
}
createTypeId={field.connectionTypeId ?? ""}
presetId={field.connectionTypeId}
onCreateNew={() =>
openConnectionStage(
field.key,
connectionTypeById(field.connectionTypeId ?? ""),
)
}
/>
) : 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>
))}
<div className="portal-source-modal__advanced-fields">
{advancedFields.map((field) => renderField(field))}
</div>
</Collapsible>
)}
{editingWebhookId && (
<FormField
@@ -4,12 +4,6 @@
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
.portal-sources__connection-picker .sui-select,
.portal-sources__connection-picker > div:first-child {
align-self: stretch;
}
.portal-sources__connection-create-actions {
@@ -169,6 +163,7 @@
cursor: pointer;
font: inherit;
color: inherit;
background: var(--c-surface-raised);
}
.portal-conn-picker__card:hover {
@@ -74,6 +74,10 @@ export interface SourceFieldDef {
helperTextKey?: string;
options?: { value: string; labelKey: string }[];
defaultValue?: string;
/** Tucked behind the "Advanced" disclosure: power settings whose default suits almost everyone. */
advanced?: boolean;
/** Only rendered while another field currently equals this value (e.g. a knob that only applies in one mode). */
visibleWhen?: { key: string; equals: string };
/**
* For `control: "connection"` - the connection-catalogue entry id this slot accepts (e.g.
* "sftp"). Filters the picker to matching connections and pins the inline "new connection" form.
@@ -117,6 +121,7 @@ function networkSourceFields(connectionTypeId: string): SourceFieldDef[] {
control: "select",
defaultValue: "consume",
helperTextKey: "portal.sources.networkFields.mode.helperText",
advanced: true,
options: [
{
value: "consume",
@@ -133,6 +138,7 @@ function networkSourceFields(connectionTypeId: string): SourceFieldDef[] {
labelKey: "portal.sources.networkFields.recursive.label",
control: "select",
defaultValue: "false",
helperTextKey: "portal.sources.networkFields.recursive.helperText",
options: [
{
value: "false",
@@ -168,6 +174,8 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [
labelKey: "portal.sources.types.folder.fields.mode.label",
control: "select",
defaultValue: "consume",
helperTextKey: "portal.sources.types.folder.fields.mode.helperText",
advanced: true,
options: [
{
value: "consume",
@@ -185,6 +193,8 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [
labelKey: "portal.sources.types.folder.fields.recursive.label",
control: "select",
defaultValue: "false",
helperTextKey:
"portal.sources.types.folder.fields.recursive.helperText",
options: [
{
value: "false",
@@ -204,6 +214,9 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [
control: "select",
defaultValue: "stat",
helperTextKey: "portal.sources.types.folder.fields.identity.helperText",
advanced: true,
// Change detection only governs the consume ledger; snapshot re-reads everything regardless.
visibleWhen: { key: "mode", equals: "consume" },
options: [
{
value: "stat",
@@ -244,6 +257,7 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [
control: "select",
defaultValue: "consume",
helperTextKey: "portal.sources.types.s3.fields.mode.helperText",
advanced: true,
options: [
{
value: "consume",
@@ -128,7 +128,7 @@ export const suiCssVariablesResolver: CSSVariablesResolver = () => ({
light: {
...suiAccessibleColorSlots,
// Popover/dropdown background + combobox search input
"--mantine-color-white": "var(--c-surface)",
"--mantine-color-white": "var(--c-surface-raised)",
// Option hover background
"--mantine-color-gray-0": "var(--c-hover)",
// Dropdown border