mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
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:
@@ -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."
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user