From cfaf777f2b776c207ffeab8aef8ff82981872027 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 10 Aug 2026 11:40:47 +0100 Subject: [PATCH 01/10] Fix more `any` type usages in frontend code (#7334) # Description of Changes Continued effort towards removing all uses of the any type in our frontend code (last PR was #7326). This PR fixes 7 more folders and removes them from the exclude list. All of them were localised within the folder in the exclude list so again were pretty easy to fix. --- .../providers/PDFAnnotationProvider.tsx | 9 ++--- .../annotation/shared/BaseAnnotationTool.tsx | 12 +++++-- .../pageEditor/DragDropGrid.stories.tsx | 6 ++-- .../components/pageEditor/DragDropGrid.tsx | 11 +++++-- .../core/components/pageEditor/PageEditor.tsx | 17 +++++++--- .../components/pageEditor/PageThumbnail.tsx | 3 +- .../components/tools/FullscreenToolList.tsx | 8 ++--- .../certSign/CertificateFilesSettings.tsx | 5 ++- .../certSign/CertificateFormatSettings.tsx | 5 ++- .../certSign/CertificateTypeSettings.tsx | 5 ++- .../certSign/HardwareCertificateSettings.tsx | 33 ++++++++++++------- .../certSign/SignatureAppearanceSettings.tsx | 12 +++++-- .../tools/certSign/SignatureSettingsInput.tsx | 9 +++-- .../tools/pdfTextEditor/PdfTextEditorView.tsx | 22 +++++++++---- .../src/core/contexts/file/fileActions.ts | 8 ++--- .../src/core/contexts/file/fileHooks.ts | 2 +- .../src/core/contexts/file/lifecycle.ts | 11 ++++--- .../tools/convert/useConvertOperation.ts | 12 ++++--- .../core/hooks/tools/ocr/useOCROperation.ts | 10 +++--- .../hooks/tools/shared/toolOperationTypes.ts | 2 +- .../hooks/tools/shared/useToolOperation.ts | 13 ++++---- frontend/oxlint.config.ts | 7 ---- 22 files changed, 145 insertions(+), 77 deletions(-) diff --git a/frontend/editor/src/core/components/annotation/providers/PDFAnnotationProvider.tsx b/frontend/editor/src/core/components/annotation/providers/PDFAnnotationProvider.tsx index bbc6484f2e..a009e3d1a1 100644 --- a/frontend/editor/src/core/components/annotation/providers/PDFAnnotationProvider.tsx +++ b/frontend/editor/src/core/components/annotation/providers/PDFAnnotationProvider.tsx @@ -1,4 +1,5 @@ import React, { createContext, useContext, ReactNode } from "react"; +import { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; interface PDFAnnotationContextValue { // Drawing mode management @@ -22,8 +23,8 @@ interface PDFAnnotationContextValue { isPlacementMode: boolean; // Signature configuration - signatureConfig: any | null; - setSignatureConfig: (config: any | null) => void; + signatureConfig: SignParameters | null; + setSignatureConfig: (config: SignParameters | null) => void; } const PDFAnnotationContext = createContext< @@ -43,8 +44,8 @@ interface PDFAnnotationProviderProps { storeImageData: (id: string, data: string) => void; getImageData: (id: string) => string | undefined; isPlacementMode: boolean; - signatureConfig: any | null; - setSignatureConfig: (config: any | null) => void; + signatureConfig: SignParameters | null; + setSignatureConfig: (config: SignParameters | null) => void; } export const PDFAnnotationProvider: React.FC = ({ diff --git a/frontend/editor/src/core/components/annotation/shared/BaseAnnotationTool.tsx b/frontend/editor/src/core/components/annotation/shared/BaseAnnotationTool.tsx index 4c0862a012..e611ba4b83 100644 --- a/frontend/editor/src/core/components/annotation/shared/BaseAnnotationTool.tsx +++ b/frontend/editor/src/core/components/annotation/shared/BaseAnnotationTool.tsx @@ -14,9 +14,17 @@ export interface AnnotationToolConfig { placeButtonText?: string; } +interface InjectedAnnotationToolProps { + selectedColor: string; + signatureData: string | null; + onSignatureDataChange: (data: string | null) => void; + onColorSwatchClick: () => void; + disabled: boolean; +} + interface BaseAnnotationToolProps { config: AnnotationToolConfig; - children: React.ReactNode; + children: React.ReactElement>; onSignatureDataChange?: (data: string | null) => void; disabled?: boolean; } @@ -90,7 +98,7 @@ export const BaseAnnotationTool: React.FC = ({ /> {/* Tool Content */} - {React.cloneElement(children as React.ReactElement, { + {React.cloneElement(children, { selectedColor, signatureData, onSignatureDataChange: handleSignatureDataChange, diff --git a/frontend/editor/src/core/components/pageEditor/DragDropGrid.stories.tsx b/frontend/editor/src/core/components/pageEditor/DragDropGrid.stories.tsx index be7616f1f9..a437402985 100644 --- a/frontend/editor/src/core/components/pageEditor/DragDropGrid.stories.tsx +++ b/frontend/editor/src/core/components/pageEditor/DragDropGrid.stories.tsx @@ -1,5 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import DragDropGrid from "@app/components/pageEditor/DragDropGrid"; +import DragDropGrid, { + type DragHandleProps, +} from "@app/components/pageEditor/DragDropGrid"; interface MockGridItem { id: string; @@ -22,7 +24,7 @@ const renderItem = ( clearBoxSelection: () => void, activeDragIds: string[], justMoved: boolean, - dragHandleProps?: any, + dragHandleProps?: DragHandleProps, zoomLevel?: number, ) => { const { ref: dndRef, ...restDragProps } = dragHandleProps ?? {}; diff --git a/frontend/editor/src/core/components/pageEditor/DragDropGrid.tsx b/frontend/editor/src/core/components/pageEditor/DragDropGrid.tsx index de1a3f59d7..03fa1dd096 100644 --- a/frontend/editor/src/core/components/pageEditor/DragDropGrid.tsx +++ b/frontend/editor/src/core/components/pageEditor/DragDropGrid.tsx @@ -20,6 +20,7 @@ import { DragEndEvent, DragStartEvent, DragOverlay, + DraggableAttributes, useSensor, useSensors, PointerSensor, @@ -28,6 +29,10 @@ import { useDroppable, } from "@dnd-kit/core"; +export type DragHandleProps = DraggableAttributes & { + ref: React.RefCallback; +} & Record; + interface DragDropItem { id: string; splitAfter?: boolean; @@ -51,7 +56,7 @@ interface DragDropGridProps { clearBoxSelection: () => void, activeDragIds: string[], justMoved: boolean, - dragHandleProps?: any, + dragHandleProps?: DragHandleProps, zoomLevel?: number, ) => React.ReactNode; getThumbnailData?: ( @@ -232,7 +237,7 @@ interface DraggableItemProps { clearBoxSelection: () => void, activeDragIds: string[], justMoved: boolean, - dragHandleProps?: any, + dragHandleProps?: DragHandleProps, zoomLevel?: number, ) => React.ReactNode; zoomLevel: number; @@ -253,7 +258,7 @@ const DraggableItemInner = ({ zoomLevel, }: DraggableItemProps) => { const isPlaceholder = Boolean(item.isPlaceholder); - const pageNumber = (item as any).pageNumber ?? index + 1; + const pageNumber = item.pageNumber ?? index + 1; const { attributes, listeners, diff --git a/frontend/editor/src/core/components/pageEditor/PageEditor.tsx b/frontend/editor/src/core/components/pageEditor/PageEditor.tsx index b635dc7dc4..08971c0c59 100644 --- a/frontend/editor/src/core/components/pageEditor/PageEditor.tsx +++ b/frontend/editor/src/core/components/pageEditor/PageEditor.tsx @@ -11,7 +11,9 @@ import { PageEditorFunctions, PDFPage } from "@app/types/pageEditor"; // Thumbnail generation is now handled by individual PageThumbnail components import "@app/components/pageEditor/PageEditor.module.css"; import PageThumbnail from "@app/components/pageEditor/PageThumbnail"; -import DragDropGrid from "@app/components/pageEditor/DragDropGrid"; +import DragDropGrid, { + type DragHandleProps, +} from "@app/components/pageEditor/DragDropGrid"; import SkeletonLoader from "@app/components/shared/SkeletonLoader"; import { FileId } from "@app/types/file"; import { GRID_CONSTANTS } from "@app/components/pageEditor/constants"; @@ -33,6 +35,13 @@ export interface PageEditorProps { onFunctionsReady?: (functions: PageEditorFunctions) => void; } +interface PageEditorFileEntry { + fileId: FileId; + name: string; + versionNumber: number | undefined; + isSelected: boolean; +} + const PageEditor = ({ onFunctionsReady }: PageEditorProps) => { const { t } = useTranslation(); // Use split contexts to prevent re-renders @@ -106,14 +115,14 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => { const selectedIdsKey = [...state.ui.selectedFileIds].sort().join(","); const filesSignature = selectors.getFilesSignature(); - const fileObjectsRef = useRef(new Map()); + const fileObjectsRef = useRef(new Map()); const gridItemRefsRef = useRef > | null>(null); const pageEditorFiles = useMemo(() => { const cache = fileObjectsRef.current; - const newFiles: any[] = []; + const newFiles: PageEditorFileEntry[] = []; fileOrder.forEach((fileId) => { const stub = selectors.getStirlingFileStub(fileId); @@ -605,7 +614,7 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => { clearBoxSelection: () => void, activeDragIds: string[], justMoved: boolean, - dragHandleProps?: any, + dragHandleProps?: DragHandleProps, zoomLevelParam?: number, ) => { gridItemRefsRef.current = refs; diff --git a/frontend/editor/src/core/components/pageEditor/PageThumbnail.tsx b/frontend/editor/src/core/components/pageEditor/PageThumbnail.tsx index da54602d81..127c982a9d 100644 --- a/frontend/editor/src/core/components/pageEditor/PageThumbnail.tsx +++ b/frontend/editor/src/core/components/pageEditor/PageThumbnail.tsx @@ -17,6 +17,7 @@ import AddIcon from "@mui/icons-material/Add"; import { PDFPage, PDFDocument } from "@app/types/pageEditor"; import { useFilesModalContext } from "@app/contexts/FilesModalContext"; import { getFileColorWithOpacity } from "@app/components/pageEditor/fileColors"; +import { type DragHandleProps } from "@app/components/pageEditor/DragDropGrid"; import styles from "@app/components/pageEditor/PageEditor.module.css"; import HoverActionMenu, { HoverAction, @@ -38,7 +39,7 @@ interface PageThumbnailProps { activeDragIds: string[]; justMoved?: boolean; pageRefs: React.MutableRefObject>; - dragHandleProps?: any; + dragHandleProps?: DragHandleProps; onReorderPages: ( sourcePageNumber: number, targetIndex: number, diff --git a/frontend/editor/src/core/components/tools/FullscreenToolList.tsx b/frontend/editor/src/core/components/tools/FullscreenToolList.tsx index ef7818ff9d..5296e21299 100644 --- a/frontend/editor/src/core/components/tools/FullscreenToolList.tsx +++ b/frontend/editor/src/core/components/tools/FullscreenToolList.tsx @@ -58,8 +58,8 @@ const FullscreenToolList = ({ ); const recommendedItems = useMemo(() => { if (!quickSection) - return [] as Array<{ id: string; tool: ToolRegistryEntry }>; - const items: Array<{ id: string; tool: ToolRegistryEntry }> = []; + return [] as Array<{ id: ToolId; tool: ToolRegistryEntry }>; + const items: Array<{ id: ToolId; tool: ToolRegistryEntry }> = []; quickSection.subcategories.forEach((sc) => sc.tools.forEach((t) => items.push(t)), ); @@ -217,13 +217,13 @@ const FullscreenToolList = ({ {showDescriptions ? (
- {recommendedItems.map((item: any) => + {recommendedItems.map((item) => renderToolItem(item.id, item.tool), )}
) : (
- {recommendedItems.map((item: any) => + {recommendedItems.map((item) => renderToolItem(item.id, item.tool), )}
diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateFilesSettings.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateFilesSettings.tsx index 035f57e5c4..4f97b0a83c 100644 --- a/frontend/editor/src/core/components/tools/certSign/CertificateFilesSettings.tsx +++ b/frontend/editor/src/core/components/tools/certSign/CertificateFilesSettings.tsx @@ -5,7 +5,10 @@ import FileUploadButton from "@app/components/shared/FileUploadButton"; interface CertificateFilesSettingsProps { parameters: CertSignParameters; - onParameterChange: (key: keyof CertSignParameters, value: any) => void; + onParameterChange: ( + key: K, + value: CertSignParameters[K], + ) => void; disabled?: boolean; } diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.tsx index cbc4b67163..a3a17f928a 100644 --- a/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.tsx +++ b/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.tsx @@ -4,7 +4,10 @@ import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParamet interface CertificateFormatSettingsProps { parameters: CertSignParameters; - onParameterChange: (key: keyof CertSignParameters, value: any) => void; + onParameterChange: ( + key: K, + value: CertSignParameters[K], + ) => void; disabled?: boolean; } diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.tsx index aaab1cb93e..1dc3865c66 100644 --- a/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.tsx +++ b/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.tsx @@ -7,7 +7,10 @@ import { useAppConfig } from "@app/contexts/AppConfigContext"; interface CertificateTypeSettingsProps { parameters: CertSignParameters; - onParameterChange: (key: keyof CertSignParameters, value: any) => void; + onParameterChange: ( + key: K, + value: CertSignParameters[K], + ) => void; disabled?: boolean; } diff --git a/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.tsx b/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.tsx index b384ce8d5c..75b93210a0 100644 --- a/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.tsx +++ b/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.tsx @@ -22,7 +22,10 @@ import { interface HardwareCertificateSettingsProps { parameters: CertSignParameters; - onParameterChange: (key: keyof CertSignParameters, value: any) => void; + onParameterChange: ( + key: K, + value: CertSignParameters[K], + ) => void; disabled?: boolean; } @@ -176,16 +179,20 @@ const HardwareCertificateSettings = ({ setError(null); listWindowsCertificates() .then(applyCerts) - .catch((e: any) => + .catch((e) => { + const err = e as { + response?: { data?: { message?: string } }; + message?: string; + }; setError( - e?.response?.data?.message || - e?.message || + err?.response?.data?.message || + err?.message || t( "certSign.hardware.windowsLoadError", "Could not read the Windows certificate store", ), - ), - ) + ); + }) .finally(() => setLoading(false)); }, [applyCerts, t]); @@ -221,16 +228,20 @@ const HardwareCertificateSettings = ({ pin: parameters.password, }) .then(applyCerts) - .catch((e: any) => + .catch((e) => { + const err = e as { + response?: { data?: { message?: string } }; + message?: string; + }; setError( - e?.response?.data?.message || - e?.message || + err?.response?.data?.message || + err?.message || t( "certSign.hardware.pkcs11LoadError", "Could not read certificates from the token. Check the PIN and driver.", ), - ), - ) + ); + }) .finally(() => setLoading(false)); }, [ applyCerts, diff --git a/frontend/editor/src/core/components/tools/certSign/SignatureAppearanceSettings.tsx b/frontend/editor/src/core/components/tools/certSign/SignatureAppearanceSettings.tsx index 75998b1c88..dcbeac026e 100644 --- a/frontend/editor/src/core/components/tools/certSign/SignatureAppearanceSettings.tsx +++ b/frontend/editor/src/core/components/tools/certSign/SignatureAppearanceSettings.tsx @@ -5,7 +5,10 @@ import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParamet interface SignatureAppearanceSettingsProps { parameters: CertSignParameters; - onParameterChange: (key: keyof CertSignParameters, value: any) => void; + onParameterChange: ( + key: K, + value: CertSignParameters[K], + ) => void; disabled?: boolean; } @@ -101,7 +104,12 @@ const SignatureAppearanceSettings = ({ onParameterChange("pageNumber", value || 1)} + onChange={(value) => + onParameterChange( + "pageNumber", + typeof value === "number" ? value : 1, + ) + } min={1} disabled={disabled} /> diff --git a/frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.tsx b/frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.tsx index d0e7a69d2f..a89d66e447 100644 --- a/frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.tsx +++ b/frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.tsx @@ -24,7 +24,10 @@ const SignatureSettingsInput = ({ }: SignatureSettingsInputProps) => { const { t } = useTranslation(); - const handleChange = (key: keyof SignatureSettings, val: any) => { + const handleChange = ( + key: K, + val: SignatureSettings[K], + ) => { onChange({ ...value, [key]: val }); }; @@ -104,7 +107,9 @@ const SignatureSettingsInput = ({ handleChange("pageNumber", val || 1)} + onChange={(val) => + handleChange("pageNumber", typeof val === "number" ? val : 1) + } min={1} disabled={disabled} size="xs" diff --git a/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx b/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx index 0cbd88e619..1d314fec14 100644 --- a/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx +++ b/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx @@ -52,6 +52,14 @@ import { const MAX_RENDER_WIDTH = 820; const MIN_BOX_SIZE = 18; +// Firefox-only fallback for document.caretRangeFromPoint (not in lib.dom.d.ts). +const docWithCaret = document as Document & { + caretPositionFromPoint?: ( + x: number, + y: number, + ) => { offsetNode: Node; offset: number } | null; +}; + const normalizeFontFormat = (format?: string | null): string => { if (!format) { return "ttf"; @@ -352,7 +360,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => { new Map(), ); const draggingImageRef = useRef(null); - const rndRefs = useRef>(new Map()); + const rndRefs = useRef>(new Map()); const pendingDragUpdateRef = useRef(null); const [fontFamilies, setFontFamilies] = useState>( new Map(), @@ -1378,7 +1386,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => { const cssTop = (pageHeight - bounds.top) * scale; // Get current position from Rnd component - const currentState = rndRef.state || {}; + const currentState = (rndRef.state as { x?: number; y?: number }) || {}; const currentX = currentState.x ?? 0; const currentY = currentState.y ?? 0; @@ -2851,11 +2859,13 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => { } } } else if ( - (document as any).caretPositionFromPoint + docWithCaret.caretPositionFromPoint ) { - const pos = ( - document as any - ).caretPositionFromPoint(clickX, clickY); + const pos = + docWithCaret.caretPositionFromPoint( + clickX, + clickY, + ); if (pos) { const range = document.createRange(); range.setStart( diff --git a/frontend/editor/src/core/contexts/file/fileActions.ts b/frontend/editor/src/core/contexts/file/fileActions.ts index db99e1c673..a95f42939d 100644 --- a/frontend/editor/src/core/contexts/file/fileActions.ts +++ b/frontend/editor/src/core/contexts/file/fileActions.ts @@ -447,10 +447,8 @@ export async function addFiles( if (file.type === "application/pdf") { try { if (await FileAnalyzer.isPDFUserPasswordProtected(file)) { - fileStub.processedFile = (fileStub.processedFile || { - pages: [], - }) as any; - fileStub.processedFile!.isEncrypted = true; + fileStub.processedFile = fileStub.processedFile || { pages: [] }; + fileStub.processedFile.isEncrypted = true; } } catch (error) { // Never block upload on analysis failure — but log so it's debuggable @@ -699,7 +697,7 @@ export async function undoConsumeFiles( file: File, fileId: FileId, existingThumbnail?: string, - ) => Promise; + ) => Promise; deleteFile: (fileId: FileId) => Promise; bumpRevision?: () => void; } | null, diff --git a/frontend/editor/src/core/contexts/file/fileHooks.ts b/frontend/editor/src/core/contexts/file/fileHooks.ts index f5c1bfc460..c6207e442e 100644 --- a/frontend/editor/src/core/contexts/file/fileHooks.ts +++ b/frontend/editor/src/core/contexts/file/fileHooks.ts @@ -398,7 +398,7 @@ export function useFileContext() { addFiles: actions.addFiles, consumeFiles: actions.consumeFiles, undoConsumeFiles: actions.undoConsumeFiles, - recordOperation: (_fileId: FileId, _operation: any) => {}, // Operation tracking not implemented + recordOperation: (_fileId: FileId, _operation: unknown) => {}, // Operation tracking not implemented markOperationApplied: (_fileId: FileId, _operationId: string) => {}, // Operation tracking not implemented markOperationFailed: ( _fileId: FileId, diff --git a/frontend/editor/src/core/contexts/file/lifecycle.ts b/frontend/editor/src/core/contexts/file/lifecycle.ts index 7e072d7db0..4b3d639ad9 100644 --- a/frontend/editor/src/core/contexts/file/lifecycle.ts +++ b/frontend/editor/src/core/contexts/file/lifecycle.ts @@ -5,6 +5,7 @@ import { FileId } from "@app/types/file"; import { FileContextAction, + FileContextState, StirlingFileStub, ProcessedFilePage, } from "@app/types/fileContext"; @@ -39,7 +40,7 @@ export class FileLifecycleManager { */ cleanupFile = ( fileId: FileId, - stateRef?: React.MutableRefObject, + stateRef?: React.MutableRefObject, ): void => { // Use comprehensive cleanup (same as removeFiles) this.cleanupAllResourcesForFile(fileId, stateRef); @@ -77,7 +78,7 @@ export class FileLifecycleManager { scheduleCleanup = ( fileId: FileId, delay: number = 30000, - stateRef?: React.MutableRefObject, + stateRef?: React.MutableRefObject, ): void => { // Cancel existing timer const existingTimer = this.cleanupTimers.get(fileId); @@ -116,7 +117,7 @@ export class FileLifecycleManager { */ removeFiles = ( fileIds: FileId[], - stateRef?: React.MutableRefObject, + stateRef?: React.MutableRefObject, ): void => { fileIds.forEach((fileId) => { // Clean up all resources for this file @@ -132,7 +133,7 @@ export class FileLifecycleManager { */ private cleanupAllResourcesForFile = ( fileId: FileId, - stateRef?: React.MutableRefObject, + stateRef?: React.MutableRefObject, ): void => { // Remove from files ref this.filesRef.current.delete(fileId); @@ -188,7 +189,7 @@ export class FileLifecycleManager { updateStirlingFileStub = ( fileId: FileId, updates: Partial, - stateRef?: React.MutableRefObject, + stateRef?: React.MutableRefObject, ): void => { // Guard against updating removed files (race condition protection) if (!this.filesRef.current.has(fileId)) { diff --git a/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts b/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts index d912cc4ede..d329f1feb0 100644 --- a/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts @@ -345,11 +345,15 @@ export const useConvertOperation = (parameters?: ConvertParameters) => { ...convertOperationConfig, customProcessor: customConvertProcessor, // Use instance-specific processor for translation support getErrorMessage: (error) => { - if (error.response?.data && typeof error.response.data === "string") { - return error.response.data; + const err = error as { + response?: { data?: unknown }; + message?: string; + }; + if (err.response?.data && typeof err.response.data === "string") { + return err.response.data; } - if (error.message) { - return error.message; + if (err.message) { + return err.message; } return t( "convert.errorConversion", diff --git a/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts b/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts index 79b339dda9..0f8016ab67 100644 --- a/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts +++ b/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts @@ -179,13 +179,15 @@ export const useOCROperation = () => { const ocrConfig: ToolOperationConfig = { ...ocrOperationConfig, responseHandler, - getErrorMessage: (error) => - error.message?.includes("OCR tools") && - error.message?.includes("not installed") + getErrorMessage: (error) => { + const message = (error as { message?: string }).message; + return message?.includes("OCR tools") && + message?.includes("not installed") ? "OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually." : createStandardErrorHandler( t("ocr.error.failed", "OCR operation failed"), - )(error), + )(error); + }, }; return useToolOperation(ocrConfig); diff --git a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts index d4d4ccceec..60453c576f 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts @@ -74,7 +74,7 @@ interface BaseToolOperationConfig { responseHandler?: ResponseHandler; /** Extract user-friendly error messages from API errors */ - getErrorMessage?: (error: any) => string; + getErrorMessage?: (error: unknown) => string; /** Default parameter values for automation */ defaultParameters?: TParams; diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts index 7ee69da142..f01c8770ab 100644 --- a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts @@ -218,7 +218,7 @@ export const useToolOperation = ( // Listen for global error file id events from HTTP interceptor during this run let externalErrorFileIds: string[] = []; const errorListener = (e: Event) => { - const detail = (e as CustomEvent)?.detail as any; + const detail = (e as CustomEvent<{ fileIds?: unknown }>)?.detail; if (detail?.fileIds) { externalErrorFileIds = Array.isArray(detail.fileIds) ? detail.fileIds @@ -588,7 +588,7 @@ export const useToolOperation = ( }; } } - } catch (error: any) { + } catch (error) { try { const handled = await handle422Error(error, (id) => fileActions.markFileError(id as FileId), @@ -691,21 +691,22 @@ export const useToolOperation = ( // Show success message actions.setStatus(t("undoSuccess", "Operation undone successfully")); - } catch (error: any) { + } catch (error) { let errorMessage = extractErrorMessage(error); // Provide more specific error messages based on error type - if (error.message?.includes("Mismatch between input files")) { + const err = error as { message?: string; name?: string }; + if (err.message?.includes("Mismatch between input files")) { errorMessage = t( "undoDataMismatch", "Cannot undo: operation data is corrupted", ); - } else if (error.message?.includes("IndexedDB")) { + } else if (err.message?.includes("IndexedDB")) { errorMessage = t( "undoStorageError", "Undo completed but some files could not be saved to storage", ); - } else if (error.name === "QuotaExceededError") { + } else if (err.name === "QuotaExceededError") { errorMessage = t( "undoQuotaError", "Cannot undo: insufficient storage space", diff --git a/frontend/oxlint.config.ts b/frontend/oxlint.config.ts index fd07f17c6d..ac29c9b2bb 100644 --- a/frontend/oxlint.config.ts +++ b/frontend/oxlint.config.ts @@ -74,21 +74,14 @@ const modernGlobals: OxlintGlobals = { // Folders not yet conformant to the stricter no-explicit-any rule const noExplicitAnyExcludes = [ - "editor/src/core/components/annotation/**/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/pageEditor/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/shared/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/shared/config/configSections/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/tools/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/addStamp/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/automate/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/tools/certSign/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/tools/pdfTextEditor/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/viewer/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/contexts/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/contexts/file/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/contexts/viewer/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/hooks/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/hooks/tools/shared/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/services/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/tools/annotate/useAnnotationSelection.ts", "editor/src/core/types/*.{js,mjs,jsx,ts,tsx}", From 78acd9a14b29c2f4afa394e10b24bff50a8103da Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 10 Aug 2026 11:41:07 +0100 Subject: [PATCH 02/10] Run Playwright on all platforms in PRs (#7304) # Description of Changes Nightlies keep failing because the Playwright tests only run on Chrome in PRs. This PR changes it so that we run all 3 browsers in all (frontend) PRs so we catch these things before they merge in. They run in parallel so it won't take any more time for the CI to finish. --- .github/workflows/e2e-stubbed.yml | 25 +++++++++++++++++++------ .taskfiles/e2e.yml | 9 +++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml index 0d972303ff..df3c5fa82a 100644 --- a/.github/workflows/e2e-stubbed.yml +++ b/.github/workflows/e2e-stubbed.yml @@ -2,7 +2,8 @@ name: Playwright E2E (stubbed) # Reusable workflow called from build.yml. Backend-free Playwright suite — # fast, no Spring Boot required. Runs against the `stubbed` project which -# mocks API responses in the browser. +# mocks API responses in the browser. Fans out one job per browser +# (chromium/firefox/webkit) so all three run in parallel on their own runner. on: workflow_call: @@ -11,7 +12,19 @@ permissions: jobs: playwright-e2e: + name: playwright-e2e (${{ matrix.browser }}) runs-on: ubuntu-latest + strategy: + # One browser breaking must not mask a failure in another - report all. + fail-fast: false + matrix: + include: + - browser: chromium + project: stubbed + - browser: firefox + project: stubbed-firefox + - browser: webkit + project: stubbed-webkit steps: - name: Harden Runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 @@ -27,16 +40,16 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - - name: Install Playwright (chromium only) - run: task e2e:install -- chromium + - name: Install Playwright (${{ matrix.browser }}) + run: task e2e:install -- ${{ matrix.browser }} - name: Build frontend (production bundle for vite preview) env: VITE_BUILD_FOR_PREVIEW: "1" run: task frontend:build - - name: Run stubbed E2E tests (chromium) + - name: Run stubbed E2E tests (${{ matrix.browser }}) env: PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json - run: task e2e:stubbed -- --workers=3 + run: task e2e:stubbed-project PROJECT=${{ matrix.project }} -- --workers=3 - name: Flag flaky tests # Runs regardless of the test outcome: a flaky test (passed on retry) # leaves the step green, so this is the only place it surfaces. Emits @@ -50,6 +63,6 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: playwright-report-stubbed-${{ github.run_id }} + name: playwright-report-stubbed-${{ matrix.browser }}-${{ github.run_id }} path: frontend/playwright-report/ retention-days: 7 diff --git a/.taskfiles/e2e.yml b/.taskfiles/e2e.yml index 618041807f..084906960b 100644 --- a/.taskfiles/e2e.yml +++ b/.taskfiles/e2e.yml @@ -15,6 +15,15 @@ tasks: cmds: - npx playwright test --project=stubbed {{.CLI_ARGS}} + stubbed-project: + desc: "Run the stubbed E2E suite for a single Playwright project" + dir: frontend/editor + deps: [ ':frontend:prepare' ] + vars: + PROJECT: '{{.PROJECT | default "stubbed"}}' + cmds: + - npx playwright test --project={{.PROJECT}} {{.CLI_ARGS}} + live: desc: "Run live E2E tests" summary: | From af5f54274d6cb08ff2ac38c4be647c673cd332b0 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 10 Aug 2026 12:00:16 +0100 Subject: [PATCH 03/10] Add defaults to calculations for ToolIO (#7289) # Description of Changes Currently when calculating the output file type for some tools, the system will get it wrong because it doesn't know about what the default parameters in tools are, so if it doesn't have a value for some key, it'll just bail out and say "it might not be compatible". This PR adds logic to `ToolIO` to read the default values set for the parameters if the tool has `ToolIOCase`s and takes them into account when figuring out the output type. I've built it with horrible Java reflection magic to avoid having to specify the default for params twice, which will make it impossible for the defaults to disagree with each other. This just runs once at startup so there's negligible performance impact. The change is easily tested with Change Parameters, which is just `add-password` behind the scenes but with the password params omitted (so Change Password is always PDF->PDF, never encrypted like Add Password). Also (somewhat hackily) fixes a bug I noticed where saving a Change Permissions step then leaving and returning to the pipeline will cause the step to be reloaded as Add Password. I've added a system to disambiguate tools which share the same endpoint (which is only these two currently). ## Currently image ## Now image --- .../swagger/ToolIOOperationCustomizer.java | 24 +++-- .../common/model/tool/ToolIOSpec.java | 46 ++++++++-- .../service/ToolIOParameterDefaults.java | 92 +++++++++++++++++++ .../common/service/ToolIORegistry.java | 5 +- .../ToolChainValidatorConformanceTest.java | 7 +- .../config/ToolIODeclarationCoverageTest.java | 33 ++++++- engine/scripts/generate_tool_models.py | 7 +- engine/src/stirling/models/tool_io.py | 29 ++++-- .../src/stirling/services/tool_io_compat.py | 9 +- .../scripts/generate-tool-api-types.mts | 2 + .../useChangePermissionsOperation.ts | 7 ++ .../hooks/tools/shared/toolAutomation.test.ts | 42 +++++++++ .../core/hooks/tools/shared/toolAutomation.ts | 28 +++++- .../hooks/tools/shared/toolOperationTypes.ts | 7 ++ frontend/editor/src/core/types/toolIO.ts | 32 +++++-- .../editor/src/core/utils/toolIOCompat.ts | 10 +- testing/tool-io-cases.json | 56 +++++++++++ 17 files changed, 390 insertions(+), 46 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/service/ToolIOParameterDefaults.java diff --git a/app/common/src/main/java/stirling/software/common/config/swagger/ToolIOOperationCustomizer.java b/app/common/src/main/java/stirling/software/common/config/swagger/ToolIOOperationCustomizer.java index c7ea1b4f7c..b5c3a3ef87 100644 --- a/app/common/src/main/java/stirling/software/common/config/swagger/ToolIOOperationCustomizer.java +++ b/app/common/src/main/java/stirling/software/common/config/swagger/ToolIOOperationCustomizer.java @@ -1,5 +1,6 @@ package stirling.software.common.config.swagger; +import java.lang.reflect.Method; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; @@ -18,6 +19,7 @@ import stirling.software.common.model.tool.ToolFormat; import stirling.software.common.model.tool.ToolIO; import stirling.software.common.model.tool.ToolIOCase; import stirling.software.common.model.tool.ToolIOWhen; +import stirling.software.common.service.ToolIOParameterDefaults; /** * Publishes each {@link ToolIO} into the spec as {@code x-stirling-io}, which is how the frontend @@ -49,40 +51,42 @@ public class ToolIOOperationCustomizer if (declaration == null) { return operation; } - operation.addExtension(EXTENSION_NAME, toExtension(declaration)); + operation.addExtension(EXTENSION_NAME, toExtension(declaration, handlerMethod.getMethod())); operation.setDescription(appendSummaryLine(operation.getDescription(), declaration)); return operation; } - private static Map toExtension(ToolIO declaration) { + private static Map toExtension(ToolIO declaration, Method handler) { Map extension = new LinkedHashMap<>(); extension.put("accepts", names(declaration.accepts())); extension.put("produces", declaration.produces().name()); extension.put("arity", declaration.arity().name()); if (declaration.cases().length > 0) { - extension.put("cases", cases(declaration)); + extension.put("cases", cases(declaration, handler)); } return extension; } - private static List> cases(ToolIO declaration) { - return Arrays.stream(declaration.cases()).map(ToolIOOperationCustomizer::toCase).toList(); + private static List> cases(ToolIO declaration, Method handler) { + return Arrays.stream(declaration.cases()).map(rule -> toCase(rule, handler)).toList(); } - private static Map toCase(ToolIOCase rule) { + private static Map toCase(ToolIOCase rule, Method handler) { Map entry = new LinkedHashMap<>(); - entry.put( - "when", - Arrays.stream(rule.when()).map(ToolIOOperationCustomizer::toCondition).toList()); + entry.put("when", Arrays.stream(rule.when()).map(c -> toCondition(c, handler)).toList()); entry.put("produces", rule.produces().name()); entry.put("arity", rule.arity().name()); return entry; } - private static Map toCondition(ToolIOWhen condition) { + private static Map toCondition(ToolIOWhen condition, Method handler) { Map entry = new LinkedHashMap<>(); entry.put("param", condition.param()); entry.put("matches", List.of(condition.matches())); + // The default the endpoint uses when this parameter is absent, so a step that never sends + // it still resolves. Omitted when the parameter is required with none. + ToolIOParameterDefaults.resolve(handler, condition.param()) + .ifPresent(value -> entry.put("default", value)); return entry; } diff --git a/app/common/src/main/java/stirling/software/common/model/tool/ToolIOSpec.java b/app/common/src/main/java/stirling/software/common/model/tool/ToolIOSpec.java index 146ae85133..cf627d97ef 100644 --- a/app/common/src/main/java/stirling/software/common/model/tool/ToolIOSpec.java +++ b/app/common/src/main/java/stirling/software/common/model/tool/ToolIOSpec.java @@ -5,13 +5,18 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; import java.util.Set; /** The runtime form of a {@link ToolIO} declaration, read off a handler method once at startup. */ public record ToolIOSpec( Set accepts, ToolFormat produces, ToolArity arity, List cases) { - public record When(String param, List matches) { + /** + * @param paramDefault the value used when the parameter is absent, or null when it has no + * default - an absent parameter then leaves the case unresolved rather than defaulted. + */ + public record When(String param, List matches, String paramDefault) { boolean holdsFor(Object value) { String normalised = normalise(value); @@ -19,6 +24,14 @@ public record ToolIOSpec( } } + /** Supplies the default value a request parameter takes when a caller omits it. */ + @FunctionalInterface + public interface ParameterDefaults { + Optional defaultFor(String param); + + ParameterDefaults NONE = param -> Optional.empty(); + } + /** * Both sides of a condition are normalised at comparison, not at construction: the declaration * reaches the frontend and the engine as published data, and normalising only one side there @@ -44,25 +57,33 @@ public record ToolIOSpec( } public static ToolIOSpec from(ToolIO annotation) { + return from(annotation, ParameterDefaults.NONE); + } + + public static ToolIOSpec from(ToolIO annotation, ParameterDefaults defaults) { return new ToolIOSpec( new LinkedHashSet<>(Arrays.asList(annotation.accepts())), annotation.produces(), annotation.arity(), - Arrays.stream(annotation.cases()).map(ToolIOSpec::toCase).toList()); + Arrays.stream(annotation.cases()).map(rule -> toCase(rule, defaults)).toList()); } - private static Case toCase(ToolIOCase rule) { - List when = Arrays.stream(rule.when()).map(ToolIOSpec::toWhen).toList(); + private static Case toCase(ToolIOCase rule, ParameterDefaults defaults) { + List when = Arrays.stream(rule.when()).map(c -> toWhen(c, defaults)).toList(); return new Case(when, rule.produces(), rule.arity()); } - private static When toWhen(ToolIOWhen condition) { - return new When(condition.param(), List.of(condition.matches())); + private static When toWhen(ToolIOWhen condition, ParameterDefaults defaults) { + return new When( + condition.param(), + List.of(condition.matches()), + defaults.defaultFor(condition.param()).orElse(null)); } /** - * First matching {@link Case} wins. If none match but one reads a parameter we cannot see, the - * declared output comes back uncertain: a value we never saw might have picked another branch. + * First matching {@link Case} wins. A parameter the caller omitted resolves to its declared + * default; only a parameter with no default leaves the output uncertain, since an unseen value + * might then have picked another branch. * * @param parameters the step's configured parameters, or null when not known */ @@ -71,12 +92,17 @@ public record ToolIOSpec( for (Case rule : cases) { boolean allHold = true; for (When condition : rule.when()) { - if (parameters == null || !parameters.containsKey(condition.param())) { + Object value; + if (parameters != null && parameters.containsKey(condition.param())) { + value = parameters.get(condition.param()); + } else if (condition.paramDefault() != null) { + value = condition.paramDefault(); + } else { sawUnknownParam = true; allHold = false; continue; } - allHold &= condition.holdsFor(parameters.get(condition.param())); + allHold &= condition.holdsFor(value); } if (allHold) { return new Output(rule.produces(), rule.arity(), true); diff --git a/app/common/src/main/java/stirling/software/common/service/ToolIOParameterDefaults.java b/app/common/src/main/java/stirling/software/common/service/ToolIOParameterDefaults.java new file mode 100644 index 0000000000..d77ca63de6 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/ToolIOParameterDefaults.java @@ -0,0 +1,92 @@ +package stirling.software.common.service; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.Optional; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import lombok.extern.slf4j.Slf4j; + +/** + * The value a request parameter takes when the caller omits it, read from the request model so a + * {@code @ToolIOCase} can be resolved even for a step that never sends the parameter it branches + * on. The default is read from the field so it cannot drift. + */ +@Slf4j +public final class ToolIOParameterDefaults { + + // Swagger's sentinel for an unset @Schema string member; not a real default value. + private static final String SCHEMA_UNSET = "##default"; + + private ToolIOParameterDefaults() {} + + /** + * The default {@code param} resolves to when absent, or empty when the parameter is required + * with no declared default - in which case an unset value leaves the output genuinely unknown + * rather than defaulted, and the chain reports it as uncertain. + * + *

Precedence: an explicit {@code @Schema(defaultValue)}, then the field's own value (a + * primitive's language default, or an initializer), then the empty string for an optional field + * left null, and finally empty for a required field with none of the above. + */ + public static Optional resolve(Method handler, String param) { + for (Parameter parameter : handler.getParameters()) { + Field field = findField(parameter.getType(), param); + if (field != null) { + return fromField(parameter.getType(), field); + } + } + return Optional.empty(); + } + + private static Optional fromField(Class owner, Field field) { + Schema schema = field.getAnnotation(Schema.class); + if (schema != null + && !schema.defaultValue().isEmpty() + && !SCHEMA_UNSET.equals(schema.defaultValue())) { + return Optional.of(schema.defaultValue()); + } + Object value = readField(owner, field); + if (value != null) { + return Optional.of(String.valueOf(value)); + } + return isRequired(field, schema) ? Optional.empty() : Optional.of(""); + } + + private static Object readField(Class owner, Field field) { + try { + Object instance = owner.getDeclaredConstructor().newInstance(); + field.setAccessible(true); + return field.get(instance); + } catch (ReflectiveOperationException | RuntimeException e) { + // A request model we cannot instantiate leaves the default unknown, which the check + // treats conservatively as uncertain. Never break startup over it. + log.warn("Could not read default of {}.{}", owner.getSimpleName(), field.getName(), e); + return null; + } + } + + private static boolean isRequired(Field field, Schema schema) { + if (schema != null && schema.requiredMode() == Schema.RequiredMode.REQUIRED) { + return true; + } + return field.isAnnotationPresent(NotNull.class) + || field.isAnnotationPresent(NotBlank.class); + } + + private static Field findField(Class type, String name) { + for (Class c = type; c != null && c != Object.class; c = c.getSuperclass()) { + try { + return c.getDeclaredField(name); + } catch (NoSuchFieldException ignored) { + // Try the superclass; request models extend a shared file-input base. + } + } + return null; + } +} diff --git a/app/common/src/main/java/stirling/software/common/service/ToolIORegistry.java b/app/common/src/main/java/stirling/software/common/service/ToolIORegistry.java index 754bfb8019..c2219536d1 100644 --- a/app/common/src/main/java/stirling/software/common/service/ToolIORegistry.java +++ b/app/common/src/main/java/stirling/software/common/service/ToolIORegistry.java @@ -67,7 +67,10 @@ public class ToolIORegistry implements ToolMetadataService, ToolIOSource { if (annotation == null) { return; } - ToolIOSpec spec = ToolIOSpec.from(annotation); + Method method = handler.getMethod(); + ToolIOSpec spec = + ToolIOSpec.from( + annotation, param -> ToolIOParameterDefaults.resolve(method, param)); for (String pattern : extractPatterns(info)) { target.put(pattern, spec); } diff --git a/app/common/src/test/java/stirling/software/common/service/ToolChainValidatorConformanceTest.java b/app/common/src/test/java/stirling/software/common/service/ToolChainValidatorConformanceTest.java index 67ba8be612..78c15d751d 100644 --- a/app/common/src/test/java/stirling/software/common/service/ToolChainValidatorConformanceTest.java +++ b/app/common/src/test/java/stirling/software/common/service/ToolChainValidatorConformanceTest.java @@ -101,7 +101,12 @@ class ToolChainValidatorConformanceTest { for (JsonNode match : condition.get("matches")) { matches.add(match.asString()); } - when.add(new ToolIOSpec.When(condition.get("param").asString(), matches)); + JsonNode paramDefault = condition.get("default"); + when.add( + new ToolIOSpec.When( + condition.get("param").asString(), + matches, + paramDefault == null ? null : paramDefault.asString())); } cases.add( new ToolIOSpec.Case( diff --git a/app/core/src/test/java/stirling/software/SPDF/config/ToolIODeclarationCoverageTest.java b/app/core/src/test/java/stirling/software/SPDF/config/ToolIODeclarationCoverageTest.java index 120bee6a2b..f6fe52f2a6 100644 --- a/app/core/src/test/java/stirling/software/SPDF/config/ToolIODeclarationCoverageTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/config/ToolIODeclarationCoverageTest.java @@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import stirling.software.common.model.tool.ToolFormat; import stirling.software.common.model.tool.ToolIO; import stirling.software.common.model.tool.ToolIOSpec; +import stirling.software.common.service.ToolIOParameterDefaults; /** * Every document-transforming endpoint must declare its I/O, or it becomes a hole in the @@ -233,6 +234,32 @@ class ToolIODeclarationCoverageTest { assertEquals(Set.of("ps", "pcl", "xps"), declared); } + @Test + void anAbsentParameterResolvesToItsRequestModelDefault() { + // A pipeline step often omits a parameter a case branches on. The default is read from the + // request model, so the output resolves anyway instead of coming back uncertain. + + // Auto Rotate never sends dryRun; its default (false) means the JSON branch cannot fire. + assertEquals( + ToolFormat.PDF, + spec("/api/v1/misc/auto-rotate-pdf").resolveOutput(Map.of()).format()); + assertTrue(spec("/api/v1/misc/auto-rotate-pdf").resolveOutput(Map.of()).certain()); + + // Change Permissions posts to add-password with no password fields; both default to blank, + // so the unencrypted branch fires and it is not mistaken for producing an encrypted PDF. + assertEquals( + ToolFormat.PDF, + spec("/api/v1/security/add-password").resolveOutput(Map.of()).format()); + assertTrue(spec("/api/v1/security/add-password").resolveOutput(Map.of()).certain()); + } + + @Test + void aRequiredParameterWithNoDefaultStaysUncertainWhenAbsent() { + // pdf/text branches on outputFormat, which is required with no default. Absent, its output + // is genuinely txt-or-rtf-dependent, so it must remain uncertain rather than assume TEXT. + assertFalse(spec("/api/v1/convert/pdf/text").resolveOutput(Map.of()).certain()); + } + @Test void onlyRemovePasswordAcceptsAnEncryptedDocument() { assertTrue( @@ -288,7 +315,11 @@ class ToolIODeclarationCoverageTest { } required.add(full); if (declaration != null) { - declared.put(full, ToolIOSpec.from(declaration)); + declared.put( + full, + ToolIOSpec.from( + declaration, + param -> ToolIOParameterDefaults.resolve(method, param))); } } } diff --git a/engine/scripts/generate_tool_models.py b/engine/scripts/generate_tool_models.py index 2a5139a263..7587d8c62f 100644 --- a/engine/scripts/generate_tool_models.py +++ b/engine/scripts/generate_tool_models.py @@ -65,6 +65,8 @@ class ToolIOWhen(ApiModel): param: str matches: list[str] + # The value the endpoint uses when this parameter is absent; None when it has none. + default: str | None = None class ToolIOCase(ApiModel): @@ -378,7 +380,10 @@ def collect_tool_io(spec: dict[str, Any]) -> dict[str, dict[str, Any]]: def _render_when(condition: dict[str, Any]) -> str: - return f"ToolIOWhen(param={json.dumps(condition['param'])}, matches={json.dumps(condition['matches'])})" + parts = [f"param={json.dumps(condition['param'])}", f"matches={json.dumps(condition['matches'])}"] + if "default" in condition: + parts.append(f"default={json.dumps(condition['default'])}") + return f"ToolIOWhen({', '.join(parts)})" def _render_case(case: dict[str, Any]) -> str: diff --git a/engine/src/stirling/models/tool_io.py b/engine/src/stirling/models/tool_io.py index 8c95be59a1..da0cd5b9ae 100644 --- a/engine/src/stirling/models/tool_io.py +++ b/engine/src/stirling/models/tool_io.py @@ -58,6 +58,8 @@ class ToolIOWhen(ApiModel): param: str matches: list[str] + # The value the endpoint uses when this parameter is absent; None when it has none. + default: str | None = None class ToolIOCase(ApiModel): @@ -101,7 +103,7 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = { arity=ToolArity.SIMO, cases=[ ToolIOCase( - when=[ToolIOWhen(param="singleOrMultiple", matches=["single"])], + when=[ToolIOWhen(param="singleOrMultiple", matches=["single"], default="multiple")], produces=ToolFormat.IMAGE, arity=ToolArity.SISO, ) @@ -130,15 +132,19 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = { arity=ToolArity.SISO, cases=[ ToolIOCase( - when=[ToolIOWhen(param="outputFormat", matches=["ps"])], + when=[ToolIOWhen(param="outputFormat", matches=["ps"], default="eps")], produces=ToolFormat.POSTSCRIPT, arity=ToolArity.SISO, ), ToolIOCase( - when=[ToolIOWhen(param="outputFormat", matches=["pcl"])], produces=ToolFormat.PCL, arity=ToolArity.SISO + when=[ToolIOWhen(param="outputFormat", matches=["pcl"], default="eps")], + produces=ToolFormat.PCL, + arity=ToolArity.SISO, ), ToolIOCase( - when=[ToolIOWhen(param="outputFormat", matches=["xps"])], produces=ToolFormat.XPS, arity=ToolArity.SISO + when=[ToolIOWhen(param="outputFormat", matches=["xps"], default="eps")], + produces=ToolFormat.XPS, + arity=ToolArity.SISO, ), ], ), @@ -151,7 +157,7 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = { arity=ToolArity.MIMO, cases=[ ToolIOCase( - when=[ToolIOWhen(param="combineIntoSinglePdf", matches=["true"])], + when=[ToolIOWhen(param="combineIntoSinglePdf", matches=["true"], default="false")], produces=ToolFormat.PDF, arity=ToolArity.MISO, ) @@ -202,7 +208,9 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = { arity=ToolArity.SISO, cases=[ ToolIOCase( - when=[ToolIOWhen(param="dryRun", matches=["true"])], produces=ToolFormat.JSON, arity=ToolArity.SISO + when=[ToolIOWhen(param="dryRun", matches=["true"], default="false")], + produces=ToolFormat.JSON, + arity=ToolArity.SISO, ) ], ), @@ -223,7 +231,9 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = { arity=ToolArity.SISO, cases=[ ToolIOCase( - when=[ToolIOWhen(param="sidecar", matches=["true"])], produces=ToolFormat.ZIP, arity=ToolArity.SISO + when=[ToolIOWhen(param="sidecar", matches=["true"], default="false")], + produces=ToolFormat.ZIP, + arity=ToolArity.SISO, ) ], ), @@ -242,7 +252,10 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = { arity=ToolArity.SISO, cases=[ ToolIOCase( - when=[ToolIOWhen(param="password", matches=[""]), ToolIOWhen(param="ownerPassword", matches=[""])], + when=[ + ToolIOWhen(param="password", matches=[""], default=""), + ToolIOWhen(param="ownerPassword", matches=[""], default=""), + ], produces=ToolFormat.PDF, arity=ToolArity.SISO, ) diff --git a/engine/src/stirling/services/tool_io_compat.py b/engine/src/stirling/services/tool_io_compat.py index b2bb5566e9..f65f0efc22 100644 --- a/engine/src/stirling/services/tool_io_compat.py +++ b/engine/src/stirling/services/tool_io_compat.py @@ -89,11 +89,16 @@ def resolve_output(spec: ToolIOSpec, parameters: dict[str, object] | None) -> Re for rule in spec.cases: all_hold = True for condition in rule.when: - if parameters is None or condition.param not in parameters: + if parameters is not None and condition.param in parameters: + raw: object = parameters[condition.param] + elif condition.default is not None: + # The caller omitted it, so it takes the endpoint's default. + raw = condition.default + else: saw_unknown_param = True all_hold = False continue - normalised = _normalise(parameters[condition.param]) + normalised = _normalise(raw) all_hold = all_hold and any(_normalise(m) == normalised for m in condition.matches) if all_hold: return ResolvedOutput(format=rule.produces, arity=rule.arity, certain=True) diff --git a/frontend/editor/scripts/generate-tool-api-types.mts b/frontend/editor/scripts/generate-tool-api-types.mts index a805f2dfa3..d5990096af 100644 --- a/frontend/editor/scripts/generate-tool-api-types.mts +++ b/frontend/editor/scripts/generate-tool-api-types.mts @@ -258,6 +258,8 @@ export type ToolArity = ${union(vocabulary.arities as string[])}; export interface ToolIOWhen { param: string; matches: string[]; + /** The value the endpoint uses when this parameter is absent; omitted when it has none. */ + default?: string; } /** An output that applies when every condition in \`when\` holds. */ diff --git a/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts b/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts index ac5f9a7d96..fa249a6162 100644 --- a/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts +++ b/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts @@ -80,6 +80,13 @@ export const changePermissionsOperationConfig = defineSingleFileTool({ operationType: "changePermissions", endpoint: ENDPOINT, // Change Permissions is a fake endpoint for the Add Password tool defaultParameters, + // Both tools post to add-password. A permissions-only step carries none of the encryption + // fields, so it is this tool and not Add Password; keyLength, always sent by Add Password, + // is the reliable tell even when a password happens to be blank. + claimsStoredStep: (apiParams) => + !("password" in apiParams) && + !("ownerPassword" in apiParams) && + !("keyLength" in apiParams), }); export const useChangePermissionsOperation = () => { diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts index f4cb974692..467e3ea5f7 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts @@ -24,6 +24,8 @@ import { SPLIT_METHODS } from "@app/constants/splitConstants"; import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation"; import { autoRotateOperationConfig } from "@app/hooks/tools/autoRotate/useAutoRotateOperation"; import { defaultParameters as autoRotateDefaults } from "@app/hooks/tools/autoRotate/useAutoRotateParameters"; +import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation"; +import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation"; function entry(over: Partial): ToolRegistryEntry { return { @@ -239,6 +241,46 @@ describe("serialize/deserialize round-trip", () => { }); }); +describe("shared-endpoint disambiguation", () => { + const addPassword = entry({ + name: "Add Password", + automationSettings: NoopSettings, + operationConfig: asRegistryConfig(addPasswordOperationConfig), + }); + const changePermissions = entry({ + name: "Change Permissions", + automationSettings: NoopSettings, + operationConfig: asRegistryConfig(changePermissionsOperationConfig), + }); + const ADD_PASSWORD = "/api/v1/security/add-password"; + + // Permissions only, no encryption fields: this is Change Permissions. + const permsOnly = { + operation: ADD_PASSWORD, + parameters: { preventPrinting: true }, + }; + // Carries keyLength (and a password): this is Add Password, even with a blank owner password. + const withPassword = { + operation: ADD_PASSWORD, + parameters: { password: "s3cret", ownerPassword: "", keyLength: 256 }, + }; + + // Both share an endpoint, so the wrong one would win by registry order without a discriminator. + for (const [label, registry] of [ + ["add-password declared first", { addPassword, changePermissions }], + ["change-permissions declared first", { changePermissions, addPassword }], + ] as const) { + test(`each stored step reloads as its own tool (${label})`, () => { + expect(deserializeToolStep(permsOnly, registry).toolId).toBe( + "changePermissions", + ); + expect(deserializeToolStep(withPassword, registry).toolId).toBe( + "addPassword", + ); + }); + } +}); + describe("stepRequiresUpload", () => { const step = (params: Record): WorkingToolStep => ({ toolId: "compress" as ToolId, diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts index d3874bc840..aab9e1ab10 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts @@ -226,11 +226,13 @@ function findToolByEndpoint( step: ToolApiStep, registry: Partial, ): [ToolId, ToolRegistryEntry] | undefined { + const staticMatches: [ToolId, ToolRegistryEntry][] = []; let dynamic: [ToolId, ToolRegistryEntry] | undefined; for (const [id, entry] of Object.entries(registry)) { const endpoint = entry?.operationConfig?.endpoint; if (typeof endpoint === "string") { - if (endpoint === step.operation) return [id as ToolId, entry]; + if (endpoint === step.operation) + staticMatches.push([id as ToolId, entry]); } else if (typeof endpoint === "function" && !dynamic) { const declared = entry?.operationConfig?.endpoints; const matched = declared @@ -239,9 +241,33 @@ function findToolByEndpoint( if (matched) dynamic = [id as ToolId, entry]; } } + if (staticMatches.length > 0) { + return disambiguateStaticMatches(staticMatches, step.parameters); + } return dynamic; } +/** + * Most endpoints belong to one tool, so the single match is returned unchanged. When several + * share an endpoint (Add Password and its permissions-only alias Change Permissions), prefer the + * specialised tool that claims the stored parameters; otherwise fall back to the general owner + * that declares no such claim. + */ +function disambiguateStaticMatches( + matches: [ToolId, ToolRegistryEntry][], + parameters: Record, +): [ToolId, ToolRegistryEntry] { + if (matches.length === 1) return matches[0]; + const claimed = matches.find(([, entry]) => + entry.operationConfig?.claimsStoredStep?.(parameters), + ); + if (claimed) return claimed; + const general = matches.find( + ([, entry]) => !entry.operationConfig?.claimsStoredStep, + ); + return general ?? matches[0]; +} + /** A stored step kept verbatim because its endpoint maps to no known tool. */ function unmappedStep(step: ToolApiStep): UnknownToolStep { return { diff --git a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts index 60453c576f..d1f306a3f0 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts @@ -101,6 +101,13 @@ interface BaseToolOperationConfig { */ fromApiParams?(apiParams: ToolApiParams[TEndpoint]): Partial; + /** + * Whether a stored step belongs to this tool, used only to tell apart tools that share an endpoint. + * Receives the raw stored request body. Absent means the tool is the general owner of its + * endpoint and claims any step no specialised sibling claims. + */ + claimsStoredStep?(apiParams: Record): boolean; + /** * For custom tools: if true, success implies all input files were successfully processed. * Use this for tools like Automate or Merge where Many-to-One relationships exist diff --git a/frontend/editor/src/core/types/toolIO.ts b/frontend/editor/src/core/types/toolIO.ts index f8a034dcee..96572c65f4 100644 --- a/frontend/editor/src/core/types/toolIO.ts +++ b/frontend/editor/src/core/types/toolIO.ts @@ -72,6 +72,8 @@ export type ToolArity = "SISO" | "SIMO" | "MISO" | "MIMO"; export interface ToolIOWhen { param: string; matches: string[]; + /** The value the endpoint uses when this parameter is absent; omitted when it has none. */ + default?: string; } /** An output that applies when every condition in `when` holds. */ @@ -168,7 +170,13 @@ export const TOOL_IO: ToolIOTable = { arity: "SIMO", cases: [ { - when: [{ param: "singleOrMultiple", matches: ["single"] }], + when: [ + { + param: "singleOrMultiple", + matches: ["single"], + default: "multiple", + }, + ], produces: "IMAGE", arity: "SISO", }, @@ -207,17 +215,17 @@ export const TOOL_IO: ToolIOTable = { arity: "SISO", cases: [ { - when: [{ param: "outputFormat", matches: ["ps"] }], + when: [{ param: "outputFormat", matches: ["ps"], default: "eps" }], produces: "POSTSCRIPT", arity: "SISO", }, { - when: [{ param: "outputFormat", matches: ["pcl"] }], + when: [{ param: "outputFormat", matches: ["pcl"], default: "eps" }], produces: "PCL", arity: "SISO", }, { - when: [{ param: "outputFormat", matches: ["xps"] }], + when: [{ param: "outputFormat", matches: ["xps"], default: "eps" }], produces: "XPS", arity: "SISO", }, @@ -244,7 +252,13 @@ export const TOOL_IO: ToolIOTable = { arity: "MIMO", cases: [ { - when: [{ param: "combineIntoSinglePdf", matches: ["true"] }], + when: [ + { + param: "combineIntoSinglePdf", + matches: ["true"], + default: "false", + }, + ], produces: "PDF", arity: "MISO", }, @@ -432,7 +446,7 @@ export const TOOL_IO: ToolIOTable = { arity: "SISO", cases: [ { - when: [{ param: "dryRun", matches: ["true"] }], + when: [{ param: "dryRun", matches: ["true"], default: "false" }], produces: "JSON", arity: "SISO", }, @@ -485,7 +499,7 @@ export const TOOL_IO: ToolIOTable = { arity: "SISO", cases: [ { - when: [{ param: "sidecar", matches: ["true"] }], + when: [{ param: "sidecar", matches: ["true"], default: "false" }], produces: "ZIP", arity: "SISO", }, @@ -534,8 +548,8 @@ export const TOOL_IO: ToolIOTable = { cases: [ { when: [ - { param: "password", matches: [""] }, - { param: "ownerPassword", matches: [""] }, + { param: "password", matches: [""], default: "" }, + { param: "ownerPassword", matches: [""], default: "" }, ], produces: "PDF", arity: "SISO", diff --git a/frontend/editor/src/core/utils/toolIOCompat.ts b/frontend/editor/src/core/utils/toolIOCompat.ts index a5f9ea0fe9..473195ff7b 100644 --- a/frontend/editor/src/core/utils/toolIOCompat.ts +++ b/frontend/editor/src/core/utils/toolIOCompat.ts @@ -102,12 +102,18 @@ export function resolveOutput( for (const rule of spec.cases ?? []) { let allHold = true; for (const condition of rule.when) { - if (!parameters || !(condition.param in parameters)) { + let raw: unknown; + if (parameters && condition.param in parameters) { + raw = parameters[condition.param]; + } else if (condition.default !== undefined) { + // The caller omitted it, so it takes the endpoint's default. + raw = condition.default; + } else { sawUnknownParam = true; allHold = false; continue; } - const value = normalise(parameters[condition.param]); + const value = normalise(raw); allHold &&= condition.matches.some((match) => normalise(match) === value); } if (allHold) { diff --git a/testing/tool-io-cases.json b/testing/tool-io-cases.json index 59c2802f8d..996b2b693b 100644 --- a/testing/tool-io-cases.json +++ b/testing/tool-io-cases.json @@ -70,6 +70,33 @@ "arity": "SISO" } ] + }, + "autoRotateShape": { + "accepts": ["PDF"], + "produces": "PDF", + "arity": "SISO", + "cases": [ + { + "when": [{ "param": "dryRun", "matches": ["true"], "default": "false" }], + "produces": "JSON", + "arity": "SISO" + } + ] + }, + "changePermsShape": { + "accepts": ["PDF"], + "produces": "PDF_ENCRYPTED", + "arity": "SISO", + "cases": [ + { + "when": [ + { "param": "password", "matches": [""], "default": "" }, + { "param": "ownerPassword", "matches": [""], "default": "" } + ], + "produces": "PDF", + "arity": "SISO" + } + ] } }, "cases": [ @@ -250,6 +277,35 @@ ], "expected": [{ "stepIndex": 1, "severity": "INFO", "code": "fan-in" }] }, + { + "name": "an absent parameter takes its default, which here does not trigger the case", + "steps": [{ "spec": "autoRotateShape" }, { "spec": "pdfToPdf" }], + "expected": [] + }, + { + "name": "an explicit value overrides the default and triggers the case", + "steps": [ + { "spec": "autoRotateShape", "parameters": { "dryRun": "true" } }, + { "spec": "pdfToPdf" } + ], + "expected": [{ "stepIndex": 1, "severity": "ERROR", "code": "format-mismatch" }] + }, + { + "name": "an absent parameter whose default triggers the case resolves to that branch", + "steps": [{ "spec": "changePermsShape" }, { "spec": "pdfToPdf" }], + "expected": [] + }, + { + "name": "setting the branch parameter away from its default flips the outcome", + "steps": [ + { + "spec": "changePermsShape", + "parameters": { "password": "hunter2", "ownerPassword": "hunter2" } + }, + { "spec": "pdfToPdf" } + ], + "expected": [{ "stepIndex": 1, "severity": "ERROR", "code": "format-mismatch" }] + }, { "name": "an undeclared step warns and stops the chain being checked past it", "steps": [{ "spec": "addPassword" }, { "spec": null }, { "spec": "pdfToPdf" }], From 7bf18cc4c75d8e821b810b42b919ee4dc80104d8 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:06:18 +0100 Subject: [PATCH 04/10] Storybook: render off the app's real theme CSS, stop hardcoding story colours, and gate a11y in dark mode too (#7187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Makes Storybook render components with the same CSS the app gives them. - The preview loaded the token primitives but **not the editor's semantic token layer** (`styles/theme.css`), so components styled on those variables rendered unthemed — three onboarding stories were importing it by hand to stop their modal surfaces rendering transparent. It's now loaded in the preview and the workarounds are gone. - **Portal stories render inside the `.portal-scope` wrapper** PortalApp mounts, so the portal's scoped reset and typography apply to them exactly as in the app — and, deliberately, to nothing else. - The folder stories invented their own hex colours, two of which aren't values the app's `FOLDER_COLOR_PALETTE` can produce. They now use the palette, so they can't drift from what a user can actually pick. Deliberately does **not** load `tailwind.css` — tailwind is on its way out of the editor, so matching the token layer alone is the target state. ## Story colours route through the tokens, enforced Stories were exempt from the `code-colors` lint, and it showed: hardcoded hexes for surfaces the tokens already name (chat bubbles, borders, demo backgrounds), `var(--x, #hex)` fallbacks that mask a renamed token by silently painting the stale colour, and mocked category accents for which real `--color-cat-*` tokens exist. - Styling literals now use tokens; the dead fallbacks are stripped. - The stories exemption is removed from `theme-lint`, so this can't regress. - Colours that are **the datum itself** — `ColorInput` values, signature ink, per-policy accents, brand-mark swatches — stay literal via `theme-allow-color`, hoisted to named consts so the exemption and its reason sit together. A practical side effect: stories styled on tokens actually respond to the dark-mode toolbar toggle, which is what makes a dark-theme a11y pass meaningful later. ## The a11y gate now runs dark as well as light Contrast is most of what axe reports and it is theme-dependent, so a light-only gate left half the surface unmeasured — and it only becomes measurable at all once the tokens above actually flip. `SCAN_THEME=dark` pins the theme for a whole scan run, every a11y task runs both themes, and each theme has its own baseline: - **light** re-recorded against the themed rendering (the old baseline measured colours the app never shows): 831 stories with violations - **dark** recorded for the first time: 798 stories with violations, 980 story-rule pairs, zero render failures across the full sweep Verified end to end: dark scans measure against dark surfaces (`#18181b` vs `#ffffff`), both baselines self-check clean, and a live scan of stories that changed on main after recording passes both gates. Nightly's timeout doubles for the second sweep. ## Testing Typecheck (all variants), ESLint and Prettier pass. Onboarding, folder, portal and control stories render in the browser scan (39/39) with the per-story CSS imports removed; every story touched by the colour sweep renders too (58/58). `task frontend:lint:colors` passes with stories included. --- .github/workflows/nightly.yml | 7 +- .taskfiles/frontend.yml | 10 +- frontend/.storybook/a11y-baseline.dark.json | 2457 +++++++++++++++++ frontend/.storybook/a11y-baseline.json | 85 +- frontend/.storybook/preview.tsx | 30 +- frontend/.storybook/vitest.config.ts | 8 + frontend/editor/scripts/lint/theme-lint.mjs | 4 +- .../editor/src/core/assets/Brand.stories.tsx | 12 +- .../FolderAppearancePicker.stories.tsx | 4 +- .../filesPage/FolderThumbnail.stories.tsx | 7 +- .../filesPage/MoveToFolderDialog.stories.tsx | 12 +- .../OnboardingModalSlide.stories.tsx | 4 - .../OnboardingSlideShell.stories.tsx | 4 - .../StaticOnboardingSlide.stories.tsx | 4 - .../components/shared/FileDocIcon.stories.tsx | 2 +- .../sign/SavedSignaturesSection.stories.tsx | 2 +- .../src/core/ui/CarouselDots.stories.tsx | 5 +- .../src/core/ui/ChatFABWindow.stories.tsx | 14 +- .../src/core/ui/Collapsible.stories.tsx | 14 +- .../src/core/ui/MantineForms.stories.tsx | 8 +- frontend/editor/src/core/ui/Tabs.stories.tsx | 16 +- .../components/ChatFABWidget.stories.tsx | 18 +- .../editor/src/portal/data/Ops.stories.tsx | 2 +- 23 files changed, 2613 insertions(+), 116 deletions(-) create mode 100644 frontend/.storybook/a11y-baseline.dark.json diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 43074d92f7..ba4054f190 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -59,9 +59,10 @@ jobs: # the story itself — a shared component, a theme token — still surfaces within # a day. a11y-all-stories: - name: a11y (every story) + name: a11y (every story, light + dark) runs-on: ubuntu-latest - timeout-minutes: 60 + # Two full sweeps (one per theme), each ~30 minutes of browser time. + timeout-minutes: 120 steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 @@ -81,7 +82,7 @@ jobs: - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - - name: a11y gate (every story) + - name: a11y gate (every story, light + dark) run: task frontend:storybook:a11y - name: Upload scan reports diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index edbeb9f14d..245933687c 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -211,11 +211,13 @@ tasks: - npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}} storybook:a11y: - desc: "a11y regression gate over every story: fail only on NEW axe violations" + desc: "a11y regression gate over every story, light and dark: fail only on NEW axe violations" deps: [prepare, storybook:browser] cmds: - node .storybook/a11y-scan.mjs - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt + - SCAN_THEME=dark node .storybook/a11y-scan.mjs + - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --baseline .storybook/a11y-baseline.dark.json storybook:a11y:changed: desc: "a11y gate over the stories this branch affects (default base origin/main)" @@ -244,13 +246,17 @@ tasks: fi node .storybook/a11y-scan.mjs {{.CHANGED}} node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt + SCAN_THEME=dark node .storybook/a11y-scan.mjs {{.CHANGED}} + node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --baseline .storybook/a11y-baseline.dark.json storybook:a11y:record: - desc: "Re-record the a11y baseline (run after intentionally fixing/adding violations)" + desc: "Re-record both a11y baselines (run after intentionally fixing/adding violations)" deps: [prepare, storybook:browser] cmds: - node .storybook/a11y-scan.mjs - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record + - SCAN_THEME=dark node .storybook/a11y-scan.mjs + - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record --baseline .storybook/a11y-baseline.dark.json # ============================================================ # Code quality diff --git a/frontend/.storybook/a11y-baseline.dark.json b/frontend/.storybook/a11y-baseline.dark.json new file mode 100644 index 0000000000..fa08401d09 --- /dev/null +++ b/frontend/.storybook/a11y-baseline.dark.json @@ -0,0 +1,2457 @@ +{ + "editor/src/core/assets/Brand.stories.tsx :: Logos": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/core/components/StorageStatsCard.stories.tsx :: Default": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/core/components/StorageStatsCard.stories.tsx :: Nearing Quota": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/core/components/StorageStatsCard.stories.tsx :: No Quota": [ + "color-contrast" + ], + "editor/src/core/components/annotation/shared/ColorPicker.stories.tsx :: Default": [ + "aria-input-field-name", + "button-name", + "color-contrast" + ], + "editor/src/core/components/annotation/shared/ColorPicker.stories.tsx :: With Opacity": [ + "aria-input-field-name", + "button-name", + "color-contrast" + ], + "editor/src/core/components/annotation/shared/DrawingControls.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: With Background Removal": [ + "color-contrast" + ], + "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: With Label And Hint": [ + "color-contrast" + ], + "editor/src/core/components/annotation/tools/ImageTool.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Default": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Empty": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Cloud Only": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Local And Cloud Choice": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx :: With Files": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: In Folder": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Local Only With Save To Server": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Multi Select": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/FileGrid.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/FileGrid.stories.tsx :: List Mode": [ + "aria-required-children" + ], + "editor/src/core/components/filesPage/FileOriginBadge.stories.tsx :: Cloud": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx :: Default": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx :: Disabled": [ + "scrollable-region-focusable" + ], + "editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx :: No Appearance Set": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/core/components/filesPage/FolderNameDialog.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/FolderNameDialog.stories.tsx :: Rename": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/FolderThumbnail.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: Empty": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: With Create Folder": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: With Disabled Descendant": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx :: Default": [ + "button-name" + ], + "editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx :: No File Selected": [ + "button-name" + ], + "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: Long Chain Collapsed": [ + "color-contrast" + ], + "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: No Header": [ + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Admin Overview Login Disabled": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Admin Overview Login Enabled": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Analytics Choice": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Analytics Choice Error": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Desktop Install": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: First Login": [ + "aria-dialog-name" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: First Login Default Credentials": [ + "aria-dialog-name" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Mfa Setup": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Security Check": [ + "aria-dialog-name" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Server License": [ + "aria-dialog-name" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Server License Over Limit": [ + "aria-dialog-name" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Stepped Flow Example": [ + "aria-dialog-name", + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Tour Overview": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Welcome": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Default": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Not Dismissible": [ + "aria-dialog-name", + "aria-progressbar-name" + ], + "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Stepped With Back": [ + "aria-dialog-name", + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx :: With Expression": [ + "color-contrast" + ], + "editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx :: Empty Input": [ + "color-contrast" + ], + "editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx :: Syntax Error": [ + "color-contrast" + ], + "editor/src/core/components/shared/BulkShareModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/BulkShareModal.stories.tsx :: Links Enabled": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx :: Single File": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Default": [ + "aria-allowed-attr" + ], + "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Empty": [ + "aria-allowed-attr" + ], + "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Multi Select With Footer": [ + "aria-allowed-attr" + ], + "editor/src/core/components/shared/EditableSecretField.stories.tsx :: Masked": [ + "label" + ], + "editor/src/core/components/shared/EditableSecretField.stories.tsx :: Masked Disabled": [ + "label" + ], + "editor/src/core/components/shared/EditableSecretField.stories.tsx :: With Error": [ + "label-title-only" + ], + "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Default": [ + "button-name" + ], + "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Incorrect Password": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Multiple Files Remaining": [ + "button-name" + ], + "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Processing": [ + "button-name" + ], + "editor/src/core/components/shared/ErrorBoundary.stories.tsx :: Caught Error": [ + "color-contrast" + ], + "editor/src/core/components/shared/FileCard.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/FileCard.stories.tsx :: Selected": [ + "color-contrast" + ], + "editor/src/core/components/shared/FileCard.stories.tsx :: Unsupported": [ + "color-contrast" + ], + "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: Default": [ + "aria-allowed-attr" + ], + "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: No Remove": [ + "aria-allowed-attr" + ], + "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: Switching": [ + "aria-allowed-attr" + ], + "editor/src/core/components/shared/FileGrid.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/FileGrid.stories.tsx :: Search And Sort": [ + "color-contrast", + "label" + ], + "editor/src/core/components/shared/FilePickerModal.stories.tsx :: Default": [ + "button-name", + "color-contrast", + "label" + ], + "editor/src/core/components/shared/FilePickerModal.stories.tsx :: Empty": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/FileSelectorPicker.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/shared/FileUploadButton.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/FileUploadButton.stories.tsx :: With File Selected": [ + "color-contrast" + ], + "editor/src/core/components/shared/Footer.stories.tsx :: All Links And Cookie Banner": [ + "color-contrast" + ], + "editor/src/core/components/shared/InfoBanner.stories.tsx :: Warning": [ + "color-contrast" + ], + "editor/src/core/components/shared/MobileUploadModal.stories.tsx :: Default": [ + "button-name", + "color-contrast", + "svg-img-alt" + ], + "editor/src/core/components/shared/MultiSelectControls.stories.tsx :: All Actions": [ + "color-contrast" + ], + "editor/src/core/components/shared/MultiSelectControls.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: With Apply And Continue": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: With Export And Continue": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/ObscuredOverlay.stories.tsx :: Unobscured": [ + "color-contrast" + ], + "editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx :: Compact Syntax Error": [ + "color-contrast" + ], + "editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx :: Syntax Error": [ + "color-contrast" + ], + "editor/src/core/components/shared/PolicyBadges.stories.tsx :: Default": [ + "scrollable-region-focusable" + ], + "editor/src/core/components/shared/PolicyBadges.stories.tsx :: Empty": [ + "scrollable-region-focusable" + ], + "editor/src/core/components/shared/PolicyBadges.stories.tsx :: Enforcing": [ + "scrollable-region-focusable" + ], + "editor/src/core/components/shared/ShareFileModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/ShareFileModal.stories.tsx :: Links Enabled": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/ShareManagementModal.stories.tsx :: Default": [ + "button-name" + ], + "editor/src/core/components/shared/ShareManagementModal.stories.tsx :: Links Enabled": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/UpdateModal.stories.tsx :: Default": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/shared/UpdateModal.stories.tsx :: Desktop Install Blocked": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/shared/UpdateModal.stories.tsx :: Desktop Install Ready To Restart": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/core/components/shared/UploadToServerModal.stories.tsx :: Already Uploaded": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/UploadToServerModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/UserSelector.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/ZipWarningModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/ZipWarningModal.stories.tsx :: Single File": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx :: Saving": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: Admin Banner": [ + "color-contrast", + "label" + ], + "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: Default": [ + "color-contrast", + "label" + ], + "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: With Backend Version": [ + "color-contrast", + "label" + ], + "editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx :: Admin": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx :: With Analytics Enabled": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: Loaded": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: With Warning": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Configured": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Read Only": [ + "color-contrast" + ], + "editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/filePreview/HoverOverlay.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: Creating": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: No File Selected": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx :: Pending Requests": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Invisible Signature": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Invisible Signature": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: With Selection": [ + "color-contrast" + ], + "editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx :: Default": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx :: Default": [ + "aria-input-field-name" + ], + "editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx :: Disabled": [ + "aria-input-field-name" + ], + "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Default": [ + "aria-input-field-name" + ], + "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Disabled": [ + "aria-input-field-name" + ], + "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Empty": [ + "aria-input-field-name" + ], + "editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/toast/ToastRenderer.stories.tsx :: With Action Button": [ + "color-contrast" + ], + "editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx :: With Quick Grid": [ + "color-contrast" + ], + "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: With Quick Grid": [ + "color-contrast" + ], + "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: With Text": [ + "color-contrast" + ], + "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Image Stamp": [ + "color-contrast" + ], + "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Text Only": [ + "button-name", + "label" + ], + "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Text Watermark": [ + "button-name", + "label" + ], + "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Default": [ + "label" + ], + "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Without Flatten Option": [ + "label" + ], + "editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx :: With Selected Image": [ + "color-contrast" + ], + "editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx :: Default": [ + "label" + ], + "editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx :: Default": [ + "button-name", + "label" + ], + "editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Adjusted": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Default": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Adjusted": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Default": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Adjusted": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Default": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/automate/AutomationCreation.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/automate/AutomationCreation.stories.tsx :: Edit Existing": [ + "color-contrast" + ], + "editor/src/core/components/tools/automate/AutomationCreation.stories.tsx :: Embedded Hide Metadata": [ + "color-contrast" + ], + "editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx :: Default": [ + "button-name", + "color-contrast", + "label" + ], + "editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx :: With Settings": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/automate/ToolList.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/automate/ToolSelector.stories.tsx :: Custom Placeholder": [ + "color-contrast" + ], + "editor/src/core/components/tools/automate/ToolSelector.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx :: Manual Duplex": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Auto Sign Mode": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx :: Jks": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx :: Pkcs 12": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx :: Selected": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx :: Pem Format": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: All Sources Available": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: Server Selected": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx :: Visible Signature": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Minimal Details": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Visible Signature": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx :: Upload Certificate": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Disabled": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Multiple Signatures": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx :: Finalized": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: All Signed": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx :: Finalized": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx :: No Signature Chosen": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Default": [ + "aria-input-field-name" + ], + "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Disabled": [ + "aria-input-field-name" + ], + "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Placement Mode": [ + "aria-input-field-name" + ], + "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Upload Ready": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: User Certificate": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Multiple Signatures": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Uploaded Certificate Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Default": [ + "aria-input-field-name" + ], + "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Disabled": [ + "aria-input-field-name" + ], + "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Type Mode": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: With Signature": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Placed": [ + "color-contrast" + ], + "editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx :: With Custom Metadata": [ + "color-contrast" + ], + "editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx :: With Entries": [ + "color-contrast" + ], + "editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Filled": [ + "button-name" + ], + "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: No Differences": [ + "color-contrast" + ], + "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: With Warnings": [ + "color-contrast" + ], + "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Default": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: File Size Method": [ + "label" + ], + "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Line Art Enabled": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx :: Default": [ + "label" + ], + "editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx :: Default": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Default": [ + "label" + ], + "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Strict Mode": [ + "label" + ], + "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: Loading With Error": [ + "color-contrast" + ], + "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: No File Selected": [ + "color-contrast" + ], + "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: With Error": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: With Results": [ + "color-contrast" + ], + "editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx :: Selected": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Partial Error": [ + "color-contrast" + ], + "editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/merge/MergeFileSorter.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx :: Default": [ + "aria-allowed-attr" + ], + "editor/src/core/components/tools/ocr/OCRSettings.stories.tsx :: Default": [ + "aria-allowed-attr" + ], + "editor/src/core/components/tools/redact/RedactAdvancedSettings.stories.tsx :: Default": [ + "button-name" + ], + "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Automatic With Words": [ + "button-name", + "color-contrast" + ], + "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Default": [ + "button-name" + ], + "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx :: With Words": [ + "color-contrast" + ], + "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Default": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Include Blank Pages": [ + "aria-input-field-name", + "label" + ], + "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Custom Color": [ + "button-name", + "label" + ], + "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Default": [ + "label" + ], + "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/shared/ErrorNotification.stories.tsx :: Custom Title": [ + "button-name" + ], + "editor/src/core/components/tools/shared/ErrorNotification.stories.tsx :: Default": [ + "button-name" + ], + "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: Default": [ + "label" + ], + "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: With Min Max": [ + "label" + ], + "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Single File": [ + "color-contrast" + ], + "editor/src/core/components/tools/shared/ToolStep.stories.tsx :: Collapsed": [ + "color-contrast" + ], + "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: With Download": [ + "color-contrast" + ], + "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Default": [ + "aria-prohibited-attr" + ], + "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Favorited": [ + "aria-prohibited-attr" + ], + "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Sizes": [ + "aria-prohibited-attr" + ], + "editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx :: Dropdown Mode": [ + "color-contrast" + ], + "editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx :: Unstyled": [ + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Default": [ + "aria-allowed-attr", + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Error": [ + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Multiple Signatures": [ + "aria-allowed-attr", + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: No Signatures": [ + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: With Cert File": [ + "color-contrast" + ], + "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Default": [ + "aria-allowed-attr" + ], + "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Invalid With Error": [ + "aria-allowed-attr" + ], + "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Self Signed Minimal Data": [ + "aria-allowed-attr" + ], + "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Default": [ + "aria-allowed-attr" + ], + "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Invalid": [ + "aria-allowed-attr" + ], + "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Untrusted Signer": [ + "aria-allowed-attr" + ], + "editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx :: Markdown": [ + "color-contrast" + ], + "editor/src/core/tokens/Tokens.stories.tsx :: Colours": ["color-contrast"], + "editor/src/core/tokens/Tokens.stories.tsx :: Typography": ["color-contrast"], + "editor/src/core/ui/Banner.stories.tsx :: Playground": ["color-contrast"], + "editor/src/core/ui/Banner.stories.tsx :: Success": ["color-contrast"], + "editor/src/core/ui/Banner.stories.tsx :: Tone Matrix": ["color-contrast"], + "editor/src/core/ui/Banner.stories.tsx :: With Action": ["color-contrast"], + "editor/src/core/ui/Button.stories.tsx :: Accents": ["color-contrast"], + "editor/src/core/ui/Button.stories.tsx :: Disabled Dark": ["color-contrast"], + "editor/src/core/ui/Button.stories.tsx :: Justify": ["color-contrast"], + "editor/src/core/ui/Button.stories.tsx :: Loading": ["button-name"], + "editor/src/core/ui/Button.stories.tsx :: Padding": ["color-contrast"], + "editor/src/core/ui/Button.stories.tsx :: Playground": ["color-contrast"], + "editor/src/core/ui/Button.stories.tsx :: Shape": ["color-contrast"], + "editor/src/core/ui/Button.stories.tsx :: Sizes": ["color-contrast"], + "editor/src/core/ui/Button.stories.tsx :: Variants": ["color-contrast"], + "editor/src/core/ui/Button.stories.tsx :: With Icons": ["color-contrast"], + "editor/src/core/ui/Card.stories.tsx :: Accent Matrix": ["color-contrast"], + "editor/src/core/ui/Card.stories.tsx :: In Context Metrics Inside Card": [ + "color-contrast" + ], + "editor/src/core/ui/Card.stories.tsx :: In Context Product Grid": [ + "color-contrast" + ], + "editor/src/core/ui/Card.stories.tsx :: Playground": ["color-contrast"], + "editor/src/core/ui/ChatFABButton.stories.tsx :: Default": ["button-name"], + "editor/src/core/ui/ChatFABButton.stories.tsx :: Loading": ["button-name"], + "editor/src/core/ui/ChatFABButton.stories.tsx :: Tick": ["button-name"], + "editor/src/core/ui/ChatFABButton.stories.tsx :: Tick While Loading": [ + "button-name" + ], + "editor/src/core/ui/ChatFABWindow.stories.tsx :: Closed": [ + "scrollable-region-focusable" + ], + "editor/src/core/ui/ChatFABWindow.stories.tsx :: Open": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/core/ui/ChatFABWindow.stories.tsx :: Toggle": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/core/ui/Chip.stories.tsx :: Accents": ["color-contrast"], + "editor/src/core/ui/Chip.stories.tsx :: Dashed Add": ["nested-interactive"], + "editor/src/core/ui/Chip.stories.tsx :: In Context Op Chain": [ + "color-contrast" + ], + "editor/src/core/ui/Chip.stories.tsx :: Playground": [ + "color-contrast", + "nested-interactive" + ], + "editor/src/core/ui/CodeBlock.stories.tsx :: In Context Quickstart": [ + "color-contrast" + ], + "editor/src/core/ui/CodeBlock.stories.tsx :: In Context Two Up Comparison": [ + "color-contrast" + ], + "editor/src/core/ui/CodeBlock.stories.tsx :: Long Scrolling": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/core/ui/CodeBlock.stories.tsx :: Playground": ["color-contrast"], + "editor/src/core/ui/Collapsible.stories.tsx :: Accordion": [ + "scrollable-region-focusable" + ], + "editor/src/core/ui/Collapsible.stories.tsx :: Default": [ + "scrollable-region-focusable" + ], + "editor/src/core/ui/DataRow.stories.tsx :: Single": ["color-contrast"], + "editor/src/core/ui/DataRow.stories.tsx :: Summary": ["color-contrast"], + "editor/src/core/ui/Drawer.stories.tsx :: Playground": [ + "aria-allowed-role", + "color-contrast" + ], + "editor/src/core/ui/Drawer.stories.tsx :: With Footer": [ + "aria-allowed-role", + "color-contrast" + ], + "editor/src/core/ui/Dropdown.stories.tsx :: Align Start": ["color-contrast"], + "editor/src/core/ui/Dropdown.stories.tsx :: Basic": ["color-contrast"], + "editor/src/core/ui/Dropdown.stories.tsx :: With Divider": ["color-contrast"], + "editor/src/core/ui/Dropdown.stories.tsx :: With Trailing Hints": [ + "color-contrast" + ], + "editor/src/core/ui/EmptyState.stories.tsx :: In Card": ["color-contrast"], + "editor/src/core/ui/EmptyState.stories.tsx :: Playground": ["color-contrast"], + "editor/src/core/ui/EmptyState.stories.tsx :: With CT As": ["color-contrast"], + "editor/src/core/ui/FilePicker.stories.tsx :: Accept Pdf": ["color-contrast"], + "editor/src/core/ui/FilePicker.stories.tsx :: Default": ["color-contrast"], + "editor/src/core/ui/FilePicker.stories.tsx :: Multiple": ["color-contrast"], + "editor/src/core/ui/Forms.stories.tsx :: Checkbox Grid Of Categories": [ + "color-contrast" + ], + "editor/src/core/ui/Forms.stories.tsx :: Checkbox Single": ["color-contrast"], + "editor/src/core/ui/Forms.stories.tsx :: Full Form": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/ui/Forms.stories.tsx :: Input Default": ["color-contrast"], + "editor/src/core/ui/Forms.stories.tsx :: Input Error": ["color-contrast"], + "editor/src/core/ui/Forms.stories.tsx :: Input With Icon": ["color-contrast"], + "editor/src/core/ui/Forms.stories.tsx :: Radio Group": ["color-contrast"], + "editor/src/core/ui/Forms.stories.tsx :: Radio Horizontal": [ + "color-contrast" + ], + "editor/src/core/ui/Forms.stories.tsx :: Slider Confidence": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/ui/Forms.stories.tsx :: Slider Retention": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/ui/Inline.stories.tsx :: Default": ["color-contrast"], + "editor/src/core/ui/Inline.stories.tsx :: Wrap": ["color-contrast"], + "editor/src/core/ui/ListRow.stories.tsx :: Default": ["color-contrast"], + "editor/src/core/ui/ListRow.stories.tsx :: In Card": ["color-contrast"], + "editor/src/core/ui/ListRow.stories.tsx :: Interactive": ["color-contrast"], + "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Default": [ + "button-name", + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Error": [ + "button-name", + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Preselected": [ + "button-name", + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Sm Size": [ + "button-name", + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Default": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Error": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Sm Size": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select With Values": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Decimal": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Default": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Error": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Sm Size": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Number Input With Unit": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Select Default": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Select Error": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Select Searchable": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Select Sm Size": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Slider Default": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Slider Disabled": [ + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Slider No Label": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Slider With Marks": [ + "aria-input-field-name", + "color-contrast" + ], + "editor/src/core/ui/MantineForms.stories.tsx :: Watermark Form": [ + "button-name", + "color-contrast" + ], + "editor/src/core/ui/MethodBadge.stories.tsx :: Default": ["color-contrast"], + "editor/src/core/ui/MethodBadge.stories.tsx :: In Row": ["color-contrast"], + "editor/src/core/ui/MethodBadge.stories.tsx :: Matrix": ["color-contrast"], + "editor/src/core/ui/MetricCard.stories.tsx :: Free Tier Strip": [ + "color-contrast" + ], + "editor/src/core/ui/MetricCard.stories.tsx :: Playground": ["color-contrast"], + "editor/src/core/ui/MetricCard.stories.tsx :: Pro Tier Strip": [ + "color-contrast" + ], + "editor/src/core/ui/MetricStrip.stories.tsx :: Default": ["color-contrast"], + "editor/src/core/ui/NavItem.stories.tsx :: In Context Sidebar Group": [ + "color-contrast" + ], + "editor/src/core/ui/NavItem.stories.tsx :: Playground": ["color-contrast"], + "editor/src/core/ui/NavItem.stories.tsx :: With Trailing Badge": [ + "color-contrast" + ], + "editor/src/core/ui/PanelHeader.stories.tsx :: With Actions": [ + "color-contrast" + ], + "editor/src/core/ui/ProgressBar.stories.tsx :: In Context Usage Meter": [ + "color-contrast" + ], + "editor/src/core/ui/ProgressBar.stories.tsx :: Playground": [ + "aria-progressbar-name" + ], + "editor/src/core/ui/ProgressBar.stories.tsx :: Threshold Ladder": [ + "aria-progressbar-name" + ], + "editor/src/core/ui/SectionDivider.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/core/ui/SectionHeader.stories.tsx :: Collapsible": [ + "color-contrast" + ], + "editor/src/core/ui/SectionHeader.stories.tsx :: Static": ["color-contrast"], + "editor/src/core/ui/SegmentedControl.stories.tsx :: Variants": [ + "color-contrast" + ], + "editor/src/core/ui/SegmentedControl.stories.tsx :: With Icons": [ + "color-contrast" + ], + "editor/src/core/ui/SettingsRow.stories.tsx :: List": ["label"], + "editor/src/core/ui/SettingsRow.stories.tsx :: Select Control": ["label"], + "editor/src/core/ui/SettingsRow.stories.tsx :: Toggle": ["label"], + "editor/src/core/ui/SettingsRow.stories.tsx :: With Description": [ + "color-contrast", + "label" + ], + "editor/src/core/ui/SettingsShell.stories.tsx :: Default": ["color-contrast"], + "editor/src/core/ui/Stack.stories.tsx :: Gap Sizes": ["color-contrast"], + "editor/src/core/ui/Stack.stories.tsx :: In Card": ["color-contrast"], + "editor/src/core/ui/StatTile.stories.tsx :: Playground": ["color-contrast"], + "editor/src/core/ui/StatTile.stories.tsx :: Tone Row": ["color-contrast"], + "editor/src/core/ui/StatusBadge.stories.tsx :: All Tones": ["color-contrast"], + "editor/src/core/ui/StatusBadge.stories.tsx :: Sizes": ["color-contrast"], + "editor/src/core/ui/StepIndicator.stories.tsx :: Small": [ + "aria-progressbar-name" + ], + "editor/src/core/ui/StepIndicator.stories.tsx :: Step 1": [ + "aria-progressbar-name" + ], + "editor/src/core/ui/StepIndicator.stories.tsx :: Step 2": [ + "aria-progressbar-name" + ], + "editor/src/core/ui/StepIndicator.stories.tsx :: Step 3": [ + "aria-progressbar-name" + ], + "editor/src/core/ui/Table.stories.tsx :: Basic": ["color-contrast"], + "editor/src/core/ui/Table.stories.tsx :: Empty": ["color-contrast"], + "editor/src/core/ui/Table.stories.tsx :: Interactive": ["color-contrast"], + "editor/src/core/ui/Tabs.stories.tsx :: In Context Document Verticals": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/core/ui/Tabs.stories.tsx :: Playground": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/core/ui/Tabs.stories.tsx :: With Disabled Tab": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/core/ui/Toast.stories.tsx :: Triggers": ["color-contrast"], + "editor/src/core/ui/ToggleSwitch.stories.tsx :: In Context Settings Rows": [ + "color-contrast" + ], + "editor/src/core/ui/ToggleSwitch.stories.tsx :: With Description": [ + "color-contrast" + ], + "editor/src/portal/components/AppShell.stories.tsx :: Mobile": [ + "color-contrast" + ], + "editor/src/portal/components/AppShell.stories.tsx :: With Home View": [ + "color-contrast" + ], + "editor/src/portal/components/AssistantPanel.stories.tsx :: Reply Fails": [ + "aria-allowed-role" + ], + "editor/src/portal/components/AssistantPanel.stories.tsx :: Slow Reply": [ + "aria-allowed-role" + ], + "editor/src/portal/components/AssistantPanel.stories.tsx :: Suggestions Only": [ + "aria-allowed-role", + "color-contrast" + ], + "editor/src/portal/components/ChatFABWidget.stories.tsx :: Closed": [ + "aria-hidden-focus" + ], + "editor/src/portal/components/ChatFABWidget.stories.tsx :: Full Flow": [ + "aria-hidden-focus", + "color-contrast" + ], + "editor/src/portal/components/ChatFABWidget.stories.tsx :: Loading While Closed": [ + "aria-hidden-focus" + ], + "editor/src/portal/components/ChatFABWidget.stories.tsx :: Unread Result": [ + "aria-hidden-focus" + ], + "editor/src/portal/components/DownloadEditorModal.stories.tsx :: Open": [ + "color-contrast" + ], + "editor/src/portal/components/EditorStatusCard.stories.tsx :: Deployment Unavailable": [ + "color-contrast" + ], + "editor/src/portal/components/EditorStatusCard.stories.tsx :: With Setup Checklist": [ + "color-contrast" + ], + "editor/src/portal/components/ErrorBoundary.stories.tsx :: Caught Error": [ + "color-contrast" + ], + "editor/src/portal/components/HomeHero.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/HomeHero.stories.tsx :: Free Tier": [ + "color-contrast" + ], + "editor/src/portal/components/LinkAccountFooterItem.stories.tsx :: Unlinked": [ + "color-contrast" + ], + "editor/src/portal/components/PortalChrome.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/ProcessorFlow.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/ProcessorFlow.stories.tsx :: Idle Empty": [ + "color-contrast" + ], + "editor/src/portal/components/ProcessorFlow.stories.tsx :: Playground": [ + "color-contrast" + ], + "editor/src/portal/components/SearchModal.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/SearchModal.stories.tsx :: Empty Catalogue": [ + "color-contrast" + ], + "editor/src/portal/components/SetupChecklist.stories.tsx :: Almost Done": [ + "color-contrast" + ], + "editor/src/portal/components/SetupChecklist.stories.tsx :: In Progress": [ + "color-contrast" + ], + "editor/src/portal/components/SetupChecklist.stories.tsx :: Not Started": [ + "color-contrast" + ], + "editor/src/portal/components/Sidebar.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/Sidebar.stories.tsx :: Enterprise Tier": [ + "color-contrast" + ], + "editor/src/portal/components/Sidebar.stories.tsx :: Free Tier": [ + "color-contrast" + ], + "editor/src/portal/components/WelcomeBanner.stories.tsx :: With Setup Checklist": [ + "color-contrast" + ], + "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Default": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Load Forbidden": [ + "color-contrast" + ], + "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Not Linked": [ + "color-contrast" + ], + "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Error": [ + "color-contrast" + ], + "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Linked": [ + "color-contrast" + ], + "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Linking": [ + "color-contrast" + ], + "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Not Linked": [ + "color-contrast" + ], + "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Unconfigured": [ + "color-contrast" + ], + "editor/src/portal/components/account-link/LinkAccountModal.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/account-link/LinkAccountModal.stories.tsx :: Reauth": [ + "color-contrast" + ], + "editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx :: Default": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/portal/components/billing/ActivationChoiceModal.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/billing/BundleCheckoutModal.stories.tsx :: First Purchase": [ + "color-contrast" + ], + "editor/src/portal/components/billing/BundleCheckoutModal.stories.tsx :: Top Up": [ + "color-contrast" + ], + "editor/src/portal/components/billing/CardPlaceholder.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/billing/EnterpriseUpsell.stories.tsx :: Bare": [ + "color-contrast" + ], + "editor/src/portal/components/billing/EnterpriseUpsell.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/billing/FreePdfEditorsCard.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/billing/FreePlanView.stories.tsx :: Leader": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/FreePlanView.stories.tsx :: Member": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/InvoicesList.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/billing/PaymentMethodCard.stories.tsx :: Managed In Stripe": [ + "color-contrast" + ], + "editor/src/portal/components/billing/PaymentMethodCard.stories.tsx :: With Card": [ + "color-contrast" + ], + "editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx :: Unsynced Only": [ + "color-contrast" + ], + "editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx :: With Breakdown": [ + "color-contrast" + ], + "editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx :: With Unsynced": [ + "color-contrast" + ], + "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Bundle Healthy": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Bundle Low": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Offer Nudge": [ + "color-contrast" + ], + "editor/src/portal/components/billing/PrepayModalHeader.stories.tsx :: Step Of Three": [ + "color-contrast" + ], + "editor/src/portal/components/billing/PrepayModalHeader.stories.tsx :: Step Of Two": [ + "color-contrast" + ], + "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Approaching Cap": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Editing": [ + "color-contrast" + ], + "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: No Cap": [ + "color-contrast" + ], + "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Within Cap": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx :: With Free Remaining": [ + "color-contrast" + ], + "editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx :: Set Cap": [ + "color-contrast" + ], + "editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx :: Uncapped": [ + "color-contrast" + ], + "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: Approaching Cap": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: Leader": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: With Prepaid": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Approaching Limit": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Limit Reached": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Plenty Left": [ + "aria-progressbar-name", + "color-contrast" + ], + "editor/src/portal/components/docs/AuthenticationSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/docs/ComponentsSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/docs/DocsNav.stories.tsx :: Badged Leaf Active": [ + "color-contrast" + ], + "editor/src/portal/components/docs/DocsNav.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/docs/DocsSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/docs/EndpointReferenceSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/docs/ErrorsSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/docs/GettingStartedSection.stories.tsx :: Default": [ + "color-contrast", + "heading-order" + ], + "editor/src/portal/components/docs/LangSnippet.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/docs/LangSnippet.stories.tsx :: Single Language": [ + "color-contrast" + ], + "editor/src/portal/components/docs/PlaybooksSection.stories.tsx :: Default": [ + "color-contrast", + "heading-order" + ], + "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Enterprise": [ + "color-contrast" + ], + "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Free": [ + "color-contrast" + ], + "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Pro": [ + "color-contrast" + ], + "editor/src/portal/components/docs/SdksSection.stories.tsx :: Default": [ + "color-contrast", + "heading-order" + ], + "editor/src/portal/components/docs/SdksSection.stories.tsx :: Ga Only": [ + "color-contrast", + "heading-order" + ], + "editor/src/portal/components/docs/SkillsSection.stories.tsx :: Default": [ + "color-contrast", + "heading-order" + ], + "editor/src/portal/components/docs/WebhooksSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/documents/DocumentAudit.stories.tsx :: Approved": [ + "color-contrast" + ], + "editor/src/portal/components/documents/DocumentAudit.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/documents/DocumentDrawer.stories.tsx :: Default": [ + "aria-allowed-role", + "color-contrast" + ], + "editor/src/portal/components/documents/DocumentDrawer.stories.tsx :: Sensitive": [ + "aria-allowed-role", + "color-contrast" + ], + "editor/src/portal/components/documents/DocumentExtractions.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/documents/DocumentExtractions.stories.tsx :: Masked": [ + "color-contrast" + ], + "editor/src/portal/components/documents/DocumentExtractions.stories.tsx :: Unlocked": [ + "color-contrast" + ], + "editor/src/portal/components/documents/DocumentOverview.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Granted": [ + "color-contrast" + ], + "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Granted Four Eyes": [ + "color-contrast" + ], + "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Locked": [ + "color-contrast" + ], + "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Locked Four Eyes": [ + "color-contrast" + ], + "editor/src/portal/components/documents/ReviewQueue.stories.tsx :: Default": [ + "aria-prohibited-attr", + "color-contrast", + "empty-table-header", + "nested-interactive" + ], + "editor/src/portal/components/documents/ReviewQueue.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Default": [ + "aria-prohibited-attr", + "color-contrast", + "empty-table-header", + "nested-interactive" + ], + "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Empty": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Needs Review": [ + "color-contrast", + "empty-table-header", + "nested-interactive" + ], + "editor/src/portal/components/editor-admin/CredentialRotationCard.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/CredentialRotationCard.stories.tsx :: Recently Rotated": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/DeploymentSummaryStrip.stories.tsx :: Enterprise": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/DeploymentSummaryStrip.stories.tsx :: Pro": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Enterprise": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Free": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Pro": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Enterprise": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Pro": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/OfflineActivationCard.stories.tsx :: Available": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/OfflineActivationCard.stories.tsx :: Locked": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/PairingPanel.stories.tsx :: Enterprise": [ + "color-contrast" + ], + "editor/src/portal/components/editor-admin/PairingPanel.stories.tsx :: Pro": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx :: Personal": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx :: Revoked": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Loading": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/AuditExportModal.stories.tsx :: Export Fails": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/AuditExportModal.stories.tsx :: Open": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Loading": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Non Lead Forbidden": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Team Lead Scoped": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx :: Form": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx :: Loading": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Enterprise": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Free": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Pro": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/SectionHeader.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/infrastructure/SectionHeader.stories.tsx :: Short Sub": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: Unsupported": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelinesTable.stories.tsx :: Default": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/components/pipelines/PipelinesTable.stories.tsx :: Empty": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/policies/CatalogueSummary.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/policies/CatalogueSummary.stories.tsx :: Loading": [ + "color-contrast" + ], + "editor/src/portal/components/policies/ClassificationLabelsSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Coming Soon": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Configured": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Not Set Up": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Active": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Custom No Activity": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Paused": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: With Flagged Items": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx :: Custom Api Escape Hatch": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx :: Notify Configured": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Chips": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Select": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Text": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx :: Configured": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx :: Connection Selected": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Classification": [ + "color-contrast" + ], + "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Create": [ + "color-contrast", + "label" + ], + "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Edit": [ + "color-contrast", + "label" + ], + "editor/src/portal/components/procurement/ActionModal.stories.tsx :: Pay": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ActionModal.stories.tsx :: Request Paid": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ActionModal.stories.tsx :: Sign": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ActionModal.stories.tsx :: Upload PO": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DealJourney.stories.tsx :: At Trial": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DealJourney.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DealJourney.stories.tsx :: Live": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Agreement": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Live": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Payment": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Quote": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Trial": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DocRow.stories.tsx :: Complete": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DocRow.stories.tsx :: Download": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DocRow.stories.tsx :: Locked": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DocRow.stories.tsx :: Paid Addon": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DocRow.stories.tsx :: Sign Action": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DocumentLedger.stories.tsx :: At Trial": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/DocumentLedger.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/LockedState.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Agreeing": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Default": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Downloading": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/portal/components/procurement/ProcurementBanner.stories.tsx :: Deal Underway": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementBanner.stories.tsx :: Upsell": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License Trial": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Schedule Call": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Manage": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Manage Maxed": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Setup": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Default": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Loading": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Unlinked": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementHome.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementModal.stories.tsx :: Open": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License Downloading": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License Online Only": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: Live": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: Payment": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: Payment Pending": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/QuoteBuilder.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/StageStepper.stories.tsx :: At Agreement": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/StageStepper.stories.tsx :: At Trial": [ + "color-contrast" + ], + "editor/src/portal/components/procurement/StageStepper.stories.tsx :: Locked": [ + "color-contrast" + ], + "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Conditional Fields Revealed": [ + "color-contrast" + ], + "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Filled": [ + "color-contrast" + ], + "editor/src/portal/components/sources/ConnectionModal.stories.tsx :: Edit": [ + "color-contrast" + ], + "editor/src/portal/components/sources/ConnectionModal.stories.tsx :: Fixed Type": [ + "color-contrast" + ], + "editor/src/portal/components/sources/ConnectionPicker.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/sources/ConnectionPicker.stories.tsx :: Delegated Create": [ + "color-contrast" + ], + "editor/src/portal/components/sources/ConnectionPicker.stories.tsx :: Load Error": [ + "color-contrast" + ], + "editor/src/portal/components/sources/ConnectionPicker.stories.tsx :: Preset Scoped": [ + "color-contrast" + ], + "editor/src/portal/components/sources/KpiStrip.stories.tsx :: Loading": [ + "color-contrast" + ], + "editor/src/portal/components/sources/KpiStrip.stories.tsx :: Ready": [ + "color-contrast" + ], + "editor/src/portal/components/sources/S3ConnectionPicker.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/sources/S3ConnectionPicker.stories.tsx :: Selected": [ + "color-contrast" + ], + "editor/src/portal/components/sources/SourceModal.stories.tsx :: Choose Type": [ + "color-contrast" + ], + "editor/src/portal/components/sources/SourceModal.stories.tsx :: Edit Folder": [ + "color-contrast" + ], + "editor/src/portal/components/sources/SourceModal.stories.tsx :: Edit Webhook": [ + "color-contrast" + ], + "editor/src/portal/components/sources/SourcesTable.stories.tsx :: Default": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/components/users/ConfirmModal.stories.tsx :: Danger": [ + "color-contrast" + ], + "editor/src/portal/components/users/ConfirmModal.stories.tsx :: Neutral": [ + "color-contrast" + ], + "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Create Account Form": [ + "color-contrast" + ], + "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: No Admin Role": [ + "color-contrast" + ], + "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Open": [ + "color-contrast" + ], + "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Scoped To Team": [ + "color-contrast" + ], + "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Self Hosted Direct Create": [ + "color-contrast" + ], + "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Self Hosted No Mail": [ + "color-contrast" + ], + "editor/src/portal/components/users/MoveToTeamModal.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/users/NewTeamModal.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Empty": [ + "color-contrast" + ], + "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Expires Today": [ + "color-contrast" + ], + "editor/src/portal/components/users/RenameTeamModal.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/users/ResetPasswordModal.stories.tsx :: Default": [ + "color-contrast", + "label" + ], + "editor/src/portal/components/users/ResetPasswordModal.stories.tsx :: With Email": [ + "color-contrast", + "label" + ], + "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Large Team": [ + "color-contrast" + ], + "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Member States": [ + "color-contrast" + ], + "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Org Only": [ + "color-contrast" + ], + "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Saas Team Leader": [ + "color-contrast" + ], + "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Single Team": [ + "color-contrast" + ], + "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Team Wide Processor": [ + "color-contrast" + ], + "editor/src/portal/components/users/UsersDirectory.stories.tsx :: With Guests": [ + "color-contrast" + ], + "editor/src/portal/data/Endpoints.stories.tsx :: By Vertical": [ + "color-contrast" + ], + "editor/src/portal/data/Ops.stories.tsx :: Agents": ["color-contrast"], + "editor/src/portal/data/Ops.stories.tsx :: Library By Category": [ + "color-contrast" + ], + "editor/src/portal/data/Ops.stories.tsx :: Pipeline Ops": ["color-contrast"], + "editor/src/portal/data/Ops.stories.tsx :: Sources And Destinations": [ + "color-contrast" + ], + "editor/src/portal/theme/MantineIntegration.stories.tsx :: Side By Side": [ + "color-contrast" + ], + "editor/src/portal/views/DeveloperDocs.stories.tsx :: Default": [ + "color-contrast", + "landmark-unique" + ], + "editor/src/portal/views/Documents.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/views/Documents.stories.tsx :: Empty": ["color-contrast"], + "editor/src/portal/views/Home.stories.tsx :: Enterprise Tier": [ + "color-contrast", + "landmark-no-duplicate-banner", + "landmark-unique" + ], + "editor/src/portal/views/Home.stories.tsx :: Free Tier": ["color-contrast"], + "editor/src/portal/views/Home.stories.tsx :: Pro Tier": [ + "color-contrast", + "landmark-no-duplicate-banner", + "landmark-unique" + ], + "editor/src/portal/views/Home.stories.tsx :: Subscribed In Procurement": [ + "color-contrast", + "landmark-no-duplicate-banner", + "landmark-unique" + ], + "editor/src/portal/views/Integrations.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/views/Integrations.stories.tsx :: No Connections": [ + "color-contrast" + ], + "editor/src/portal/views/PipelineBuilder.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/views/Pipelines.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/views/Pipelines.stories.tsx :: Empty": ["color-contrast"], + "editor/src/portal/views/Policies.stories.tsx :: Default": ["color-contrast"], + "editor/src/portal/views/Policies.stories.tsx :: Empty": ["color-contrast"], + "editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"], + "editor/src/portal/views/Sources.stories.tsx :: Empty": ["color-contrast"], + "editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: First Time Setup": [ + "color-contrast" + ], + "editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: Signup": [ + "aria-hidden-focus" + ], + "editor/src/proprietary/components/policies/ClassificationCategoryManager.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/policies/PolicyPiiField.stories.tsx :: With Selection": [ + "color-contrast" + ], + "editor/src/proprietary/components/policies/PolicyRedactConfig.stories.tsx :: With Selection": [ + "color-contrast" + ], + "editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx :: Default": [ + "button-name", + "label" + ], + "editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx :: Disabled": [ + "label" + ], + "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Default": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Mail Disabled": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Non Email Username": [ + "aria-dialog-name", + "color-contrast" + ], + "editor/src/proprietary/components/shared/DividerWithText.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/DividerWithText.stories.tsx :: Subcategory": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/ManageBillingButton.stories.tsx :: Custom Return Url": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/ManageBillingButton.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: At Minimum": [ + "button-name", + "color-contrast" + ], + "editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: Admin Banner": [ + "color-contrast", + "label" + ], + "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: Default": [ + "color-contrast", + "label" + ], + "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: With Backend Version": [ + "color-contrast", + "label" + ], + "editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx :: Signed In": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Mfa Enabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Audit Logging Disabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Enabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Login Disabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx :: Default": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx :: Empty": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Default": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Empty": [ + "color-contrast", + "empty-table-header" + ], + "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Login Disabled": [ + "empty-table-header" + ], + "editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.stories.tsx :: Default": [ + "button-name", + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx :: Login Disabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: All Fields Enabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Interactive": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: With Filters Applied": [ + "button-name", + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Day": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Month": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx :: Disabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: Login Disabled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: With Currency Selector": [ + "color-contrast", + "label" + ], + "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Current Tier": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx :: Default": [ + "button-name" + ], + "editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx :: Hosted Checkout Success": [ + "button-name", + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx :: Savings": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: Filled": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: With Error": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx :: Network Error": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: Enterprise": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: With Savings": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Polling": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Timeout": [ + "color-contrast" + ], + "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Upgrade Complete": [ + "color-contrast" + ], + "editor/src/proprietary/components/workflow/ParticipantView.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/proprietary/routes/login/OAuthButtons.stories.tsx :: Vertical": [ + "image-redundant-alt" + ] +} diff --git a/frontend/.storybook/a11y-baseline.json b/frontend/.storybook/a11y-baseline.json index a31d84613b..91db120c19 100644 --- a/frontend/.storybook/a11y-baseline.json +++ b/frontend/.storybook/a11y-baseline.json @@ -1,4 +1,7 @@ { + "editor/src/core/assets/Brand.stories.tsx :: Logos": [ + "scrollable-region-focusable" + ], "editor/src/core/components/StorageStatsCard.stories.tsx :: Default": [ "aria-progressbar-name", "color-contrast" @@ -886,9 +889,6 @@ "editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Filled": [ "button-name" ], - "editor/src/core/components/tools/compare/CompareDocumentPane.stories.tsx :: Default": [ - "color-contrast" - ], "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: Default": [ "color-contrast" ], @@ -1377,8 +1377,17 @@ "editor/src/core/ui/ChatFABButton.stories.tsx :: Tick While Loading": [ "button-name" ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Open": ["color-contrast"], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Toggle": ["color-contrast"], + "editor/src/core/ui/ChatFABWindow.stories.tsx :: Closed": [ + "scrollable-region-focusable" + ], + "editor/src/core/ui/ChatFABWindow.stories.tsx :: Open": [ + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/core/ui/ChatFABWindow.stories.tsx :: Toggle": [ + "color-contrast", + "scrollable-region-focusable" + ], "editor/src/core/ui/Chip.stories.tsx :: Accents": ["color-contrast"], "editor/src/core/ui/Chip.stories.tsx :: Dashed Add": ["nested-interactive"], "editor/src/core/ui/Chip.stories.tsx :: In Context Op Chain": [ @@ -1399,6 +1408,12 @@ "scrollable-region-focusable" ], "editor/src/core/ui/CodeBlock.stories.tsx :: Playground": ["color-contrast"], + "editor/src/core/ui/Collapsible.stories.tsx :: Accordion": [ + "scrollable-region-focusable" + ], + "editor/src/core/ui/Collapsible.stories.tsx :: Default": [ + "scrollable-region-focusable" + ], "editor/src/core/ui/Drawer.stories.tsx :: Playground": [ "aria-allowed-role", "color-contrast" @@ -1565,11 +1580,16 @@ "editor/src/core/ui/Table.stories.tsx :: Basic": ["color-contrast"], "editor/src/core/ui/Table.stories.tsx :: Interactive": ["color-contrast"], "editor/src/core/ui/Tabs.stories.tsx :: In Context Document Verticals": [ - "color-contrast" + "color-contrast", + "scrollable-region-focusable" + ], + "editor/src/core/ui/Tabs.stories.tsx :: Playground": [ + "color-contrast", + "scrollable-region-focusable" ], - "editor/src/core/ui/Tabs.stories.tsx :: Playground": ["color-contrast"], "editor/src/core/ui/Tabs.stories.tsx :: With Disabled Tab": [ - "color-contrast" + "color-contrast", + "scrollable-region-focusable" ], "editor/src/core/ui/Toast.stories.tsx :: Triggers": ["color-contrast"], "editor/src/portal/components/AppShell.stories.tsx :: Mobile": [ @@ -1951,26 +1971,12 @@ "editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx :: Form": [ "color-contrast" ], - "editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx :: Default": [ - "aria-progressbar-name", - "color-contrast" - ], "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Enterprise": [ "color-contrast" ], "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Free": [ "color-contrast" ], - "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Pro": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/infrastructure/SecurityTab.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/StorageTab.stories.tsx :: Default": [ - "color-contrast" - ], "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [ "color-contrast" ], @@ -2266,18 +2272,6 @@ "color-contrast", "landmark-unique" ], - "editor/src/portal/views/Documents.stories.tsx :: Default": [ - "aria-prohibited-attr", - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/views/Documents.stories.tsx :: Empty": [ - "aria-prohibited-attr", - "color-contrast", - "empty-table-header", - "nested-interactive" - ], "editor/src/portal/views/Home.stories.tsx :: Enterprise Tier": [ "color-contrast", "landmark-no-duplicate-banner", @@ -2304,23 +2298,11 @@ "color-contrast" ], "editor/src/portal/views/Pipelines.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/views/Pipelines.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/views/Policies.stories.tsx :: Default": ["color-contrast"], - "editor/src/portal/views/Policies.stories.tsx :: Empty": ["color-contrast"], - "editor/src/portal/views/Sources.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/views/Sources.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" + "color-contrast" ], + "editor/src/portal/views/Pipelines.stories.tsx :: Empty": ["color-contrast"], + "editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"], + "editor/src/portal/views/Sources.stories.tsx :: Empty": ["color-contrast"], "editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: Signup": [ "aria-hidden-focus" ], @@ -2354,6 +2336,9 @@ "aria-dialog-name", "color-contrast" ], + "editor/src/proprietary/components/shared/DividerWithText.stories.tsx :: Default": [ + "color-contrast" + ], "editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: At Minimum": [ "button-name", "color-contrast" diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index a5cc4161c4..e9d7e7f3d9 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -29,7 +29,16 @@ import { rtlLanguages, supportedLanguages } from "@core/i18n/languages"; import "@mantine/core/styles.css"; import "@core/tokens/tokens.css"; import "@core/theme/index.css"; +// The editor's semantic token layer (--bg-surface, --onboarding-title, …). +// The app reaches it through its style entry; without it here, components +// styled on those variables render unthemed (e.g. transparent modal surfaces) +// and axe measures contrast against colours the app never shows. +import "@core/styles/theme.css"; import "@core/tokens/base.css"; +// Portal element reset + typography. Scoped to .portal-scope in the app so it +// can't leak into the editor; the decorator below adds that class around +// portal stories only, mirroring how PortalApp mounts. +import "@portal/theme/base.css"; // Storybook-only: bundle every shipped locale's TOML at build time via a ?raw // glob, so the toolbar language switcher can flip between all languages with no @@ -201,6 +210,13 @@ const withProviders: Decorator = (Story, context) => { // anything that isn't "dark" as light — matching the addon's own // `selected || defaultTheme` fallback where defaultTheme is light. const colorScheme = context.globals.theme === "dark" ? "dark" : "light"; + // PortalApp mounts its views inside a .portal-scope wrapper, which is what + // the portal's base.css keys its reset/typography on. Give portal stories + // the same wrapper (and only them — the scoping exists precisely so portal + // styles never apply to editor components). + const isPortalStory = (context.parameters.fileName ?? "").includes( + "/portal/", + ); return ( @@ -214,7 +230,13 @@ const withProviders: Decorator = (Story, context) => { - + {isPortalStory ? ( +

+ +
+ ) : ( + + )} @@ -229,6 +251,12 @@ const withProviders: Decorator = (Story, context) => { const preview: Preview = { loaders: [mswLoader], + // The scan runs once per theme (SCAN_THEME=light|dark, forwarded by + // .storybook/vitest.config.ts); pinning the global here themes every story in + // the run. Unset — the Storybook UI — falls back to the toolbar default. + initialGlobals: { + theme: import.meta.env.VITE_SCAN_THEME === "dark" ? "dark" : "light", + }, parameters: { layout: "padded", controls: { diff --git a/frontend/.storybook/vitest.config.ts b/frontend/.storybook/vitest.config.ts index 8519e50267..28d25f9732 100644 --- a/frontend/.storybook/vitest.config.ts +++ b/frontend/.storybook/vitest.config.ts @@ -14,6 +14,14 @@ import { storybookTest } from "@storybook/addon-vitest/vitest-plugin"; * Run with: npx vitest run --config .storybook/vitest.config.ts */ export default defineConfig({ + // Forwards the SCAN_THEME env var into the browser bundle, where preview.tsx + // uses it to pin the theme global for the whole run. The Storybook dev/build + // pipeline never sets it, so the toolbar default stays "light" there. + define: { + "import.meta.env.VITE_SCAN_THEME": JSON.stringify( + process.env.SCAN_THEME ?? "", + ), + }, optimizeDeps: { // Pre-scan every story + the preview so Vite discovers the story set's large // dep surface (embedpdf plugins, @mui icons, …) in one pass up front. diff --git a/frontend/editor/scripts/lint/theme-lint.mjs b/frontend/editor/scripts/lint/theme-lint.mjs index 42f6153119..22a3f4dfe8 100644 --- a/frontend/editor/scripts/lint/theme-lint.mjs +++ b/frontend/editor/scripts/lint/theme-lint.mjs @@ -632,7 +632,9 @@ const CODE_EXEMPT_PATH = [ /\/onboarding\//, /addStamp|addWatermark|\/tooltips\//, /UpgradeBanner|AdminPlanSection/, - /\.test\.[jt]sx?$|\.stories\.[jt]sx?$|\/types\//, + // Stories are checked like app code; colour-as-data lines opt out with + // `theme-allow-color`. + /\.test\.[jt]sx?$|\/types\//, ]; const CODE_HEX = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})(?![0-9a-fA-F])/g; diff --git a/frontend/editor/src/core/assets/Brand.stories.tsx b/frontend/editor/src/core/assets/Brand.stories.tsx index ac144c88b1..b70d30cca1 100644 --- a/frontend/editor/src/core/assets/Brand.stories.tsx +++ b/frontend/editor/src/core/assets/Brand.stories.tsx @@ -13,6 +13,10 @@ import classicBlack from "@app/assets/brand/classic-logo/StirlingPDFLogoBlackTex import classicWhite from "@app/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg"; import classicGrey from "@app/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg"; +// Fixed swatch so the light-on-dark mark variant previews on a dark +// surface in either theme. +const DARK_SWATCH = "#1a1a1a"; // theme-allow-color fixed preview swatch + type Asset = { label: string; src: string; onDark?: boolean }; type VariantSet = { variant: string; mark: Asset[]; wordmark: Asset[] }; @@ -55,15 +59,13 @@ function Swatch({ label, src, onDark, h }: Asset & { h: number }) { padding: 16, minWidth: 140, borderRadius: 8, - border: "1px solid rgba(128,128,128,0.25)", - background: onDark ? "#1a1a1a" : "#ffffff", + border: "1px solid var(--c-border)", + background: onDark ? DARK_SWATCH : "#ffffff", }} > {label} -
+
{label}
diff --git a/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx b/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx index 4dc8cb3fd1..b5b1bbe5a9 100644 --- a/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx +++ b/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx @@ -2,13 +2,13 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { fn } from "storybook/test"; import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker"; -import { FolderRecord } from "@app/types/folder"; +import { FOLDER_COLOR_PALETTE, FolderRecord } from "@app/types/folder"; const folder: FolderRecord = { id: "folder-1" as FolderRecord["id"], name: "Contracts", parentFolderId: null, - color: "#3b82f6", + color: FOLDER_COLOR_PALETTE[0], icon: "star", createdAt: Date.now(), updatedAt: Date.now(), diff --git a/frontend/editor/src/core/components/filesPage/FolderThumbnail.stories.tsx b/frontend/editor/src/core/components/filesPage/FolderThumbnail.stories.tsx index 7208834119..ab3e9ed2c3 100644 --- a/frontend/editor/src/core/components/filesPage/FolderThumbnail.stories.tsx +++ b/frontend/editor/src/core/components/filesPage/FolderThumbnail.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail"; +import { FOLDER_COLOR_PALETTE } from "@app/types/folder"; const meta = { title: "FilesPage/FolderThumbnail", @@ -10,14 +11,14 @@ type Story = StoryObj; export const Default: Story = { args: { - color: "#6366f1", + color: FOLDER_COLOR_PALETTE[4], fileCount: 12, }, }; export const RowSize: Story = { args: { - color: "#22c55e", + color: FOLDER_COLOR_PALETTE[1], fileCount: 3, size: "row", }, @@ -25,7 +26,7 @@ export const RowSize: Story = { export const WithIconGlyph: Story = { args: { - color: "#f97316", + color: FOLDER_COLOR_PALETTE[7], fileCount: 5, iconGlyph: "📄", }, diff --git a/frontend/editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx b/frontend/editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx index d01a697f04..63f5bbcc50 100644 --- a/frontend/editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx +++ b/frontend/editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx @@ -1,6 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { MoveToFolderDialog } from "@app/components/filesPage/MoveToFolderDialog"; -import { createFolderId, FolderRecord } from "@app/types/folder"; +import { + createFolderId, + FOLDER_COLOR_PALETTE, + FolderRecord, +} from "@app/types/folder"; const workId = createFolderId(); const invoicesId = createFolderId(); @@ -11,7 +15,7 @@ const folders: FolderRecord[] = [ id: workId, name: "Work", parentFolderId: null, - color: "#3b82f6", + color: FOLDER_COLOR_PALETTE[0], createdAt: Date.now(), updatedAt: Date.now(), }, @@ -19,7 +23,7 @@ const folders: FolderRecord[] = [ id: invoicesId, name: "Invoices", parentFolderId: workId, - color: "#10b981", + color: FOLDER_COLOR_PALETTE[1], createdAt: Date.now(), updatedAt: Date.now(), }, @@ -27,7 +31,7 @@ const folders: FolderRecord[] = [ id: archivedId, name: "Archived", parentFolderId: null, - color: "#f59e0b", + color: FOLDER_COLOR_PALETTE[2], createdAt: Date.now(), updatedAt: Date.now(), }, diff --git a/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx index f493051403..061df336c6 100644 --- a/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx +++ b/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx @@ -1,9 +1,5 @@ import { useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; -// The shared preview only loads the portal tokens; the onboarding modal reads -// the editor theme tokens (--bg-surface, --onboarding-title, …), so load them -// here or the modal surface renders transparent over the dark overlay. -import "@app/styles/theme.css"; import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide"; import { SLIDE_DEFINITIONS, diff --git a/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx b/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx index 7878980f0b..50d0c4331a 100644 --- a/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx +++ b/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx @@ -1,8 +1,4 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -// The shared preview only loads the portal tokens; the shell reads the editor -// theme tokens (--bg-surface, --onboarding-title, …), so load them here or the -// card renders transparent over the dark overlay. -import "@app/styles/theme.css"; import OnboardingSlideShell, { ShellHero, type ShellButton, diff --git a/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.stories.tsx index 46aa79429f..71e1bdb918 100644 --- a/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.stories.tsx +++ b/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.stories.tsx @@ -1,8 +1,4 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -// The shared preview only loads the portal tokens; the onboarding modal reads -// the editor theme tokens (--bg-surface, --onboarding-title, …), so load them -// here or the modal surface renders transparent over the dark overlay. -import "@app/styles/theme.css"; import StaticOnboardingSlide from "@app/components/onboarding/StaticOnboardingSlide"; import { DEFAULT_RUNTIME_STATE } from "@app/components/onboarding/orchestrator/onboardingConfig"; diff --git a/frontend/editor/src/core/components/shared/FileDocIcon.stories.tsx b/frontend/editor/src/core/components/shared/FileDocIcon.stories.tsx index b42f7085a2..f84193609e 100644 --- a/frontend/editor/src/core/components/shared/FileDocIcon.stories.tsx +++ b/frontend/editor/src/core/components/shared/FileDocIcon.stories.tsx @@ -31,5 +31,5 @@ export const AllVariants: Story = { /** Explicit `color` overrides the variant's default accent. */ export const CustomColor: Story = { - args: { variant: "pdf", color: "#e64980" }, + args: { variant: "pdf", color: "#e64980" }, // theme-allow-color demoes the colour override }; diff --git a/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx b/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx index 9079bb08ab..d852358d1f 100644 --- a/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx +++ b/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx @@ -12,7 +12,7 @@ const mockSignatures: SavedSignature[] = [ signerName: "Jordan Lee", fontFamily: "cursive", fontSize: 32, - textColor: "#1a1a1a", + textColor: "#1a1a1a", // theme-allow-color signature ink is user data createdAt: Date.now(), updatedAt: Date.now(), }, diff --git a/frontend/editor/src/core/ui/CarouselDots.stories.tsx b/frontend/editor/src/core/ui/CarouselDots.stories.tsx index 31de307ca2..2d09a548f8 100644 --- a/frontend/editor/src/core/ui/CarouselDots.stories.tsx +++ b/frontend/editor/src/core/ui/CarouselDots.stories.tsx @@ -36,11 +36,14 @@ export const Interactive: Story = { export const Default: Story = { args: { activeIndex: 1 } }; /** White dots for use over dark photography (auth carousel). */ +// Stand-in for the photo the onImage tone is designed to sit on. +const IMAGE_BG = "#1e293b"; // theme-allow-color photo stand-in + export const OnImage: Story = { args: { activeIndex: 1, tone: "onImage" }, decorators: [ (S) => ( -
+
), diff --git a/frontend/editor/src/core/ui/ChatFABWindow.stories.tsx b/frontend/editor/src/core/ui/ChatFABWindow.stories.tsx index 1c3c40ab05..97857a1f86 100644 --- a/frontend/editor/src/core/ui/ChatFABWindow.stories.tsx +++ b/frontend/editor/src/core/ui/ChatFABWindow.stories.tsx @@ -29,7 +29,7 @@ function MockChat() {
What do you want to do? @@ -129,7 +129,7 @@ export const Toggle: Story = { position: "absolute", bottom: -48, right: 0, - background: "#3b82f6", + background: "var(--c-primary)", color: "#fff", border: "none", borderRadius: 8, diff --git a/frontend/editor/src/core/ui/Collapsible.stories.tsx b/frontend/editor/src/core/ui/Collapsible.stories.tsx index 3d49a9465f..36dffb527b 100644 --- a/frontend/editor/src/core/ui/Collapsible.stories.tsx +++ b/frontend/editor/src/core/ui/Collapsible.stories.tsx @@ -22,7 +22,12 @@ export const Default: Story = { header={Section title} aside={3 items} > -
+
Body content revealed when the section is open.
@@ -52,7 +57,12 @@ export const Accordion: Story = { onToggle={() => setOpen(open === i ? null : i)} header={{label}} > -
+
{label} details.
diff --git a/frontend/editor/src/core/ui/MantineForms.stories.tsx b/frontend/editor/src/core/ui/MantineForms.stories.tsx index dfa40644dd..08e2b2e179 100644 --- a/frontend/editor/src/core/ui/MantineForms.stories.tsx +++ b/frontend/editor/src/core/ui/MantineForms.stories.tsx @@ -271,7 +271,7 @@ export const ColorInput_Default: Story = { export const ColorInput_Preselected: Story = { render: () => { function Bound() { - const [color, setColor] = useState("#3B82F6"); + const [color, setColor] = useState("#3B82F6"); // theme-allow-color the ColorInput value is the datum return ( @@ -285,7 +285,7 @@ export const ColorInput_Preselected: Story = { export const ColorInput_SmSize: Story = { render: () => { function Bound() { - const [color, setColor] = useState("#EF4444"); + const [color, setColor] = useState("#EF4444"); // theme-allow-color the ColorInput value is the datum return ( @@ -308,10 +308,12 @@ export const ColorInput_Error: Story = { ), }; +const DISABLED_COLOR_VALUE = "#3B82F6"; // theme-allow-color the ColorInput value is the datum + export const ColorInput_Disabled: Story = { render: () => ( - {}} disabled /> + {}} disabled /> ), }; diff --git a/frontend/editor/src/core/ui/Tabs.stories.tsx b/frontend/editor/src/core/ui/Tabs.stories.tsx index 6e9780f622..554793b0be 100644 --- a/frontend/editor/src/core/ui/Tabs.stories.tsx +++ b/frontend/editor/src/core/ui/Tabs.stories.tsx @@ -63,29 +63,29 @@ export const InContext_DocumentVerticals: Story = { key: "insurance", label: "Insurance", count: 7, - accentColor: "#0ea5e9", - dotColor: "#0ea5e9", + accentColor: "var(--color-cat-insurance)", + dotColor: "var(--color-cat-insurance)", }, { key: "finance", label: "Finance", count: 7, - accentColor: "#10b981", - dotColor: "#10b981", + accentColor: "var(--color-cat-finance)", + dotColor: "var(--color-cat-finance)", }, { key: "legal", label: "Legal", count: 6, - accentColor: "#3B82F6", - dotColor: "#3B82F6", + accentColor: "var(--color-cat-legal)", + dotColor: "var(--color-cat-legal)", }, { key: "healthcare", label: "Healthcare", count: 6, - accentColor: "#8B5CF6", - dotColor: "#8B5CF6", + accentColor: "var(--color-cat-healthcare)", + dotColor: "var(--color-cat-healthcare)", }, ]} /> diff --git a/frontend/editor/src/portal/components/ChatFABWidget.stories.tsx b/frontend/editor/src/portal/components/ChatFABWidget.stories.tsx index af7d67eea7..1bc485299b 100644 --- a/frontend/editor/src/portal/components/ChatFABWidget.stories.tsx +++ b/frontend/editor/src/portal/components/ChatFABWidget.stories.tsx @@ -55,7 +55,7 @@ function MockChatContent({ alignItems: "center", justifyContent: "space-between", padding: "14px 16px 10px", - borderBottom: "1px solid var(--c-border, #e3e8ee)", + borderBottom: "1px solid var(--c-border)", flexShrink: 0, }} > @@ -65,7 +65,7 @@ function MockChatContent({ shape="circle" onClick={onClose} aria-label="Close chat" - style={{ color: "var(--c-text-subtle, #64748b)" }} + style={{ color: "var(--c-text-subtle)" }} > ✕ @@ -90,8 +90,8 @@ function MockChatContent({ maxWidth: "82%", background: m.role === "user" - ? "#3b82f6" - : "var(--c-surface-sunken, #f3f4f6)", + ? "var(--c-primary)" + : "var(--c-surface-sunken)", color: m.role === "user" ? "#fff" : "inherit", borderRadius: 10, padding: "8px 12px", @@ -108,17 +108,17 @@ function MockChatContent({
What do you want to do? @@ -149,7 +149,7 @@ function ChatFABWidgetDemo({ width: "100%", height: "100%", overflow: "hidden", - background: "var(--c-bg, #f8f9fb)", + background: "var(--c-bg)", }} > {/* FAB button */} @@ -249,7 +249,7 @@ function ChatFABFullFlowDemo() { padding: "4px 10px", borderRadius: 6, background: - step === s ? "#3b82f6" : "var(--c-surface-sunken, #f3f4f6)", + step === s ? "var(--c-primary)" : "var(--c-surface-sunken)", color: step === s ? "#fff" : "inherit", fontWeight: step === s ? 600 : 400, }} diff --git a/frontend/editor/src/portal/data/Ops.stories.tsx b/frontend/editor/src/portal/data/Ops.stories.tsx index 8e59e5f5ef..502791e2d1 100644 --- a/frontend/editor/src/portal/data/Ops.stories.tsx +++ b/frontend/editor/src/portal/data/Ops.stories.tsx @@ -27,7 +27,7 @@ const STAGE_ORDER: OpKind[] = [ const STAGE_COLOUR: Record = { ingest: "var(--color-green)", validate: "var(--c-primary)", - modify: "#F97316", + modify: "var(--color-orange)", secure: "var(--color-red)", store: "var(--color-purple)", alert: "var(--color-amber)", From 59ed4f5fd117cac60997cc3148c02c09e0743f41 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:14:56 +0100 Subject: [PATCH 05/10] Fix automate unrunnable tools (#7311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Fix automate unrunnable tools ## Problem - Remove Image failed in Automate with `Tool operation not supported: removeImage` - Its registry entry had `operationConfig: undefined` even though the config existed and was already tested - The Automate picker only filtered on `supportsAutomate`, never on `operationConfig` — so broken tools were selectable and failed only at run time ## Fixes - Wire up `removeImage` and `pageLayout` operation configs (both already existed, just never registered) - Exclude `validateSignature` (report tool, not on the operationConfig seam) and `scannerEffect` (no frontend implementation) via `supportsAutomate: false` - Picker now also filters on `operationConfig`, so this class of bug can't reach users again - `overlay-pdfs` returns 400 instead of 500 when overlay files or mode are missing - Fix `new URL().pathname` Windows path bug that stopped 2 test suites from loading --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../controller/api/PdfOverlayController.java | 20 +++++++++++ .../tools/automate/ToolSelector.tsx | 8 +++-- ...tomatableToolsHaveOperationConfig.test.tsx | 33 +++++++++++++++++++ .../core/data/useTranslatedToolRegistry.tsx | 11 ++++++- .../src/core/utils/toolIOCompat.test.ts | 5 ++- .../src/core/utils/toolIOLabels.test.ts | 5 ++- 6 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java index 7f17738d98..1d369d282d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java @@ -61,6 +61,7 @@ public class PdfOverlayController { int overlayPos = request.getOverlayPosition(); MultipartFile[] overlayFiles = request.getOverlayFiles(); + validateOverlayFiles(overlayFiles); File[] overlayPdfFiles = new File[overlayFiles.length]; List tempFiles = new ArrayList<>(); // List to keep track of temporary files @@ -120,10 +121,29 @@ public class PdfOverlayController { } } + // Both fields are declared required, but @ModelAttribute binding leaves them null when the + // caller omits them, which would otherwise surface as a 500 instead of a 400. + private void validateOverlayFiles(MultipartFile[] overlayFiles) { + if (overlayFiles == null || overlayFiles.length == 0) { + throw ExceptionUtils.createIllegalArgumentException( + "error.overlayFilesRequired", "At least one overlay file is required"); + } + for (MultipartFile overlayFile : overlayFiles) { + if (overlayFile == null || overlayFile.isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.overlayFileEmpty", "Overlay files must not be empty"); + } + } + } + private Map prepareOverlayGuide( int basePageCount, File[] overlayFiles, String mode, int[] counts, List tempFiles) throws IOException { Map overlayGuide = new HashMap<>(); + if (mode == null) { + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidFormat", "Invalid {0} format: {1}", "overlay mode", "null"); + } switch (mode) { case "SequentialOverlay": sequentialOverlay(overlayGuide, overlayFiles, basePageCount, tempFiles); diff --git a/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx b/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx index c5c3998bc0..ad7bb33a6e 100644 --- a/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx +++ b/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx @@ -34,13 +34,17 @@ export default function ToolSelector({ const [shouldAutoFocus, setShouldAutoFocus] = useState(false); const containerRef = useRef(null); - // Filter out excluded tools (like 'automate' itself) and tools that don't support automation + // Filter out excluded tools (like 'automate' itself), tools that don't support + // automation, and tools with no operationConfig - the executor resolves a step + // through operationConfig, so offering one without it fails only at run time. const baseFilteredTools = useMemo(() => { return ( Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][] ).filter( ([key, tool]) => - !excludeTools.includes(key) && getToolSupportsAutomate(tool), + !excludeTools.includes(key) && + getToolSupportsAutomate(tool) && + Boolean(tool.operationConfig), ); }, [toolRegistry, excludeTools]); diff --git a/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx b/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx new file mode 100644 index 0000000000..461c07ebdf --- /dev/null +++ b/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx @@ -0,0 +1,33 @@ +/** + * Registry invariant: the Automate picker offers a tool whenever it doesn't opt out via + * `supportsAutomate: false`, but automationExecutor resolves each step through the tool's + * `operationConfig`. A tool that is offered without one is selectable in the builder and + * only fails when the automation runs, with "Tool operation not supported: ". + * + * So a tool must either carry an operationConfig or declare supportsAutomate: false. + */ +import { describe, expect, test, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry"; +import { getToolSupportsAutomate } from "@app/data/toolsTaxonomy"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback ?? key, + i18n: { changeLanguage: vi.fn(), language: "en-US" }, + }), + Trans: ({ children }: { children?: unknown }) => children, +})); + +describe("automatable tools", () => { + test("every tool offered to Automate can be executed as a step", () => { + const { result } = renderHook(() => useTranslatedToolCatalog()); + + const offeredWithoutConfig = Object.entries(result.current.regularTools) + .filter(([, entry]) => entry && getToolSupportsAutomate(entry)) + .filter(([, entry]) => !entry.operationConfig) + .map(([id]) => id); + + expect(offeredWithoutConfig).toEqual([]); + }); +}); diff --git a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx index 16b69d6983..0a39da2fb9 100644 --- a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx @@ -50,6 +50,8 @@ import { changeMetadataOperationConfig } from "@app/hooks/tools/changeMetadata/u import { signOperationConfig } from "@app/hooks/tools/sign/useSignOperation"; import { cropOperationConfig } from "@app/hooks/tools/crop/useCropOperation"; import { removeAnnotationsOperationConfig } from "@app/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation"; +import { removeImageOperationConfig } from "@app/hooks/tools/removeImage/useRemoveImageOperation"; +import { pageLayoutOperationConfig } from "@app/hooks/tools/pageLayout/usePageLayoutOperation"; import { extractImagesOperationConfig } from "@app/hooks/tools/extractImages/useExtractImagesOperation"; import { replaceColorOperationConfig } from "@app/hooks/tools/replaceColor/useReplaceColorOperation"; import { removePagesOperationConfig } from "@app/hooks/tools/removePages/useRemovePagesOperation"; @@ -526,6 +528,9 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { maxFiles: -1, endpoints: ["validate-signature"], synonyms: getSynonyms(t, "validateSignature"), + // Reports on signatures rather than transforming the PDF, and its hook is + // not on the operationConfig seam, so it cannot run as an automation step. + supportsAutomate: false, automationSettings: null, }, @@ -755,6 +760,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { subcategoryId: SubcategoryId.PAGE_FORMATTING, maxFiles: -1, endpoints: ["multi-page-layout"], + operationConfig: asRegistryConfig(pageLayoutOperationConfig), automationSettings: lazySettings( () => import("@app/components/tools/pageLayout/PageLayoutSettings"), ), @@ -967,7 +973,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { subcategoryId: SubcategoryId.REMOVAL, maxFiles: -1, endpoints: ["remove-image-pdf"], - operationConfig: undefined, + operationConfig: asRegistryConfig(removeImageOperationConfig), synonyms: getSynonyms(t, "removeImage"), automationSettings: null, }, @@ -1196,6 +1202,9 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { subcategoryId: SubcategoryId.ADVANCED_FORMATTING, endpoints: ["scanner-effect"], synonyms: getSynonyms(t, "scannerEffect"), + // No frontend implementation yet (component is null), so it has no + // operationConfig to execute as an automation step. + supportsAutomate: false, automationSettings: null, }, diff --git a/frontend/editor/src/core/utils/toolIOCompat.test.ts b/frontend/editor/src/core/utils/toolIOCompat.test.ts index c0e9d9efa3..b3fb117705 100644 --- a/frontend/editor/src/core/utils/toolIOCompat.test.ts +++ b/frontend/editor/src/core/utils/toolIOCompat.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { validateToolChain, @@ -24,7 +25,9 @@ interface SharedCase { /** Shared with the backend and engine, so it lives at the repo root. */ function casesFile(): string { - let current = dirname(new URL(import.meta.url).pathname); + // fileURLToPath, not URL.pathname: on Windows the latter yields "/C:/..." and + // resolving against it produces a "C:\C:\..." path that never matches. + let current = dirname(fileURLToPath(import.meta.url)); for (let i = 0; i < 12; i++) { const candidate = resolve(current, "testing/tool-io-cases.json"); try { diff --git a/frontend/editor/src/core/utils/toolIOLabels.test.ts b/frontend/editor/src/core/utils/toolIOLabels.test.ts index ac6b4c155b..98c529e677 100644 --- a/frontend/editor/src/core/utils/toolIOLabels.test.ts +++ b/frontend/editor/src/core/utils/toolIOLabels.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { TOOL_FORMATS, type ToolFormat } from "@app/types/toolIO"; import { @@ -9,7 +10,9 @@ import { /** The en-US `[toolFormat]` block, read straight from the locale file. */ function toolFormatLabels(): Record { - let current = dirname(new URL(import.meta.url).pathname); + // fileURLToPath, not URL.pathname: on Windows the latter yields "/C:/..." and + // resolving against it produces a "C:\C:\..." path that never matches. + let current = dirname(fileURLToPath(import.meta.url)); for (let i = 0; i < 12; i++) { try { const toml = readFileSync( From 35a861f4f80f2816b22ec57fef2c1a0a6b9906b2 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:42:44 +0100 Subject: [PATCH 06/10] feat(editor): move endpoint availability onto TanStack Query (#7285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes > Stacked on #7264, sibling of #7283. Independent of #7283 — the only overlap is two additive lines in `core/query/keys.ts` and `core/api/config.ts`. Either can merge first. ## The problem `useEndpointConfig` kept its own cache: a module-level `globalFetchDone` boolean, a mutable `globalEndpointCache` object, and a `resetGlobalCache()` called from the JWT listener. Which consumer mounted first decided who paid for the request, and nothing invalidated it except a page reload. ## End state One shared query for the whole availability map; each of the 12 consumers projects the endpoints it asked for. **251 lines to 101**, same return shape, no consumer changes. | | Before | After | |---|---|---| | Cross-consumer cache | `globalFetchDone` + mutable module object | query key | | Invalidation | `resetGlobalCache()` mutating that object | `invalidateQueries` | | Per-endpoint check | own `useState` triple | query keyed by endpoint | Behaviour kept deliberately: - **Unknown endpoints and any failure still read as enabled.** This fires before auth settles, and disabling every tool on a hiccup is worse than letting one call fail later. - **`retry` is off for the availability map.** The fallback *is* the answer, so retrying only doubles a request every logged-out visitor makes on load. ## Desktop is untouched `desktop/hooks/useEndpointConfig.ts` shadows this module entirely — no shared code, so core converting doesn't affect it and there's no half-migrated state. It's 482 lines of orchestration rather than fetching: dependency-ready gating, `tauriBackendService` and `selfHostedServerMonitor` subscriptions, a 2.5s timeout retry for backend startup, a legacy `?endpoints=` fallback for old servers, and SaaS-routing optimism that rewrites disabled endpoints to enabled. It also has no test coverage to convert against, and it decides whether tools appear at all in the desktop app. That's a different job from this one and wants its own review. Next PR. ## Testing 9 new tests: projection onto the requested subset, one request across consumers, unknown-endpoint fallback, failure fallback with no retry, empty-list no-fetch, JWT invalidation, and the three single-endpoint cases. `task frontend:check` green: 1677 tests across 192 files, typecheck on all five flavours, eslint, dpdm, prettier. Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com> --- frontend/editor/src/core/api/config.ts | 30 ++ .../src/core/hooks/useEndpointConfig.test.tsx | 149 +++++++++ .../src/core/hooks/useEndpointConfig.ts | 282 ++++-------------- frontend/editor/src/core/query/keys.ts | 3 + 4 files changed, 248 insertions(+), 216 deletions(-) create mode 100644 frontend/editor/src/core/hooks/useEndpointConfig.test.tsx diff --git a/frontend/editor/src/core/api/config.ts b/frontend/editor/src/core/api/config.ts index f0ba730e8c..94caba2c82 100644 --- a/frontend/editor/src/core/api/config.ts +++ b/frontend/editor/src/core/api/config.ts @@ -1,6 +1,7 @@ import apiClient from "@app/services/apiClient"; import { getSimulatedAppConfig } from "@app/testing/serverExperienceSimulations"; import type { AppConfig } from "@app/types/appConfig"; +import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability"; /** Unauthenticated and unreachable both mean "assume login is on". */ export const DEFAULT_APP_CONFIG: AppConfig = { enableLogin: true }; @@ -23,6 +24,35 @@ export async function fetchAppConfig(): Promise { } } +export type EndpointAvailabilityMap = Record< + string, + EndpointAvailabilityDetails +>; + +/** + * Fires on app load before auth settles, so a 401 must not trigger the global + * login redirect. Callers treat a failure as "assume enabled". + */ +export async function fetchEndpointsAvailability(): Promise { + const response = await apiClient.get( + "/api/v1/config/endpoints-availability", + { suppressErrorToast: true, skipAuthRedirect: true }, + ); + return Object.fromEntries( + Object.entries(response.data).map(([name, detail]) => [ + name, + { enabled: detail?.enabled ?? true, reason: detail?.reason ?? null }, + ]), + ); +} + +export async function fetchEndpointEnabled(endpoint: string): Promise { + const response = await apiClient.get( + `/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, + ); + return response.data; +} + export interface FooterInfo { analyticsEnabled?: boolean; termsAndConditions?: string; diff --git a/frontend/editor/src/core/hooks/useEndpointConfig.test.tsx b/frontend/editor/src/core/hooks/useEndpointConfig.test.tsx new file mode 100644 index 0000000000..59010dacd5 --- /dev/null +++ b/frontend/editor/src/core/hooks/useEndpointConfig.test.tsx @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider"; +import { + useEndpointEnabled, + useMultipleEndpointsEnabled, +} from "@app/hooks/useEndpointConfig"; +import { + fetchEndpointEnabled, + fetchEndpointsAvailability, +} from "@app/api/config"; + +vi.mock("@app/api/config", () => ({ + fetchEndpointEnabled: vi.fn(), + fetchEndpointsAvailability: vi.fn(), +})); + +const mockOne = vi.mocked(fetchEndpointEnabled); +const mockAll = vi.mocked(fetchEndpointsAvailability); + +describe("useEndpointEnabled", () => { + beforeEach(() => vi.clearAllMocks()); + + it("reports null while loading, then the server's answer", async () => { + mockOne.mockResolvedValue(false); + + const { result } = renderHook(() => useEndpointEnabled("ocr-pdf"), { + wrapper: TestQueryProvider, + }); + + expect(result.current.enabled).toBeNull(); + await waitFor(() => expect(result.current.enabled).toBe(false)); + }); + + it("stays null on failure rather than claiming disabled", async () => { + mockOne.mockRejectedValue(new Error("boom")); + + const { result } = renderHook(() => useEndpointEnabled("ocr-pdf"), { + wrapper: TestQueryProvider, + }); + + await waitFor(() => expect(result.current.error).toBe("boom")); + expect(result.current.enabled).toBeNull(); + }); + + it("does not fetch without an endpoint", () => { + const { result } = renderHook(() => useEndpointEnabled(""), { + wrapper: TestQueryProvider, + }); + + expect(result.current.loading).toBe(false); + expect(mockOne).not.toHaveBeenCalled(); + }); +}); + +describe("useMultipleEndpointsEnabled", () => { + beforeEach(() => vi.clearAllMocks()); + + it("projects the shared map onto the requested endpoints", async () => { + mockAll.mockResolvedValue({ + "ocr-pdf": { enabled: false, reason: "DEPENDENCY" }, + "add-stamp": { enabled: true, reason: null }, + }); + + const { result } = renderHook( + () => useMultipleEndpointsEnabled(["ocr-pdf"]), + { wrapper: TestQueryProvider }, + ); + + await waitFor(() => + expect(result.current.endpointStatus).toEqual({ "ocr-pdf": false }), + ); + expect(result.current.endpointDetails["ocr-pdf"].reason).toBe("DEPENDENCY"); + }); + + it("serves every consumer from one request", async () => { + mockAll.mockResolvedValue({ "ocr-pdf": { enabled: true, reason: null } }); + + const { result } = renderHook( + () => ({ + a: useMultipleEndpointsEnabled(["ocr-pdf"]), + b: useMultipleEndpointsEnabled(["ocr-pdf", "add-stamp"]), + }), + { wrapper: TestQueryProvider }, + ); + + await waitFor(() => expect(result.current.a.loading).toBe(false)); + expect(mockAll).toHaveBeenCalledTimes(1); + }); + + it("treats unknown endpoints as enabled", async () => { + mockAll.mockResolvedValue({}); + + const { result } = renderHook( + () => useMultipleEndpointsEnabled(["brand-new-tool"]), + { wrapper: TestQueryProvider }, + ); + + await waitFor(() => + expect(result.current.endpointStatus).toEqual({ "brand-new-tool": true }), + ); + }); + + it("falls back to enabled when the check fails", async () => { + mockAll.mockRejectedValue( + Object.assign(new Error("unauthorised"), { response: { status: 401 } }), + ); + + const { result } = renderHook( + () => useMultipleEndpointsEnabled(["ocr-pdf", "add-stamp"]), + { wrapper: TestQueryProvider }, + ); + + await waitFor(() => + expect(result.current.endpointStatus).toEqual({ + "ocr-pdf": true, + "add-stamp": true, + }), + ); + // The fallback is the answer, so no retry. + expect(mockAll).toHaveBeenCalledTimes(1); + }); + + it("does not fetch for an empty endpoint list", () => { + const { result } = renderHook(() => useMultipleEndpointsEnabled([]), { + wrapper: TestQueryProvider, + }); + + expect(result.current.loading).toBe(false); + expect(mockAll).not.toHaveBeenCalled(); + }); + + it("refetches when a JWT becomes available", async () => { + mockAll.mockResolvedValue({ "ocr-pdf": { enabled: true, reason: null } }); + + const { result } = renderHook( + () => useMultipleEndpointsEnabled(["ocr-pdf"]), + { wrapper: TestQueryProvider }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + window.dispatchEvent(new CustomEvent("jwt-available")); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await waitFor(() => expect(mockAll).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/frontend/editor/src/core/hooks/useEndpointConfig.ts b/frontend/editor/src/core/hooks/useEndpointConfig.ts index 4615164787..488375c853 100644 --- a/frontend/editor/src/core/hooks/useEndpointConfig.ts +++ b/frontend/editor/src/core/hooks/useEndpointConfig.ts @@ -1,75 +1,50 @@ -import { useCallback, useEffect, useState } from "react"; -import { isAxiosError } from "axios"; -import apiClient from "@app/services/apiClient"; +import { useCallback, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + fetchEndpointEnabled, + fetchEndpointsAvailability, +} from "@app/api/config"; +import { qk } from "@app/query/keys"; +import { CONFIG_STALE_TIME } from "@app/query/staleTime"; import { useJwtConfigSync } from "@app/hooks/useJwtConfigSync"; import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability"; -// Track whether we've done the global fetch to prevent duplicate requests -let globalFetchDone = false; -const globalEndpointCache: Record = {}; +const OPTIMISTIC: EndpointAvailabilityDetails = { enabled: true, reason: null }; -function resetGlobalCache() { - globalFetchDone = false; - Object.keys(globalEndpointCache).forEach( - (key) => delete globalEndpointCache[key], - ); +function message(error: unknown): string | null { + if (!error) return null; + return error instanceof Error ? error.message : "Unknown error occurred"; } -/** - * Hook to check if a specific endpoint is enabled - * This wraps the context for single endpoint checks - */ +/** Whether one endpoint is enabled. `null` while loading and on failure. */ export function useEndpointEnabled(endpoint: string): { enabled: boolean | null; loading: boolean; error: string | null; refetch: () => Promise; } { - const [enabled, setEnabled] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchEndpointStatus = async () => { - if (!endpoint) { - setEnabled(null); - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - console.debug("[useEndpointConfig] Fetch endpoint status", { endpoint }); - - const response = await apiClient.get( - `/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, - ); - const isEnabled = response.data; - setEnabled(isEnabled); - } catch (err) { - const errorMessage = - err instanceof Error ? err.message : "Unknown error occurred"; - setError(errorMessage); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchEndpointStatus(); - }, [endpoint]); + const { data, isPending, error, refetch } = useQuery({ + queryKey: qk.endpointEnabled(endpoint), + queryFn: () => fetchEndpointEnabled(endpoint), + enabled: Boolean(endpoint), + staleTime: CONFIG_STALE_TIME, + }); return { - enabled, - loading, - error, - refetch: fetchEndpointStatus, + enabled: data ?? null, + loading: Boolean(endpoint) && isPending, + error: message(error), + refetch: useCallback(async () => { + await refetch(); + }, [refetch]), }; } /** - * Hook to check multiple endpoints at once using batch API - * Returns a map of endpoint -> enabled status + * Availability for a set of endpoints, projected from one shared request for + * the whole map. Unknown endpoints and any failure read as enabled — this runs + * before auth settles, and disabling every tool on a hiccup is worse than + * letting a call fail later. */ export function useMultipleEndpointsEnabled(endpoints: string[]): { endpointStatus: Record; @@ -78,174 +53,49 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { error: string | null; refetch: () => Promise; } { - const [endpointStatus, setEndpointStatus] = useState>( - {}, - ); - const [endpointDetails, setEndpointDetails] = useState< - Record - >({}); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); + const wanted = endpoints ?? []; - const fetchAllEndpointStatuses = useCallback( - async (force = false) => { - // Skip if already fetched globally and not forced - if (!force && globalFetchDone) { - console.debug("[useEndpointConfig] Using global cache"); - const cached = endpoints.reduce( - (acc, endpoint) => { - const cachedDetails = globalEndpointCache[endpoint]; - if (cachedDetails) { - acc.status[endpoint] = cachedDetails.enabled; - acc.details[endpoint] = cachedDetails; - } else { - acc.status[endpoint] = true; - } - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - setEndpointStatus(cached.status); - setEndpointDetails((prev) => ({ ...prev, ...cached.details })); - setLoading(false); - return; - } + const { data, isPending, error, refetch } = useQuery({ + queryKey: qk.endpointsAvailability(), + queryFn: fetchEndpointsAvailability, + enabled: wanted.length > 0, + staleTime: CONFIG_STALE_TIME, + // A failure already falls back to enabled, so a retry buys nothing and + // doubles a request that fires on load for every logged-out visitor. + retry: false, + }); - if (!endpoints || endpoints.length === 0) { - setEndpointStatus({}); - setEndpointDetails({}); - setLoading(false); - return; - } + const reload = useCallback(async () => { + await refetch(); + }, [refetch]); - try { - setLoading(true); - setError(null); - console.debug( - "[useEndpointConfig] Fetching all endpoint statuses from server", - ); - - // Fetch all endpoints at once; auto-fires on app load, so a 401 must - // fail silently instead of triggering the global login redirect. - const response = await apiClient.get< - Record - >(`/api/v1/config/endpoints-availability`, { - suppressErrorToast: true, - skipAuthRedirect: true, - }); - - // Populate global cache with all results - Object.entries(response.data).forEach(([endpoint, details]) => { - globalEndpointCache[endpoint] = { - enabled: details?.enabled ?? true, - reason: details?.reason ?? null, - }; - }); - globalFetchDone = true; - - // Return status for the requested endpoints - const fullStatus = endpoints.reduce( - (acc, endpoint) => { - const cachedDetails = globalEndpointCache[endpoint]; - if (cachedDetails) { - acc.status[endpoint] = cachedDetails.enabled; - acc.details[endpoint] = cachedDetails; - } else { - acc.status[endpoint] = true; - } - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - - setEndpointStatus(fullStatus.status); - setEndpointDetails((prev) => ({ ...prev, ...fullStatus.details })); - } catch (err: unknown) { - // On 401 (auth error), use optimistic fallback instead of disabling - if (isAxiosError(err) && err.response?.status === 401) { - console.warn( - "[useEndpointConfig] 401 error - using optimistic fallback", - ); - endpoints.forEach((endpoint) => { - globalEndpointCache[endpoint] = { enabled: true, reason: null }; - }); - const optimisticStatus = endpoints.reduce( - (acc, endpoint) => { - acc.status[endpoint] = true; - acc.details[endpoint] = { enabled: true, reason: null }; - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - setEndpointStatus(optimisticStatus.status); - setEndpointDetails((prev) => ({ - ...prev, - ...optimisticStatus.details, - })); - setLoading(false); - return; - } - - const errorMessage = - err instanceof Error ? err.message : "Unknown error occurred"; - setError(errorMessage); - console.error("[EndpointConfig] Failed to check endpoints:", err); - - // Fallback: assume all endpoints are enabled on error (optimistic) - const optimisticStatus = endpoints.reduce( - (acc, endpoint) => { - acc.status[endpoint] = true; - acc.details[endpoint] = { enabled: true, reason: null }; - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - setEndpointStatus(optimisticStatus.status); - setEndpointDetails((prev) => ({ - ...prev, - ...optimisticStatus.details, - })); - } finally { - setLoading(false); - } - }, - [endpoints.join(",")], + useJwtConfigSync( + useCallback(() => { + void queryClient.invalidateQueries({ + queryKey: qk.endpointsAvailability(), + }); + }, [queryClient]), ); - useEffect(() => { - fetchAllEndpointStatuses(); - }, [fetchAllEndpointStatuses]); - - // Re-fetch when auth state changes. Core implementation listens for the - // proprietary `jwt-available` event; the SaaS no-op override means the - // cache simply isn't invalidated on Supabase auth changes (today's behavior). - // If SaaS later needs that, wire it up inside saas/hooks/useJwtConfigSync.ts. - const handleAuthChange = useCallback(() => { - console.debug( - "[useEndpointConfig] Auth changed - clearing cache for refetch", - ); - resetGlobalCache(); - fetchAllEndpointStatuses(true); - }, [fetchAllEndpointStatuses]); - useJwtConfigSync(handleAuthChange); + const key = wanted.join(","); + const projected = useMemo(() => { + const status: Record = {}; + const details: Record = {}; + if (!data && !error) return { status, details }; + for (const endpoint of key ? key.split(",") : []) { + const detail = data?.[endpoint] ?? OPTIMISTIC; + status[endpoint] = detail.enabled; + details[endpoint] = detail; + } + return { status, details }; + }, [data, error, key]); return { - endpointStatus, - endpointDetails, - loading, - error, - refetch: () => fetchAllEndpointStatuses(true), + endpointStatus: projected.status, + endpointDetails: projected.details, + loading: wanted.length > 0 && isPending, + error: message(error), + refetch: reload, }; } diff --git a/frontend/editor/src/core/query/keys.ts b/frontend/editor/src/core/query/keys.ts index 6c6bacd552..a7a68ea256 100644 --- a/frontend/editor/src/core/query/keys.ts +++ b/frontend/editor/src/core/query/keys.ts @@ -1,6 +1,9 @@ /** Editor query keys: ["editor", , ...params]. */ export const qk = { appConfig: () => ["editor", "appConfig"] as const, + endpointsAvailability: () => ["editor", "endpointsAvailability"] as const, + endpointEnabled: (endpoint: string) => + ["editor", "endpointEnabled", endpoint] as const, footerInfo: () => ["editor", "footerInfo"] as const, groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const, users: () => ["editor", "users"] as const, From 0ff4ef629cf294bf474700ffb505f748bbefb4aa Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 10 Aug 2026 17:05:05 +0100 Subject: [PATCH 07/10] New Pipeline UI redesign (#7202) # Description of Changes Supersedes #7144. Redesign the Processor New Pipeline page to use a graph-based interface. Far from perfect at this stage but I'm pretty happy with the interactions on the graph itself. The bar at the top needs some work to make it prettier and more clear what everything is for, but I'd rather get this in and do changes in a follow-up PR because this is big enough on its own and leaves us better than where we were before. image image image image image --- frontend/.storybook/a11y-baseline.dark.json | 29 +- frontend/.storybook/a11y-baseline.json | 38 +- .../public/locales/en-US/translation.toml | 75 +- .../useAddPasswordOperation.test.ts | 9 + .../addPassword/useAddPasswordOperation.ts | 2 +- .../hooks/tools/shared/toolAutomation.test.ts | 15 + .../core/hooks/tools/shared/toolAutomation.ts | 22 +- .../src/core/tests/stubbed/files-page.spec.ts | 6 +- frontend/editor/src/core/ui/CodeBlock.tsx | 2 +- frontend/editor/src/core/ui/NodeCard.css | 87 ++ .../editor/src/core/ui/NodeCard.stories.tsx | 53 + frontend/editor/src/core/ui/NodeCard.tsx | 81 ++ frontend/editor/src/core/ui/index.ts | 1 + frontend/editor/src/portal/api/http.ts | 15 + frontend/editor/src/portal/api/pipelines.ts | 52 +- .../editor/src/portal/components/AppShell.css | 4 + .../pipelines/DestinationPicker.tsx | 65 +- .../pipelines/PipelineDefinitionModal.css | 26 + .../PipelineDefinitionModal.stories.tsx | 48 + .../PipelineDefinitionModal.test.tsx | 37 + .../pipelines/PipelineDefinitionModal.tsx | 42 + .../components/pipelines/PipelineHeader.css | 133 +++ .../pipelines/PipelineHeader.stories.tsx | 118 +++ .../pipelines/PipelineHeader.test.tsx | 200 ++++ .../components/pipelines/PipelineHeader.tsx | 301 ++++++ .../pipelines/PipelineInspector.css | 34 + .../pipelines/PipelineInspector.stories.tsx | 71 ++ .../pipelines/PipelineInspector.test.tsx | 60 ++ .../pipelines/PipelineInspector.tsx | 79 ++ .../pipelines/ToolPicker.stories.tsx | 28 + .../components/pipelines/ToolPicker.tsx | 84 +- .../components/pipelines/graph/GraphEdge.css | 180 ++++ .../components/pipelines/graph/GraphEdge.tsx | 109 ++ .../components/pipelines/graph/GraphNode.css | 140 +++ .../components/pipelines/graph/GraphNode.tsx | 183 ++++ .../pipelines/graph/GraphPlaceholderNode.css | 44 + .../pipelines/graph/GraphPlaceholderNode.tsx | 30 + .../pipelines/graph/PipelineGraph.css | 79 ++ .../pipelines/graph/PipelineGraph.stories.tsx | 207 ++++ .../pipelines/graph/PipelineGraph.test.tsx | 455 ++++++++ .../pipelines/graph/PipelineGraph.tsx | 379 +++++++ .../pipelines/graph/pipelineLayout.test.ts | 147 +++ .../pipelines/graph/pipelineLayout.ts | 184 ++++ .../pipelines/graph/useChainDragDrop.test.ts | 91 ++ .../pipelines/graph/useChainDragDrop.ts | 230 ++++ .../src/portal/mocks/handlers/pipelines.ts | 23 + .../src/portal/views/PipelineBuilder.css | 446 +++----- .../portal/views/PipelineBuilder.stories.tsx | 26 +- .../src/portal/views/PipelineBuilder.test.tsx | 451 +++++++- .../src/portal/views/PipelineBuilder.tsx | 985 ++++++++++-------- .../editor/src/portal/views/Pipelines.css | 17 - 51 files changed, 5310 insertions(+), 913 deletions(-) create mode 100644 frontend/editor/src/core/ui/NodeCard.css create mode 100644 frontend/editor/src/core/ui/NodeCard.stories.tsx create mode 100644 frontend/editor/src/core/ui/NodeCard.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.css create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.test.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineHeader.css create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineInspector.css create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineInspector.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineInspector.test.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineInspector.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/GraphEdge.css create mode 100644 frontend/editor/src/portal/components/pipelines/graph/GraphEdge.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/GraphNode.css create mode 100644 frontend/editor/src/portal/components/pipelines/graph/GraphNode.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.css create mode 100644 frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.css create mode 100644 frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.test.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.test.ts create mode 100644 frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.ts create mode 100644 frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.test.ts create mode 100644 frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.ts diff --git a/frontend/.storybook/a11y-baseline.dark.json b/frontend/.storybook/a11y-baseline.dark.json index fa08401d09..fb461fe6d2 100644 --- a/frontend/.storybook/a11y-baseline.dark.json +++ b/frontend/.storybook/a11y-baseline.dark.json @@ -1828,6 +1828,21 @@ "editor/src/portal/components/infrastructure/SectionHeader.stories.tsx :: Short Sub": [ "color-contrast" ], + "editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Failed Step": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Multiple Selected": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Nothing Selected": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Settings": [ + "color-contrast" + ], "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [ "color-contrast" ], @@ -1845,6 +1860,12 @@ "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: Default": [ "color-contrast" ], + "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: Incompatible Preceding Output": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: No Matches": [ + "color-contrast" + ], "editor/src/portal/components/policies/CatalogueSummary.stories.tsx :: Default": [ "color-contrast" ], @@ -2235,9 +2256,13 @@ "color-contrast" ], "editor/src/portal/views/Pipelines.stories.tsx :: Default": [ - "color-contrast" + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/views/Pipelines.stories.tsx :: Empty": [ + "color-contrast", + "empty-table-header" ], - "editor/src/portal/views/Pipelines.stories.tsx :: Empty": ["color-contrast"], "editor/src/portal/views/Policies.stories.tsx :: Default": ["color-contrast"], "editor/src/portal/views/Policies.stories.tsx :: Empty": ["color-contrast"], "editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"], diff --git a/frontend/.storybook/a11y-baseline.json b/frontend/.storybook/a11y-baseline.json index 91db120c19..df46eb9dc4 100644 --- a/frontend/.storybook/a11y-baseline.json +++ b/frontend/.storybook/a11y-baseline.json @@ -1404,8 +1404,7 @@ "color-contrast" ], "editor/src/core/ui/CodeBlock.stories.tsx :: Long Scrolling": [ - "color-contrast", - "scrollable-region-focusable" + "color-contrast" ], "editor/src/core/ui/CodeBlock.stories.tsx :: Playground": ["color-contrast"], "editor/src/core/ui/Collapsible.stories.tsx :: Accordion": [ @@ -1977,6 +1976,30 @@ "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Free": [ "color-contrast" ], + "editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Editing": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Paused": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Testing": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: With Failed Run": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: With Run Result": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Failed Step": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Settings": [ + "color-contrast" + ], "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [ "color-contrast" ], @@ -2294,13 +2317,14 @@ "editor/src/portal/views/Integrations.stories.tsx :: No Connections": [ "color-contrast" ], - "editor/src/portal/views/PipelineBuilder.stories.tsx :: Default": [ - "color-contrast" - ], "editor/src/portal/views/Pipelines.stories.tsx :: Default": [ - "color-contrast" + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/views/Pipelines.stories.tsx :: Empty": [ + "color-contrast", + "empty-table-header" ], - "editor/src/portal/views/Pipelines.stories.tsx :: Empty": ["color-contrast"], "editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"], "editor/src/portal/views/Sources.stories.tsx :: Empty": ["color-contrast"], "editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: Signup": [ diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 0fffc04dbc..4f70f2b813 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7800,7 +7800,6 @@ title = "Pipelines" newPipeline = "New pipeline" [portal.pipelines.builder] -addStep = "Add tool" back = "Back to pipelines" cannotFollow = "Can't take {{produced}}" chooseAccount = "Choose an account" @@ -7813,59 +7812,64 @@ inputs = "Input" inputSource = "Input source" inputTrigger = "Trigger" keepEditing = "Keep editing" +moreActions = "More actions" needsConfiguring = "Needs setting up" +needsDestination = "No destination chosen" +needsSource = "No source chosen" needsUpload = "Needs an uploaded file" -noSources = "No sources connected yet. Create one to use it as an input." noToolMatches = "No tools match your search." -pipelineSettings = "Pipeline settings" searchTools = "Search tools" -selectToolBody = "Add a tool to build your pipeline." -selectToolTitle = "No tools yet" sendToSystem = "Send to another system" stepsIncompatible = "These steps can't run on what their prior step produces: {{tools}}." stepsNeedSetup = "These steps still need setting up before saving: {{tools}}." -toolSettings = "Tool settings" +testRun = "Test with a file" unknownStep = "Unrecognized operation, kept as-is." unsavedBody = "You have unsaved changes. Save them before leaving, or discard them?" unsavedTitle = "Unsaved changes" uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these steps can't be saved: {{tools}}." usesDefaults = "Runs with default settings" +viewDefinition = "View definition" [portal.pipelines.builder.diagnostic] -fan-in = "Combines every file from the previous step" -fan-out = "Runs once per file from the previous step" -format-mismatch = "Needs {{accepts}}, but the previous step produces {{produced}}" -output-uncertain = "May not run: the previous step's output depends on how it's set up" -source-mismatch = "Needs {{accepts}}, but this pipeline's input is {{produced}}" +fan-in = "Combines every incoming file" +fan-out = "Runs once per incoming file" +format-mismatch = "Sends {{produced}}, needs {{accepts}}" +output-uncertain = "May not run: output depends on setup" +source-mismatch = "Input is {{produced}}, needs {{accepts}}" undeclared-operation = "Can't check what this step accepts" [portal.pipelines.composer] -addTool = "Add tool" +addTool = "Add a tool" cancel = "Cancel" -chainEmpty = "Add a tool to start building your pipeline." create = "Create pipeline" editingUnsupported = "Displaying these tool params for editing is not supported yet." -moveDown = "Move down" -moveUp = "Move up" +editSource = "Edit source" name = "Name" namePlaceholder = "e.g. Redaction sweep" noToolSettings = "This tool has no configurable settings." -operations_one = "Operation ({{count}})" -operations_other = "Operations ({{count}})" output = "Destination" -removeStep = "Remove operation" save = "Save changes" scheduleEvery = "Run every" -sources = "Sources" -sourcesLoading = "Loading sources..." trigger = "Trigger" triggerManual = "Manual only" +[portal.pipelines.composer.runsEvery] +days_one = "Runs every day" +days_other = "Runs every {{count}} days" +hours_one = "Runs every hour" +hours_other = "Runs every {{count}} hours" +minutes_one = "Runs every minute" +minutes_other = "Runs every {{count}} minutes" + [portal.pipelines.composer.unit] days = "days" hours = "hours" minutes = "minutes" +[portal.pipelines.definition] +subtitle = "The pipeline as it would be saved." +title = "Definition" + [portal.pipelines.delete] body = "Delete \"{{name}}\"? This can't be undone." cancel = "Cancel" @@ -7883,6 +7887,37 @@ connectSource = "Connect a source" description = "Create your first pipeline: pick the sources it runs over, chain the operations, and choose where output goes." title = "No pipelines yet" +[portal.pipelines.graph] +addFirstTool = "Add a tool" +dragHint = "Drop on a line to move it" +insertHere = "Add a tool here" +removeNode = "Remove {{name}}" +showError = "Show why {{name}} failed" + +[portal.pipelines.graph.add] +input = "Add a source" +output = "Add a destination" + +[portal.pipelines.graph.run] +done = "Done" +failed = "Failed" +running = "Running" + +[portal.pipelines.inspector] +multipleBody = "Drag any of them onto a line to move them together, or press Delete to remove them." +multipleSelected_one = "{{count}} step selected" +multipleSelected_other = "{{count}} steps selected" +noSelectionBody = "Pick a node in the graph to change what it does." +noSelectionTitle = "Nothing selected" + +[portal.pipelines.inspector.status] +completed_one = "Finished the only step" +completed_other = "Finished all {{count}} steps" +failed_one = "Failed on the only step" +failed_other = "Failed after {{done}} of {{count}} steps" +running_one = "Running the only step" +running_other = "Running step {{done}} of {{count}}" + [portal.pipelines.kpi] active = "Active" paused = "Paused" diff --git a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts index 77958629b0..28e8e1baa6 100644 --- a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts +++ b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts @@ -147,6 +147,15 @@ describe("useAddPasswordOperation", () => { }); describe("addPassword mappers", () => { + test("falls back to the default key length when the stored step omits it", () => { + // A pipeline step saved without keyLength must not deserialize to + // undefined: the settings UI calls keyLength.toString() on it. + const restored = addPasswordFromApiParams({ + password: "user-pw", + } as never); + expect(restored.keyLength).toBe(128); + }); + test("round-trips backend params, including the flattened permissions", () => { // Baseline differs from the configured values so the round trip fails if // fromApiParams drops a field instead of reconstructing it. diff --git a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts index d68154c9b1..4e843c8da7 100644 --- a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts +++ b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts @@ -48,7 +48,7 @@ export const addPasswordFromApiParams = ( ): Partial => ({ password: apiParams.password ?? defaultParameters.password, ownerPassword: apiParams.ownerPassword ?? defaultParameters.ownerPassword, - keyLength: apiParams.keyLength, + keyLength: apiParams.keyLength ?? defaultParameters.keyLength, permissions: { preventAssembly: apiParams.preventAssembly ?? permissionsDefaults.preventAssembly, diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts index 467e3ea5f7..e8d34ef9ad 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts @@ -153,6 +153,21 @@ describe("serialize/deserialize round-trip", () => { }); }); + test("a stored step missing fields falls back to defaults, not undefined", () => { + // Mappers echo absent stored fields as explicit undefined; settings UIs + // then crash on things like keyLength.toString(). Defaults must win. + const back = deserializeToolStep( + { operation: "/api/v1/misc/compress-pdf", parameters: {} }, + registry, + ); + expect(back.params.compressionLevel).toBe( + compressDefaults.compressionLevel, + ); + expect( + Object.values(back.params).every((value) => value !== undefined), + ).toBe(true); + }); + test("an unknown endpoint is preserved as an unmapped step", () => { const step = deserializeToolStep( { operation: "/api/v1/unknown/thing", parameters: { keep: true } }, diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts index aab9e1ab10..b32f783b06 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts @@ -291,12 +291,22 @@ export function deserializeToolStep( if (!match) return unmappedStep(step); const [toolId, entry] = match; const config = entry.operationConfig; - const params: ErasedToolParams = config?.fromApiParams - ? { - ...(config.defaultParameters ?? {}), - ...config.fromApiParams(step.parameters as never), - } - : { ...(config?.defaultParameters ?? {}) }; + // Mappers echo missing stored fields as explicit `undefined`, which would + // clobber the default underneath; strip those so defaults always win. + const mapped = config?.fromApiParams + ? Object.fromEntries( + Object.entries( + config.fromApiParams(step.parameters as never) as Record< + string, + unknown + >, + ).filter(([, value]) => value !== undefined), + ) + : {}; + const params: ErasedToolParams = { + ...(config?.defaultParameters ?? {}), + ...mapped, + } as ErasedToolParams; // Validate against the generated endpoint set instead of casting the matched string. const operation = resolveEndpoint(config, params) ?? diff --git a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts index 56687b2217..4c6e714ec0 100644 --- a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts @@ -173,7 +173,7 @@ test.describe("Files page", () => { await gotoFilesPage(page); const cards = page.locator(".files-page-card:not(.is-folder)"); await cards.nth(0).click(); - await cards.nth(1).click({ modifiers: ["Control"] }); + await cards.nth(1).click({ modifiers: ["ControlOrMeta"] }); await expect(page.locator(".files-page-card.is-selected")).toHaveCount(2); // In multi-select (2+), plain-click ADDS instead of replacing. @@ -198,7 +198,7 @@ test.describe("Files page", () => { await expect(page.locator(".files-page-card-selector")).toHaveCount(0); // 2+ selected: checkboxes appear on every file card. - await cards.nth(1).click({ modifiers: ["Control"] }); + await cards.nth(1).click({ modifiers: ["ControlOrMeta"] }); await expect( page.locator(".files-page-card-selector").first(), ).toBeVisible(); @@ -488,7 +488,7 @@ test.describe("Files page", () => { const cards = page.locator(".files-page-card:not(.is-folder)"); await cards.nth(0).click(); // Drawer stays closed so the second click reaches the card. - await cards.nth(1).click({ modifiers: ["Control"] }); + await cards.nth(1).click({ modifiers: ["ControlOrMeta"] }); await expect(page.locator(".files-page-card.is-selected")).toHaveCount(2); }); }); diff --git a/frontend/editor/src/core/ui/CodeBlock.tsx b/frontend/editor/src/core/ui/CodeBlock.tsx index cff078057d..2d66bd2353 100644 --- a/frontend/editor/src/core/ui/CodeBlock.tsx +++ b/frontend/editor/src/core/ui/CodeBlock.tsx @@ -70,7 +70,7 @@ export function CodeBlock({ )}
-
+      
         {code}
       
diff --git a/frontend/editor/src/core/ui/NodeCard.css b/frontend/editor/src/core/ui/NodeCard.css new file mode 100644 index 0000000000..7982e2581a --- /dev/null +++ b/frontend/editor/src/core/ui/NodeCard.css @@ -0,0 +1,87 @@ +/** + * NodeCard — a selectable labelled tile (icon badge + title + sub-line) on a raised surface. + * Shared surface, selection ring and content layout; feature-specific state is layered by callers. + */ + +.sui-node-card { + position: relative; + display: flex; + align-items: stretch; + box-sizing: border-box; + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + transition: + border-color var(--motion-fast), + box-shadow var(--motion-fast), + opacity var(--motion-fast); +} + +/* The whole card selects. Re-assert the tile look over the shared Button base (which otherwise + imposes a fixed height, its own padding and an accent text colour). */ +.sui-node-card__select.sui-btn { + flex: 1; + min-width: 0; + height: auto; + min-height: 0; + border: none; + background: none; + padding: 0.625rem 0.75rem; + text-align: left; + font-weight: 400; + color: var(--c-text); + border-radius: inherit; +} + +/* Mantine wraps a button's children in its label element, so the glyph and text are laid out + there - a gap on the button root would only space the wrapper, not what is inside it. */ +.sui-node-card__select.sui-btn .mantine-Button-label { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.625rem; + min-width: 0; + overflow: visible; +} + +.sui-node-card__text { + display: flex; + flex-direction: column; + min-width: 0; + gap: 0.0625rem; +} + +.sui-node-card__title { + font-size: 0.875rem; + font-weight: 500; + color: var(--c-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sui-node-card__detail { + font-size: 0.6875rem; + /* --c-text-subtle does not clear 4.5:1 at this size in either theme (axe: 4.39 light, 3.66 + dark); --c-text-muted is the next rung up and does. */ + color: var(--c-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Selected: the primary ring. Wins over hover and the warning tone. */ +.sui-node-card.is-selected { + border-color: var(--c-primary); + box-shadow: 0 0 0 1px var(--c-primary); +} + +.sui-node-card:hover:not(.is-selected) { + border-color: var(--c-border-strong); +} + +.sui-node-card--warning:not(.is-selected) { + border-color: var(--c-warning); +} diff --git a/frontend/editor/src/core/ui/NodeCard.stories.tsx b/frontend/editor/src/core/ui/NodeCard.stories.tsx new file mode 100644 index 0000000000..acfe28f71d --- /dev/null +++ b/frontend/editor/src/core/ui/NodeCard.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import TuneRoundedIcon from "@mui/icons-material/TuneRounded"; +import CloseRoundedIcon from "@mui/icons-material/CloseRounded"; +import { NodeCard } from "@app/ui/NodeCard"; +import { ActionIcon } from "@app/ui/ActionIcon"; + +const meta = { + title: "UI/NodeCard", + component: NodeCard, + parameters: { layout: "padded" }, + args: { + icon: , + title: "Compress", + detail: "level 7", + onSelect: () => {}, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** The default tile: icon badge, title, one-line sub-detail, selectable. */ +export const Default: Story = {}; + +/** Selected — the primary ring the inspector points at. */ +export const Selected: Story = { args: { selected: true } }; + +/** Warning tone — an amber border, for a tile that needs attention. */ +export const Warning: Story = { + args: { tone: "warning", detail: "Needs setting up" }, +}; + +/** A trailing control (here a remove button) sits beside the select target, not nested in it. */ +export const WithTrailing: Story = { + args: { + trailing: ( + + + + ), + }, +}; diff --git a/frontend/editor/src/core/ui/NodeCard.tsx b/frontend/editor/src/core/ui/NodeCard.tsx new file mode 100644 index 0000000000..451cc8823b --- /dev/null +++ b/frontend/editor/src/core/ui/NodeCard.tsx @@ -0,0 +1,81 @@ +import type { HTMLAttributes, MouseEvent, ReactNode, Ref } from "react"; +import { Button } from "@app/ui/Button"; +import { IconBadge, type IconBadgeAccent } from "@app/ui/IconBadge"; +import "@app/ui/NodeCard.css"; + +/** Border tone. `selected` (a separate prop) overrides this with the primary ring. */ +export type NodeCardTone = "default" | "warning"; + +export interface NodeCardProps extends Omit< + HTMLAttributes, + "title" | "onSelect" +> { + /** Glyph shown in a tone-tinted badge at the leading edge. */ + icon: ReactNode; + iconAccent?: IconBadgeAccent; + title: ReactNode; + /** One-line summary under the title. Any node - a plain string, or a richer line. */ + detail?: ReactNode; + tone?: NodeCardTone; + selected?: boolean; + /** + * When given, the whole card is a single select button (aria-pressed tracks `selected`). Trailing + * controls stay siblings of that button, never nested inside it, so the card holds no invalid + * nested interactive elements. + */ + onSelect?: (event: MouseEvent) => void; + /** Controls rendered over the card's trailing edge (a remove button, a status glyph, ...). */ + trailing?: ReactNode; + ref?: Ref; +} + +/** + * A labelled tile: an icon badge, a title, and an optional sub-line, on a raised card surface that + * can be selected. The recurring "node" motif - a step in a graph, an item in a board - lifted into + * a primitive so its surface, selection ring and content layout are shared rather than re-styled per + * feature. Callers layer their own state (drag, run status, ...) via `className` and `trailing`. + */ +export function NodeCard({ + icon, + iconAccent, + title, + detail, + tone = "default", + selected = false, + onSelect, + trailing, + className, + ref, + ...rest +}: NodeCardProps) { + return ( +
+ + {trailing} +
+ ); +} diff --git a/frontend/editor/src/core/ui/index.ts b/frontend/editor/src/core/ui/index.ts index 45212d8038..b3e2ca6ac0 100644 --- a/frontend/editor/src/core/ui/index.ts +++ b/frontend/editor/src/core/ui/index.ts @@ -8,6 +8,7 @@ export * from "@app/ui/MethodBadge"; export * from "@app/ui/ToggleSwitch"; export * from "@app/ui/ProgressBar"; export * from "@app/ui/MetricCard"; +export * from "@app/ui/NodeCard"; export * from "@app/ui/NavItem"; export * from "@app/ui/NavSurface"; export * from "@app/ui/PanelHeader"; diff --git a/frontend/editor/src/portal/api/http.ts b/frontend/editor/src/portal/api/http.ts index 5c8afcd2cb..2ec00e9818 100644 --- a/frontend/editor/src/portal/api/http.ts +++ b/frontend/editor/src/portal/api/http.ts @@ -212,6 +212,20 @@ async function localForm( return unwrap(res); } +/** POST a multipart/form-data body (file uploads), via the localBackend seam. The Content-Type is + * deliberately left unset so the browser writes it with the multipart boundary. */ +async function localMultipart(path: string, body: FormData): Promise { + const res = await fetch(`${localBaseUrl()}${path}`, { + method: "POST", + headers: { Accept: "application/json", ...(await localAuthHeader()) }, + body, + }); + if (res.status === 401) { + onLocalUnauthorized(); + } + return unwrap(res); +} + // ──────────────────────────────────────────────────────────────────────────── // saas — hosted SaaS Java, admin's Supabase JWT // ──────────────────────────────────────────────────────────────────────────── @@ -302,6 +316,7 @@ export const apiClient = { local: { json: localJson, form: localForm, + multipart: localMultipart, blob: localBlob, }, /** Hosted SaaS Java. Admin's Supabase JWT auto-attached. */ diff --git a/frontend/editor/src/portal/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts index d6088d06ff..50bdc173c4 100644 --- a/frontend/editor/src/portal/api/pipelines.ts +++ b/frontend/editor/src/portal/api/pipelines.ts @@ -1,4 +1,5 @@ import { apiClient } from "@portal/api/http"; +import { type ToolApiStep } from "@app/hooks/tools/shared/toolAutomation"; /** * Pipelines service layer: the backend contract. @@ -122,7 +123,13 @@ export type PolicyRunStatus = | "FAILED" | "CANCELLED"; -/** A run's current state. Mirrors the backend `PolicyRunView` (outputs elided). */ +/** One file a run produced, downloadable via /api/v1/general/files/{fileId}. */ +export interface RunOutputFile { + fileId: string; + fileName: string | null; +} + +/** A run's current state. Mirrors the backend `PolicyRunView`. */ export interface PolicyRunView { runId: string; policyId: string | null; @@ -132,6 +139,11 @@ export interface PolicyRunView { /** Human-readable failure message; set when status is FAILED. */ error: string | null; errorCode: string | null; + /** + * Files the run produced, present once it completes. Whole-run, not per step: the backend keeps + * one flat list, so nothing here can be attributed to an individual step. + */ + outputs?: RunOutputFile[] | null; createdAt: number; } @@ -198,6 +210,44 @@ export async function triggerPipeline(id: string): Promise { ); } +/** What an ad-hoc test run posts: the steps as they stand, with no source and no trigger. */ +export interface TestRunDefinition { + name: string; + steps: ToolApiStep[]; + output: OutputSpec; +} + +/** + * POST /api/v1/policies/run: run a definition against one uploaded file now. The builder's test + * path - callers force an inline output so nothing reaches the pipeline's real destination, and + * the pipeline need not be saved first. + */ +export async function runPipelineTest( + definition: TestRunDefinition, + file: File, +): Promise<{ runId: string }> { + const form = new FormData(); + form.append( + "json", + new Blob([JSON.stringify(definition)], { type: "application/json" }), + ); + form.append("fileInput", file); + // The POST returns the identifier as `jobId`, but it is the same run id every other endpoint + // (fetchRun, fetchRunOutput) calls `runId`; normalise to that here so callers see one name. + const res = await apiClient.local.multipart<{ jobId: string }>( + "/api/v1/policies/run", + form, + ); + return { runId: res.jobId }; +} + +/** GET /api/v1/general/files/{id}: download one of a run's outputs. */ +export async function fetchRunOutput(fileId: string): Promise { + return apiClient.local.blob( + `/api/v1/general/files/${encodeURIComponent(fileId)}`, + ); +} + /** GET /api/v1/policies/run/{runId}: current status, error, and step cursor of a run. */ export async function fetchRun(runId: string): Promise { return apiClient.local.json( diff --git a/frontend/editor/src/portal/components/AppShell.css b/frontend/editor/src/portal/components/AppShell.css index 152c45f7c5..1657b56bdf 100644 --- a/frontend/editor/src/portal/components/AppShell.css +++ b/frontend/editor/src/portal/components/AppShell.css @@ -26,6 +26,10 @@ flex: 1 1 auto; min-height: 0; /* scroll instead of growing past the viewport */ overflow-y: auto; + /* Hold the scrollbar's width whether or not it is showing. Without this, a page that grows past + the viewport (an editor panel filling in, say) makes the bar appear and shunts everything + sideways as it does. */ + scrollbar-gutter: stable; animation: fadeInUp var(--motion-enter) both; } diff --git a/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx b/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx index de1187c3da..864668b764 100644 --- a/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx +++ b/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx @@ -1,15 +1,16 @@ import { useTranslation } from "react-i18next"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; -import { Button, Select } from "@app/ui"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import { ActionIcon, Button, FormField, Select } from "@app/ui"; /** * Picks the saved source a pipeline delivers its output to. A destination is just a * source used as a write target. The value stays a list ({@code outputIds}) because * the model supports several, but the product caps a pipeline at one destination * today, so this renders a single dropdown over the same locations the builder - * loaded (filtered to writable types by the caller). Creating a new one is delegated - * to {@code onCreateNew} (the builder navigates to the source builder, prompting - * about unsaved edits first). + * loaded (filtered to writable types by the caller). Creating and editing one are + * delegated to {@code onCreateNew} / {@code onEdit}, which open the source modal + * over the builder - mirroring the input row. */ interface DestinationOption { id: string; @@ -20,8 +21,10 @@ interface DestinationPickerProps { sources: DestinationOption[]; value: string[]; onChange: (outputIds: string[]) => void; - /** Leave the builder to create a new source location (navigate-away, like inputs). */ + /** Create a new source location to write to (opens the source modal). */ onCreateNew: () => void; + /** Edit the chosen destination's own settings (opens the source modal on it). */ + onEdit: (sourceId: string) => void; } export function DestinationPicker({ @@ -29,25 +32,45 @@ export function DestinationPicker({ value, onChange, onCreateNew, + onEdit, }: DestinationPickerProps) { const { t } = useTranslation(); + const chosen = value[0] ?? ""; + const hasSources = sources.length > 0; + // Mirrors the input row: the dropdown-plus-edit sits in a field, and "Connect source" lives on its + // own line below rather than inline. With nowhere to write to yet, only the connect button shows. return ( -
-
- onChange(id ? [id] : [])} + options={sources.map((source) => ({ + value: source.id, + label: source.name, + }))} + /> +
+ onEdit(chosen)} + > + + +
+
+ )} -
+ ); } diff --git a/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.css b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.css new file mode 100644 index 0000000000..7f1984b4b1 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.css @@ -0,0 +1,26 @@ +/* The code is this modal's entire content, so it *is* the body rather than a window sitting inside + one. Framed, it drew a second box inside the panel's box - and the panel's own header already + says what the code is, which the code window's chrome was repeating. */ +.portal-definition__modal .sui-modal__body { + padding: 0; +} + +.portal-definition__code { + border: none; + border-radius: 0; + box-shadow: none; +} + +/* Traffic-light dots imitate a window frame; this code already sits in a real one. */ +.portal-definition__code .sui-code__dots { + display: none; +} + +/* Line the toolbar and the code up with the modal header's text. */ +.portal-definition__code .sui-code__chrome { + padding: 0.5rem 1.125rem; +} + +.portal-definition__code .sui-code__pre { + padding: 0.875rem 1.125rem; +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx new file mode 100644 index 0000000000..4800689d7d --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Button } from "@app/ui"; +import { PipelineDefinitionModal } from "@portal/components/pipelines/PipelineDefinitionModal"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineDefinitionModal", + component: PipelineDefinitionModal, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const JSON_BODY = JSON.stringify( + { + name: "Claims redaction", + enabled: true, + inputs: [{ sourceId: "src-in", trigger: { type: "schedule" } }], + steps: [ + { operation: "/api/v1/misc/ocr-pdf", parameters: { language: "eng" } }, + { operation: "/api/v1/security/redact", parameters: { terms: 2 } }, + ], + outputIds: ["src-out"], + }, + null, + 2, +); + +/** Starts closed so the trigger can be exercised; click through to the tabs. */ +function Playground({ initialOpen = false }: { initialOpen?: boolean }) { + const [open, setOpen] = useState(initialOpen); + return ( + <> + + setOpen(false)} + json={JSON_BODY} + /> + + ); +} + +/** The definition as it opens from the header: JSON first, cURL a tab away. */ +export const Default: Story = { render: () => }; + +/** The trigger it opens from, so the closed state can be exercised too. */ +export const FromTrigger: Story = { render: () => }; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.test.tsx new file mode 100644 index 0000000000..32e55e3826 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.test.tsx @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { render as baseRender, screen } from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { PipelineDefinitionModal } from "@portal/components/pipelines/PipelineDefinitionModal"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const JSON_BODY = '{\n "name": "Claims"\n}'; + +describe("PipelineDefinitionModal", () => { + it("renders nothing while closed", () => { + render( + , + ); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("opens on the JSON tab", () => { + render(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText(/"name": "Claims"/)).toBeInTheDocument(); + }); + + it("shows the definition alone - no tab strip to choose between", () => { + render(); + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.tsx b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.tsx new file mode 100644 index 0000000000..adcd19f5f5 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from "react-i18next"; +import { CodeBlock, Modal } from "@app/ui"; +import "@portal/components/pipelines/PipelineDefinitionModal.css"; + +export interface PipelineDefinitionModalProps { + open: boolean; + onClose: () => void; + /** The pipeline as it would be saved, pretty-printed. Re-read while the modal is open. */ + json: string; +} + +/** + * The pipeline's definition as it would be saved. + * + * Pipeline-scoped, so it opens from the header rather than the node inspector, and a modal rather + * than a panel because a definition grows with the chain and needs the width. + */ +export function PipelineDefinitionModal({ + open, + onClose, + json, +}: PipelineDefinitionModalProps) { + const { t } = useTranslation(); + + return ( + + + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.css b/frontend/editor/src/portal/components/pipelines/PipelineHeader.css new file mode 100644 index 0000000000..f559e42795 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineHeader.css @@ -0,0 +1,133 @@ +/** + * The builder's opening section: identity above the rule, actions below it. + */ + +.portal-pipeline-header { + display: flex; + flex-direction: column; + gap: 0.875rem; + padding: 1.125rem; + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-lg); +} + +/* Leaving the page and saving it are the same kind of decision, so they share a row - and the back + link is short, so the save pair always has room beside it. */ +.portal-pipeline-header__top { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +/* The back link is the shared Button restyled to a plain link, so re-assert that over the + design-system base (which imposes a fixed height, its own padding and an accent colour). */ +.portal-pipeline-header__back.sui-btn { + height: auto; + min-height: 0; + padding: 0; + font-size: 0.8125rem; + font-weight: 400; + color: var(--c-text-muted); +} + +.portal-pipeline-header__back.sui-btn:hover { + background: none; + color: var(--c-text); +} + +.portal-pipeline-header__identity { + display: flex; + align-items: center; + gap: 1.25rem; + flex-wrap: wrap; +} + +/* The shared Checkbox aligns its box to the top of the first text line, with a nudge tuned for its + own font size - that is for the label-plus-description case. This one is a single line, so centre + the box on it and leave the component's sizing alone (overriding the font size shifts the line + box and leaves the tick floating high). */ +.portal-pipeline-header__enabled.sui-check { + flex: none; + align-items: center; +} + +.portal-pipeline-header__enabled.sui-check .sui-check__box { + margin-top: 0; +} + +/* The name is the page's title, so it takes the room and reads at title size. */ +.portal-pipeline-header__name { + flex: 1 1 16rem; + min-width: 12rem; +} + +.portal-pipeline-header__name input { + font-size: 1rem; + font-weight: 500; +} + +/* Never let the labels squash: buttons hold their width and the row wraps instead of clipping. */ +.portal-pipeline-header__save { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + flex: none; +} + +.portal-pipeline-header__save .sui-btn { + flex: none; + white-space: nowrap; +} + +/* Operational actions: what you can do to this pipeline, kept off the identity row. */ +.portal-pipeline-header__actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + padding-top: 0.875rem; + border-top: 1px solid var(--c-border-subtle); +} + +/* Destructive, so it sits away from the rest rather than next in line. */ +.portal-pipeline-header__delete.sui-btn { + margin-left: auto; +} + +/* The last test run's outcome, beside the button that started it. Whole-pipeline, because the + backend reports one flat file list plus the step it stopped at - nothing per node to attach. */ +.portal-pipeline-header__result { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + padding-top: 0.875rem; + border-top: 1px solid var(--c-border-subtle); +} + +.portal-pipeline-header__result-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: var(--c-text); +} + +.portal-pipeline-header__result-icon.is-ok { + color: var(--c-success); +} + +.portal-pipeline-header__result-icon.is-bad { + color: var(--c-danger); +} + +/* Why the run failed, shown inline in the strip. Neutral text (the icon already carries the tone); + it may wrap to keep a long backend message readable rather than clipping it. */ +.portal-pipeline-header__result-error { + font-size: 0.8125rem; + color: var(--c-text-muted); + min-width: 0; +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx new file mode 100644 index 0000000000..41c73a5ade --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx @@ -0,0 +1,118 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + PipelineHeader, + type RunResultSummary, +} from "@portal/components/pipelines/PipelineHeader"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineHeader", + component: PipelineHeader, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const noop = () => {}; + +/** The name and the enabled switch are live, so the section can be seen in both states. */ +function Playground({ + initialName, + isEdit, + initialEnabled = true, + runResult = null, + ...rest +}: { + initialName: string; + isEdit: boolean; + initialEnabled?: boolean; + runResult?: RunResultSummary | null; + saving?: boolean; + testing?: boolean; + running?: boolean; + canSave?: boolean; + stepCount?: number; +}) { + const [name, setName] = useState(initialName); + const [enabled, setEnabled] = useState(initialEnabled); + return ( + + ); +} + +/** An existing pipeline: everything is available. */ +export const Editing: Story = { + render: () => , +}; + +/** + * A pipeline that has never been saved. It can still be tested against a file, but there is + * nothing yet to run on a schedule, clear history for, or delete. + */ +export const New: Story = { + render: () => , +}; + +/** Paused: the pipeline exists but its trigger will not fire. */ +export const Paused: Story = { + render: () => ( + + ), +}; + +/** Mid test-run: the picker shows its own progress while the graph shows the steps. */ +export const Testing: Story = { + render: () => , +}; + +/** After a test run: the outcome and its files sit beside the button that started them. */ +export const WithRunResult: Story = { + render: () => ( + + ), +}; + +/** A failed run: the summary is here, the failing step's own message is on its node. */ +export const WithFailedRun: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx new file mode 100644 index 0000000000..2c5552be07 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx @@ -0,0 +1,200 @@ +import { describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, +} from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { + PipelineHeader, + type PipelineHeaderProps, +} from "@portal/components/pipelines/PipelineHeader"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function renderHeader(overrides: Partial = {}) { + const handlers = { + onNameChange: vi.fn(), + onEnabledChange: vi.fn(), + onSave: vi.fn(), + onCancel: vi.fn(), + onBack: vi.fn(), + onTest: vi.fn(), + onRun: vi.fn(), + onClearHistory: vi.fn(), + onDelete: vi.fn(), + onViewDefinition: vi.fn(), + onDownloadOutput: vi.fn(), + }; + render( + , + ); + return handlers; +} + +describe("PipelineHeader", () => { + it("edits the pipeline's name and enabled state", () => { + const handlers = renderHeader(); + fireEvent.change( + screen.getByRole("textbox", { name: "portal.pipelines.composer.name" }), + { target: { value: "Renamed" } }, + ); + expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed"); + + fireEvent.click(screen.getByRole("checkbox")); + expect(handlers.onEnabledChange).toHaveBeenCalledWith(false); + }); + + it("offers run, clear history and delete only once the pipeline exists", () => { + renderHeader({ isEdit: false }); + expect( + screen.queryByText("portal.pipelines.detail.run"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.detail.delete"), + ).not.toBeInTheDocument(); + // A test run needs no saved record, so it stays: it is how you check the steps as you build. + expect( + screen.getByText("portal.pipelines.builder.testRun"), + ).toBeInTheDocument(); + }); + + it("labels the save action for what it will do", () => { + renderHeader({ isEdit: false }); + expect( + screen.getByText("portal.pipelines.composer.create"), + ).toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.composer.save"), + ).not.toBeInTheDocument(); + }); + + it("blocks saving until the pipeline is valid", () => { + renderHeader({ canSave: false }); + expect( + screen.getByText("portal.pipelines.composer.save").closest("button"), + ).toBeDisabled(); + }); + + it("hands the chosen file to the test run", () => { + const handlers = renderHeader(); + const file = new File(["x"], "claim.pdf", { type: "application/pdf" }); + const input = + document.querySelector('input[type="file"]'); + expect(input).not.toBeNull(); + fireEvent.change(input as HTMLInputElement, { target: { files: [file] } }); + expect(handlers.onTest).toHaveBeenCalledWith(file); + }); + + it("will not offer a test run on a chain with no steps", () => { + renderHeader({ stepCount: 0 }); + expect( + screen.getByText("portal.pipelines.builder.testRun").closest("button"), + ).toBeDisabled(); + }); + + it("shows why a test run failed, not only that it did", () => { + renderHeader({ + runResult: { + status: "failed", + completedSteps: 1, + stepCount: 3, + error: "OCR failed: unreadable page", + }, + }); + expect(screen.getByText("OCR failed: unreadable page")).toBeInTheDocument(); + }); + + it("runs and deletes from the row, clears history from the tray", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByText("portal.pipelines.detail.run")); + expect(handlers.onRun).toHaveBeenCalled(); + fireEvent.click(screen.getByText("portal.pipelines.detail.delete")); + expect(handlers.onDelete).toHaveBeenCalled(); + + fireEvent.click( + screen.getByLabelText("portal.pipelines.builder.moreActions"), + ); + fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory")); + expect(handlers.onClearHistory).toHaveBeenCalled(); + }); + + it("leaves the page through cancel and back", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByText("portal.pipelines.composer.cancel")); + expect(handlers.onCancel).toHaveBeenCalled(); + fireEvent.click(screen.getByText("portal.pipelines.builder.back")); + expect(handlers.onBack).toHaveBeenCalled(); + }); + + it("keeps the occasional actions out of the row, behind a tray", () => { + renderHeader(); + // Running and testing earn a button each; reading the definition and wiping history do not. + expect( + screen.queryByText("portal.pipelines.builder.viewDefinition"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.detail.clearHistory"), + ).not.toBeInTheDocument(); + expect( + screen.getByLabelText("portal.pipelines.builder.moreActions"), + ).toBeInTheDocument(); + }); + + it("opens the definition from the tray", () => { + const handlers = renderHeader(); + fireEvent.click( + screen.getByLabelText("portal.pipelines.builder.moreActions"), + ); + fireEvent.click( + screen.getByText("portal.pipelines.builder.viewDefinition"), + ); + expect(handlers.onViewDefinition).toHaveBeenCalled(); + }); + + it("shows no run strip until a test has been run", () => { + renderHeader(); + expect( + screen.queryByText(/portal.pipelines.inspector.status/), + ).not.toBeInTheDocument(); + }); + + it("reports a finished run and downloads the file clicked", () => { + const handlers = renderHeader({ + runResult: { + status: "completed", + completedSteps: 2, + stepCount: 2, + outputs: [ + { fileId: "f1", fileName: "claim.pdf" }, + { fileId: "f2", fileName: null }, + ], + }, + }); + fireEvent.click(screen.getByText("claim.pdf")); + expect(handlers.onDownloadOutput).toHaveBeenCalledWith({ + fileId: "f1", + fileName: "claim.pdf", + }); + // A file the backend did not name still has to be reachable. + expect(screen.getByText("f2")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx new file mode 100644 index 0000000000..25bfbd044f --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx @@ -0,0 +1,301 @@ +import { useTranslation } from "react-i18next"; +import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; +import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; +import HistoryRoundedIcon from "@mui/icons-material/HistoryRounded"; +import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded"; +import ScienceOutlinedIcon from "@mui/icons-material/ScienceOutlined"; +import CodeRoundedIcon from "@mui/icons-material/CodeRounded"; +import MoreHorizRoundedIcon from "@mui/icons-material/MoreHorizRounded"; +import CheckCircleOutlineRoundedIcon from "@mui/icons-material/CheckCircleOutlineRounded"; +import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded"; +import ErrorOutlineRoundedIcon from "@mui/icons-material/ErrorOutlineRounded"; +import { + ActionIcon, + Button, + Checkbox, + Dropdown, + FilePicker, + Input, + Spinner, +} from "@app/ui"; +import "@portal/components/pipelines/PipelineHeader.css"; + +/** One file a test run produced, downloadable from the result strip. */ +export interface RunOutputFile { + fileId: string; + fileName: string | null; +} + +/** + * A test run's outcome. Whole-pipeline, not per-node: the backend reports one flat list of files + * plus the step it stopped at, so there is no per-node output to attach to a node. + */ +export interface RunResultSummary { + status: "running" | "completed" | "failed"; + completedSteps: number; + stepCount: number; + error?: string | null; + outputs?: RunOutputFile[]; +} + +export interface PipelineHeaderProps { + name: string; + onNameChange: (name: string) => void; + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; + /** False for a pipeline that has never been saved: it cannot yet be run, cleared or deleted. */ + isEdit: boolean; + /** How many steps the chain has, so an empty pipeline cannot offer a test that does nothing. */ + stepCount: number; + + canSave: boolean; + saving: boolean; + onSave: () => void; + onCancel: () => void; + onBack: () => void; + + /** Run the steps as they stand against one uploaded file, without saving or delivering. */ + onTest: (file: File) => void; + testing: boolean; + /** Run the saved pipeline against its real input, delivering to its real destination. */ + onRun: () => void; + running: boolean; + onClearHistory: () => void; + clearingHistory: boolean; + onDelete: () => void; + + /** Opens the definition (JSON + cURL), which is pipeline-scoped like the rest of this row. */ + onViewDefinition: () => void; + /** The last test run in this session, or null if there has not been one. */ + runResult: RunResultSummary | null; + onDownloadOutput: (output: RunOutputFile) => void; +} + +/** + * The pipeline's identity and its whole-pipeline actions, at the top of the builder. + * + * Split in two so neither half gets lost in a single crowded row: what the pipeline *is* (name, + * whether it is live) sits with the actions that leave the page, and what you can *do to it* sits + * below the rule. A test run is part of building, so it lives here rather than off in a corner - + * its progress shows on the graph's nodes and its results in the inspector. + */ +export function PipelineHeader({ + name, + onNameChange, + enabled, + onEnabledChange, + isEdit, + stepCount, + canSave, + saving, + onSave, + onCancel, + onBack, + onTest, + testing, + onRun, + running, + onClearHistory, + clearingHistory, + onDelete, + onViewDefinition, + runResult, + onDownloadOutput, +}: PipelineHeaderProps) { + const { t } = useTranslation(); + + return ( +
+
+ +
+ + +
+
+ +
+ onNameChange(e.target.value)} + /> + {/* A checkbox, not a switch: this is a form value that takes effect on save, and a switch + would imply it applies the moment it is flipped. No description - a second line beside + the single-line name field leaves the row ragged. */} + onEnabledChange(e.target.checked)} + label={t("portal.pipelines.builder.enabled")} + /> +
+ +
+ file && onTest(file)} + leftSection={} + > + {t("portal.pipelines.builder.testRun")} + + + {isEdit && ( + + )} + + {/* Occasional things - reading the definition, wiping the processed history - kept behind a + tray so they do not compete with running and testing, which is what this row is for. */} + + + + + + + + } + > + {t("portal.pipelines.builder.viewDefinition")} + + {isEdit && ( + + } + > + {t("portal.pipelines.detail.clearHistory")} + + )} + + + + {isEdit && ( + + )} +
+ + {runResult && ( + + )} +
+ ); +} + +interface RunResultStripProps { + result: RunResultSummary; + onDownload: (output: RunOutputFile) => void; +} + +/** What the last test run did, beside the button that started it. */ +function RunResultStrip({ result, onDownload }: RunResultStripProps) { + const { t } = useTranslation(); + const outputs = result.outputs ?? []; + + return ( +
+
+ {result.status === "running" && } + {result.status === "completed" && ( + + )} + {result.status === "failed" && ( + + )} + + {t(`portal.pipelines.inspector.status.${result.status}`, { + done: result.completedSteps, + count: result.stepCount, + })} + +
+ + {/* The reason it failed, where the failure is announced - not only on the node, which the user + has to know to click. */} + {result.status === "failed" && result.error && ( + + {result.error} + + )} + + {outputs.map((output) => ( + + ))} +
+ ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineInspector.css b/frontend/editor/src/portal/components/pipelines/PipelineInspector.css new file mode 100644 index 0000000000..0fc50597e8 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineInspector.css @@ -0,0 +1,34 @@ +/** + * The builder's right-hand panel: the selected node's settings. + */ + +.portal-inspector { + display: flex; + flex-direction: column; + gap: 0.875rem; + padding: 1.125rem; + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-lg); + /* The builder caps its columns so the page itself does not scroll, which means a settings form + taller than the viewport has to scroll in here - otherwise its lower half is unreachable. */ + max-height: 100%; +} + +.portal-inspector__body { + display: flex; + flex-direction: column; + gap: 0.875rem; + min-height: 0; + overflow-y: auto; +} + +/* Names the node being edited, so the panel is not just a nameless form. */ +.portal-inspector__title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--c-text); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineInspector.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineInspector.stories.tsx new file mode 100644 index 0000000000..ff4c8f3d21 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineInspector.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FormField, Input, Select } from "@app/ui"; +import { PipelineInspector } from "@portal/components/pipelines/PipelineInspector"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineInspector", + component: PipelineInspector, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** Stands in for a node's real editor, which the builder supplies. */ +function StubSettings() { + return ( + <> + + + + + } onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") onClose(); @@ -104,36 +104,48 @@ export function ToolPicker({
{group.label}
- {group.tools.map((tool) => ( - - ))} + + ); + })}
)) )} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.css b/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.css new file mode 100644 index 0000000000..a821b9910e --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.css @@ -0,0 +1,180 @@ +/** + * One wire between two nodes, plus its insert affordance. The graph positions it; the wire is a + * 1px rule centred on the column with a filled arrowhead at the arriving end. + */ + +.portal-graph-edge { + position: absolute; + /* A wide drop target: the wire is a thin line, but a dragged step can be released anywhere across + the row, so the whole band between the nodes catches it. `left` is the column centre, so pull + back by half to keep the band centred on it. Line, insert and warning are placed absolutely + within. */ + width: 16rem; + transform: translateX(-50%); + --edge-color: var(--c-border-strong); +} + +.portal-graph-edge__line { + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + transform: translateX(-50%); + background: var(--edge-color); +} + +/** + * Arrowhead at the arriving end, so the chain reads as directed. + */ +.portal-graph-edge__line::after { + content: ""; + position: absolute; + bottom: 0; + left: 50%; + width: 0; + height: 0; + transform: translateX(-50%); + border-left: 0.1875rem solid transparent; + border-right: 0.1875rem solid transparent; + border-top: 0.3125rem solid var(--edge-color); +} + +/* Insert: the shared ActionIcon restyled to a small dot beside the wire (not on top of it, where it + hid the line). Hidden at rest - a solid plus on every wire reads as busy - and revealed only when + the pointer is over this wire's drop band (see the reveal rule below). When shown it is solid, not + faint: off to one side on the canvas it needs a real border and glyph to be seen at all. */ +.portal-graph-edge__insert.sui-ai { + position: absolute; + top: 50%; + /* Beside the wire: the column centre is 50%, nudge clear of the line and centre on the row. */ + left: 50%; + transform: translate(0.6rem, -50%); + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + min-width: 1.25rem; + min-height: 1.25rem; + border: 1px solid var(--c-border-strong); + background: var(--c-surface); + color: var(--c-text-muted); + opacity: 0; + transition: + opacity var(--motion-fast), + color var(--motion-fast), + border-color var(--motion-fast), + background var(--motion-fast); +} + +/* Optically centre the glyph in the circle: MUI's Add icon carries a hair of bottom bias. */ +.portal-graph-edge__insert.sui-ai svg { + display: block; +} + +/* On a warning wire the insert sits just past the pill, out of flow so the pill stays centred on the + wire whether or not the insert is showing (it reveals on hover like every other wire's). */ +.portal-graph-edge__note .portal-graph-edge__insert.sui-ai { + left: 100%; + margin-left: 0.375rem; + transform: translateY(-50%); +} + +/* Reveal the insert when the pointer is anywhere in this wire's drop band, or it has keyboard focus. + Revealing is not highlighting: it comes in at its resting weight and only goes primary once the + pointer is on the button itself (below) - not from anywhere in the wide band. */ +.portal-graph-edge:hover .portal-graph-edge__insert.sui-ai, +.portal-graph-edge__insert.sui-ai:focus-visible { + opacity: 1; +} + +.portal-graph-edge__insert.sui-ai:hover, +.portal-graph-edge__insert.sui-ai:focus-visible { + color: var(--c-accent-fg, var(--c-primary)); + border-color: var(--c-primary); +} + +/* A wire with no slot of its own (either side of the placeholder): line only. */ +.portal-graph-edge.is-plain { + --edge-color: var(--c-border-subtle); +} + +/** + * The pairing does not make much sense. Advisory: the wire still accepts drops and the chain still + * runs - the order stays the user's choice. + */ +.portal-graph-edge.has-warning { + --edge-color: var(--c-warning); +} + +/* The note and its insert ride together, centred on the wire, so a warned pairing keeps a way to + take a fixing step between its ends. Grows to its content and may overhang the band, which the + wider graph column absorbs. */ +.portal-graph-edge__note { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: inline-flex; + align-items: center; + gap: 0.375rem; +} + +.portal-graph-edge__warning { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.0625rem 0.375rem; + border-radius: var(--radius-pill); + border: 1px solid var(--c-warning); + /* Matches the shared Banner's warning treatment: tinted ground, amber border and glyph, ordinary + text. Amber words would not clear 4.5:1 at this size, and the tint is what makes the neutral + text read as part of a warning rather than as stray body copy. */ + background: color-mix(in srgb, var(--c-warning) 12%, var(--c-surface)); + color: var(--c-text); + font-size: 0.6875rem; + line-height: 1.4; +} + +.portal-graph-edge__warning svg { + color: var(--c-warning); + flex: none; +} + +/* A blocking pairing: the chain cannot run in this order, so it must not read as the same gentle + advice as an odd-but-workable one. Same shape, danger tone - including the wire and its head, + which follow --edge-color. */ +.portal-graph-edge.is-blocking { + --edge-color: var(--c-danger); +} + +.portal-graph-edge.is-blocking .portal-graph-edge__warning { + border-color: var(--c-danger); + background: color-mix(in srgb, var(--c-danger) 12%, var(--c-surface)); +} + +.portal-graph-edge.is-blocking .portal-graph-edge__warning svg { + color: var(--c-danger); +} + +.portal-graph-edge__warning-label { + white-space: nowrap; +} + +/* While a step is being dragged, the insert is beside the point - the wire itself is the target - + and it would only clutter the row and collide with the drag hint. Hide it until the drag ends. + The wire itself stays at rest until the step is actually over it: lighting every wire the moment + a drag starts is noise, not a cue. */ +.portal-graph-edge.is-available .portal-graph-edge__insert.sui-ai { + display: none; +} + +/* The step is over this wire and would land here on release: only then does the wire go primary. */ +.portal-graph-edge.is-over { + --edge-color: var(--c-primary); +} + +.portal-graph-edge.is-over .portal-graph-edge__line { + width: 2px; +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.tsx b/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.tsx new file mode 100644 index 0000000000..7f7dcca5d4 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.tsx @@ -0,0 +1,109 @@ +import { useTranslation } from "react-i18next"; +import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded"; +import { ActionIcon } from "@app/ui"; +import type { LaidOutEdge } from "@portal/components/pipelines/graph/pipelineLayout"; +import { useEdgeDrop } from "@portal/components/pipelines/graph/useChainDragDrop"; +import "@portal/components/pipelines/graph/GraphEdge.css"; + +/** + * A note on the wire arriving at a node: why what flows in will not suit it. + * + * `blocking` separates "this cannot run in this order" from "this is probably not what you meant". + * Both are shown on the wire and neither refuses the edit - the order stays the user's to choose - + * but only a blocking one stops the pipeline being saved, so it must not read as mere advice. + */ +export interface ChainWarning { + text: string; + blocking?: boolean; +} + +export interface GraphEdgeProps { + edge: LaidOutEdge; + /** Add a new step in the slot this wire opens. */ + onInsert: (index: number) => void; + stepCount: number; + /** Given the chain's new order as original step indices, and which steps the drag carried. */ + onReorder: (order: number[], moved: readonly number[]) => void; + /** A step is in flight, so open wires advertise themselves as landing spots. */ + dragActive: boolean; + /** + * Why what flows along this wire will not be much use to the node it arrives at (encrypting + * before an OCR, say). Never refuses the edit - the order stays the user's to choose - but a + * blocking one means the chain cannot run at all, and is coloured apart from mere advice. + */ + warning?: ChainWarning; +} + +/** + * One wire between two nodes: a directed line carrying an insert affordance, and the drop target + * that catches a step dragged onto it. Where the pairing does not make sense the wire says so, + * rather than refusing it. + */ +export function GraphEdge({ + edge, + onInsert, + stepCount, + onReorder, + dragActive, + warning, +}: GraphEdgeProps) { + const { t } = useTranslation(); + const { ref, over } = useEdgeDrop({ + insertIndex: edge.insertIndex, + stepCount, + onReorder, + }); + const open = edge.insertIndex !== null; + + // The insert affordance is shown whenever the wire opens a slot - including on a warned wire, so a + // bad pairing can still take a fixing step between its ends rather than losing its only way in. + const insertButton = open ? ( + onInsert(edge.insertIndex as number)} + > + + + ) : null; + + return ( +
+ + {warning ? ( + + + + + {warning.text} + + + {insertButton} + + ) : ( + insertButton + )} +
+ ); +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphNode.css b/frontend/editor/src/portal/components/pipelines/graph/GraphNode.css new file mode 100644 index 0000000000..9bea07fd4b --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphNode.css @@ -0,0 +1,140 @@ +/** + * Graph-only extras layered on the shared NodeCard tile (see @app/ui/NodeCard): the config warning + * line, drag dimming, the remove control and run-state glyphs. The surface, selection ring and + * icon/title/detail layout all live in NodeCard. + */ + +/* Amber carries the tone on the glyph; the words stay body-coloured. --c-warning is amber-600, + which is only 3.18:1 on a light surface at this size (axe) - readable as an icon, not as text. */ +.portal-graph-node__warning { + display: inline-flex; + align-items: center; + gap: 0.25rem; + color: var(--c-text); +} + +.portal-graph-node__warning svg { + color: var(--c-warning); + flex: none; +} + +/* Lifted out of the chain: the origin dims so the drop target reads as the real position. */ +.portal-graph-node.is-dragging { + opacity: 0.4; +} + +/* Steps can be picked up and moved; the input and output are fixed ends. */ +.portal-graph-node--step .sui-node-card__select.sui-btn { + cursor: grab; +} + +.portal-graph-node--step.is-dragging .sui-node-card__select.sui-btn { + cursor: grabbing; +} + +/* Remove: quiet until the node is hovered or focused, so the chain stays calm. */ +.portal-graph-node__remove.sui-ai { + position: absolute; + top: -0.4375rem; + /* Logical, so in RTL the remove sits on the card's trailing (left) corner rather than on top of + the leading icon badge. */ + inset-inline-end: -0.4375rem; + width: 1.25rem; + height: 1.25rem; + min-width: 1.25rem; + min-height: 1.25rem; + border: 1px solid var(--c-border); + background: var(--c-surface); + color: var(--c-text-subtle); + opacity: 0; + transition: + opacity var(--motion-fast), + color var(--motion-fast), + border-color var(--motion-fast); +} + +.portal-graph-node:hover .portal-graph-node__remove.sui-ai, +.portal-graph-node.is-selected .portal-graph-node__remove.sui-ai, +.portal-graph-node__remove.sui-ai:focus-visible { + opacity: 1; +} + +.portal-graph-node__remove.sui-ai:hover { + color: var(--c-danger); + border-color: var(--c-danger); +} + +/* Run state: a status glyph on the card's trailing edge, inside the node. The state is carried by + the icon's shape as well as its colour, with the wording kept for assistive tech. */ +.portal-graph-node__run { + flex: none; + align-self: center; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + margin-inline-end: 0.625rem; + color: var(--c-text-subtle); +} + +/* A failed step's glyph is a button (it opens the error), so re-assert the plain glyph look over + the shared ActionIcon base. */ +.portal-graph-node__run--open.sui-ai { + width: 1.25rem; + height: 1.25rem; + min-width: 1.25rem; + min-height: 1.25rem; + border: none; + background: none; + color: var(--c-danger); +} + +.portal-graph-node__run-label { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.portal-graph-node.is-done .portal-graph-node__run { + color: var(--c-success); +} + +.portal-graph-node.is-failed .portal-graph-node__run { + color: var(--c-danger); +} + +/* Running is carried by the pulsing glyph alone: a primary border here would be the selected + treatment, and "the step I am editing" must stay distinguishable from "the step running now". */ +.portal-graph-node.is-running .portal-graph-node__run { + color: var(--c-primary); +} + +.portal-graph-node__pulse { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: currentColor; + animation: portal-graph-pulse 1.2s ease-in-out infinite; +} + +@keyframes portal-graph-pulse { + 0%, + 100% { + opacity: 0.35; + } + 50% { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .portal-graph-node__pulse { + animation: none; + } +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphNode.tsx b/frontend/editor/src/portal/components/pipelines/graph/GraphNode.tsx new file mode 100644 index 0000000000..c19a7c15f2 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphNode.tsx @@ -0,0 +1,183 @@ +import type { MouseEvent, ReactNode, Ref } from "react"; +import { useTranslation } from "react-i18next"; +import CheckRoundedIcon from "@mui/icons-material/CheckRounded"; +import CloseRoundedIcon from "@mui/icons-material/CloseRounded"; +import ErrorOutlineRoundedIcon from "@mui/icons-material/ErrorOutlineRounded"; +import MoveToInboxRoundedIcon from "@mui/icons-material/MoveToInboxRounded"; +import SendRoundedIcon from "@mui/icons-material/SendRounded"; +import TuneRoundedIcon from "@mui/icons-material/TuneRounded"; +import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded"; +import { ActionIcon, NodeCard } from "@app/ui"; +import { type IconBadgeAccent } from "@app/ui/IconBadge"; +import type { GraphNodeKind } from "@portal/components/pipelines/graph/pipelineLayout"; +import "@portal/components/pipelines/graph/GraphNode.css"; + +/** How a node is faring in the current or last test run. */ +export type NodeRunState = "running" | "done" | "failed"; + +/** The kinds this card renders. The placeholder is its own component, not a node variant. */ +type CardKind = Exclude; + +const KIND_ICON: Record = { + input: , + step: , + output: , +}; + +const KIND_ACCENT: Record = { + input: "green", + step: "blue", + output: "purple", +}; + +export interface GraphNodeProps { + kind: CardKind; + title: string; + /** One-line summary under the title (the source's path, a step's parameters). */ + detail?: string; + /** + * Problem with this node's configuration, shown in place of the detail. Distinct from a run + * failure: this is why the pipeline cannot be saved yet. + */ + warning?: string; + /** The step's own tool glyph; falls back to a per-kind default. */ + icon?: ReactNode; + selected: boolean; + runState?: NodeRunState; + onOpenRunState?: () => void; + onSelect: (event: MouseEvent) => void; + /** Takes the node off the chain. For an end, that returns its row to a placeholder. */ + onRemove?: () => void; + /** True while this node is being dragged to another place in the chain. */ + dragging?: boolean; + /** + * The step's place in the chain, so a multi-step drag preview can find the other selected cards + * in the DOM. Absent for the input and output, which are never dragged. + */ + stepIndex?: number; + /** The card element, for the drag adapter to register against. */ + ref?: Ref; +} + +/** + * One node in the pipeline graph: the shared {@link NodeCard} tile carrying its glyph, title and a + * one-line summary, plus the graph-only extras layered on top - run status, a remove control, drag + * dimming, and the "why this cannot be saved" warning line. Position is applied by the graph, so the + * node itself knows nothing about layout. + */ +export function GraphNode({ + kind, + title, + detail, + warning, + icon, + selected, + runState, + onOpenRunState, + onSelect, + onRemove, + dragging, + stepIndex, + ref, +}: GraphNodeProps) { + const { t } = useTranslation(); + + const runStatus = runState && ( + + ); + const remove = onRemove && ( + + + + ); + + return ( + + + {warning} + + ) : ( + detail + ) + } + trailing={ + <> + {runStatus} + {remove} + + } + /> + ); +} + +interface RunStatusProps { + runState: NodeRunState; + title: string; + onOpenRunState?: () => void; +} + +/** The run glyph on the card's trailing edge; a button when it opens a failure, else a status. */ +function RunStatus({ runState, title, onOpenRunState }: RunStatusProps) { + const { t } = useTranslation(); + if (runState === "failed" && onOpenRunState) { + return ( + + + + ); + } + return ( + + {runState === "running" && ( + + )} + {runState === "done" && ( + + )} + {runState === "failed" && ( + + )} + + {t(`portal.pipelines.graph.run.${runState}`)} + + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.css b/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.css new file mode 100644 index 0000000000..dba96072c4 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.css @@ -0,0 +1,44 @@ +/** + * The empty-pipeline stand-in: a dashed node in the row the first step will take. Dashed rather + * than solid so it reads as "not yet a step", and full width so it is an obvious target. + */ + +.portal-graph-placeholder.sui-btn { + width: 100%; + height: auto; + min-height: 0; + padding: 0.625rem 0.75rem; + background: none; + border: 1px dashed var(--c-border-strong); + border-radius: var(--radius-lg); + color: var(--c-text-muted); + font-weight: 400; + text-align: left; + transition: + border-color var(--motion-fast), + color var(--motion-fast), + background var(--motion-fast); +} + +.portal-graph-placeholder.sui-btn:hover { + border-color: var(--c-primary); + border-style: solid; + color: var(--c-accent-fg, var(--c-primary)); + background: var(--c-surface); +} + +/* Mantine lays a button's children out inside its label element, not on the root. */ +.portal-graph-placeholder.sui-btn .mantine-Button-label { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.625rem; + min-width: 0; + overflow: visible; +} + +.portal-graph-placeholder__title { + font-size: 0.875rem; + font-weight: 500; +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.tsx b/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.tsx new file mode 100644 index 0000000000..0ecee1bfc7 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.tsx @@ -0,0 +1,30 @@ +import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import { Button } from "@app/ui"; +import "@portal/components/pipelines/graph/GraphPlaceholderNode.css"; + +export interface GraphPlaceholderNodeProps { + label: string; + onAdd: () => void; +} + +/** + * The stand-in for a row the pipeline has not filled yet - the first step, or either end of the + * chain on a new pipeline. It sits in the row that thing will occupy, so the chain reads as + * input -> something -> output straight away, and it is the thing you click to fill it: a full-width + * target, rather than a caption pointing at a small plus on a wire. + */ +export function GraphPlaceholderNode({ + label, + onAdd, +}: GraphPlaceholderNodeProps) { + return ( + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.css b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.css new file mode 100644 index 0000000000..c9aff67012 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.css @@ -0,0 +1,79 @@ +/** + * The graph surface. The canvas is sized by the derived layout and centred in the scroll area, so + * the chain stays put as steps are added or removed. + */ + +.portal-graph { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 1.5rem 1rem; + /* Grows with the chain up to whatever the page gives it, then scrolls rather than pushing the + inspector out of reach. A short chain still hugs its content, so there is no empty canvas. */ + max-height: 100%; + overflow: auto; + /* The canvas must sit below the node cards on the surface ladder so they read as raised off it in + both themes. --c-surface-sunken is the only rung darker than --c-surface in light *and* dark; + the legacy --color-bg-subtle collapsed into the page in dark, and --c-bg-raised is lighter than + the cards in light. */ + background: var(--c-surface-sunken); + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-lg); +} + +/* Sits beside the wire it is describing. Absolute, so appearing mid-drag moves nothing. */ +.portal-graph__drag-hint { + position: absolute; + margin: 0; + /* `left` is the column centre; clear the wire's hit area before the text starts. */ + transform: translate(1.75rem, -50%); + white-space: nowrap; + font-size: 0.75rem; + font-weight: 500; + color: var(--c-accent-fg, var(--c-primary)); + pointer-events: none; +} + +/* transform has no logical form, so mirror it by hand: in RTL the hint clears the wire on the other + side rather than reaching back across it onto the chain. */ +[dir="rtl"] .portal-graph__drag-hint { + transform: translate(-1.75rem, -50%); +} + +/* What follows the cursor when several steps are dragged at once: a copy of each card, stacked, so + the drag shows what is actually moving rather than only the card that was grabbed. Cloned nodes + keep their own styling; they are inert copies, hence no pointer events. */ +.portal-graph__drag-preview { + display: flex; + flex-direction: column; + gap: 0.375rem; + pointer-events: none; +} + +.portal-graph__drag-preview .portal-graph-node { + opacity: 0.9; +} + +.portal-graph__canvas { + position: relative; + flex: none; +} + +/* Nodes are placed by the layout; the slot carries the position, the card fills it. */ +.portal-graph__slot { + position: absolute; + display: flex; +} + +.portal-graph__slot > * { + flex: 1; + min-width: 0; +} + +.portal-graph__hint { + margin: 0; + font-size: 0.8125rem; + color: var(--c-text-subtle); + text-align: center; +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.stories.tsx b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.stories.tsx new file mode 100644 index 0000000000..c16291bd9c --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.stories.tsx @@ -0,0 +1,207 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + PipelineGraph, + selectedSteps, + type ChainEnd, + type GraphNodeContent, + type GraphSelection, + type GraphStepContent, +} from "@portal/components/pipelines/graph/PipelineGraph"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineGraph", + component: PipelineGraph, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const INPUT = { + label: "Claims intake", + detail: "/srv/claims/in - every hour", +}; +const OUTPUT = { + label: "Archive bucket", + detail: "s3://claims-archive/done", +}; + +/** + * The builder owns the chain in the app, so the stories own it here - otherwise adding, removing + * and dragging would fire their handlers and visibly do nothing. Everything in these stories is + * live: click a wire's plus to insert, the node's X to remove, and drag a step onto a wire to move + * it there. + */ +function Playground({ + initialSteps, + output = OUTPUT, + /** Start with neither end on the chain, the way a brand new pipeline opens. */ + unplacedEnds = false, +}: { + initialSteps: GraphStepContent[]; + output?: { label: string; detail?: string; warning?: string }; + unplacedEnds?: boolean; +}) { + const [steps, setSteps] = useState(initialSteps); + const [selected, setSelected] = useState(null); + const [added, setAdded] = useState(0); + const [inputEnd, setInputEnd] = useState( + unplacedEnds ? null : INPUT, + ); + const [outputEnd, setOutputEnd] = useState( + unplacedEnds ? null : output, + ); + + // Placing an end leaves it owing a choice, which is the warning state the builder shows until the + // user picks a source or destination. + function addEnd(end: ChainEnd) { + if (end === "input") { + setInputEnd({ label: "Choose a source", warning: "No source chosen" }); + } else { + setOutputEnd({ + label: "Choose a destination", + warning: "No destination chosen", + }); + } + setSelected(end); + } + + function removeEnd(end: ChainEnd) { + if (end === "input") setInputEnd(null); + else setOutputEnd(null); + setSelected((current) => (current === end ? null : current)); + } + + function insert(at: number) { + const label = `New tool ${added + 1}`; + setAdded((n) => n + 1); + setSteps((current) => { + const next = [...current]; + next.splice(at, 0, { label }); + return next; + }); + setSelected({ steps: [at] }); + } + + function remove(indices: number[]) { + const gone = new Set(indices); + setSteps((current) => current.filter((_, i) => !gone.has(i))); + setSelected(null); + } + + function reorder(order: number[]) { + const moving = new Set(selectedSteps(selected)); + setSteps((current) => order.map((i) => current[i])); + const landed = order + .map((original, position) => ({ original, position })) + .filter(({ original }) => moving.has(original)) + .map(({ position }) => position); + setSelected(landed.length > 0 ? { steps: landed } : null); + } + + return ( + + ); +} + +/** A typical chain. Drag a step onto any wire to move it there. */ +export const Default: Story = { + render: () => ( + + ), +}; + +/** A pipeline with its ends settled but no steps yet: the placeholder holds the first step's place. */ +export const Empty: Story = { + render: () => , +}; + +/** + * A brand new pipeline, before anything has been chosen. Every row is an invitation rather than a + * complaint - nothing is wrong yet, because nothing has been asked of the user. Click an end to + * place it (it then owes a choice, and says so), and its X puts it back. + */ +export const NewPipeline: Story = { + render: () => , +}; + +/** + * An order that will not do what the user probably meant: OCR cannot read a file that the previous + * step encrypted. The wire says so and the chain still runs - nothing is refused, and the step can + * still be dragged anywhere. + */ +export const OddOrdering: Story = { + render: () => ( + + ), +}; + +/** Mid test-run: finished steps carry a tick, the current one pulses. */ +export const Running: Story = { + render: () => ( + + ), +}; + +/** A failed run, and a step that cannot be saved: the warning replaces the detail line. */ +export const Problems: Story = { + render: () => ( + + ), +}; + +/** + * Multi-selection: cmd/ctrl-click to add a step, shift-click for a run of them, then drag any one + * onto a line to move the whole set together. + */ +export const MultiSelect: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.test.tsx b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.test.tsx new file mode 100644 index 0000000000..c94bd512f3 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.test.tsx @@ -0,0 +1,455 @@ +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, +} from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { + PipelineGraph, + type GraphSelection, + type PipelineGraphProps, +} from "@portal/components/pipelines/graph/PipelineGraph"; + +// The nodes and wires are built from the shared Mantine-backed controls, so they need the provider. +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +// Deterministic i18n: keys returned verbatim, interpolation applied so aria-labels stay distinct. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, vars?: Record) => + vars?.name ? `${key}:${String(vars.name)}` : key, + }), +})); + +function renderGraph(overrides: Partial = {}) { + const handlers = { + onSelect: vi.fn(), + onAddEnd: vi.fn(), + onRemoveEnd: vi.fn(), + onInsertStep: vi.fn(), + onRemoveSteps: vi.fn(), + onReorderSteps: vi.fn(), + }; + render( + , + ); + return handlers; +} + +describe("PipelineGraph", () => { + it("renders the chain: input, each step in order, output", () => { + renderGraph(); + const titles = screen + .getAllByRole("button", { pressed: false }) + .map((node) => node.textContent); + expect(titles[0]).toContain("Claims intake"); + expect(titles[1]).toContain("OCR"); + expect(titles[2]).toContain("Redact"); + expect(titles[3]).toContain("Archive bucket"); + }); + + it("shows each node's one-line detail", () => { + renderGraph(); + expect(screen.getByText("/in - every hour")).toBeInTheDocument(); + expect(screen.getByText("s3://claims/done")).toBeInTheDocument(); + }); + + it("selects the ends by their kind and steps by index", () => { + const handlers = renderGraph(); + fireEvent.click(screen.getByText("Claims intake")); + expect(handlers.onSelect).toHaveBeenCalledWith("input"); + fireEvent.click(screen.getByText("Archive bucket")); + expect(handlers.onSelect).toHaveBeenCalledWith("output"); + fireEvent.click(screen.getByText("Redact")); + expect(handlers.onSelect).toHaveBeenCalledWith({ steps: [1] }); + }); + + it("adds and removes steps from the selection with cmd/ctrl-click", () => { + const handlers = renderGraph({ selected: { steps: [0] } }); + fireEvent.click(screen.getByText("Redact"), { metaKey: true }); + expect(handlers.onSelect).toHaveBeenCalledWith({ steps: [0, 1] }); + }); + + it("cmd/ctrl-clicking the only selected step clears the selection", () => { + const handlers = renderGraph({ selected: { steps: [1] } }); + fireEvent.click(screen.getByText("Redact"), { ctrlKey: true }); + expect(handlers.onSelect).toHaveBeenCalledWith(null); + }); + + it("shift-click takes everything between the anchor and the clicked step", () => { + const handlers = renderGraph({ + selected: { steps: [0] }, + steps: [ + { label: "OCR" }, + { label: "Redact" }, + { label: "Compress" }, + { label: "Stamp" }, + ], + }); + fireEvent.click(screen.getByText("Stamp"), { shiftKey: true }); + expect(handlers.onSelect).toHaveBeenCalledWith({ steps: [0, 1, 2, 3] }); + }); + + it("marks every selected step as pressed, not just one", () => { + renderGraph({ selected: { steps: [0, 1] } }); + for (const label of ["OCR", "Redact"]) { + expect(screen.getByText(label).closest("button")).toHaveAttribute( + "aria-pressed", + "true", + ); + } + }); + + it("keeps the drag hint silent until a drag starts", () => { + // Only the silent half is testable here: starting a real drag needs native HTML5 drag events, + // which jsdom does not implement, so the visible half is checked in a browser. + renderGraph(); + expect( + screen.queryByText("portal.pipelines.graph.dragHint"), + ).not.toBeInTheDocument(); + }); + + it("clears the selection when the canvas itself is clicked", () => { + const handlers = renderGraph({ selected: { steps: [1] } }); + fireEvent.click(document.querySelector(".portal-graph") as HTMLElement); + expect(handlers.onSelect).toHaveBeenCalledWith(null); + }); + + it("does not clear the selection when a node is clicked", () => { + // The node's own handler runs; the background handler must not undo it. + const handlers = renderGraph({ selected: { steps: [1] } }); + fireEvent.click(screen.getByText("OCR")); + expect(handlers.onSelect).toHaveBeenCalledWith({ steps: [0] }); + expect(handlers.onSelect).not.toHaveBeenCalledWith(null); + }); + + it("marks the selected node as pressed", () => { + renderGraph({ selected: { steps: [0] } }); + expect(screen.getByText("OCR").closest("button")).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + + it("puts an insert on every wire, reporting the slot it opens", () => { + const handlers = renderGraph(); + // input->OCR, OCR->Redact, Redact->output + const inserts = screen.getAllByLabelText( + "portal.pipelines.graph.insertHere", + ); + expect(inserts).toHaveLength(3); + fireEvent.click(inserts[1]); + expect(handlers.onInsertStep).toHaveBeenCalledWith(1); + }); + + it("holds the first step's place with a placeholder when the chain is empty", () => { + const handlers = renderGraph({ steps: [] }); + // The placeholder is the affordance, so the wires either side of it carry no plus of their + // own - two ways to fill the same slot would be a choice with no difference. + expect( + screen.queryByLabelText("portal.pipelines.graph.insertHere"), + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByText("portal.pipelines.graph.addFirstTool")); + expect(handlers.onInsertStep).toHaveBeenCalledWith(0); + }); + + it("drops the placeholder once the chain has a step", () => { + renderGraph({ steps: [{ label: "OCR" }] }); + expect( + screen.queryByText("portal.pipelines.graph.addFirstTool"), + ).not.toBeInTheDocument(); + }); + + it("warns on the wire arriving at a step it makes little sense to feed", () => { + renderGraph({ + steps: [ + { label: "Add Password" }, + { + label: "OCR", + inputWarning: { text: "OCR cannot read an encrypted file" }, + }, + ], + }); + expect( + screen.getByText("OCR cannot read an encrypted file"), + ).toBeInTheDocument(); + }); + + it("still allows the odd pairing: warned wires keep taking inserts", () => { + // Advisory, not a block - the order stays the user's to choose. + const handlers = renderGraph({ + steps: [ + { label: "Add Password" }, + { + label: "OCR", + inputWarning: { text: "OCR cannot read an encrypted file" }, + }, + ], + }); + // The warned wire shows its note and keeps its plus, so a fixing step can still go between the + // ends that do not suit each other - every wire takes an insert. + const inserts = screen.getAllByLabelText( + "portal.pipelines.graph.insertHere", + ); + expect(inserts).toHaveLength(3); + // The middle wire is the warned one (Add Password -> OCR); inserting there lands between them. + fireEvent.click(inserts[1]); + expect(handlers.onInsertStep).toHaveBeenCalledWith(1); + }); + + it("marks a blocking pairing apart from a merely odd one", () => { + // Both sit on the wire and neither refuses the edit, but one means the chain cannot run at + // all - so it must not read as the same gentle advice. + renderGraph({ + steps: [ + { label: "Extract images" }, + { + label: "Compress", + inputWarning: { text: "Compress needs a PDF", blocking: true }, + }, + ], + }); + const wire = screen + .getByText("Compress needs a PDF") + .closest(".portal-graph-edge"); + expect(wire).toHaveClass("is-blocking"); + }); + + it("leaves an advisory pairing unblocked", () => { + renderGraph({ + steps: [ + { label: "Add Password" }, + { label: "OCR", inputWarning: { text: "OCR cannot read this" } }, + ], + }); + expect( + screen.getByText("OCR cannot read this").closest(".portal-graph-edge"), + ).not.toHaveClass("is-blocking"); + }); + + it("warns on the wire into the output too", () => { + renderGraph({ + output: { + label: "Archive", + inputWarning: { text: "Nothing writes a folder here" }, + }, + }); + expect( + screen.getByText("Nothing writes a folder here"), + ).toBeInTheDocument(); + }); + + it("removes a step from the node itself", () => { + const handlers = renderGraph(); + fireEvent.click( + screen.getByLabelText("portal.pipelines.graph.removeNode:Redact"), + ); + expect(handlers.onRemoveSteps).toHaveBeenCalledWith([1]); + }); + + it("every node on the chain carries its own remove, ends included", () => { + renderGraph(); + // Two steps plus both ends: an end can be taken back off to its placeholder. + expect(screen.getAllByLabelText(/graph.removeNode/)).toHaveLength(4); + }); + + it("takes an end back off the chain", () => { + const handlers = renderGraph(); + fireEvent.click( + screen.getByLabelText("portal.pipelines.graph.removeNode:Claims intake"), + ); + expect(handlers.onRemoveEnd).toHaveBeenCalledWith("input"); + }); + + it("deletes every selected step with the Delete key", () => { + const handlers = renderGraph({ selected: { steps: [0, 1] } }); + fireEvent.keyDown(screen.getByText("Redact"), { key: "Delete" }); + expect(handlers.onRemoveSteps).toHaveBeenCalledWith([0, 1]); + }); + + it("takes a selected end off the chain with the Delete key, like its X does", () => { + const handlers = renderGraph({ selected: "input" }); + fireEvent.keyDown(screen.getByText("Claims intake"), { key: "Delete" }); + expect(handlers.onRemoveEnd).toHaveBeenCalledWith("input"); + // An end is not a step, so the step remover stays out of it. + expect(handlers.onRemoveSteps).not.toHaveBeenCalled(); + }); + + it("ignores Delete when nothing is selected", () => { + const handlers = renderGraph({ selected: null }); + fireEvent.keyDown(screen.getByText("OCR"), { key: "Delete" }); + expect(handlers.onRemoveSteps).not.toHaveBeenCalled(); + expect(handlers.onRemoveEnd).not.toHaveBeenCalled(); + }); + + it("moves the selected step down the chain with Alt+ArrowDown", () => { + const handlers = renderGraph({ selected: { steps: [0] } }); + fireEvent.keyDown(screen.getByText("OCR"), { + key: "ArrowDown", + altKey: true, + }); + // [OCR, Redact] with OCR moved down -> [Redact, OCR]; the dragged step is the reorder payload. + expect(handlers.onReorderSteps).toHaveBeenCalledWith([1, 0], [0]); + }); + + it("moves the selected step up the chain with Alt+ArrowUp", () => { + const handlers = renderGraph({ selected: { steps: [1] } }); + fireEvent.keyDown(screen.getByText("Redact"), { + key: "ArrowUp", + altKey: true, + }); + // [OCR, Redact] with Redact moved up -> [Redact, OCR]. + expect(handlers.onReorderSteps).toHaveBeenCalledWith([1, 0], [1]); + }); + + it("moves a multi-step selection together, keeping it as the payload", () => { + const handlers = renderGraph({ + selected: { steps: [0, 1] }, + steps: [{ label: "OCR" }, { label: "Redact" }, { label: "Compress" }], + }); + fireEvent.keyDown(screen.getByText("OCR"), { + key: "ArrowDown", + altKey: true, + }); + // [OCR, Redact, Compress] with [OCR, Redact] moved down -> [Compress, OCR, Redact]. + expect(handlers.onReorderSteps).toHaveBeenCalledWith([2, 0, 1], [0, 1]); + }); + + it("does not reorder past the end of the chain", () => { + const handlers = renderGraph({ selected: { steps: [0] } }); + fireEvent.keyDown(screen.getByText("OCR"), { + key: "ArrowUp", + altKey: true, + }); + expect(handlers.onReorderSteps).not.toHaveBeenCalled(); + }); + + it("leaves a bare arrow alone, so only the modifier reorders", () => { + const handlers = renderGraph({ selected: { steps: [0] } }); + fireEvent.keyDown(screen.getByText("OCR"), { key: "ArrowDown" }); + expect(handlers.onReorderSteps).not.toHaveBeenCalled(); + }); + + it("carries focus to the moved step so it can be walked several slots", () => { + // A real reorder renumbers the nodes, so focus has to follow the step or a second key press + // would act on whatever now sits where it started. Drive it through a stateful host. + function Host() { + const [steps, setSteps] = useState([ + { label: "OCR" }, + { label: "Redact" }, + { label: "Compress" }, + ]); + const [selected, setSelected] = useState({ steps: [0] }); + return ( + { + setSteps((current) => order.map((i) => current[i])); + const landed = order + .map((original, position) => ({ original, position })) + .filter(({ original }) => moved.includes(original)) + .map(({ position }) => position); + setSelected({ steps: landed }); + }} + /> + ); + } + render(); + fireEvent.keyDown(screen.getByText("OCR"), { + key: "ArrowDown", + altKey: true, + }); + // OCR now sits at position 1, and its card's select button holds focus. + expect(document.activeElement?.textContent).toContain("OCR"); + expect( + document.activeElement?.closest("[data-step-index]"), + ).toHaveAttribute("data-step-index", "1"); + }); + + describe("an end the pipeline has not asked for yet", () => { + it("offers to add it instead of naming it", () => { + renderGraph({ input: null }); + expect( + screen.getByText("portal.pipelines.graph.add.input"), + ).toBeInTheDocument(); + expect(screen.queryByText("Claims intake")).not.toBeInTheDocument(); + }); + + it("greets a brand new pipeline with no warnings at all", () => { + // The whole point: an end nobody has been offered yet is not a problem to report. + renderGraph({ + input: null, + output: null, + steps: [], + }); + expect(screen.queryByText(/warning|chosen/i)).not.toBeInTheDocument(); + expect(screen.getAllByText(/graph.add\./)).toHaveLength(2); + }); + + it("asks for the end when its placeholder is clicked", () => { + const handlers = renderGraph({ output: null }); + fireEvent.click(screen.getByText("portal.pipelines.graph.add.output")); + expect(handlers.onAddEnd).toHaveBeenCalledWith("output"); + }); + + it("has nothing to remove until it is placed", () => { + renderGraph({ input: null, output: null, steps: [] }); + expect( + screen.queryByLabelText(/graph.removeNode/), + ).not.toBeInTheDocument(); + }); + + it("carries no warning onto the wire that arrives at it", () => { + renderGraph({ output: null }); + expect( + screen.queryByText("Nothing writes a folder here"), + ).not.toBeInTheDocument(); + }); + }); + + it("shows a node's warning in place of its detail", () => { + renderGraph({ + steps: [ + { label: "Watermark", detail: "logo.png", warning: "Needs a file" }, + ], + }); + expect(screen.getByText("Needs a file")).toBeInTheDocument(); + expect(screen.queryByText("logo.png")).not.toBeInTheDocument(); + }); + + it("reports a run's progress on the steps it touched", () => { + renderGraph({ + steps: [ + { label: "OCR", runState: "done" }, + { label: "Redact", runState: "running" }, + ], + }); + expect( + screen.getByText("portal.pipelines.graph.run.done"), + ).toBeInTheDocument(); + expect( + screen.getByText("portal.pipelines.graph.run.running"), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.tsx b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.tsx new file mode 100644 index 0000000000..7afb3a9f41 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.tsx @@ -0,0 +1,379 @@ +import { + useLayoutEffect, + useRef, + useState, + type KeyboardEvent, + type MouseEvent as ReactMouseEvent, + type ReactNode, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + GraphNode, + type NodeRunState, +} from "@portal/components/pipelines/graph/GraphNode"; +import { + GraphEdge, + type ChainWarning, +} from "@portal/components/pipelines/graph/GraphEdge"; +import { GraphPlaceholderNode } from "@portal/components/pipelines/graph/GraphPlaceholderNode"; +import { + NODE_HEIGHT, + NODE_WIDTH, + layoutChain, + reorderMany, + stepIndexOf, +} from "@portal/components/pipelines/graph/pipelineLayout"; +import { useStepDraggable } from "@portal/components/pipelines/graph/useChainDragDrop"; +import "@portal/components/pipelines/graph/PipelineGraph.css"; + +// The wire renders it, but callers build it, so it is re-exported from the graph they talk to. +export type { ChainWarning }; + +/** + * What is selected: an end of the chain, one or more steps, or nothing. Only steps come in sets - + * the input and output are fixed ends, so there is nothing to gather or move. + */ +export type GraphSelection = "input" | "output" | { steps: number[] } | null; + +/** The selected step indices, in chain order. Empty unless steps are what is selected. */ +export function selectedSteps(selection: GraphSelection): number[] { + return selection !== null && typeof selection === "object" + ? selection.steps + : []; +} + +/** A node's display content. The graph never derives copy - the builder owns every label. */ +export interface GraphNodeContent { + label: string; + /** One-line summary: the source's path, a step's parameters, the destination. */ + detail?: string; + /** Why this node blocks saving, shown in place of the detail. */ + warning?: string; + /** Why the input will not be much use. */ + inputWarning?: ChainWarning; +} + +export interface GraphStepContent extends GraphNodeContent { + icon?: ReactNode; + runState?: NodeRunState; +} + +/** Which end of the chain: the two nodes every finished pipeline has, one of each. */ +export type ChainEnd = "input" | "output"; + +export interface PipelineGraphProps { + /** + * The chain's ends, or null while a new pipeline has yet to ask for one. Null renders the row as a + * placeholder to fill rather than a node owing a choice, which is what keeps a brand new pipeline + * from opening on a pair of warnings about decisions its author has not been offered yet. + */ + input: GraphNodeContent | null; + output: GraphNodeContent | null; + steps: GraphStepContent[]; + selected: GraphSelection; + onSelect: (selection: GraphSelection) => void; + /** Put an end on the chain, ready to be configured. */ + onAddEnd: (end: ChainEnd) => void; + /** Take an end back off, returning its row to a placeholder. */ + onRemoveEnd: (end: ChainEnd) => void; + /** Add a step in the slot the clicked wire opens. */ + onInsertStep: (index: number) => void; + /** Remove every step given, in one go. */ + onRemoveSteps: (indices: number[]) => void; + /** Reorder the chain to the given original step indices; `moved` is what the drag carried. */ + onReorderSteps: (order: number[], moved: readonly number[]) => void; + onOpenStepError?: (index: number) => void; +} + +/** + * The pipeline as a graph: one input, the steps in run order, one output. + * + * Layout is derived from the chain (see pipelineLayout), so there is nothing to lock, nothing to + * re-tidy and no stored positions - a node is always where its place in the order says it is. + * Dragging a step onto a wire moves it into that slot; clicking a node opens its settings in the + * inspector; the wires carry the insert affordance. + */ +export function PipelineGraph({ + input, + output, + steps, + selected, + onSelect, + onAddEnd, + onRemoveEnd, + onInsertStep, + onRemoveSteps, + onReorderSteps, + onOpenStepError, +}: PipelineGraphProps) { + const { t } = useTranslation(); + const [draggingIndex, setDraggingIndex] = useState(null); + const { nodes, edges, width, height } = layoutChain({ + stepCount: steps.length, + }); + + const graphRef = useRef(null); + // A keyboard reorder renumbers the nodes, so the focused card is no longer under the cursor's + // hand: without moving focus to where the step landed, a second Alt+Arrow would act on whatever + // now sits at the old position. Set by the handler, applied once the new order has rendered. + const focusStepAfterRender = useRef(null); + useLayoutEffect(() => { + const position = focusStepAfterRender.current; + if (position === null) return; + focusStepAfterRender.current = null; + graphRef.current + ?.querySelector( + `[data-step-index="${position}"] .sui-node-card__select`, + ) + ?.focus(); + }); + + // A wire carries the warning belonging to the node it arrives at. + const arrivalWarning = (nodeId: string): ChainWarning | undefined => { + if (nodeId === "output") return output?.inputWarning; + const index = stepIndexOf(nodeId); + return index === null ? undefined : steps[index]?.inputWarning; + }; + + /** + * Clicking the canvas itself clears the selection. Anything that is part of a node, a wire or the + * placeholder handles its own click, so only bare background gets here. + */ + function onBackgroundClick(event: ReactMouseEvent) { + const target = event.target as HTMLElement; + if ( + target.closest( + "[data-graph-node], .portal-graph-edge, .portal-graph-placeholder", + ) + ) { + return; + } + onSelect(null); + } + + const chosen = selectedSteps(selected); + + /** + * Plain click selects one step. Cmd/Ctrl toggles a step in or out of the selection; Shift takes + * everything between the first selected step and this one. The ends of the chain are single-only. + */ + function selectStep(index: number, event: ReactMouseEvent) { + if (event.metaKey || event.ctrlKey) { + const next = chosen.includes(index) + ? chosen.filter((i) => i !== index) + : [...chosen, index].sort((a, b) => a - b); + onSelect(next.length > 0 ? { steps: next } : null); + return; + } + if (event.shiftKey && chosen.length > 0) { + const anchor = chosen[0]; + const [from, to] = anchor <= index ? [anchor, index] : [index, anchor]; + const span = []; + for (let i = from; i <= to; i++) span.push(i); + onSelect({ steps: span }); + return; + } + onSelect({ steps: [index] }); + } + + /** + * Move the selected step(s) one slot along the chain - the keyboard alternative to dragging, which + * pointer-only users cannot reach. Alt with an arrow, so a plain arrow is still free for anything + * that later wants it. The moved block stays selected and takes focus with it, so it can be walked + * several slots in a row. + */ + function moveSelection(direction: "up" | "down"): boolean { + if (chosen.length === 0) return false; + const min = chosen[0]; + const max = chosen[chosen.length - 1]; + // reorderMany's slot is against the original chain: a step's own neighbouring slots are no-ops, + // so up aims one before the block and down one past it. + const slot = direction === "up" ? min - 1 : max + 2; + const order = reorderMany(steps.length, chosen, slot); + if (order === null) return false; // already at that end of the chain + focusStepAfterRender.current = order.indexOf(min); + onReorderSteps(order, chosen); + return true; + } + + /** + * Delete removes whatever is selected: every selected step, or an end of the chain (which returns + * its row to a placeholder, exactly as that node's X does). Alt + Up/Down reorders the selection. + * Scoped to the graph, so typing in the inspector's fields is never intercepted. + */ + function onKeyDown(event: KeyboardEvent) { + if ( + event.altKey && + (event.key === "ArrowUp" || event.key === "ArrowDown") + ) { + // Ends do not reorder (chosen is empty for them), so this only fires for a step selection. + if (moveSelection(event.key === "ArrowUp" ? "up" : "down")) { + event.preventDefault(); + } + return; + } + if (event.key !== "Delete" && event.key !== "Backspace") return; + if (selected === "input" || selected === "output") { + event.preventDefault(); + onRemoveEnd(selected); + return; + } + if (chosen.length === 0) return; + event.preventDefault(); + onRemoveSteps(chosen); + } + + return ( +
+
+ {edges.map((edge) => ( + + ))} + + {draggingIndex !== null && edges.length > 0 && ( +

+ {t("portal.pipelines.graph.dragHint")} +

+ )} + + {nodes.map((node) => { + const style = { + left: `${node.x}px`, + top: `${node.y}px`, + width: `${NODE_WIDTH}px`, + minHeight: `${NODE_HEIGHT}px`, + }; + if (node.kind === "placeholder") { + return ( +
+ onInsertStep(0)} + /> +
+ ); + } + if (node.kind === "input" || node.kind === "output") { + const kind = node.kind; + const content = kind === "input" ? input : output; + return ( +
+ {content === null ? ( + onAddEnd(kind)} + /> + ) : ( + onSelect(kind)} + onRemove={() => onRemoveEnd(kind)} + /> + )} +
+ ); + } + const index = node.stepIndex ?? 0; + return ( +
+ selectStep(index, event)} + onRemove={() => onRemoveSteps([index])} + onDragChange={(dragging) => + setDraggingIndex(dragging ? index : null) + } + onOpenRunState={ + steps[index].runState === "failed" && onOpenStepError + ? () => onOpenStepError(index) + : undefined + } + /> +
+ ); + })} +
+
+ ); +} + +interface ChainStepNodeProps { + index: number; + step: GraphStepContent; + selected: boolean; + dragging: boolean; + /** The steps this node's drag carries: the selection when it is part of it, else just itself. */ + moving: number[]; + onSelect: (event: ReactMouseEvent) => void; + onRemove: () => void; + onDragChange: (dragging: boolean) => void; + onOpenRunState?: () => void; +} + +/** A step node plus its drag wiring, which needs a hook per node and so a component per node. */ +function ChainStepNode({ + index, + step, + selected, + dragging, + moving, + onSelect, + onRemove, + onDragChange, + onOpenRunState, +}: ChainStepNodeProps) { + const { ref, guardClick } = useStepDraggable({ moving, onDragChange }); + return ( + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.test.ts b/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.test.ts new file mode 100644 index 0000000000..d03e6b3c42 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "vitest"; +import { + EDGE_LENGTH, + NODE_HEIGHT, + NODE_WIDTH, + layoutChain, + reorderMany, + stepIndexOf, + stepNodeId, +} from "@portal/components/pipelines/graph/pipelineLayout"; + +describe("layoutChain", () => { + test("an empty chain reserves the first step's row for the placeholder", () => { + const { nodes, edges } = layoutChain({ stepCount: 0 }); + expect(nodes.map((n) => n.kind)).toEqual([ + "input", + "placeholder", + "output", + ]); + // The placeholder is the affordance, so neither wire around it offers a plus as well. + expect(edges.map((e) => e.insertIndex)).toEqual([null, null]); + }); + + test("the empty chain is as tall as a one-step chain", () => { + expect(layoutChain({ stepCount: 0 }).height).toBe( + layoutChain({ stepCount: 1 }).height, + ); + }); + + test("steps sit between input and output, in order", () => { + const { nodes } = layoutChain({ stepCount: 3 }); + expect(nodes.map((n) => n.id)).toEqual([ + "input", + "step:0", + "step:1", + "step:2", + "output", + ]); + expect(nodes.map((n) => n.stepIndex)).toEqual([null, 0, 1, 2, null]); + }); + + test("rows are evenly pitched down one column", () => { + const { nodes, width } = layoutChain({ stepCount: 2 }); + expect(nodes.every((n) => n.x === 0)).toBe(true); + const ys = nodes.map((n) => n.y); + const pitch = NODE_HEIGHT + EDGE_LENGTH; + expect(ys).toEqual([0, pitch, pitch * 2, pitch * 3]); + expect(width).toBe(NODE_WIDTH); + }); + + test("the canvas is tall enough for the last node", () => { + const { nodes, height } = layoutChain({ stepCount: 4 }); + const last = nodes[nodes.length - 1]; + expect(height).toBe(last.y + NODE_HEIGHT); + }); + + test("wires span exactly from one node's bottom border to the next node's top", () => { + const { nodes, edges } = layoutChain({ stepCount: 1 }); + expect(edges).toHaveLength(2); + for (const edge of edges) { + expect(edge.x).toBe(NODE_WIDTH / 2); + expect(edge.y2 - edge.y1).toBe(EDGE_LENGTH); + } + expect(edges[0].y1).toBe(nodes[0].y + NODE_HEIGHT); + expect(edges[0].y2).toBe(nodes[1].y); + }); + + test("each wire opens the slot it sits above", () => { + const { edges } = layoutChain({ stepCount: 3 }); + // input->0, 0->1, 1->2, 2->output + expect(edges.map((e) => e.insertIndex)).toEqual([0, 1, 2, 3]); + }); + + test("every wire between real nodes stays open", () => { + // Ordering is the user's to choose: no pairing is refused, however odd it is. + const { edges } = layoutChain({ stepCount: 4 }); + expect(edges.every((e) => e.insertIndex !== null)).toBe(true); + }); +}); + +describe("node ids", () => { + test("step ids round-trip through their index", () => { + expect(stepIndexOf(stepNodeId(7))).toBe(7); + }); + + test("the input and output nodes have no step index", () => { + expect(stepIndexOf("input")).toBeNull(); + expect(stepIndexOf("output")).toBeNull(); + }); +}); + +describe("reorderMany", () => { + test("moving one step below its own place accounts for it lifting out first", () => { + // [a b c], drag a onto the wire above c (slot 2) -> [b a c]. + expect(reorderMany(3, [0], 2)).toEqual([1, 0, 2]); + }); + + test("moving one step above its own place lands on the slot as given", () => { + // [a b c], drag c onto the wire above b (slot 1) -> [a c b]. + expect(reorderMany(3, [2], 1)).toEqual([0, 2, 1]); + }); + + test("the wires either side of a lone step are no-ops", () => { + expect(reorderMany(3, [1], 1)).toBeNull(); + expect(reorderMany(3, [1], 2)).toBeNull(); + }); + + test("moves to either end", () => { + expect(reorderMany(3, [2], 0)).toEqual([2, 0, 1]); + expect(reorderMany(3, [0], 3)).toEqual([1, 2, 0]); + }); + + test("a set of steps lands together, keeping its own order", () => { + // [a b c d], move a+c to the end -> [b d a c]. + expect(reorderMany(4, [0, 2], 4)).toEqual([1, 3, 0, 2]); + }); + + test("a set gathers from apart into one run", () => { + // [a b c d e], move a+e above c (slot 2) -> [b a e c d]. + expect(reorderMany(5, [0, 4], 2)).toEqual([1, 0, 4, 2, 3]); + }); + + test("a contiguous set dropped back where it already is, is a no-op", () => { + expect(reorderMany(4, [1, 2], 1)).toBeNull(); + expect(reorderMany(4, [1, 2], 3)).toBeNull(); + }); + + test("order of the given indices does not matter", () => { + expect(reorderMany(4, [2, 0], 4)).toEqual(reorderMany(4, [0, 2], 4)); + }); + + test("moving every step is a no-op wherever it lands", () => { + expect(reorderMany(3, [0, 1, 2], 0)).toBeNull(); + expect(reorderMany(3, [2, 1, 0], 3)).toBeNull(); + }); + + test("nothing selected moves nothing", () => { + expect(reorderMany(3, [], 1)).toBeNull(); + }); + + test("ignores out-of-range indices rather than injecting undefined steps", () => { + // A stray index (negative or past the end) must be dropped, not carried into the new order. + expect(reorderMany(3, [0, 9], 2)).toEqual([1, 0, 2]); + expect(reorderMany(3, [-1], 2)).toBeNull(); + expect(reorderMany(3, [5], 0)).toBeNull(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.ts b/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.ts new file mode 100644 index 0000000000..10fadfb6f8 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.ts @@ -0,0 +1,184 @@ +/** + * Geometry for the pipeline graph. + * + * A pipeline is a strict sequence - one input, an ordered run of steps, one output - so a node's + * position carries no information that its place in the chain does not already carry. Layout is + * therefore *derived* here on every render rather than owned by the user and persisted: there are + * no stored coordinates to drift, nothing to lock, and nothing to re-tidy. Dragging a node is free + * to mean "move it in the chain" instead of "move it on screen" (see useChainDragDrop). + * + * Everything is a single centred column, which makes each wire a straight vertical line. When the + * model grows past one input/output and a pipeline can branch, x stops being constant and this is + * the module that changes - callers only ever read the result. + */ + +/** + * What a node represents. The chain always has exactly one input and one output. `placeholder` is + * the stand-in shown when a pipeline has no steps yet: it occupies the row the first step will take + * so the chain's shape is visible, and it is the affordance for adding that step. + */ +export type GraphNodeKind = "input" | "step" | "output" | "placeholder"; + +/** Node id: `"input"`, `"output"`, or `"step:"`. Stable for a given chain position. */ +export type GraphNodeId = string; + +export function stepNodeId(index: number): GraphNodeId { + return `step:${index}`; +} + +/** The step index a node id refers to, or null for the input/output nodes. */ +export function stepIndexOf(id: GraphNodeId): number | null { + const match = /^step:(\d+)$/.exec(id); + return match ? Number(match[1]) : null; +} + +export interface LaidOutNode { + id: GraphNodeId; + kind: GraphNodeKind; + /** Position in the chain's step list; null for input/output. */ + stepIndex: number | null; + /** Top-left corner, in canvas coordinates. */ + x: number; + y: number; +} + +export interface LaidOutEdge { + id: string; + from: GraphNodeId; + to: GraphNodeId; + /** + * Where a step dropped on this wire lands in the step list. Null only for the wires either side + * of the placeholder, which is itself the affordance for adding the first step. + */ + insertIndex: number | null; + /** Straight vertical wire, from the upper node's bottom port to the lower node's top port. */ + x: number; + y1: number; + y2: number; +} + +export interface LaidOutChain { + nodes: LaidOutNode[]; + edges: LaidOutEdge[]; + /** Canvas extent, so the scroll container can size itself without measuring. */ + width: number; + height: number; +} + +/** Node box, and the vertical room a wire plus its insert affordance needs between two of them. */ +export const NODE_WIDTH = 260; +export const NODE_HEIGHT = 64; +export const EDGE_LENGTH = 48; + +const ROW_PITCH = NODE_HEIGHT + EDGE_LENGTH; + +export interface LayoutChainOptions { + stepCount: number; +} + +/** + * Lay the chain out top to bottom: input, each step in order, output. Rows are evenly pitched and + * share one x, so wires are vertical and always aligned. + */ +export function layoutChain({ stepCount }: LayoutChainOptions): LaidOutChain { + const nodes: LaidOutNode[] = []; + const row = (index: number) => index * ROW_PITCH; + // An empty pipeline still shows a step row, filled by the placeholder, so the chain reads as + // input -> something -> output rather than as a bare wire. + const rows = Math.max(stepCount, 1); + + nodes.push({ id: "input", kind: "input", stepIndex: null, x: 0, y: row(0) }); + if (stepCount === 0) { + nodes.push({ + id: "placeholder", + kind: "placeholder", + stepIndex: null, + x: 0, + y: row(1), + }); + } + for (let i = 0; i < stepCount; i++) { + nodes.push({ + id: stepNodeId(i), + kind: "step", + stepIndex: i, + x: 0, + y: row(i + 1), + }); + } + nodes.push({ + id: "output", + kind: "output", + stepIndex: null, + x: 0, + y: row(rows + 1), + }); + + const centreX = NODE_WIDTH / 2; + const edges: LaidOutEdge[] = []; + for (let i = 0; i < nodes.length - 1; i++) { + const upper = nodes[i]; + const lower = nodes[i + 1]; + // A wire's insert index is the step slot it sits above: the wire below the input opens slot 0, + // the wire below step i opens slot i+1. A final-only step closes the wire beneath it. + const above = upper.stepIndex; + // The placeholder is itself the "add the first step" affordance, so the wires either side of it + // stay plain - two pluses for the same slot would be a choice with no difference. + const placeholderRow = + upper.kind === "placeholder" || lower.kind === "placeholder"; + const insertIndex = placeholderRow ? null : (above ?? -1) + 1; + edges.push({ + id: `${upper.id}->${lower.id}`, + from: upper.id, + to: lower.id, + insertIndex, + x: centreX, + // Exactly node-bottom to node-top: the wire meets both borders. The arrowhead is kept inside + // this box (see GraphEdge.css) so the node, drawn after it, cannot paint over the tip. + y1: upper.y + NODE_HEIGHT, + y2: lower.y, + }); + } + + return { + nodes, + edges, + width: NODE_WIDTH, + height: row(rows + 1) + NODE_HEIGHT, + }; +} + +/** + * The chain's new order after dropping `moving` on the wire that opens `insertIndex`. + * + * Returns the original step indices in their new positions, or null when the move changes nothing + * (dropping a step on either of its own wires, say). The moved steps land together in the target + * slot, keeping their order relative to each other; the slot is expressed against the *original* + * chain, so lifting the moved steps out first has to be accounted for - which is done by counting + * how many of the steps that stay put sit above the slot. + */ +export function reorderMany( + stepCount: number, + moving: readonly number[], + insertIndex: number, +): number[] | null { + // Range-check: a stray index would survive into `next` and then read as an undefined step, so + // keep only positions that exist in the chain before lifting anything out. + const lifted = [...new Set(moving)] + .filter((i) => i >= 0 && i < stepCount) + .sort((a, b) => a - b); + if (lifted.length === 0) return null; + + const staying: number[] = []; + for (let i = 0; i < stepCount; i++) { + if (!lifted.includes(i)) staying.push(i); + } + + const landing = staying.filter((i) => i < insertIndex).length; + const next = [ + ...staying.slice(0, landing), + ...lifted, + ...staying.slice(landing), + ]; + return next.every((value, i) => value === i) ? null : next; +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.test.ts b/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.test.ts new file mode 100644 index 0000000000..1816f1a0ca --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + createDragClickGuard, + fillDragPreview, +} from "@portal/components/pipelines/graph/useChainDragDrop"; + +/** Stands in for the graph's rendered cards, which the preview clones out of the DOM. */ +function renderCards(labels: string[]) { + document.body.innerHTML = labels + .map( + (label, i) => + `
${label}
`, + ) + .join(""); +} + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("fillDragPreview", () => { + it("stacks a copy of every dragged card, in the order given", () => { + renderCards(["OCR", "Redact", "Compress"]); + const container = document.createElement("div"); + fillDragPreview(container, [0, 2]); + expect([...container.children].map((c) => c.textContent)).toEqual([ + "OCR", + "Compress", + ]); + }); + + it("leaves the originals alone", () => { + renderCards(["OCR", "Redact"]); + fillDragPreview(document.createElement("div"), [0, 1]); + expect(document.querySelectorAll("[data-step-index]")).toHaveLength(2); + }); + + it("copies are solid, not dimmed like the cards they came from", () => { + renderCards(["OCR"]); + const container = document.createElement("div"); + fillDragPreview(container, [0]); + expect(document.querySelector("[data-step-index]")).toHaveClass( + "is-dragging", + ); + expect(container.firstElementChild).not.toHaveClass("is-dragging"); + }); + + it("skips an index with no card rather than throwing", () => { + renderCards(["OCR"]); + const container = document.createElement("div"); + fillDragPreview(container, [0, 7]); + expect(container.children).toHaveLength(1); + }); +}); + +// The drag itself needs native HTML5 drag events, which jsdom does not implement, so the guard's +// state machine is exercised here directly - it is the half that decides whether a click selects. +describe("createDragClickGuard", () => { + it("lets a plain press through", () => { + const guard = createDragClickGuard(); + guard.beginGesture(); + expect(guard.swallowsClick()).toBe(false); + }); + + it("swallows the click that trails a drag", () => { + const guard = createDragClickGuard(); + guard.beginGesture(); + guard.noteDrag(); + expect(guard.swallowsClick()).toBe(true); + }); + + it("still selects on the next press after a drag left no click behind", () => { + // The regression: native drag usually emits no trailing click, so a guard cleared only by + // consuming one stayed raised and ate the user's next real click on that node. + const guard = createDragClickGuard(); + guard.beginGesture(); + guard.noteDrag(); + // ...drop, and no click follows. + guard.beginGesture(); + expect(guard.swallowsClick()).toBe(false); + }); + + it("guards each drag, not just the first", () => { + const guard = createDragClickGuard(); + for (const _ of [1, 2]) { + guard.beginGesture(); + guard.noteDrag(); + expect(guard.swallowsClick()).toBe(true); + } + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.ts b/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.ts new file mode 100644 index 0000000000..f80949d077 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.ts @@ -0,0 +1,230 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + draggable, + dropTargetForElements, +} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview"; +import { preserveOffsetOnSource } from "@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source"; +import { + NODE_WIDTH, + reorderMany, +} from "@portal/components/pipelines/graph/pipelineLayout"; + +/** + * Drag-to-reorder for the pipeline chain. + * + * The chain is a sequence, so a step's meaningful move is "somewhere else in the order" - which + * makes the *wires* the drop targets, not the nodes. Each wire already knows the slot it opens + * (see layoutChain), so a drop is one call to reorderMany with that slot; there is no midpoint + * arithmetic or above/below bookkeeping, and no free coordinates to store. + */ + +const DRAG_TYPE = "pipeline-step"; + +interface StepDragData extends Record { + type: typeof DRAG_TYPE; + /** Every step this drag carries, in chain order - one, or the whole selection. */ + moving: number[]; +} + +function isStepDrag(data: Record): data is StepDragData { + return data.type === DRAG_TYPE && Array.isArray(data.moving); +} + +export interface UseStepDraggableOptions { + /** + * The steps this node's drag should carry: the current selection when this node is part of it, + * otherwise just itself. Resolved by the graph, which is what knows the selection. + */ + moving: number[]; + /** Told when this step's drag starts and ends, so the graph can light up the wires. */ + onDragChange: (dragging: boolean) => void; +} + +export interface UseStepDraggableResult { + ref: React.RefObject; + /** + * Wraps the node's click so the click that can trail a drag does not also select. Native drag + * usually swallows it, but the page editor carries the same guard - cheap insurance. + */ + guardClick: (action: (event: E) => void) => (event: E) => void; +} + +/** + * Tells a click that trails a drag apart from a genuine one. + * + * A gesture begins on pointerdown and may turn into a drag; only a click belonging to a gesture + * that dragged is swallowed. The clearing has to happen when the *next* gesture begins rather than + * when a click is swallowed - native HTML5 drag usually leaves no trailing click at all, so a flag + * cleared only by consuming one stays raised and eats the user's next real click on that node. + */ +export function createDragClickGuard() { + let dragged = false; + return { + /** A new press has started; nothing has dragged yet. */ + beginGesture: () => { + dragged = false; + }, + /** This gesture became a drag. */ + noteDrag: () => { + dragged = true; + }, + /** True if a click arriving now is the tail of a drag rather than a plain press. */ + swallowsClick: () => dragged, + }; +} + +export type DragClickGuard = ReturnType; + +/** + * Stack a copy of every dragged card into the preview container, so a multi-step drag shows what is + * actually moving rather than only the card that was grabbed. Exported for testing; the cards are + * found in the DOM by their chain position. + */ +export function fillDragPreview( + container: HTMLElement, + moving: readonly number[], +): void { + container.className = "portal-graph__drag-preview"; + container.style.width = `${NODE_WIDTH}px`; + for (const index of moving) { + const card = document.querySelector(`[data-step-index="${index}"]`); + if (!card) continue; + const copy = card.cloneNode(true) as HTMLElement; + // The originals dim once the drag starts; the copies are the drag, so they stay solid. + copy.classList.remove("is-dragging"); + container.appendChild(copy); + } +} + +/** Makes one step node draggable, tagged with the chain position it started from. */ +export function useStepDraggable({ + moving, + onDragChange, +}: UseStepDraggableOptions): UseStepDraggableResult { + const ref = useRef(null); + const guardRef = useRef(null); + guardRef.current ??= createDragClickGuard(); + const guard = guardRef.current; + + // Read through refs so a reorder (which renumbers every later step) never re-registers the + // adapter mid-gesture. + const movingRef = useRef(moving); + movingRef.current = moving; + const onDragChangeRef = useRef(onDragChange); + onDragChangeRef.current = onDragChange; + + useEffect(() => { + const element = ref.current; + if (!element) return; + // Any fresh input on the node starts a new gesture and clears the guard, so the only click it + // ever swallows is one trailing that same gesture's drag. Keyboard counts: activating the card + // with Enter or Space produces a click with no pointerdown before it. + const startGesture = () => guard.beginGesture(); + element.addEventListener("pointerdown", startGesture); + element.addEventListener("keydown", startGesture); + const stopDraggable = draggable({ + element, + getInitialData: (): StepDragData => ({ + type: DRAG_TYPE, + moving: movingRef.current, + }), + onGenerateDragPreview: ({ location, nativeSetDragImage }) => { + const moving = movingRef.current; + // One step drags as itself; the browser's own preview of the grabbed card is right. A set + // needs to show what is actually moving, so the preview stacks a copy of every card. + if (moving.length < 2) return; + setCustomNativeDragPreview({ + nativeSetDragImage, + getOffset: preserveOffsetOnSource({ + element, + input: location.current.input, + }), + render: ({ container }) => fillDragPreview(container, moving), + }); + }, + onDragStart: () => { + guard.noteDrag(); + onDragChangeRef.current(true); + }, + onDrop: () => onDragChangeRef.current(false), + }); + return () => { + element.removeEventListener("pointerdown", startGesture); + element.removeEventListener("keydown", startGesture); + stopDraggable(); + }; + }, [guard]); + + const guardClick = useCallback( + (action: (event: E) => void) => + (event: E) => { + if (guard.swallowsClick()) return; + action(event); + }, + [guard], + ); + + return { ref, guardClick }; +} + +export interface UseEdgeDropOptions { + /** The slot this wire opens; null for the wires either side of the empty-chain placeholder. */ + insertIndex: number | null; + stepCount: number; + /** + * Given the chain's new order as original step indices, and the original indices of the steps the + * drag actually carried - so the caller can keep the dragged steps selected rather than guessing + * from the prior selection. + */ + onReorder: (order: number[], moved: readonly number[]) => void; +} + +export interface UseEdgeDropResult { + ref: React.RefObject; + /** A step is hovering this wire and would land here. */ + over: boolean; +} + +/** Makes one wire a drop target that moves the dropped step into the slot the wire opens. */ +export function useEdgeDrop({ + insertIndex, + stepCount, + onReorder, +}: UseEdgeDropOptions): UseEdgeDropResult { + const ref = useRef(null); + const [over, setOver] = useState(false); + + const insertIndexRef = useRef(insertIndex); + insertIndexRef.current = insertIndex; + const stepCountRef = useRef(stepCount); + stepCountRef.current = stepCount; + const onReorderRef = useRef(onReorder); + onReorderRef.current = onReorder; + + useEffect(() => { + const element = ref.current; + if (!element) return; + return dropTargetForElements({ + element, + // A wire with no slot is not a target at all, so a step dragged over it shows no landing spot. + canDrop: ({ source }) => + insertIndexRef.current !== null && isStepDrag(source.data), + onDragEnter: () => setOver(true), + onDragLeave: () => setOver(false), + onDrop: ({ source }) => { + setOver(false); + const slot = insertIndexRef.current; + if (slot === null || !isStepDrag(source.data)) return; + const order = reorderMany( + stepCountRef.current, + source.data.moving, + slot, + ); + if (order !== null) onReorderRef.current(order, source.data.moving); + }, + }); + }, []); + + return { ref, over }; +} diff --git a/frontend/editor/src/portal/mocks/handlers/pipelines.ts b/frontend/editor/src/portal/mocks/handlers/pipelines.ts index f4c085ade7..0158d1e168 100644 --- a/frontend/editor/src/portal/mocks/handlers/pipelines.ts +++ b/frontend/editor/src/portal/mocks/handlers/pipelines.ts @@ -69,6 +69,29 @@ function seedPipelines(): StoredPolicy[] { output: { type: "inline", options: {} }, outputIds: ["src-contracts"], }, + { + // A chain long enough to overflow the builder's graph column, which is where the graph has to + // start scrolling instead of pushing the inspector off the page. + id: "plc-long", + name: "Full document pipeline", + owner: "ops@acme.com", + enabled: true, + inputs: [{ sourceId: "src-claims", trigger: null }], + steps: [ + { operation: "/api/v1/misc/repair", parameters: {} }, + { operation: "/api/v1/misc/ocr-pdf", parameters: {} }, + { operation: "/api/v1/general/rotate-pdf", parameters: {} }, + { operation: "/api/v1/general/crop", parameters: {} }, + { operation: "/api/v1/general/remove-pages", parameters: {} }, + { operation: "/api/v1/misc/add-page-numbers", parameters: {} }, + { operation: "/api/v1/security/add-watermark", parameters: {} }, + { operation: "/api/v1/security/sanitize-pdf", parameters: {} }, + { operation: "/api/v1/misc/flatten", parameters: {} }, + { operation: "/api/v1/misc/compress-pdf", parameters: {} }, + ], + output: { type: "inline", options: {} }, + outputIds: ["src-archive"], + }, { id: "plc-onboarding", name: "Onboarding OCR (paused)", diff --git a/frontend/editor/src/portal/views/PipelineBuilder.css b/frontend/editor/src/portal/views/PipelineBuilder.css index 128e8d4a1b..464d3fad55 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.css +++ b/frontend/editor/src/portal/views/PipelineBuilder.css @@ -1,3 +1,13 @@ +/** + * The builder claims the shell's view rather than lengthening it, so a long chain scrolls *inside* + * the graph while the header and the inspector stay put. A chain grows 112px per step, so it passes + * a typical viewport at around five steps - and if the page scrolled instead, clicking a node near + * the bottom would put the inspector (and Save) off-screen, which is the one interaction this whole + * layout exists to serve. + * + * The shell already makes .portal-shell__view the scroll container, so height here resolves against + * a definite box; capping the columns is what stops that view scrolling at all. + */ .portal-builder { display: flex; flex-direction: column; @@ -5,6 +15,14 @@ padding: 1.5rem; max-width: 84rem; margin: 0 auto; + height: 100%; + min-height: 0; +} + +/* The header and any banners keep their own size; only the grid absorbs (or gives up) space. Left + to the flex default they would all shrink together and squash on a short viewport. */ +.portal-builder > *:not(.portal-builder__grid) { + flex: none; } .portal-builder__loading { @@ -13,254 +31,38 @@ padding: 4rem 0; } -/* Header */ -.portal-builder__head { - display: flex; - align-items: center; - gap: 0.75rem; - flex-wrap: wrap; - padding-bottom: 1rem; - border-bottom: 1px solid var(--c-border-subtle); -} - -.portal-builder__back { - display: inline-flex; - align-items: center; - gap: 0.25rem; - border: none; - background: none; - padding: 0; - font-size: 0.8125rem; - color: var(--c-text-subtle); - cursor: pointer; - white-space: nowrap; -} - -.portal-builder__back:hover { - color: var(--c-text); -} - -.portal-builder__head-main { - flex: 1; - min-width: 12rem; -} - -.portal-builder__head-actions { - display: flex; - align-items: center; - gap: 0.75rem; -} - /* Two-pane layout */ .portal-builder__grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 1.25rem; + /* Takes whatever the header leaves, and pins the row to exactly that. `minmax(0, 1fr)` rather + than the implicit `auto` row is what makes the columns' `max-height: 100%` mean anything: an + auto row is sized BY its tallest item, so a long settings form would size the row to itself and + then resolve its own 100% against it - capping nothing, and clipping the form with no scrollbar. + `align-items: start` still lets a short column hug its content inside the bounded row. */ + grid-template-rows: minmax(0, 1fr); + flex: 1 1 auto; + min-height: 0; align-items: start; } +/* Stacked, the inspector sits below the graph, so there is nothing to hold in view - and capping + here would nest a scroll region inside a scrolling page, which is worse than a long page. Let the + builder grow and hand scrolling back to the shell. */ @media (max-width: 60rem) { + .portal-builder { + height: auto; + } + .portal-builder__grid { grid-template-columns: 1fr; + grid-template-rows: auto; + flex: none; } } -.portal-builder__flow { - display: flex; - flex-direction: column; - gap: 0.625rem; -} - -.portal-builder__section-label { - font-size: 0.6875rem; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--c-text-subtle); - font-weight: 600; -} - -.portal-builder__empty { - font-size: 0.8125rem; - color: var(--c-text-subtle); - margin: 0; - padding: 0.5rem 0; -} - -/* Step cards */ -.portal-builder__steps { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.portal-builder__step { - display: flex; - align-items: center; - gap: 0.5rem; - background: var(--c-surface); - border: 1px solid var(--c-border-subtle); - border-radius: var(--radius-lg); - padding: 0.5rem 0.625rem; - transition: - border-color var(--motion-fast), - background var(--motion-fast); -} - -.portal-builder__step--active { - border-color: var(--c-primary); - background: var(--c-primary-tint); -} - -.portal-builder__step-main { - flex: 1; - min-width: 0; - display: flex; - align-items: center; - gap: 0.625rem; - border: none; - background: none; - padding: 0.25rem; - text-align: left; - cursor: pointer; - color: inherit; -} - -.portal-builder__step-index { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.375rem; - height: 1.375rem; - flex-shrink: 0; - border-radius: 50%; - font-size: 0.6875rem; - font-weight: 600; - background: var(--c-primary-tint); - color: var(--c-primary); -} - -.portal-builder__step--active .portal-builder__step-index { - background: var(--c-primary); - color: #fff; -} - -.portal-builder__step-text { - display: flex; - flex-direction: column; - min-width: 0; -} - -.portal-builder__step-name { - font-size: 0.875rem; - font-weight: 500; - color: var(--c-text); -} - -.portal-builder__step-note { - font-size: 0.6875rem; - color: var(--c-text-subtle); -} - -/* A step that cannot run on what the one before it produces. */ -.portal-builder__step-note--danger { - color: var(--c-danger); -} - -/* A picker entry that cannot run on what the chain currently produces. */ -.portal-pipelines__picker-note { - margin-left: auto; - padding-left: 0.5rem; - font-size: 0.6875rem; - color: var(--c-text-subtle); -} - -.portal-builder__step-actions { - display: flex; - gap: 0.25rem; -} - -.portal-builder__step-actions button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.5rem; - height: 1.5rem; - padding: 0; - border-radius: var(--radius-md); - border: 1px solid var(--c-border); - background: var(--c-surface); - color: var(--c-text-subtle); - cursor: pointer; - transition: - background var(--motion-fast), - color var(--motion-fast); -} - -.portal-builder__step-actions button:hover:not(:disabled) { - background: var(--c-hover); - color: var(--c-text); -} - -.portal-builder__step-actions button:disabled { - opacity: 0.4; - cursor: default; -} - -.portal-builder__add-step { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.375rem; - width: 100%; - padding: 0.625rem; - border: 1px dashed var(--c-border); - border-radius: var(--radius-lg); - background: none; - color: var(--c-text-subtle); - font-size: 0.8125rem; - cursor: pointer; - transition: - border-color var(--motion-fast), - color var(--motion-fast); -} - -.portal-builder__add-step:hover { - border-color: var(--c-primary); - color: var(--c-primary); -} - -/* Pipeline settings (above the operation list) */ -.portal-builder__settings { - display: flex; - flex-direction: column; - gap: 0.75rem; - background: var(--color-bg-subtle); - border: 1px solid var(--c-border-subtle); - border-radius: var(--radius-lg); - padding: 1.125rem; -} - -.portal-builder__settings-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); - gap: 1.25rem; -} - -.portal-builder__settings-col { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -/* Input and destination each span the full settings width so their row has room. */ -.portal-builder__inputs-col { - grid-column: 1 / -1; -} - -/* The input row (source + trigger + optional schedule) and the destination row. */ +/* The input's source dropdown and its edit affordance, in the inspector. */ .portal-builder__input-row { display: flex; flex-wrap: wrap; @@ -273,58 +75,23 @@ min-width: 10rem; } -/* The connect-source button trails to the end of the row. */ -.portal-builder__input-row > button:last-child { - margin-left: auto; -} - -/* Inspector: heading sits outside the card so it aligns with the operations heading. */ -.portal-builder__inspector-col { - position: sticky; - top: 1rem; - display: flex; - flex-direction: column; - gap: 0.625rem; -} - -/* Once the grid stacks (60rem), a sticky inspector would ride over content */ -@media (max-width: 60rem) { - .portal-builder__inspector-col { - position: static; - } -} - -.portal-builder__inspector { - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); - padding: 1.125rem; -} - /* Tool picker */ -.portal-pipelines__picker { - border: 1px solid var(--c-border); - border-radius: var(--radius-lg); - background: var(--c-surface); - overflow: hidden; +/* The picker fills the modal hosting it rather than sitting in a card of its own: same surface, + same radius, so a bordered box in here would frame nothing. Its rows carry the structure, and + they run to the panel's edges - which is what lets the search divider span the full width. */ +.portal-pipelines__picker-modal .sui-modal__body { + padding: 0; } +/* Holds the shared Input, which brings its own border/focus ring; the row just insets it and rules + it off from the list below. */ .portal-pipelines__picker-search { - display: flex; - align-items: center; - padding: 0.5rem 0.75rem; + padding: 0.75rem 0.75rem 0.625rem; border-bottom: 1px solid var(--c-border-subtle); } -.portal-pipelines__picker-search input { - flex: 1; - border: none; - background: none; - padding: 0; - font-size: 0.875rem; - color: var(--c-text); - outline: none; +.portal-pipelines__picker-search .sui-input { + width: 100%; } .portal-pipelines__picker-list { @@ -334,7 +101,7 @@ } .portal-pipelines__picker-group-label { - padding: 0.5rem 0.75rem 0.25rem; + padding: 0.5rem 1.125rem 0.25rem; font-size: 0.6875rem; color: var(--c-text-subtle); } @@ -346,7 +113,7 @@ width: 100%; border: none; background: none; - padding: 0.4375rem 0.75rem; + padding: 0.4375rem 1.125rem; text-align: left; cursor: pointer; color: var(--c-text); @@ -368,63 +135,52 @@ display: block; } +/* Name over its optional note - a two-line item, so the note reads as a sub-line rather than + running straight on from the name. */ +.portal-pipelines__picker-text { + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; +} + .portal-pipelines__picker-name { font-size: 0.8125rem; } +/* Why this tool cannot follow the step before it. Advisory: the item is still pickable, just dimmed + and captioned so the reason is clear without shouting. */ +.portal-pipelines__picker-note { + font-size: 0.6875rem; + color: var(--c-text-muted); + white-space: normal; + line-height: 1.3; +} + +/* Muted via a token, not opacity: opacity on text drops the contrast below the floor. The name + still reads as de-emphasised, and the icon (not text) can take the opacity. */ +.portal-pipelines__picker-item.is-incompatible .portal-pipelines__picker-name { + color: var(--c-text-muted); +} + +.portal-pipelines__picker-item.is-incompatible .portal-pipelines__picker-icon { + opacity: 0.55; +} + .portal-pipelines__picker-empty { - padding: 1rem 0.75rem; + padding: 1rem 1.125rem; font-size: 0.8125rem; color: var(--c-text-subtle); margin: 0; } -/* The back link, step row, add-step affordance, tool-picker item and step - actions are the shared Button/ActionIcon carrying bespoke styling. Re-assert - their original look over the design-system button base (which otherwise - imposes a fixed height, its own padding/border and accent text colour). */ -.portal-builder__back.sui-btn { - height: auto; - min-height: 0; - padding: 0; - font-weight: 400; - font-size: 0.8125rem; - color: var(--c-text-subtle); -} - -.portal-builder__back.sui-btn:hover { - color: var(--c-text); -} - -.portal-builder__step-main.sui-btn { - flex: 1; - height: auto; - min-height: 0; - padding: 0.25rem; - font-weight: 400; - color: inherit; -} - -.portal-builder__add-step.sui-btn { - height: auto; - min-height: 0; - padding: 0.625rem; - border: 1px dashed var(--c-border); - background: none; - font-weight: 400; - font-size: 0.8125rem; - color: var(--c-text-subtle); -} - -.portal-builder__add-step.sui-btn:hover { - border-color: var(--c-primary); - color: var(--c-primary); -} - +/* The tool-picker item is the shared Button carrying bespoke styling, so re-assert its look over + the design-system base (which otherwise imposes a fixed height, its own padding and an accent + text colour). */ .portal-pipelines__picker-item.sui-btn { height: auto; min-height: 0; - padding: 0.4375rem 0.75rem; + padding: 0.4375rem 1.125rem; font-weight: 400; color: var(--c-text); } @@ -433,9 +189,39 @@ background: var(--c-hover); } -.portal-builder__step-actions .sui-ai { - width: 1.5rem; - height: 1.5rem; - min-width: 1.5rem; - min-height: 1.5rem; +/* A quiet way into the chosen source's own settings, beside its dropdown. */ +.portal-builder__input-row .portal-builder__source-edit { + color: var(--c-text-subtle); +} + +.portal-builder__input-row .portal-builder__source-edit:hover:not(:disabled) { + color: var(--c-accent-fg, var(--c-primary)); +} + +/* The input's schedule row, its number field, and a builder-owned muted line. These lived in + Pipelines.css and only rendered because the router bundles both views together; a code-split (or + any Storybook story of the builder alone) left them unstyled. Kept here so the builder is + self-contained. */ +.portal-builder__schedule { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.portal-builder__schedule-count { + width: 5rem; +} + +.portal-builder__muted { + font-size: 0.8125rem; + color: var(--c-text-subtle); + margin: 0; +} + +/* The space-between button row shared by the builder's modal footers. */ +.portal-builder__composer-footer { + display: flex; + justify-content: space-between; + gap: 0.5rem; + width: 100%; } diff --git a/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx b/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx index 95d2987e52..c204721c38 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx @@ -22,10 +22,21 @@ function withRoute(path: string) { const meta: Meta = { title: "Portal/Views/PipelineBuilder", component: PipelineBuilder, - parameters: { layout: "padded" }, - // The builder reads the tool registry (for step labels + settings UIs), so - // it needs this provider to render at all. + parameters: { layout: "fullscreen" }, decorators: [ + // The builder sizes itself against the shell's view - a fixed-height, non-scrolling box - which + // is what lets it cap its columns instead of lengthening the page. Given an auto-height parent + // its `height: 100%` resolves to nothing and the cap silently stops applying, so the story has + // to honour that contract or it reviews a layout the app never renders. + // Matches .portal-shell__view: a definite height with `auto` overflow, so the capped desktop + // layout has something to size against and the stacked layout can still scroll. + (Story) => ( +
+ +
+ ), + // The builder reads the tool registry (for step labels + settings UIs), so + // it needs this provider to render at all. (Story) => ( @@ -45,3 +56,12 @@ export const Default: Story = { export const Edit: Story = { decorators: [withRoute("/processor/pipelines/plc-redaction")], }; + +/** + * A chain taller than the page. The graph column scrolls on its own so the header and the inspector + * stay where they are - if the page scrolled instead, selecting a step near the end of the chain + * would carry its settings off-screen. + */ +export const LongChain: Story = { + decorators: [withRoute("/processor/pipelines/plc-long")], +}; diff --git a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx index e06025889e..24d215a99f 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx @@ -7,6 +7,8 @@ import { } from "@testing-library/react"; import { PortalTestProviders } from "@portal/test/TestQueryProvider"; import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; +import { qk } from "@portal/queries/keys"; import type { Policy, TriggerOutcome } from "@portal/api/pipelines"; import type { SourceView } from "@portal/api/sources"; import type { ToolRegistryCatalog } from "@app/contexts/ToolRegistryContext"; @@ -61,22 +63,63 @@ vi.mock("@portal/api/integrations", () => ({ createIntegration: (...args: unknown[]) => createIntegration(...args), })); -// The destination picker just selects saved sources; stub it to a button that -// picks a fixed source, keeping this suite focused on the builder. +// The destination picker just selects saved sources; stub its three affordances +// (pick, create, edit) to buttons, keeping this suite focused on the builder. vi.mock("@portal/components/pipelines/DestinationPicker", () => ({ DestinationPicker: ({ value, onChange, + onCreateNew, + onEdit, }: { value: string[]; onChange: (ids: string[]) => void; + onCreateNew: () => void; + onEdit: (sourceId: string) => void; }) => ( - + <> + + + + ), })); +// The source modal has its own suite; stub it to the two things the builder +// depends on - the record it was opened on, and the sources-cache invalidation +// that follows a save (which is how a new source reaches the pickers). +vi.mock("@portal/components/sources/SourceModal", () => ({ + SourceModal: ({ + open, + sourceId, + }: { + open: boolean; + sourceId?: string | null; + }) => { + const queryClient = useQueryClient(); + if (!open) return null; + return ( +
+ source-modal:{sourceId || "new"} + +
+ ); + }, +})); + // One editable tool, Compress, so the picker and step settings have something to render. vi.mock("@app/contexts/ToolRegistryContext", () => { const compress = { @@ -129,9 +172,41 @@ vi.mock("@app/contexts/ToolRegistryContext", () => { fromApiParams: (params: Record) => ({ ...params }), }, } as unknown as ToolRegistryEntry; + // A tool that will not run on its defaults: its validateParams is the same predicate its own Run + // button uses, so a step for it is "unconfigured" until a language is chosen. + const ocr = { + name: "OCR", + icon: null, + component: null, + description: "", + categoryId: "recommendedTools", + subcategoryId: "general", + automationSettings: (props: { + onParameterChange: (key: string, value: unknown) => void; + }) => ( + + ), + operationConfig: { + operationType: "ocr", + toolType: 0, + endpoint: "/api/v1/misc/ocr-pdf", + defaultParameters: { languages: [] }, + validateParams: (params: { languages?: string[] }) => + (params.languages ?? []).length > 0, + buildFormData: () => new FormData(), + toApiParams: (params: Record) => ({ ...params }), + fromApiParams: (params: Record) => ({ ...params }), + }, + } as unknown as ToolRegistryEntry; const allTools = { compress, extractImages, + ocr, } as unknown as ToolRegistryCatalog["allTools"]; const catalog: ToolRegistryCatalog = { regularTools: allTools, @@ -215,8 +290,44 @@ describe("PipelineBuilder", () => { createIntegration.mockReset(); }); - // Choose the given source in the (pre-seeded) input row's dropdown. + // The settings of a node are reached by selecting it in the graph, so every helper below opens + // its node first. Nodes are found by position rather than by label, because a node's title is + // its current value - it changes as the pipeline is filled in. + + /** The graph's selectable nodes, in chain order: input, each step, output. */ + function graphNodes(): HTMLElement[] { + return screen + .getAllByRole("button") + .filter((b) => b.hasAttribute("aria-pressed")); + } + + /** + * The control that opens an end of the chain, once the graph has rendered. A new pipeline has not + * placed its ends yet, so that control is the "add" placeholder and clicking it both puts the node + * on the chain and selects it; a loaded pipeline already has the node, so it is a plain select. + */ + function endOpener(end: "input" | "output"): Promise { + return waitFor(() => { + const placeholder = screen.queryByText( + `portal.pipelines.graph.add.${end}`, + ); + if (placeholder) return placeholder; + const nodes = graphNodes(); + if (nodes.length === 0) throw new Error("the graph has not rendered yet"); + return end === "input" ? nodes[0] : nodes[nodes.length - 1]; + }); + } + + async function openInput() { + fireEvent.click(await endOpener("input")); + } + + async function openOutput() { + fireEvent.click(await endOpener("output")); + } + async function pickInputSource(sourceName: string) { + await openInput(); fireEvent.click( await screen.findByRole("textbox", { name: "portal.pipelines.builder.inputSource", @@ -225,24 +336,147 @@ describe("PipelineBuilder", () => { fireEvent.click(await screen.findByText(sourceName)); } - it("always shows exactly one input row, with no add or remove controls", async () => { + /** + * Add a tool. An empty chain offers the placeholder; once it has steps, the wires carry the + * inserts instead. + */ + async function addTool(toolName: string) { + const placeholder = screen.queryByText( + "portal.pipelines.graph.addFirstTool", + ); + if (placeholder) { + fireEvent.click(placeholder); + } else { + // The LAST wire, so repeated calls append. Taking the first would insert each new tool ahead + // of the ones already there, silently reversing the order a caller asked for. + const inserts = screen.getAllByLabelText( + "portal.pipelines.graph.insertHere", + ); + fireEvent.click(inserts[inserts.length - 1]); + } + fireEvent.click(await screen.findByText(toolName)); + } + + async function pickDestination() { + await openOutput(); + fireEvent.click(await screen.findByText("pick output")); + } + + /** Open the header's overflow tray. */ + async function openTray() { + fireEvent.click( + await screen.findByLabelText("portal.pipelines.builder.moreActions"), + ); + } + + it("greets a new pipeline with places to fill, not problems to fix", async () => { renderBuilder("/processor/pipelines/new"); - // The input row is a fixed part of the form: its source dropdown is present from the - // start, and there is nothing to add or remove. + // Both ends offer to be added rather than complaining about being empty: the user has not been + // asked for a source or a destination yet, so there is nothing yet to warn them about. expect( - await screen.findAllByRole("textbox", { + await screen.findByText("portal.pipelines.graph.add.input"), + ).toBeInTheDocument(); + expect( + screen.getByText("portal.pipelines.graph.add.output"), + ).toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.builder.needsSource"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.builder.needsDestination"), + ).not.toBeInTheDocument(); + // Nothing is on the chain, so there is nothing to remove either. + expect( + screen.queryByLabelText(/portal.pipelines.graph.removeNode/), + ).not.toBeInTheDocument(); + }); + + it("warns on a step whose tool cannot run on its defaults", async () => { + renderBuilder("/processor/pipelines/new"); + await addTool("OCR"); + + // The tool declares its own mandatory parameters, so the node says so without the builder + // knowing anything about OCR. + expect( + await screen.findByText("portal.pipelines.builder.needsConfiguring"), + ).toBeInTheDocument(); + expect( + screen.getByText("portal.pipelines.composer.create").closest("button"), + ).toBeDisabled(); + }); + + it("clears the warning once the step is configured", async () => { + renderBuilder("/processor/pipelines/new"); + await addTool("OCR"); + await screen.findByText("portal.pipelines.builder.needsConfiguring"); + + // Adding a step selects it, so its settings are already open. + fireEvent.click(screen.getByText("pick language")); + + await waitFor(() => + expect( + screen.queryByText("portal.pipelines.builder.needsConfiguring"), + ).not.toBeInTheDocument(), + ); + }); + + it("leaves a tool that runs happily on its defaults unwarned", async () => { + renderBuilder("/processor/pipelines/new"); + await addTool("Compress"); + + expect( + await screen.findByText("portal.pipelines.graph.add.input"), + ).toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.builder.needsConfiguring"), + ).not.toBeInTheDocument(); + }); + + it("only asks for a source once the user has asked for the node", async () => { + renderBuilder("/processor/pipelines/new"); + await openInput(); + + // Placing the node is what turns it into an outstanding choice. + expect( + await screen.findByText("portal.pipelines.builder.needsSource"), + ).toBeInTheDocument(); + // Still nothing chosen, so the pipeline cannot be saved. + expect( + screen.getByText("portal.pipelines.composer.create").closest("button"), + ).toBeDisabled(); + }); + + it("puts an end back to a placeholder when it is removed", async () => { + renderBuilder("/processor/pipelines/new"); + await openInput(); + await screen.findByText("portal.pipelines.builder.needsSource"); + + fireEvent.click(screen.getByLabelText("portal.pipelines.graph.removeNode")); + + expect( + await screen.findByText("portal.pipelines.graph.add.input"), + ).toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.builder.needsSource"), + ).not.toBeInTheDocument(); + }); + + it("edits a node's settings only once it is selected", async () => { + renderBuilder("/processor/pipelines/new"); + await screen.findByText("portal.pipelines.graph.add.input"); + + // Nothing selected: the inspector says so rather than showing a form. + expect( + screen.getByText("portal.pipelines.inspector.noSelectionTitle"), + ).toBeInTheDocument(); + + await openInput(); + expect( + screen.getByRole("textbox", { name: "portal.pipelines.builder.inputSource", }), - ).toHaveLength(1); - expect( - screen.queryByText("portal.pipelines.builder.addInput"), - ).not.toBeInTheDocument(); - expect( - screen.queryByRole("button", { - name: "portal.pipelines.builder.removeInput", - }), - ).not.toBeInTheDocument(); + ).toBeInTheDocument(); }); it("builds a new pipeline: name it, add a tool, an input, a destination, and save", async () => { @@ -257,12 +491,11 @@ describe("PipelineBuilder", () => { }, ); - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click(await screen.findByText("Compress")); + await addTool("Compress"); // A pipeline must have at least one input source and one output destination. await pickInputSource("Claims intake"); - fireEvent.click(screen.getByText("pick output")); + await pickDestination(); fireEvent.click(screen.getByText("portal.pipelines.composer.create")); @@ -291,13 +524,11 @@ describe("PipelineBuilder", () => { { target: { value: "Broken chain" } }, ); await pickInputSource("Claims intake"); - fireEvent.click(screen.getByText("pick output")); + await pickDestination(); // Extract images emits images; compress only takes a PDF, so it can never run. - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click(await screen.findByText("Extract images")); - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click(await screen.findByText("Compress")); + await addTool("Extract images"); + await addTool("Compress"); expect( await screen.findByText("portal.pipelines.builder.stepsIncompatible"), @@ -307,6 +538,21 @@ describe("PipelineBuilder", () => { ).toBeDisabled(); }); + it("says why on the wire arriving at the step that cannot run", async () => { + renderBuilder("/processor/pipelines/new"); + await pickInputSource("Claims intake"); + await pickDestination(); + + await addTool("Extract images"); + await addTool("Compress"); + + // The banner names which steps are at fault; the wire explains what is wrong where it happens. + const note = await screen.findByText( + /portal\.pipelines\.builder\.diagnostic\./, + ); + expect(note.closest(".portal-graph-edge")).toHaveClass("is-blocking"); + }); + it("allows a chain whose steps line up", async () => { renderBuilder("/processor/pipelines/new"); @@ -317,10 +563,9 @@ describe("PipelineBuilder", () => { { target: { value: "Fine chain" } }, ); await pickInputSource("Claims intake"); - fireEvent.click(screen.getByText("pick output")); + await pickDestination(); - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click(await screen.findByText("Compress")); + await addTool("Compress"); expect( screen.queryByText("portal.pipelines.builder.stepsIncompatible"), @@ -352,7 +597,7 @@ describe("PipelineBuilder", () => { expect(saveButton()).toBeDisabled(); // Both chosen: allowed, and both are sent. - fireEvent.click(screen.getByText("pick output")); + await pickDestination(); fireEvent.click(screen.getByText("portal.pipelines.composer.create")); await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1)); expect(savePipeline).toHaveBeenCalledWith( @@ -363,6 +608,130 @@ describe("PipelineBuilder", () => { ); }); + it("creates and edits sources in place through the modal", async () => { + renderBuilder("/processor/pipelines/new"); + await pickInputSource("Claims intake"); + + // Connect source opens the modal in create mode, without leaving the + // builder (and its unsaved edits) for the Sources page. + fireEvent.click(screen.getByText("portal.sources.actions.connectSource")); + expect(screen.getByText("source-modal:new")).toBeInTheDocument(); + expect(screen.queryByText("pipelines list")).not.toBeInTheDocument(); + + // The pencil beside the input opens the same modal on the chosen source. + fireEvent.click( + screen.getByLabelText("portal.pipelines.composer.editSource"), + ); + expect(screen.getByText("source-modal:src-in")).toBeInTheDocument(); + }); + + it("cannot edit an input source before one is chosen", async () => { + renderBuilder("/processor/pipelines/new"); + await openInput(); + + expect( + screen.getByLabelText("portal.pipelines.composer.editSource"), + ).toBeDisabled(); + await pickInputSource("Claims intake"); + expect( + screen.getByLabelText("portal.pipelines.composer.editSource"), + ).not.toBeDisabled(); + }); + + it("makes a source created from the input row the pipeline's input", async () => { + fetchSources + .mockResolvedValueOnce({ kpis: [], sources: [SOURCE] }) + .mockResolvedValue({ + kpis: [], + sources: [SOURCE, { ...SOURCE, id: "src-new", name: "Scanner drop" }], + }); + renderBuilder("/processor/pipelines/new"); + await openInput(); + + fireEvent.click(screen.getByText("portal.sources.actions.connectSource")); + fireEvent.click(screen.getByText("source saved")); + + // The new source is the one the pipeline was missing, so it becomes the input. + await waitFor(() => + expect( + screen.getByRole("textbox", { + name: "portal.pipelines.builder.inputSource", + }), + ).toHaveValue("Scanner drop"), + ); + }); + + it("makes a destination created from the picker the pipeline's output", async () => { + fetchSources + .mockResolvedValueOnce({ kpis: [], sources: [SOURCE] }) + .mockResolvedValue({ + kpis: [], + sources: [ + SOURCE, + { ...SOURCE, id: "src-new", name: "Archive bucket", type: "s3" }, + ], + }); + renderBuilder("/processor/pipelines/new"); + await openInput(); + openOutput(); + await screen.findByText("pick output"); + + fireEvent.click(screen.getByText("new destination")); + fireEvent.click(screen.getByText("source saved")); + + // Created from the destination picker, so it lands in the output rather + // than the input. + await waitFor(() => + expect(screen.getByText("output:src-new")).toBeInTheDocument(), + ); + // The input was left alone: its node still shows the prompt. + expect( + screen.getByText("portal.pipelines.builder.chooseSource"), + ).toBeInTheDocument(); + }); + + it("leaves a new source that cannot be written to out of the destination", async () => { + // A webhook can be read from but not written to, so it must not be picked + // as a destination the dropdown has no option for. + fetchSources + .mockResolvedValueOnce({ kpis: [], sources: [SOURCE] }) + .mockResolvedValue({ + kpis: [], + sources: [ + SOURCE, + { ...SOURCE, id: "src-hook", name: "Partner hook", type: "webhook" }, + ], + }); + renderBuilder("/processor/pipelines/new"); + await openInput(); + openOutput(); + await screen.findByText("pick output"); + + fireEvent.click(screen.getByText("new destination")); + fireEvent.click(screen.getByText("source saved")); + + // It was not made the destination... + await waitFor(() => expect(fetchSources).toHaveBeenCalledTimes(2)); + expect(screen.getByText("pick output")).toBeInTheDocument(); + + // ...but it did arrive, and is offered as an input, where a webhook makes sense. + await openInput(); + fireEvent.click( + screen.getByRole("textbox", { + name: "portal.pipelines.builder.inputSource", + }), + ); + expect(await screen.findByText("Partner hook")).toBeInTheDocument(); + }); + + it("edits the chosen destination through the same modal", async () => { + renderBuilder("/processor/pipelines/new"); + await pickDestination(); + + fireEvent.click(screen.getByText("edit destination")); + expect(screen.getByText("source-modal:src-1")).toBeInTheDocument(); + }); + it("runs an existing pipeline and reports success", async () => { renderBuilder("/processor/pipelines/plc-1"); @@ -401,9 +770,8 @@ describe("PipelineBuilder", () => { it("clears processed history from the header and confirms", async () => { renderBuilder("/processor/pipelines/plc-1"); - fireEvent.click( - await screen.findByText("portal.pipelines.detail.clearHistory"), - ); + await openTray(); + fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory")); await waitFor(() => expect(clearProcessedHistory).toHaveBeenCalledWith("plc-1"), @@ -424,8 +792,7 @@ describe("PipelineBuilder", () => { target: { value: "Watermarked" }, }, ); - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click(await screen.findByText("Compress")); + await addTool("Compress"); // The tool's settings upload a file, which a stored pipeline can't persist yet. fireEvent.click(await screen.findByText("upload logo")); @@ -450,10 +817,7 @@ describe("PipelineBuilder", () => { target: { value: "Notify only" }, }, ); - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click( - await screen.findByText("portal.policies.operations.discordNotify.label"), - ); + await addTool("portal.policies.operations.discordNotify.label"); // Operation chosen, account not: still not saveable. expect( @@ -516,10 +880,7 @@ describe("PipelineBuilder", () => { }, ); - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click( - await screen.findByText("portal.policies.operations.discordNotify.label"), - ); + await addTool("portal.policies.operations.discordNotify.label"); fireEvent.click( await screen.findByPlaceholderText( @@ -530,7 +891,7 @@ describe("PipelineBuilder", () => { // Saving needs the input's source and a destination. await pickInputSource("Claims intake"); - fireEvent.click(screen.getByText("pick output")); + await pickDestination(); fireEvent.click(screen.getByText("portal.pipelines.composer.create")); diff --git a/frontend/editor/src/portal/views/PipelineBuilder.tsx b/frontend/editor/src/portal/views/PipelineBuilder.tsx index d788fc4f35..ecc249d88e 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.tsx @@ -1,19 +1,15 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; -import KeyboardArrowUpRoundedIcon from "@mui/icons-material/KeyboardArrowUpRounded"; -import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded"; -import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; -import HistoryRoundedIcon from "@mui/icons-material/HistoryRounded"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; -import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded"; +import MoveToInboxRoundedIcon from "@mui/icons-material/MoveToInboxRounded"; +import SendRoundedIcon from "@mui/icons-material/SendRounded"; import { ActionIcon, Banner, Button, - Checkbox, - EmptyState, + FormField, Input, Modal, Select, @@ -47,11 +43,14 @@ import { deletePipeline, fetchPipeline, fetchRun, + fetchRunOutput, fetchTriggers, + runPipelineTest, savePipeline, triggerPipeline, type Policy, type PolicyRunView, + type RunOutputFile, type TriggerConfig, type TriggerInfo, type TriggerOutcome, @@ -61,14 +60,27 @@ import { DestinationPicker } from "@portal/components/pipelines/DestinationPicke import { availableOutputModes } from "@portal/components/pipelines/outputModes"; import { type SourceView } from "@portal/api/sources"; import { useSources } from "@portal/queries/sources"; +import { SourceModal } from "@portal/components/sources/SourceModal"; import { EDITOR_SOURCE_TYPE } from "@portal/components/sources/sourceTypes"; import { useAsync } from "@portal/hooks/useAsync"; import { useQueryClient } from "@tanstack/react-query"; import { qk } from "@portal/queries/keys"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations"; +import { PipelineHeader } from "@portal/components/pipelines/PipelineHeader"; +import { PipelineInspector } from "@portal/components/pipelines/PipelineInspector"; +import { PipelineDefinitionModal } from "@portal/components/pipelines/PipelineDefinitionModal"; +import { + PipelineGraph, + selectedSteps, + type ChainEnd, + type ChainWarning, + type GraphSelection, + type GraphStepContent, +} from "@portal/components/pipelines/graph/PipelineGraph"; import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings"; import { ToolPicker } from "@portal/components/pipelines/ToolPicker"; +import { BrandMark } from "@portal/components/BrandMarks"; import { STEP_OPERATIONS } from "@portal/components/policies/stepOperations"; import { integrationStepConfigured, @@ -158,6 +170,11 @@ function buildTriggerFor(input: WorkingInput): TriggerConfig | null { return { type: input.triggerType, options: {} }; } +/** Whether a source can be written to, i.e. offered as a pipeline destination. */ +function isWritableSource(source: SourceView): boolean { + return (availableOutputModes() as string[]).includes(source.type); +} + /** * Full-page pipeline builder (route: /pipelines/new and /pipelines/:id). Pipeline-level settings * (sources, trigger, output) sit above the operation list; the operation list and the selected @@ -206,10 +223,7 @@ export function PipelineBuilder() { // A destination is a source used as a write target: only writable types (folder/S3, filtered per // deployment) can be picked, and the virtual editor is already excluded from availableSources. const writableSources = useMemo( - () => - availableSources.filter((source) => - (availableOutputModes() as string[]).includes(source.type), - ), + () => availableSources.filter(isWritableSource), [availableSources], ); const triggers = useMemo( @@ -223,9 +237,23 @@ export function PipelineBuilder() { // wire shape stays a list (see save()). const [input, setInput] = useState(blankInput); const [steps, setSteps] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(null); - const [pickerOpen, setPickerOpen] = useState(false); + /** Which node the inspector is editing: an end of the chain, a step, or nothing. */ + const [selected, setSelected] = useState(null); + /** Slot the tool picker will insert into, or null when it is closed. */ + const [pickerAt, setPickerAt] = useState(null); + const [definitionOpen, setDefinitionOpen] = useState(false); + /** The last test run in this session: one file through the steps as they stand. */ + const [testRun, setTestRun] = useState(null); + const [testing, setTesting] = useState(false); const [outputIds, setOutputIds] = useState([]); + /** + * Whether the user has asked for each end of the chain yet, distinguishing "not offered" from + * "offered and still owed a choice" - the two states an empty sourceId cannot tell apart. Only a + * brand new pipeline starts with either false; anything loaded arrives with both ends set, and + * choosing one places it, so these are just the "clicked add, chosen nothing" window. + */ + const [inputAsked, setInputAsked] = useState(false); + const [outputAsked, setOutputAsked] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [seeded, setSeeded] = useState(false); @@ -236,6 +264,39 @@ export function PipelineBuilder() { const [deleting, setDeleting] = useState(false); const [pendingNav, setPendingNav] = useState(null); + // Create or edit a source in place, instead of leaving the builder (and its + // unsaved edits) for the Sources page. + const [sourceModal, setSourceModal] = useState<{ + open: boolean; + sourceId: string | null; + }>({ open: false, sourceId: null }); + // A source created from here is the one the pipeline was missing, so select it + // on arrival - as the input or the destination, whichever asked for it. + const autoSelectRef = useRef<"input" | "output" | null>(null); + const knownSourceIdsRef = useRef>(new Set()); + useEffect(() => { + const target = autoSelectRef.current; + const known = knownSourceIdsRef.current; + knownSourceIdsRef.current = new Set(availableSources.map((s) => s.id)); + if (!target) return; + const fresh = availableSources.find((s) => !known.has(s.id)); + if (!fresh) return; + // One arrival answers the request, whatever type it turned out to be. + autoSelectRef.current = null; + if (target === "input") { + changeInputSource(fresh.id); + } else if (isWritableSource(fresh)) { + // A source of an unwritable type is left alone rather than becoming a + // destination the picker has no option for. + setOutputIds([fresh.id]); + } + }, [availableSources]); + + function createSourceFor(target: "input" | "output") { + autoSelectRef.current = target; + setSourceModal({ open: true, sourceId: null }); + } + const mounted = useRef(true); useEffect(() => { mounted.current = true; @@ -273,14 +334,6 @@ export function PipelineBuilder() { setSeeded(true); }, [isEdit, policyState.data, allTools, seeded]); - // Keep one tool's settings open: auto-select the first step whenever a pipeline has steps but - // nothing is selected (initial load, or after the selected step is removed). - useEffect(() => { - if (seeded && selectedIndex === null && steps.length > 0) { - setSelectedIndex(0); - } - }, [seeded, selectedIndex, steps.length]); - const sourceType = (sourceId: string) => availableSources.find((s) => s.id === sourceId)?.type; @@ -339,38 +392,65 @@ export function PipelineBuilder() { }); } - function addOperationStep(op: (typeof STEP_OPERATIONS)[number]) { + /** Put an end on the chain and open it, so the click that asks for it also offers the choice. */ + function addEnd(end: ChainEnd) { + if (end === "input") setInputAsked(true); + else setOutputAsked(true); + setSelected(end); + } + + /** Take an end back off, discarding whatever it held so its row reads as unfilled again. */ + function removeEnd(end: ChainEnd) { + if (end === "input") { + setInputAsked(false); + setInput(blankInput()); + } else { + setOutputAsked(false); + setOutputIds([]); + } + setSelected((current) => (current === end ? null : current)); + } + + /** Drop a new step into the slot the picker was opened on, and select it to be configured. */ + function insertStep(step: WorkingToolStep) { + const at = pickerAt ?? steps.length; setSteps((current) => { - const next = [...current, newIntegrationStep(op)]; - setSelectedIndex(next.length - 1); + const next = [...current]; + next.splice(at, 0, step); return next; }); - setPickerOpen(false); + setSelected({ steps: [at] }); + setPickerAt(null); + } + + function addOperationStep(op: (typeof STEP_OPERATIONS)[number]) { + insertStep(newIntegrationStep(op)); } function addStep(tool: ExecutableTool) { - setSteps((current) => { - const next = [...current, newWorkingToolStep(tool, allTools)]; - setSelectedIndex(next.length - 1); - return next; - }); - setPickerOpen(false); + insertStep(newWorkingToolStep(tool, allTools)); } - function removeStep(index: number) { - setSelectedIndex(null); - setSteps((current) => current.filter((_, i) => i !== index)); + function removeSteps(indices: number[]) { + const gone = new Set(indices); + setSelected(null); + setSteps((current) => current.filter((_, i) => !gone.has(i))); } - function moveStep(index: number, delta: number) { - setSteps((current) => { - const target = index + delta; - if (target < 0 || target >= current.length) return current; - const next = [...current]; - [next[index], next[target]] = [next[target], next[index]]; - return next; - }); - setSelectedIndex((cur) => (cur === index ? index + delta : cur)); + /** + * Apply a reordered chain, given as the original step indices in their new positions. The steps + * the drag carried stay selected where they land, so a set can be dragged again without re-picking + * it - and dragging an unselected step selects it, rather than leaving the inspector on whatever + * was selected before. + */ + function reorderSteps(order: number[], moved: readonly number[]) { + const moving = new Set(moved); + setSteps((current) => order.map((i) => current[i])); + const landed = order + .map((original, position) => ({ original, position })) + .filter(({ original }) => moving.has(original)) + .map(({ position }) => position); + setSelected(landed.length > 0 ? { steps: landed } : null); } function updateStepParams(index: number, params: ErasedToolParams) { @@ -396,6 +476,21 @@ export function PipelineBuilder() { return entry?.name ?? humanizeOperation(step.operation); } + /** + * A step's glyph, matching how the tool picker draws it: an integration step carries its vendor's + * mark, a tool step its own icon. Without this every node falls back to the generic slider glyph, + * so a chain reads as a stack of identical cards. + */ + function stepIcon(step: WorkingToolStep): ReactNode { + const op = stepOperation(step); + if (op) + return ( + + ); + if (isIntegrationStep(step)) return ; + return step.toolId ? allTools[step.toolId]?.icon : undefined; + } + // Steps whose params carry an uploaded file can't be saved: the bytes aren't persisted with the // policy, so a later run would send null for that field (see stepRequiresUpload). const uploadStepLabels = steps.filter(stepRequiresUpload).map(stepLabel); @@ -431,16 +526,22 @@ export function PipelineBuilder() { .map((d) => stepLabel(steps[d.stepIndex])); const hasIncompatibleSteps = hasBlockingDiagnostics(chainDiagnostics); - // What a newly added step would be handed, so the picker can flag tools that cannot take it. - const chainOutput = useMemo( + /** + * What a step added at the open slot would be handed, so the picker can flag tools that cannot + * take it. Scoped to the steps *before* that slot rather than the whole chain: the graph inserts + * anywhere, so what precedes the new step is not necessarily the chain's final output. + */ + const precedingOutput = useMemo( () => - chainOutputFormat( - steps.map((step) => ({ - operation: step.operation, - parameters: step.params, - })), - ), - [steps], + pickerAt === null + ? undefined + : chainOutputFormat( + steps.slice(0, pickerAt).map((step) => ({ + operation: step.operation, + parameters: step.params, + })), + ), + [steps, pickerAt], ); function diagnosticNote(diagnostic: ToolDiagnostic): string { @@ -451,26 +552,21 @@ export function PipelineBuilder() { }); } - /** The most severe diagnostic for a step, rendered as its note. */ - function renderStepDiagnostic(index: number) { + /** + * The step's most severe diagnostic, for the wire arriving at it - which is where a note about + * what the step is being handed belongs, rather than on the step itself. + */ + function stepInputWarning(index: number): ChainWarning | undefined { const forStep = diagnosticsForStep(chainDiagnostics, index); const diagnostic = forStep.find((d) => d.severity === "ERROR") ?? forStep.find((d) => d.severity === "WARN") ?? forStep[0]; - if (!diagnostic) return null; - return ( - - {diagnosticNote(diagnostic)} - - ); + if (!diagnostic) return undefined; + return { + text: diagnosticNote(diagnostic), + blocking: diagnostic.severity === "ERROR", + }; } // Track unsaved edits: snapshot the form and compare against the state captured just after @@ -505,7 +601,6 @@ export function PipelineBuilder() { !submitting; const listPath = toPortalPath(VIEW_PATHS.pipelines); - const sourcesPath = `${toPortalPath(VIEW_PATHS.sources)}/new`; function close() { navigate(listPath); @@ -517,12 +612,6 @@ export function PipelineBuilder() { else navigate(destination); } - // Jump to the source builder, for when the source you want to read from or write to doesn't - // exist yet. Inputs and the output destination are both saved sources, so both create one here. - function goToSources() { - attemptLeave(sourcesPath); - } - async function save(destination: string) { if (!canSave) return; setSubmitting(true); @@ -550,16 +639,69 @@ export function PipelineBuilder() { } // Poll a run until it reaches a terminal state (or we give up), so a failure surfaces. - async function awaitRun(runId: string): Promise { + async function awaitRun( + runId: string, + onProgress?: (view: PolicyRunView) => void, + ): Promise { for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) { if (!mounted.current) return null; const view = await fetchRun(runId); + onProgress?.(view); if (TERMINAL_STATUSES.has(view.status)) return view; await sleep(POLL_INTERVAL_MS); } return null; } + /** + * Run the steps as they stand against one uploaded file. Output is forced inline so nothing + * reaches the pipeline's real destination, and the pipeline need not be saved first - this is + * how the chain gets checked while it is still being built. + */ + async function handleTest(file: File) { + if (testing) return; + setTesting(true); + setTestRun(null); + setRunResult(null); + try { + const { runId } = await runPipelineTest( + { + name: name.trim() || t("portal.pipelines.builder.testRun"), + steps: steps.map((step) => serializeToolStep(step, allTools)), + output: { type: "inline", options: {} }, + }, + file, + ); + const final = await awaitRun(runId, (view) => { + if (mounted.current) setTestRun(view); + }); + if (mounted.current && final) setTestRun(final); + } catch (e) { + if (mounted.current) + setRunResult({ tone: "danger", text: errorMessage(e) }); + } finally { + if (mounted.current) setTesting(false); + } + } + + /** Save one of a test run's outputs to disk. */ + async function downloadOutput(output: RunOutputFile) { + try { + const blob = await fetchRunOutput(output.fileId); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = output.fileName ?? output.fileId; + link.click(); + // Revoke on the next tick: some browsers have not yet begun reading the + // blob when click() returns, and revoking now would cancel the download. + setTimeout(() => URL.revokeObjectURL(url), 0); + } catch (e) { + if (mounted.current) + setRunResult({ tone: "danger", text: errorMessage(e) }); + } + } + /** Explain an empty trigger: parked files outrank blander reasons. */ function emptySweepResult(outcome: TriggerOutcome): RunResult { if (outcome.parked > 0) { @@ -669,95 +811,257 @@ export function PipelineBuilder() { ); } + const chosenSteps = selectedSteps(selected); + // One step selected means its settings; several means there is no single thing to configure. const selectedStep = - selectedIndex !== null ? (steps[selectedIndex] ?? null) : null; + chosenSteps.length === 1 ? (steps[chosenSteps[0]] ?? null) : null; - return ( -
-
- -
- setName(e.target.value)} - /> -
-
- setEnabled(e.target.checked)} - label={t("portal.pipelines.builder.enabled")} - /> - {isEdit && ( + const chosenSource = availableSources.find((s) => s.id === input.sourceId); + const chosenDestination = writableSources.find((s) => s.id === outputIds[0]); + + /** How this input fires, in a few words, for the input node's summary line. */ + function triggerSummary(): string { + if (input.triggerType === MANUAL) + return t("portal.pipelines.composer.triggerManual"); + if (input.triggerType === "schedule") + // One counted phrase per unit, so it reads "Runs every hour" / "Runs every 3 hours" rather + // than the ungrammatical, untranslatable "Run every 1 hours". + return t( + `portal.pipelines.composer.runsEvery.${input.scheduleUnit.toLowerCase()}`, + { count: Number(input.scheduleCount) || 1 }, + ); + return t(`portal.pipelines.trigger.${input.triggerType}`, { + defaultValue: input.triggerType, + }); + } + + /** Why a step cannot be saved yet, if anything. */ + function stepWarning(step: WorkingToolStep): string | undefined { + if (isIntegrationStep(step)) { + if (!stepOperation(step)) + return t("portal.pipelines.builder.chooseOperation"); + if (!integrationStepConfigured(step)) + return t("portal.pipelines.builder.chooseAccount"); + return undefined; + } + if (stepRequiresUpload(step)) + return t("portal.pipelines.builder.needsUpload"); + if (stepNeedsConfiguring(step, allTools)) + return t("portal.pipelines.builder.needsConfiguring"); + return undefined; + } + + /** A step's one-line summary: what it will do beyond its name. */ + function stepDetail(step: WorkingToolStep): string | undefined { + if (step.support === "unsupported") + return t("portal.pipelines.builder.usesDefaults"); + if (step.support === "unknown") + return t("portal.pipelines.builder.unknownStep"); + return undefined; + } + + // A run reports one step cursor, so progress reads off it: everything before the cursor is done, + // the cursor itself is whatever the run currently is. + function stepRunState(index: number): GraphStepContent["runState"] { + if (!testRun) return undefined; + if (index < testRun.currentStep) return "done"; + if (index > testRun.currentStep) return undefined; + if (testRun.status === "FAILED") return "failed"; + if (testRun.status === "COMPLETED") return "done"; + return "running"; + } + + const graphSteps: GraphStepContent[] = steps.map((step, i) => ({ + label: stepLabel(step), + detail: stepDetail(step), + icon: stepIcon(step), + warning: stepWarning(step), + inputWarning: stepInputWarning(i), + runState: stepRunState(i), + })); + + const definitionJson = JSON.stringify( + { + name: name.trim(), + enabled, + inputs: [{ sourceId: input.sourceId, trigger: buildTriggerFor(input) }], + steps: steps.map((step) => serializeToolStep(step, allTools)), + outputIds, + }, + null, + 2, + ); + + const testSummary = + testRun === null + ? null + : { + status: + testRun.status === "FAILED" + ? ("failed" as const) + : testRun.status === "COMPLETED" + ? ("completed" as const) + : ("running" as const), + completedSteps: testRun.currentStep, + stepCount: testRun.stepCount, + error: testRun.error, + outputs: testRun.outputs ?? [], + }; + + /** The editor for whatever node is selected. Undefined when nothing is. */ + function inspectorBody() { + if (selected === "input") { + // Nothing to pick from yet: a dropdown of nothing helps no one, so offer only the way to make + // the first source. The trigger has no meaning without a source either, so it waits too. + const hasSources = availableSources.length > 0; + return ( + <> + {hasSources && ( <> - - - + +
+
+ + updateInput({ + triggerType: + value && value !== MANUAL_OPTION ? value : MANUAL, + }) + } + options={triggerOptionsFor(input.sourceId)} + /> + + + {input.triggerType === "schedule" && ( +
+ + {t("portal.pipelines.composer.scheduleEvery")} + + + updateInput({ scheduleCount: e.target.value }) + } + className="portal-builder__schedule-count" + /> + changeInputSource(value ?? "")} - options={sourceOptions} - /> -
-
- - updateInput({ scheduleCount: e.target.value }) - } - className="portal-pipelines__schedule-count" - /> -