From 22ec0947c908d7fe1815fbcd0f73305c908c8732 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:06:06 +0100 Subject: [PATCH] Storybook coverage: scan harness + stories (#7073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Gets most of the app's components into Storybook and adds a scan that runs every story in a real browser, so we have a base to build accessibility testing on next. - **~380 new stories**, taking story files from 144 to 526. Components with a story: | Layer | Before | After | |---|---|---| | core | 41 / 309 (13%) | **183 / 309 (59%)** | | portal | 91 / 161 (57%) | **127 / 161 (79%)** | | proprietary | 1 / 105 (1%) | **39 / 105 (37%)** | | cloud / desktop / saas / portal-saas / prototypes | 0 / 84 | 0 / 84 (unchanged) | | **Total** | **133 / 659 (20%)** | **349 / 659 (53%)** | Both columns are counted the same way — every `.tsx` exporting a component, so the denominator includes things that aren't really visual units (contexts, providers, barrels). Excluding those it's 22% → 58%. Either way it's reproducible from the tree rather than a number you have to take on trust. - **Scan harness** — the Storybook Vitest addon runs each story in headless Chromium as a **render/smoke check** (a story must mount without throwing). New task: `task frontend:storybook:test` (pass a filter, e.g. `-- Button`). Separate Vitest config so it doesn't touch the existing jsdom unit tests. ## Scope - **Stories and Storybook config only, with one exception:** a one-line fix to `ProviderCard`, which re-rendered forever whenever its optional `settings` prop was omitted. Called out because it's the only component source change here. - The preview gains a `QueryClientProvider` (the portal app has one, so stories reaching a query hook threw without it), and the scan task now installs the browser it drives. - **a11y is report-only** and **nothing runs the scan in CI yet** — enforcing a11y and wiring it into CI is the follow-up, #7086. - Components that can't render as an isolated unit are **not** included: anything needing the full editor runtime (ToolWorkflow / FileManager / AppConfig / a live PDF engine) or that's headless (providers, gates, API bridges, config factories). Stories that only rendered by mounting the whole `AppProviders` tree were dropped for the same reason — that isn't isolation, and the tree's ErrorBoundary swallowed render failures so those stories could never fail. A few that need assets the headless browser can't serve are tagged `!test`, so they still show in the UI but sit out the scan. ## Testing Typecheck (all build variants), ESLint, Prettier and the unit suite pass. Every story in the scanned set mounts without throwing. ## Notes for reviewers - Stories use the `@app`/`@core`/`@portal`/`@proprietary` aliases (no deep relative imports) and mock data-fetching components with MSW. - Running the full suite in one go can flake on the Vite dep-optimizer; scan in small batches (or by filter) for a stable local run. --- .taskfiles/frontend.yml | 16 ++ frontend/.storybook/main.ts | 6 +- frontend/.storybook/preview.tsx | 47 ++-- frontend/.storybook/vitest.config.ts | 62 +++++ frontend/.storybook/vitest.setup.ts | 10 + .../src/core/components/AppLayout.stories.tsx | 75 ++++++ .../components/StorageStatsCard.stories.tsx | 49 ++++ .../shared/BaseAnnotationTool.stories.tsx | 68 ++++++ .../shared/ColorControl.stories.tsx | 35 +++ .../annotation/shared/ColorPicker.stories.tsx | 45 ++++ .../shared/DrawingCanvas.stories.tsx | 52 +++++ .../shared/DrawingControls.stories.tsx | 39 ++++ .../shared/ImageUploader.stories.tsx | 39 ++++ .../shared/OpacityControl.stories.tsx | 23 ++ .../shared/PropertiesPopover.stories.tsx | 35 +++ .../shared/TextInputWithFont.stories.tsx | 78 +++++++ .../shared/WidthControl.stories.tsx | 35 +++ .../annotation/tools/DrawingTool.stories.tsx | 50 ++++ .../annotation/tools/ImageTool.stories.tsx | 50 ++++ .../fileEditor/FileEditorFileName.stories.tsx | 40 ++++ .../CompactFileDetails.stories.tsx | 68 ++++++ .../fileManager/DragOverlay.stories.tsx | 30 +++ .../fileManager/FileDetails.stories.tsx | 73 ++++++ .../filesPage/DeleteFilesDialog.stories.tsx | 58 +++++ .../filesPage/DeleteFolderDialog.stories.tsx | 39 ++++ .../filesPage/FileDetailsPanel.stories.tsx | 88 +++++++ .../components/filesPage/FileGrid.stories.tsx | 114 +++++++++ .../filesPage/FileOriginBadge.stories.tsx | 35 +++ .../FolderAppearancePicker.stories.tsx | 46 ++++ .../filesPage/FolderNameDialog.stories.tsx | 31 +++ .../filesPage/FolderThumbnail.stories.tsx | 38 +++ .../filesPage/FolderTreeSidebar.stories.tsx | 51 ++++ .../filesPage/MoveToFolderDialog.stories.tsx | 86 +++++++ .../OpenInNewWindowMenuItem.stories.tsx | 29 +++ .../filesPage/VersionHistoryModal.stories.tsx | 64 ++++++ .../filesPage/VersionTimeline.stories.tsx | 98 ++++++++ .../OnboardingSlideShell.stories.tsx | 79 +++++++ .../onboarding/OnboardingStepper.stories.tsx | 21 ++ .../onboarding/OnboardingTour.stories.tsx | 69 ++++++ .../StaticOnboardingSlide.stories.tsx | 105 +++++++++ .../slides/AnalyticsChoiceSlide.stories.tsx | 41 ++++ .../AnimatedSlideBackground.stories.tsx | 44 ++++ .../slides/DesktopInstallTitle.stories.tsx | 39 ++++ .../slides/FirstLoginSlide.stories.tsx | 47 ++++ .../slides/PlanOverviewSlide.stories.tsx | 60 +++++ .../slides/ProcessorIntroSlide.stories.tsx | 33 +++ .../slides/SecurityCheckSlide.stories.tsx | 46 ++++ .../slides/ServerLicenseSlide.stories.tsx | 64 ++++++ .../slides/WelcomeSlide.stories.tsx | 33 +++ .../pageEditor/DragDropGrid.stories.tsx | 112 +++++++++ .../pageEditor/PageEditorControls.stories.tsx | 57 +++++ .../AdvancedSelectionPanel.stories.tsx | 40 ++++ .../OperatorsSection.stories.tsx | 23 ++ .../SelectPages.stories.tsx | 43 ++++ .../SelectedPagesDisplay.stories.tsx | 34 +++ .../shared/AllToolsNavButton.stories.tsx | 63 +++++ .../components/shared/AppSwitch.stories.tsx | 35 +++ .../shared/BulkShareModal.stories.tsx | 75 ++++++ .../BulkUploadToServerModal.stories.tsx | 67 ++++++ .../shared/ButtonToggle.stories.tsx | 58 +++++ .../shared/CloudStorageIcons.stories.tsx | 33 +++ .../shared/DropdownListWithFooter.stories.tsx | 86 +++++++ .../shared/EditableSecretField.stories.tsx | 61 +++++ .../EncryptedPdfUnlockModal.stories.tsx | 60 +++++ .../shared/ErrorBoundary.stories.tsx | 44 ++++ .../components/shared/FileCard.stories.tsx | 65 ++++++ .../components/shared/FileDocIcon.stories.tsx | 35 +++ .../shared/FileDropdownMenu.stories.tsx | 49 ++++ .../components/shared/FileGrid.stories.tsx | 104 +++++++++ .../shared/FilePickerModal.stories.tsx | 37 +++ .../components/shared/FilePreview.stories.tsx | 57 +++++ .../shared/FileSelectorPicker.stories.tsx | 48 ++++ .../shared/FileUploadButton.stories.tsx | 56 +++++ .../shared/FirstLoginModal.stories.tsx | 18 ++ .../components/shared/FitText.stories.tsx | 41 ++++ .../core/components/shared/Footer.stories.tsx | 27 +++ .../shared/HoverActionMenu.stories.tsx | 76 ++++++ .../components/shared/InfoBanner.stories.tsx | 38 +++ .../shared/LandingDocumentStack.stories.tsx | 13 ++ .../shared/LanguageSelector.stories.tsx | 25 ++ .../shared/LoadingFallback.stories.tsx | 13 ++ .../components/shared/LocalIcon.stories.tsx | 32 +++ .../shared/LoginAgreementModal.stories.tsx | 18 ++ .../shared/MobileUploadModal.stories.tsx | 25 ++ .../shared/MultiSelectControls.stories.tsx | 34 +++ .../shared/NavigationWarningModal.stories.tsx | 103 +++++++++ .../shared/ObscuredOverlay.stories.tsx | 49 ++++ .../PageSelectionSyntaxHint.stories.tsx | 35 +++ .../shared/PolicyBadges.stories.tsx | 44 ++++ .../shared/PrivateContent.stories.tsx | 24 ++ .../shared/ShareFileModal.stories.tsx | 63 +++++ .../shared/ShareManagementModal.stories.tsx | 66 ++++++ .../shared/SkeletonLoader.stories.tsx | 37 +++ .../components/shared/ToolChain.stories.tsx | 56 +++++ .../components/shared/ToolIcon.stories.tsx | 35 +++ .../shared/ToolPanelHeader.stories.tsx | 28 +++ .../components/shared/UpdateModal.stories.tsx | 79 +++++++ .../shared/UpdateStartupPopup.stories.tsx | 63 +++++ .../shared/UploadToServerModal.stories.tsx | 62 +++++ .../shared/UserSelector.stories.tsx | 29 +++ .../shared/ZipWarningModal.stories.tsx | 30 +++ .../config/LoginRequiredBanner.stories.tsx | 22 ++ .../shared/config/OverviewHeader.stories.tsx | 12 + .../shared/config/PendingBadge.stories.tsx | 29 +++ .../RestartConfirmationModal.stories.tsx | 26 +++ .../config/SettingsSearchBar.stories.tsx | 62 +++++ .../config/SettingsStickyFooter.stories.tsx | 34 +++ .../configSections/GeneralSection.stories.tsx | 71 ++++++ .../configSections/HelpSection.stories.tsx | 25 ++ .../configSections/HotkeysSection.stories.tsx | 40 ++++ .../configSections/LegalSection.stories.tsx | 74 ++++++ .../configSections/Overview.stories.tsx | 59 +++++ .../configSections/ProviderCard.stories.tsx | 80 +++++++ .../config/configSections/ProviderCard.tsx | 8 +- .../ThirdPartyLicensesSection.stories.tsx | 18 ++ .../filePreview/DocumentStack.stories.tsx | 51 ++++ .../filePreview/DocumentThumbnail.stories.tsx | 49 ++++ .../filePreview/HoverOverlay.stories.tsx | 31 +++ .../filePreview/NavigationArrows.stories.tsx | 34 +++ .../QuickAccessButton.stories.tsx | 46 ++++ .../signing/CreateSessionFlow.stories.tsx | 71 ++++++ .../signing/SharedSigningLauncher.stories.tsx | 104 +++++++++ ...ConfigureSignatureDefaultsStep.stories.tsx | 83 +++++++ .../steps/ReviewSessionStep.stories.tsx | 50 ++++ .../steps/SelectDocumentStep.stories.tsx | 34 +++ .../steps/SelectParticipantsStep.stories.tsx | 27 +++ .../SliderWithInput.stories.tsx | 46 ++++ .../shared/tooltip/TooltipContent.stories.tsx | 63 +++++ .../DrawSignatureCanvas.stories.tsx | 38 +++ .../SignatureTypeSelector.stories.tsx | 37 +++ .../TypeSignatureText.stories.tsx | 61 +++++ .../UploadSignatureImage.stories.tsx | 36 +++ .../toast/ToastRenderer.stories.tsx | 102 ++++++++ .../tools/ToolLoadingFallback.stories.tsx | 20 ++ .../tools/ToolPanelModePrompt.stories.tsx | 51 ++++ .../components/tools/ToolRenderer.stories.tsx | 55 +++++ .../PageNumberPreview.stories.tsx | 26 +++ .../AddPasswordSettings.stories.tsx | 70 ++++++ .../tools/addStamp/StampPreview.stories.tsx | 36 +++ .../addStamp/StampSetupSettings.stories.tsx | 70 ++++++ ...AddWatermarkSingleStepSettings.stories.tsx | 87 +++++++ .../WatermarkFormatting.stories.tsx | 32 +++ .../WatermarkImageFile.stories.tsx | 72 ++++++ .../WatermarkStyleSettings.stories.tsx | 38 +++ .../WatermarkTextStyle.stories.tsx | 41 ++++ .../WatermarkTypeSettings.stories.tsx | 28 +++ .../addWatermark/WatermarkWording.stories.tsx | 53 +++++ .../AdjustContrastBasicSettings.stories.tsx | 57 +++++ .../AdjustContrastColorSettings.stories.tsx | 57 +++++ .../AdjustContrastPreview.stories.tsx | 20 ++ ...justContrastSingleStepSettings.stories.tsx | 60 +++++ .../AdjustPageScaleSettings.stories.tsx | 46 ++++ .../autoRename/AutoRenameSettings.stories.tsx | 30 +++ .../automate/AutomationCreation.stories.tsx | 59 +++++ .../AutomationImportModal.stories.tsx | 31 +++ .../tools/automate/IconSelector.stories.tsx | 28 +++ .../ToolConfigurationModal.stories.tsx | 80 +++++++ .../tools/automate/ToolList.stories.tsx | 55 +++++ .../tools/automate/ToolSelector.stories.tsx | 31 +++ .../BookletImpositionSettings.stories.tsx | 42 ++++ .../CertSignAutomationSettings.stories.tsx | 59 +++++ .../CertificateFilesSettings.stories.tsx | 81 +++++++ .../CertificateFormatSettings.stories.tsx | 33 +++ .../certSign/CertificateSelector.stories.tsx | 82 +++++++ .../CertificateTypeSettings.stories.tsx | 82 +++++++ .../HardwareCertificateSettings.stories.tsx | 79 +++++++ .../SignatureAppearanceSettings.stories.tsx | 67 ++++++ .../SignatureSettingsDisplay.stories.tsx | 40 ++++ .../SignatureSettingsInput.stories.tsx | 59 +++++ .../certSign/WetSignatureInput.stories.tsx | 60 +++++ .../modals/AddParticipantsFlow.stories.tsx | 22 ++ .../modals/CertificateConfigModal.stories.tsx | 26 +++ .../modals/SelectSignatureModal.stories.tsx | 18 ++ .../panels/ParticipantListPanel.stories.tsx | 63 +++++ .../panels/SessionActionsPanel.stories.tsx | 73 ++++++ .../panels/SessionDetailPanel.stories.tsx | 74 ++++++ .../panels/SignControlsPanel.stories.tsx | 51 ++++ .../steps/AddSignaturesStep.stories.tsx | 33 +++ .../CertificateSelectionStep.stories.tsx | 102 ++++++++ .../steps/ReviewSignatureStep.stories.tsx | 57 +++++ .../steps/SignatureCreationStep.stories.tsx | 53 +++++ .../steps/SignaturePlacementStep.stories.tsx | 53 +++++ .../steps/AdvancedOptionsStep.stories.tsx | 36 +++ .../steps/CustomMetadataStep.stories.tsx | 79 +++++++ .../steps/DeleteAllStep.stories.tsx | 53 +++++ .../steps/DocumentDatesStep.stories.tsx | 66 ++++++ .../steps/StandardMetadataStep.stories.tsx | 62 +++++ .../ChangePermissionsSettings.stories.tsx | 48 ++++ .../compare/CompareDocumentPane.stories.tsx | 75 ++++++ .../CompareNavigationDropdown.stories.tsx | 48 ++++ .../ComparePixelWorkbenchView.stories.tsx | 113 +++++++++ .../compress/CompressSettings.stories.tsx | 76 ++++++ .../ConvertFromCbrSettings.stories.tsx | 66 ++++++ .../ConvertFromCbzSettings.stories.tsx | 52 +++++ .../ConvertFromEbookSettings.stories.tsx | 42 ++++ .../ConvertFromEmailSettings.stories.tsx | 62 +++++ .../ConvertFromImageSettings.stories.tsx | 38 +++ .../ConvertFromSvgSettings.stories.tsx | 38 +++ .../ConvertFromWebSettings.stories.tsx | 38 +++ .../convert/ConvertToCbrSettings.stories.tsx | 52 +++++ .../convert/ConvertToCbzSettings.stories.tsx | 25 ++ .../convert/ConvertToEpubSettings.stories.tsx | 54 +++++ .../ConvertToImageSettings.stories.tsx | 38 +++ .../convert/ConvertToPdfaSettings.stories.tsx | 34 +++ .../convert/ConvertToPdfxSettings.stories.tsx | 57 +++++ .../convert/GroupedFormatDropdown.stories.tsx | 55 +++++ .../tools/crop/CropAreaSelector.stories.tsx | 83 +++++++ .../crop/CropAutomationSettings.stories.tsx | 59 +++++ .../crop/CropCoordinateInputs.stories.tsx | 63 +++++ .../BookmarkEditor.stories.tsx | 45 ++++ .../EditTableOfContentsSettings.stories.tsx | 69 ++++++ ...itTableOfContentsWorkbenchView.stories.tsx | 86 +++++++ .../ExtractImagesSettings.stories.tsx | 60 +++++ .../ExtractPagesSettings.stories.tsx | 39 ++++ .../tools/flatten/FlattenSettings.stories.tsx | 48 ++++ .../fullscreen/DetailedToolItem.stories.tsx | 103 +++++++++ .../GetPdfInfoReportView.stories.tsx | 112 +++++++++ .../getPdfInfo/GetPdfInfoResults.stories.tsx | 103 +++++++++ .../sections/ComplianceSection.stories.tsx | 59 +++++ .../sections/KeyValueSection.stories.tsx | 32 +++ .../sections/OtherSection.stories.tsx | 43 ++++ .../sections/PerPageSection.stories.tsx | 72 ++++++ .../sections/SummarySection.stories.tsx | 92 ++++++++ .../TableOfContentsSection.stories.tsx | 31 +++ .../shared/KeyValueList.stories.tsx | 28 +++ .../shared/ScrollableCodeBlock.stories.tsx | 38 +++ .../shared/SectionBlock.stories.tsx | 28 +++ .../tools/merge/MergeFileSorter.stories.tsx | 23 ++ .../tools/merge/MergeSettings.stories.tsx | 44 ++++ .../tools/ocr/AdvancedOCRSettings.stories.tsx | 43 ++++ .../tools/ocr/LanguagePicker.stories.tsx | 63 +++++ .../tools/ocr/OCRSettings.stories.tsx | 43 ++++ .../pageLayout/LayoutPreview.stories.tsx | 44 ++++ .../PageLayoutAdvancedSettings.stories.tsx | 68 ++++++ ...geLayoutMarginsBordersSettings.stories.tsx | 58 +++++ .../pageLayout/PageLayoutPreview.stories.tsx | 40 ++++ .../pageLayout/PageLayoutSettings.stories.tsx | 61 +++++ .../redact/RedactAdvancedSettings.stories.tsx | 40 ++++ .../redact/RedactModeSelector.stories.tsx | 54 +++++ .../RedactSingleStepSettings.stories.tsx | 78 +++++++ .../redact/WordsToRedactInput.stories.tsx | 47 ++++ .../RemoveAnnotationsSettings.stories.tsx | 12 + .../RemoveBlanksSettings.stories.tsx | 41 ++++ .../RemoveCertificateSignSettings.stories.tsx | 28 +++ .../RemovePagesSettings.stories.tsx | 44 ++++ .../RemovePasswordSettings.stories.tsx | 39 ++++ .../ReorganizePagesSettings.stories.tsx | 58 +++++ .../tools/repair/RepairSettings.stories.tsx | 25 ++ .../ReplaceColorSettings.stories.tsx | 35 +++ .../RotateAutomationSettings.stories.tsx | 46 ++++ .../sanitize/SanitizeSettings.stories.tsx | 40 ++++ .../ScannerImageSplitSettings.stories.tsx | 36 +++ .../shared/ErrorNotification.stories.tsx | 25 ++ .../tools/shared/FileMetadata.stories.tsx | 27 +++ .../shared/NavigationControls.stories.tsx | 36 +++ .../tools/shared/NoToolsFound.stories.tsx | 11 + .../shared/NumberInputWithUnit.stories.tsx | 37 +++ .../tools/shared/ResultsPreview.stories.tsx | 46 ++++ .../shared/SubcategoryHeader.stories.tsx | 24 ++ .../tools/shared/ToolStep.stories.tsx | 36 +++ .../tools/showJS/ShowJSView.stories.tsx | 45 ++++ .../tools/sign/PenSizeSelector.stories.tsx | 36 +++ .../sign/SavedSignaturesSection.stories.tsx | 99 ++++++++ .../SingleLargePageSettings.stories.tsx | 27 +++ .../split/SplitAutomationSettings.stories.tsx | 45 ++++ .../tools/split/SplitSettings.stories.tsx | 104 +++++++++ .../TimestampPdfSettings.stories.tsx | 26 +++ .../tools/toolPicker/FavoriteStar.stories.tsx | 32 +++ .../tools/toolPicker/ToolSearch.stories.tsx | 82 +++++++ .../UnlockPdfFormsSettings.stories.tsx | 27 +++ .../ValidateSignatureReportView.stories.tsx | 140 +++++++++++ .../ValidateSignatureSettings.stories.tsx | 43 ++++ .../reportView/FieldBlock.stories.tsx | 21 ++ .../reportView/FileSummaryHeader.stories.tsx | 34 +++ .../reportView/SignatureSection.stories.tsx | 80 +++++++ .../SignatureStatusBadge.stories.tsx | 68 ++++++ .../reportView/ThumbnailPreview.stories.tsx | 29 +++ .../viewer/AnnotationMenuButtons.stories.tsx | 73 ++++++ .../AnnotationSelectionMenu.stories.tsx | 20 ++ .../viewer/AnnotationTypeButtons.stories.tsx | 69 ++++++ .../viewer/CustomSearchLayer.stories.tsx | 27 +++ .../viewer/DocumentReadyWrapper.stories.tsx | 35 +++ .../components/viewer/LinkLayer.stories.tsx | 22 ++ .../viewer/LocalEmbedPDF.stories.tsx | 39 ++++ .../viewer/NonPdfViewer.stories.tsx | 75 ++++++ .../RedactionPendingTracker.stories.tsx | 16 ++ .../viewer/RedactionSelectionMenu.stories.tsx | 16 ++ .../viewer/SearchInterface.stories.tsx | 27 +++ .../viewer/SignatureFieldOverlay.stories.tsx | 22 ++ .../SignaturePlacementOverlay.stories.tsx | 94 ++++++++ .../viewer/SignaturePreviewLayer.stories.tsx | 63 +++++ .../viewer/StampPlacementOverlay.stories.tsx | 95 ++++++++ .../viewer/TextSelectionMenu.stories.tsx | 36 +++ .../viewer/nonpdf/CsvViewer.stories.tsx | 48 ++++ .../viewer/nonpdf/HtmlViewer.stories.tsx | 23 ++ .../viewer/nonpdf/ImageViewer.stories.tsx | 39 ++++ .../viewer/nonpdf/JsonViewer.stories.tsx | 47 ++++ .../viewer/nonpdf/NonPdfBanner.stories.tsx | 30 +++ .../viewer/nonpdf/TextViewer.stories.tsx | 53 +++++ .../editor/src/core/ui/FilePicker.stories.tsx | 41 ++++ .../components/AssistantButton.stories.tsx | 32 +++ .../portal/components/BrandMarks.stories.tsx | 24 ++ .../DownloadEditorModal.stories.tsx | 14 ++ .../components/ErrorBoundary.stories.tsx | 43 ++++ .../components/HomeGreeting.stories.tsx | 12 + .../portal/components/HomeHero.stories.tsx | 32 +++ .../LinkAccountFooterItem.stories.tsx | 21 ++ .../portal/components/LoginScreen.stories.tsx | 12 + .../components/PortalChrome.stories.tsx | 12 + .../account-link/AccountLinkPanel.stories.tsx | 72 ++++++ .../account-link/LinkAccountModal.stories.tsx | 23 ++ .../account-link/LinkGate.stories.tsx | 24 -- .../billing/CardPlaceholder.stories.tsx | 14 ++ .../billing/InvoicesList.stories.tsx | 77 +++++++ .../billing/PrepaidCapacityCard.stories.tsx | 43 ++++ .../billing/PrepayModalHeader.stories.tsx | 30 +++ .../infrastructure/AuditTab.stories.tsx | 16 +- .../PipelineStepSettings.stories.tsx | 76 ++++++ .../pipelines/PipelinesTable.stories.tsx | 43 ++++ .../pipelines/ToolPicker.stories.tsx | 70 ++++++ .../ClassificationLabelsSection.stories.tsx | 12 + .../PolicyExternalApiConfig.stories.tsx | 66 ++++++ .../policies/PolicyPurviewConfig.stories.tsx | 46 ++++ .../PolicyPurviewReadConfig.stories.tsx | 32 +++ .../procurement/CalendlyInline.stories.tsx | 19 ++ .../ProcurementAgreement.stories.tsx | 92 ++++++++ .../procurement/ProcurementBanner.stories.tsx | 79 +++++++ .../procurement/ProcurementExtras.stories.tsx | 113 +++++++++ .../procurement/ProcurementFlow.stories.tsx | 143 ++++++++++++ .../procurement/ProcurementStages.stories.tsx | 77 +++++++ .../sources/ConnectionForm.stories.tsx | 52 +++++ .../sources/ConnectionModal.stories.tsx | 51 ++++ .../sources/ConnectionPicker.stories.tsx | 55 +++++ .../sources/ConnectionTypePicker.stories.tsx | 28 +++ .../sources/S3ConnectionPicker.stories.tsx | 18 ++ .../sources/SourceModal.stories.tsx | 39 ++++ .../sources/SourceTypeIcon.stories.tsx | 27 +++ .../users/PendingInvitations.stories.tsx | 56 +++++ .../src/portal/views/Integrations.stories.tsx | 28 +++ .../portal/views/PipelineBuilder.stories.tsx | 47 ++++ .../proprietary/auth/ui/AuthShell.stories.tsx | 58 +++++ .../auth/ui/EmailPasswordForm.stories.tsx | 70 ++++++ .../auth/ui/ErrorMessage.stories.tsx | 32 +++ .../auth/ui/SpringLoginForm.stories.tsx | 99 ++++++++ .../auth/ui/SupabaseLoginForm.stories.tsx | 66 ++++++ .../agents/StirlingLogoAnimated.stories.tsx | 22 ++ .../agents/StirlingLogoOutline.stories.tsx | 22 ++ .../policies/PolicyPiiField.stories.tsx | 71 ++++++ .../policies/PolicyRedactConfig.stories.tsx | 70 ++++++ .../PolicyWatermarkConfig.stories.tsx | 38 +++ .../ChangeUserPasswordModal.stories.tsx | 36 +++ .../shared/DividerWithText.stories.tsx | 31 +++ .../shared/ManageBillingButton.stories.tsx | 23 ++ .../shared/UpdateSeatsModal.stories.tsx | 30 +++ .../EnterpriseRequiredBanner.stories.tsx | 24 ++ .../GeneralWithLoginLanding.stories.tsx | 74 ++++++ .../shared/config/OverviewHeader.stories.tsx | 49 ++++ .../configSections/AccountSection.stories.tsx | 101 ++++++++ .../AdminAiDocumentsSection.stories.tsx | 98 ++++++++ .../AdminAiGeneralSection.stories.tsx | 82 +++++++ .../AdminAiLimitsSection.stories.tsx | 85 +++++++ .../AdminAiModelsSection.stories.tsx | 106 +++++++++ .../AdminAuditSection.stories.tsx | 100 ++++++++ .../AdminFolderAccessSection.stories.tsx | 95 ++++++++ .../config/configSections/ApiKeys.stories.tsx | 55 +++++ .../LoginAgreementEditor.stories.tsx | 20 ++ .../TeamDetailsSection.stories.tsx | 98 ++++++++ .../configSections/TeamsSection.stories.tsx | 71 ++++++ .../apiKeys/ApiKeySection.stories.tsx | 31 +++ .../apiKeys/RefreshModal.stories.tsx | 25 ++ .../audit/AuditChartsSection.stories.tsx | 26 +++ .../audit/AuditClearDataSection.stories.tsx | 24 ++ .../audit/AuditEventsTable.stories.tsx | 29 +++ .../audit/AuditExportSection.stories.tsx | 32 +++ .../audit/AuditFiltersForm.stories.tsx | 68 ++++++ .../audit/AuditStatsCards.stories.tsx | 31 +++ .../audit/AuditSystemStatus.stories.tsx | 41 ++++ .../plan/AvailablePlansSection.stories.tsx | 80 +++++++ .../plan/FeatureComparisonTable.stories.tsx | 51 ++++ .../configSections/plan/PlanCard.stories.tsx | 130 +++++++++++ .../usage/UsageAnalyticsChart.stories.tsx | 28 +++ .../usage/UsageAnalyticsTable.stories.tsx | 32 +++ .../stripeCheckout/StripeCheckout.stories.tsx | 58 +++++ .../components/PriceDisplay.stories.tsx | 47 ++++ .../components/PricingBadge.stories.tsx | 35 +++ .../stages/EmailStage.stories.tsx | 35 +++ .../stages/ErrorStage.stories.tsx | 27 +++ .../stages/PaymentStage.stories.tsx | 42 ++++ .../stages/PlanSelectionStage.stories.tsx | 103 +++++++++ .../stages/SuccessStage.stories.tsx | 47 ++++ .../workflow/ParticipantView.stories.tsx | 99 ++++++++ frontend/package-lock.json | 217 ++++++++++++++++++ frontend/package.json | 2 + frontend/vitest.config.ts | 7 + 394 files changed, 19750 insertions(+), 46 deletions(-) create mode 100644 frontend/.storybook/vitest.config.ts create mode 100644 frontend/.storybook/vitest.setup.ts create mode 100644 frontend/editor/src/core/components/AppLayout.stories.tsx create mode 100644 frontend/editor/src/core/components/StorageStatsCard.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/BaseAnnotationTool.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/ColorControl.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/ColorPicker.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/DrawingCanvas.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/DrawingControls.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/ImageUploader.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/OpacityControl.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/PropertiesPopover.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/TextInputWithFont.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/WidthControl.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/tools/DrawingTool.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/tools/ImageTool.stories.tsx create mode 100644 frontend/editor/src/core/components/fileEditor/FileEditorFileName.stories.tsx create mode 100644 frontend/editor/src/core/components/fileManager/CompactFileDetails.stories.tsx create mode 100644 frontend/editor/src/core/components/fileManager/DragOverlay.stories.tsx create mode 100644 frontend/editor/src/core/components/fileManager/FileDetails.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FileGrid.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FileOriginBadge.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FolderNameDialog.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FolderThumbnail.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FolderTreeSidebar.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/OpenInNewWindowMenuItem.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/VersionTimeline.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/OnboardingStepper.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/OnboardingTour.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/slides/PlanOverviewSlide.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/slides/ServerLicenseSlide.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx create mode 100644 frontend/editor/src/core/components/pageEditor/DragDropGrid.stories.tsx create mode 100644 frontend/editor/src/core/components/pageEditor/PageEditorControls.stories.tsx create mode 100644 frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx create mode 100644 frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx create mode 100644 frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.stories.tsx create mode 100644 frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/AllToolsNavButton.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/AppSwitch.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/BulkShareModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/ButtonToggle.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/CloudStorageIcons.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/DropdownListWithFooter.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/EditableSecretField.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/ErrorBoundary.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FileCard.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FileDocIcon.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FileDropdownMenu.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FileGrid.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FilePickerModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FilePreview.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FileSelectorPicker.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FileUploadButton.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FirstLoginModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FitText.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/Footer.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/HoverActionMenu.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/InfoBanner.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/LandingDocumentStack.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/LanguageSelector.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/LoadingFallback.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/LocalIcon.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/LoginAgreementModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/MobileUploadModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/MultiSelectControls.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/NavigationWarningModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/ObscuredOverlay.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/PrivateContent.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/ShareFileModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/ShareManagementModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/SkeletonLoader.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/ToolChain.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/ToolIcon.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/ToolPanelHeader.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/UpdateModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/UpdateStartupPopup.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/UploadToServerModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/UserSelector.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/ZipWarningModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/LoginRequiredBanner.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/OverviewHeader.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/PendingBadge.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/configSections/Overview.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/filePreview/DocumentStack.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/filePreview/HoverOverlay.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/filePreview/NavigationArrows.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessButton.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/tooltip/TooltipContent.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx create mode 100644 frontend/editor/src/core/components/toast/ToastRenderer.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/ToolLoadingFallback.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/ToolRenderer.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addPassword/AddPasswordSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addStamp/StampPreview.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addWatermark/WatermarkTypeSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addWatermark/WatermarkWording.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastPreview.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/autoRename/AutoRenameSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/automate/AutomationCreation.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/automate/IconSelector.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/automate/ToolList.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/automate/ToolSelector.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/changeMetadata/steps/DeleteAllStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/changeMetadata/steps/StandardMetadataStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/changePermissions/ChangePermissionsSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/compare/CompareDocumentPane.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/compare/CompareNavigationDropdown.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/compress/CompressSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertFromCbrSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertFromCbzSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertFromImageSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertToCbrSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertToCbzSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertToImageSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertToPdfxSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/crop/CropAreaSelector.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/crop/CropCoordinateInputs.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/extractImages/ExtractImagesSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/extractPages/ExtractPagesSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/shared/SectionBlock.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/merge/MergeFileSorter.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/merge/MergeSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/ocr/AdvancedOCRSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/ocr/OCRSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/pageLayout/LayoutPreview.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/pageLayout/PageLayoutAdvancedSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/pageLayout/PageLayoutMarginsBordersSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/pageLayout/PageLayoutPreview.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/pageLayout/PageLayoutSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/redact/RedactAdvancedSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/redact/RedactModeSelector.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/removeAnnotations/RemoveAnnotationsSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/removeCertificateSign/RemoveCertificateSignSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/removePages/RemovePagesSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/removePassword/RemovePasswordSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/repair/RepairSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/rotate/RotateAutomationSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/shared/ErrorNotification.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/shared/FileMetadata.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/shared/NavigationControls.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/shared/NoToolsFound.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/shared/ResultsPreview.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/shared/SubcategoryHeader.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/shared/ToolStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/showJS/ShowJSView.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/sign/PenSizeSelector.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/singleLargePage/SingleLargePageSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/split/SplitAutomationSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/split/SplitSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/unlockPdfForms/UnlockPdfFormsSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/validateSignature/reportView/ThumbnailPreview.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/AnnotationMenuButtons.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/AnnotationSelectionMenu.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/AnnotationTypeButtons.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/CustomSearchLayer.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/DocumentReadyWrapper.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/LinkLayer.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/NonPdfViewer.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/RedactionPendingTracker.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/RedactionSelectionMenu.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/SearchInterface.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/SignatureFieldOverlay.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/SignaturePlacementOverlay.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/SignaturePreviewLayer.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/StampPlacementOverlay.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/TextSelectionMenu.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/nonpdf/ImageViewer.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/nonpdf/JsonViewer.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx create mode 100644 frontend/editor/src/core/ui/FilePicker.stories.tsx create mode 100644 frontend/editor/src/portal/components/AssistantButton.stories.tsx create mode 100644 frontend/editor/src/portal/components/BrandMarks.stories.tsx create mode 100644 frontend/editor/src/portal/components/DownloadEditorModal.stories.tsx create mode 100644 frontend/editor/src/portal/components/ErrorBoundary.stories.tsx create mode 100644 frontend/editor/src/portal/components/HomeGreeting.stories.tsx create mode 100644 frontend/editor/src/portal/components/HomeHero.stories.tsx create mode 100644 frontend/editor/src/portal/components/LinkAccountFooterItem.stories.tsx create mode 100644 frontend/editor/src/portal/components/LoginScreen.stories.tsx create mode 100644 frontend/editor/src/portal/components/PortalChrome.stories.tsx create mode 100644 frontend/editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx create mode 100644 frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx delete mode 100644 frontend/editor/src/portal/components/account-link/LinkGate.stories.tsx create mode 100644 frontend/editor/src/portal/components/billing/CardPlaceholder.stories.tsx create mode 100644 frontend/editor/src/portal/components/billing/InvoicesList.stories.tsx create mode 100644 frontend/editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx create mode 100644 frontend/editor/src/portal/components/billing/PrepayModalHeader.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelinesTable.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/ToolPicker.stories.tsx create mode 100644 frontend/editor/src/portal/components/policies/ClassificationLabelsSection.stories.tsx create mode 100644 frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx create mode 100644 frontend/editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx create mode 100644 frontend/editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx create mode 100644 frontend/editor/src/portal/components/procurement/CalendlyInline.stories.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementBanner.stories.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementFlow.stories.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementStages.stories.tsx create mode 100644 frontend/editor/src/portal/components/sources/ConnectionForm.stories.tsx create mode 100644 frontend/editor/src/portal/components/sources/ConnectionModal.stories.tsx create mode 100644 frontend/editor/src/portal/components/sources/ConnectionPicker.stories.tsx create mode 100644 frontend/editor/src/portal/components/sources/ConnectionTypePicker.stories.tsx create mode 100644 frontend/editor/src/portal/components/sources/S3ConnectionPicker.stories.tsx create mode 100644 frontend/editor/src/portal/components/sources/SourceModal.stories.tsx create mode 100644 frontend/editor/src/portal/components/sources/SourceTypeIcon.stories.tsx create mode 100644 frontend/editor/src/portal/components/users/PendingInvitations.stories.tsx create mode 100644 frontend/editor/src/portal/views/Integrations.stories.tsx create mode 100644 frontend/editor/src/portal/views/PipelineBuilder.stories.tsx create mode 100644 frontend/editor/src/proprietary/auth/ui/AuthShell.stories.tsx create mode 100644 frontend/editor/src/proprietary/auth/ui/EmailPasswordForm.stories.tsx create mode 100644 frontend/editor/src/proprietary/auth/ui/ErrorMessage.stories.tsx create mode 100644 frontend/editor/src/proprietary/auth/ui/SpringLoginForm.stories.tsx create mode 100644 frontend/editor/src/proprietary/auth/ui/SupabaseLoginForm.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/agents/StirlingLogoAnimated.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyPiiField.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/DividerWithText.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/ManageBillingButton.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/EnterpriseRequiredBanner.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiDocumentsSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiLimitsSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiModelsSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditChartsSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsChart.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/workflow/ParticipantView.stories.tsx create mode 100644 frontend/vitest.config.ts diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 7ac5300a15..3e608855f3 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -194,6 +194,22 @@ tasks: cmds: - npx storybook build {{.CLI_ARGS}} + storybook:browser: + internal: true + desc: "Install the Chromium build the story scan runs in" + run: once + deps: [install] + cmds: + - npx playwright install chromium + + storybook:test: + desc: "Scan every story in real Chromium — each must mount without throwing" + deps: [install, storybook:browser] + cmds: + # Runs each story as a browser test. Pass a filter through, e.g. + # task frontend:storybook:test -- Button + - npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}} + # ============================================================ # Code quality # ============================================================ diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index be281b9ae3..c0436a3a82 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -15,7 +15,11 @@ const config: StorybookConfig = { "../editor/src/portal/**/*.mdx", "../editor/src/**/*.stories.@(ts|tsx)", ], - addons: ["@storybook/addon-themes", "@storybook/addon-a11y"], + addons: [ + "@storybook/addon-themes", + "@storybook/addon-a11y", + "@storybook/addon-vitest", + ], framework: { name: "@storybook/react-vite", options: {}, diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 339fe4f842..cd2e53153d 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -13,6 +13,7 @@ import { withThemeByDataAttribute } from "@storybook/addon-themes"; // classic runtime needs it present even though it's not named in the JSX. void React; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { TierProvider, type Tier } from "@portal/contexts/TierContext"; import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; @@ -86,6 +87,14 @@ if (!i18next.isInitialized) { // Start MSW once. Storybook runs in a browser so this uses the service worker. initialize({ onUnhandledRequest: "bypass" }, handlers); +// PortalApp wraps the app in a QueryClientProvider, so any component reaching a +// shared query hook throws "No QueryClient set" without one here. `retry: false` +// matches the portal test providers: a story showing an error state should show +// it immediately rather than sitting through backoff retries. +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + // Storybook-only: stub a SaaS session so apiClient.saas reads (invoices, payment // method, wallet) clear the session check and reach the MSW handlers instead of // failing with "No SaaS session". VITE_SUPABASE_URL/KEY are defined empty (see @@ -194,24 +203,26 @@ const withProviders: Decorator = (Story, context) => { const colorScheme = context.globals.theme === "dark" ? "dark" : "light"; return ( - - - - - {/* LinkProvider must wrap TierProvider: TierContext derives its tier - from useLink() (matches App.tsx's nesting). */} - - - - - - - - - - - - + + + + + + {/* LinkProvider must wrap TierProvider: TierContext derives its tier + from useLink() (matches App.tsx's nesting). */} + + + + + + + + + + + + + ); }; diff --git a/frontend/.storybook/vitest.config.ts b/frontend/.storybook/vitest.config.ts new file mode 100644 index 0000000000..9535f63a47 --- /dev/null +++ b/frontend/.storybook/vitest.config.ts @@ -0,0 +1,62 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; +import { storybookTest } from "@storybook/addon-vitest/vitest-plugin"; + +/** + * Dedicated Vitest config that turns every story into a browser test: it mounts + * the story in real Chromium as a render/smoke check (a story must mount without + * throwing). a11y is currently report-only (preview's `a11y.test: "todo"`) and is + * not yet enforced here — flipping it to pass/fail is a follow-up. Kept separate + * from editor/vitest.config.ts (the jsdom unit tests) so the two suites don't collide. + * + * The storybook test must live in a `test.projects[]` entry (not a flat config) + * so Vitest wires up the browser test runner correctly. + * + * Run with: npx vitest run --config .storybook/vitest.config.ts + */ +export default defineConfig({ + 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. + entries: ["editor/src/**/*.stories.@(ts|tsx)", ".storybook/preview.tsx"], + // `entries` alone does not catch deps reached only through a transformed + // JSX runtime import, so Vite optimizes them lazily mid-run and emits + // "optimized dependencies changed, reloading". That reload tears down the + // browser worker and whichever stories were mid-load fail with a bogus + // "Failed to fetch dynamically imported module" — a result that looks real. + // Naming them here keeps a run deterministic. + include: [ + "react", + "react/jsx-runtime", + "react/jsx-dev-runtime", + "react-dom", + "react-dom/client", + ], + }, + test: { + projects: [ + { + extends: true, + // Reads .storybook/main.ts (stories glob + viteFinal aliases) so every + // discovered story becomes a test with the app's real module resolution. + plugins: [storybookTest({ configDir: resolve(__dirname) })], + test: { + name: "storybook", + // Mounting a story takes well over Vitest's 5s default on the heavier + // screens, and a story that trips the timeout is reported as a failure + // with no message — which reads like a crash. Give it room; a + // genuinely hung story still fails, just later. + testTimeout: 60_000, + hookTimeout: 60_000, + browser: { + enabled: true, + headless: true, + provider: "playwright", + instances: [{ browser: "chromium" }], + }, + setupFiles: [resolve(__dirname, "vitest.setup.ts")], + }, + }, + ], + }, +}); diff --git a/frontend/.storybook/vitest.setup.ts b/frontend/.storybook/vitest.setup.ts new file mode 100644 index 0000000000..6265c9bf54 --- /dev/null +++ b/frontend/.storybook/vitest.setup.ts @@ -0,0 +1,10 @@ +import { beforeAll } from "vitest"; +import { setProjectAnnotations } from "@storybook/react-vite"; +// eslint-disable-next-line no-restricted-imports -- Storybook-only: the sibling preview config has no @-alias. +import * as projectAnnotations from "./preview"; + +// Apply the same decorators/parameters/globals the Storybook UI uses (providers, +// i18n, theme) so stories run under Vitest render identically to the browser. +const project = setProjectAnnotations([projectAnnotations]); + +beforeAll(project.beforeAll); diff --git a/frontend/editor/src/core/components/AppLayout.stories.tsx b/frontend/editor/src/core/components/AppLayout.stories.tsx new file mode 100644 index 0000000000..4d7e6780cf --- /dev/null +++ b/frontend/editor/src/core/components/AppLayout.stories.tsx @@ -0,0 +1,75 @@ +import { useEffect } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AppLayout } from "@app/components/AppLayout"; +import { BannerProvider, useBanner } from "@app/contexts/BannerContext"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { InfoBanner } from "@app/components/shared/InfoBanner"; + +const meta = { + title: "Components/AppLayout", + component: AppLayout, + parameters: { layout: "fullscreen" }, + // AppLayout reads the active banner from BannerContext and renders + // NavigationWarningModal + LoginAgreementModal, which need the navigation + // guard (backed by the tool registry) mounted above them. + decorators: [ + (Story) => ( + + + + + + + + ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const sampleContent = ( +
+

Workbench

+

Tool panels and file previews render in this area.

+
+); + +export const Default: Story = { + args: { + children: sampleContent, + }, +}; + +// Calls BannerContext's setBanner on mount so the story can exercise +// AppLayout's height-adjustment behaviour (the child area shrinks to make +// room for the banner) without adding a banner prop to AppLayout itself. +function BannerSetter() { + const { setBanner } = useBanner(); + useEffect(() => { + setBanner( + , + ); + return () => setBanner(null); + }, [setBanner]); + return null; +} + +export const WithBanner: Story = { + args: { + children: sampleContent, + }, + decorators: [ + (Story) => ( + <> + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/StorageStatsCard.stories.tsx b/frontend/editor/src/core/components/StorageStatsCard.stories.tsx new file mode 100644 index 0000000000..d8c61ec3b3 --- /dev/null +++ b/frontend/editor/src/core/components/StorageStatsCard.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import StorageStatsCard from "@app/components/StorageStatsCard"; +import { StorageStats } from "@app/services/fileStorage"; + +const storageStats: StorageStats = { + used: 128 * 1024 * 1024, + available: 512 * 1024 * 1024, + fileCount: 12, + quota: 512 * 1024 * 1024, +}; + +const meta = { + title: "Components/StorageStatsCard", + component: StorageStatsCard, + parameters: { layout: "padded" }, + args: { + storageStats, + filesCount: 12, + onClearAll: () => {}, + onReloadFiles: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const NearingQuota: Story = { + args: { + storageStats: { + ...storageStats, + used: 460 * 1024 * 1024, + fileCount: 48, + }, + filesCount: 48, + }, +}; + +export const NoQuota: Story = { + args: { + storageStats: { + used: 32 * 1024 * 1024, + available: 0, + fileCount: 3, + }, + filesCount: 3, + }, +}; diff --git a/frontend/editor/src/core/components/annotation/shared/BaseAnnotationTool.stories.tsx b/frontend/editor/src/core/components/annotation/shared/BaseAnnotationTool.stories.tsx new file mode 100644 index 0000000000..9b896a62d4 --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/BaseAnnotationTool.stories.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool"; +import { PDFAnnotationProvider } from "@app/components/annotation/providers/PDFAnnotationProvider"; +import { SignatureProvider } from "@app/contexts/SignatureContext"; + +// BaseAnnotationTool reads usePDFAnnotation()/useSignature() for undo/redo and +// placement wiring — stub both providers so the story can mount standalone. +const StoryProviders = ({ children }: { children: ReactNode }) => ( + + {}} + deactivateDrawMode={() => {}} + activateSignaturePlacementMode={() => {}} + activateDeleteMode={() => {}} + updateDrawSettings={() => {}} + undo={() => {}} + redo={() => {}} + storeImageData={() => {}} + getImageData={() => undefined} + isPlacementMode={false} + signatureConfig={null} + setSignatureConfig={() => {}} + > + {children} + + +); + +// BaseAnnotationTool clones its child with tool-specific props (selectedColor, +// signatureData, onSignatureDataChange, onColorSwatchClick, disabled) — a real +// tool component absorbs these; a bare DOM element would just log prop warnings. +const ToolContent = (_props: Record) => ( +
Tool content
+); + +const meta = { + title: "Annotation/BaseAnnotationTool", + component: BaseAnnotationTool, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + config: { + enableImageUpload: true, + showPlaceButton: true, + placeButtonText: "Place Image", + }, + children: , + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/annotation/shared/ColorControl.stories.tsx b/frontend/editor/src/core/components/annotation/shared/ColorControl.stories.tsx new file mode 100644 index 0000000000..fff7ffc224 --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/ColorControl.stories.tsx @@ -0,0 +1,35 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ColorControl } from "@app/components/annotation/shared/ColorControl"; + +const meta: Meta = { + title: "Annotation/ColorControl", + component: ColorControl, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function ColorControlDemo({ + initialColor = "#ff0000", + disabled, +}: { + initialColor?: string; + disabled?: boolean; +}) { + const [color, setColor] = useState(initialColor); + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/annotation/shared/ColorPicker.stories.tsx b/frontend/editor/src/core/components/annotation/shared/ColorPicker.stories.tsx new file mode 100644 index 0000000000..1ffb5d76d2 --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/ColorPicker.stories.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ColorPicker } from "@app/components/annotation/shared/ColorPicker"; + +const meta = { + title: "Annotation/Shared/ColorPicker", + component: ColorPicker, + parameters: { layout: "fullscreen" }, + args: { + isOpen: true, + onClose: () => {}, + selectedColor: "#cc0000", + onColorChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function ColorPickerDemo( + props: Partial>, +) { + const [color, setColor] = useState(props.selectedColor ?? "#cc0000"); + const [opacity, setOpacity] = useState(props.opacity ?? 100); + return ( + {}} + {...props} + selectedColor={color} + onColorChange={setColor} + opacity={opacity} + onOpacityChange={setOpacity} + /> + ); +} + +/** The base modal: swatches + hex picker, no opacity slider. */ +export const Default: Story = { + render: () => , +}; + +/** With the opacity slider shown, for tools that need translucent fills (e.g. highlight, watermark). */ +export const WithOpacity: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/annotation/shared/DrawingCanvas.stories.tsx b/frontend/editor/src/core/components/annotation/shared/DrawingCanvas.stories.tsx new file mode 100644 index 0000000000..ebdc7282ce --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/DrawingCanvas.stories.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas"; + +const meta = { + title: "Annotation/DrawingCanvas", + component: DrawingCanvas, + parameters: { layout: "padded" }, + args: { + selectedColor: "#000000", + penSize: 3, + penSizeInput: "3", + onColorSwatchClick: () => {}, + onPenSizeChange: () => {}, + onPenSizeInputChange: () => {}, + onSignatureDataChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +function DrawingCanvasDemo( + props: Partial>, +) { + const [penSize, setPenSize] = useState(3); + const [penSizeInput, setPenSizeInput] = useState("3"); + + return ( + {}} + onPenSizeChange={(size) => { + setPenSize(size); + setPenSizeInput(String(size)); + }} + onPenSizeInputChange={setPenSizeInput} + onSignatureDataChange={() => {}} + {...props} + /> + ); +} + +/** Empty canvas preview with the drawing modal closed. */ +export const Default: Story = { render: () => }; + +/** Preview disabled — clicking the canvas should not open the modal. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/annotation/shared/DrawingControls.stories.tsx b/frontend/editor/src/core/components/annotation/shared/DrawingControls.stories.tsx new file mode 100644 index 0000000000..b847f9046e --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/DrawingControls.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DrawingControls } from "@app/components/annotation/shared/DrawingControls"; + +const meta: Meta = { + title: "Annotation/DrawingControls", + component: DrawingControls, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + onUndo: () => {}, + onRedo: () => {}, + onPlaceSignature: () => {}, + hasSignatureData: true, + canUndo: true, + canRedo: true, + }, +}; + +export const NoHistory: Story = { + args: { + onUndo: () => {}, + onRedo: () => {}, + onPlaceSignature: () => {}, + hasSignatureData: false, + canUndo: false, + canRedo: false, + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/annotation/shared/ImageUploader.stories.tsx b/frontend/editor/src/core/components/annotation/shared/ImageUploader.stories.tsx new file mode 100644 index 0000000000..f8500795ba --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/ImageUploader.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ImageUploader } from "@app/components/annotation/shared/ImageUploader"; + +const meta = { + title: "Annotation/Shared/ImageUploader", + component: ImageUploader, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + onImageChange: () => {}, + }, +}; + +export const WithLabelAndHint: Story = { + args: { + onImageChange: () => {}, + label: "Signature image", + hint: "PNG, JPG, or SVG - transparent backgrounds work best", + }, +}; + +export const WithBackgroundRemoval: Story = { + args: { + onImageChange: () => {}, + allowBackgroundRemoval: true, + onProcessedImageData: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + onImageChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/annotation/shared/OpacityControl.stories.tsx b/frontend/editor/src/core/components/annotation/shared/OpacityControl.stories.tsx new file mode 100644 index 0000000000..79bb3fddd8 --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/OpacityControl.stories.tsx @@ -0,0 +1,23 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { OpacityControl } from "@app/components/annotation/shared/OpacityControl"; + +const meta: Meta = { + title: "Annotation/OpacityControl", + component: OpacityControl, +}; +export default meta; +type Story = StoryObj; + +function OpacityControlDemo({ disabled }: { disabled?: boolean }) { + const [value, setValue] = useState(80); + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/annotation/shared/PropertiesPopover.stories.tsx b/frontend/editor/src/core/components/annotation/shared/PropertiesPopover.stories.tsx new file mode 100644 index 0000000000..b8b37dd980 --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/PropertiesPopover.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PropertiesPopover } from "@app/components/annotation/shared/PropertiesPopover"; + +const meta = { + title: "Annotation/Shared/PropertiesPopover", + component: PropertiesPopover, + parameters: { layout: "centered" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Text: Story = { + args: { + annotationType: "text", + annotation: undefined, + onUpdate: () => {}, + }, +}; + +export const Shape: Story = { + args: { + annotationType: "shape", + annotation: undefined, + onUpdate: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + annotationType: "text", + annotation: undefined, + onUpdate: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/annotation/shared/TextInputWithFont.stories.tsx b/frontend/editor/src/core/components/annotation/shared/TextInputWithFont.stories.tsx new file mode 100644 index 0000000000..4390f1b62d --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/TextInputWithFont.stories.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TextInputWithFont } from "@app/components/annotation/shared/TextInputWithFont"; + +const meta = { + title: "Annotation/Shared/TextInputWithFont", + component: TextInputWithFont, + parameters: { layout: "padded" }, + args: { + text: "Confidential", + onTextChange: () => {}, + fontSize: 24, + onFontSizeChange: () => {}, + fontFamily: "Helvetica", + onFontFamilyChange: () => {}, + label: "Text", + placeholder: "Enter text", + fontLabel: "Font", + fontSizeLabel: "Size", + fontSizePlaceholder: "24", + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function TextInputWithFontDemo( + props: Partial>, +) { + const [text, setText] = useState(props.text ?? "Confidential"); + const [fontSize, setFontSize] = useState(props.fontSize ?? 24); + const [fontFamily, setFontFamily] = useState(props.fontFamily ?? "Helvetica"); + const [textColor, setTextColor] = useState(props.textColor ?? "#000000"); + const [textAlign, setTextAlign] = useState<"left" | "center" | "right">( + props.textAlign ?? "left", + ); + + return ( + + ); +} + +/** Full control set: text, font, size, colour and alignment. */ +export const Default: Story = { + render: () => , +}; + +/** Without the colour picker or alignment control, for tools that don't need them. */ +export const WithoutColorOrAlign: Story = { + render: () => ( + + ), +}; + +/** All fields disabled. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/annotation/shared/WidthControl.stories.tsx b/frontend/editor/src/core/components/annotation/shared/WidthControl.stories.tsx new file mode 100644 index 0000000000..46cc4980ad --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/WidthControl.stories.tsx @@ -0,0 +1,35 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { WidthControl } from "@app/components/annotation/shared/WidthControl"; + +const meta: Meta = { + title: "Annotation/WidthControl", + component: WidthControl, +}; +export default meta; +type Story = StoryObj; + +function WidthControlDemo({ + min = 1, + max = 12, + disabled, +}: { + min?: number; + max?: number; + disabled?: boolean; +}) { + const [value, setValue] = useState(Math.round((min + max) / 2)); + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/annotation/tools/DrawingTool.stories.tsx b/frontend/editor/src/core/components/annotation/tools/DrawingTool.stories.tsx new file mode 100644 index 0000000000..002d07663f --- /dev/null +++ b/frontend/editor/src/core/components/annotation/tools/DrawingTool.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DrawingTool } from "@app/components/annotation/tools/DrawingTool"; +import { SignatureProvider } from "@app/contexts/SignatureContext"; +import { PDFAnnotationProvider } from "@app/components/annotation/providers/PDFAnnotationProvider"; + +// DrawingTool renders BaseAnnotationTool, which reads both SignatureContext and +// PDFAnnotationContext — neither is mounted by the shared preview, so stub both +// here with no-op handlers. +const meta = { + title: "Annotation/DrawingTool", + component: DrawingTool, + decorators: [ + (Story) => ( + + {}} + deactivateDrawMode={() => {}} + activateSignaturePlacementMode={() => {}} + activateDeleteMode={() => {}} + updateDrawSettings={() => {}} + undo={() => {}} + redo={() => {}} + storeImageData={() => {}} + getImageData={() => undefined} + isPlacementMode={false} + signatureConfig={null} + setSignatureConfig={() => {}} + > + + + + ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onDrawingChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + onDrawingChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/annotation/tools/ImageTool.stories.tsx b/frontend/editor/src/core/components/annotation/tools/ImageTool.stories.tsx new file mode 100644 index 0000000000..ee0ae3ca24 --- /dev/null +++ b/frontend/editor/src/core/components/annotation/tools/ImageTool.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ImageTool } from "@app/components/annotation/tools/ImageTool"; +import { SignatureProvider } from "@app/contexts/SignatureContext"; +import { PDFAnnotationProvider } from "@app/components/annotation/providers/PDFAnnotationProvider"; + +// ImageTool renders BaseAnnotationTool, which reads both SignatureContext and +// PDFAnnotationContext — neither is mounted by the shared preview, so stub both +// here with no-op handlers. +const meta = { + title: "Annotation/ImageTool", + component: ImageTool, + decorators: [ + (Story) => ( + + {}} + deactivateDrawMode={() => {}} + activateSignaturePlacementMode={() => {}} + activateDeleteMode={() => {}} + updateDrawSettings={() => {}} + undo={() => {}} + redo={() => {}} + storeImageData={() => {}} + getImageData={() => undefined} + isPlacementMode={false} + signatureConfig={null} + setSignatureConfig={() => {}} + > + + + + ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onImageChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + onImageChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/fileEditor/FileEditorFileName.stories.tsx b/frontend/editor/src/core/components/fileEditor/FileEditorFileName.stories.tsx new file mode 100644 index 0000000000..55645e9a0b --- /dev/null +++ b/frontend/editor/src/core/components/fileEditor/FileEditorFileName.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FileEditorFileName from "@app/components/fileEditor/FileEditorFileName"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const buildFileStub = ( + overrides: Partial = {}, +): StirlingFileStub => ({ + id: "file-1" as FileId, + name: "report.pdf", + type: "application/pdf", + size: 1024, + lastModified: 0, + isLeaf: true, + originalFileId: "file-1", + versionNumber: 1, + ...overrides, +}); + +const meta = { + title: "FileEditor/FileEditorFileName", + component: FileEditorFileName, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + file: buildFileStub(), + }, +}; + +export const LongFileName: Story = { + args: { + file: buildFileStub({ + name: "annual-financial-report-quarter-four-2026-final-version.pdf", + }), + maxLength: 30, + }, +}; diff --git a/frontend/editor/src/core/components/fileManager/CompactFileDetails.stories.tsx b/frontend/editor/src/core/components/fileManager/CompactFileDetails.stories.tsx new file mode 100644 index 0000000000..760b4b7781 --- /dev/null +++ b/frontend/editor/src/core/components/fileManager/CompactFileDetails.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CompactFileDetails from "@app/components/fileManager/CompactFileDetails"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const buildFileStub = ( + overrides: Partial = {}, +): StirlingFileStub => ({ + id: "file-1" as FileId, + name: "report.pdf", + type: "application/pdf", + size: 1024 * 512, + lastModified: 0, + isLeaf: true, + originalFileId: "file-1", + versionNumber: 1, + ...overrides, +}); + +const meta = { + title: "FileManager/CompactFileDetails", + component: CompactFileDetails, + args: { + onPrevious: () => {}, + onNext: () => {}, + onOpenFiles: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + currentFile: buildFileStub(), + thumbnail: null, + selectedFiles: [buildFileStub()], + currentFileIndex: 0, + numberOfFiles: 1, + isAnimating: false, + }, +}; + +export const MultipleFiles: Story = { + args: { + currentFile: buildFileStub({ name: "invoice-final.pdf", versionNumber: 2 }), + thumbnail: null, + selectedFiles: [ + buildFileStub({ id: "file-1" as FileId, name: "invoice-final.pdf" }), + buildFileStub({ id: "file-2" as FileId, name: "receipt.pdf" }), + buildFileStub({ id: "file-3" as FileId, name: "statement.pdf" }), + ], + currentFileIndex: 1, + numberOfFiles: 3, + isAnimating: false, + }, +}; + +export const NoFileLoaded: Story = { + args: { + currentFile: null, + thumbnail: null, + selectedFiles: [], + currentFileIndex: 0, + numberOfFiles: 0, + isAnimating: false, + }, +}; diff --git a/frontend/editor/src/core/components/fileManager/DragOverlay.stories.tsx b/frontend/editor/src/core/components/fileManager/DragOverlay.stories.tsx new file mode 100644 index 0000000000..a047cd9e94 --- /dev/null +++ b/frontend/editor/src/core/components/fileManager/DragOverlay.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import DragOverlay from "@app/components/fileManager/DragOverlay"; + +const meta = { + title: "FileManager/DragOverlay", + component: DragOverlay, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + isVisible: true, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const Hidden: Story = { + args: { + isVisible: false, + }, +}; diff --git a/frontend/editor/src/core/components/fileManager/FileDetails.stories.tsx b/frontend/editor/src/core/components/fileManager/FileDetails.stories.tsx new file mode 100644 index 0000000000..38ba423deb --- /dev/null +++ b/frontend/editor/src/core/components/fileManager/FileDetails.stories.tsx @@ -0,0 +1,73 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FileDetails from "@app/components/fileManager/FileDetails"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import { FileManagerProvider } from "@app/contexts/FileManagerContext"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const mockFile: StirlingFileStub = { + id: "story-file-1" as FileId, + name: "quarterly-report.pdf", + type: "application/pdf", + size: 2_400_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "story-file-1", + versionNumber: 1, + // Set so useIndexedDBThumbnail short-circuits on the stored thumbnail + // instead of trying to read file bytes out of IndexedDB. + thumbnailUrl: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='160'%3E%3Crect width='120' height='160' fill='%23e9ecef'/%3E%3C/svg%3E", +}; + +/** + * FileDetails reads from FileManagerContext, which itself needs FileContext + * (for useFileActions/useFileManagement) and IndexedDBContext (pulled in by + * FileContextProvider) further up the tree — neither is part of the shared + * preview decorators, so both are stood up here with static mock data. + */ +function withFileManager(activeFileIds: FileId[]) { + return (Story: () => ReactElement) => ( + + {}} + onNewFilesSelect={() => {}} + onClose={() => {}} + isFileSupported={() => true} + isOpen + onFileRemove={() => {}} + modalHeight="600px" + refreshRecentFiles={async () => {}} + isLoading={false} + activeFileIds={activeFileIds} + > + + + + ); +} + +const meta = { + title: "FileManager/FileDetails", + component: FileDetails, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + decorators: [withFileManager([mockFile.id])], +}; + +export const Empty: Story = { + decorators: [withFileManager([])], +}; + +export const Compact: Story = { + args: { + compact: true, + }, + decorators: [withFileManager([mockFile.id])], +}; diff --git a/frontend/editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx b/frontend/editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx new file mode 100644 index 0000000000..db4a285fcc --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DeleteFilesDialog } from "@app/components/filesPage/DeleteFilesDialog"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const buildFileStub = ( + overrides: Partial = {}, +): StirlingFileStub => ({ + id: "file-1" as FileId, + name: "report.pdf", + type: "application/pdf", + size: 1024, + lastModified: 0, + isLeaf: true, + originalFileId: "file-1", + versionNumber: 1, + ...overrides, +}); + +const localFile = buildFileStub(); +const cloudOnlyFile = buildFileStub({ + id: "server-1" as FileId, + name: "shared-invoice.pdf", + originalFileId: "server-1", + remoteStorageId: 42, + remoteOwnedByCurrentUser: true, +}); + +const meta = { + title: "FilesPage/DeleteFilesDialog", + component: DeleteFilesDialog, + args: { + opened: true, + onClose: () => {}, + onConfirm: async () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + files: [localFile], + }, +}; + +export const CloudOnly: Story = { + args: { + files: [cloudOnlyFile], + }, +}; + +export const LocalAndCloudChoice: Story = { + args: { + files: [localFile, cloudOnlyFile], + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx b/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx new file mode 100644 index 0000000000..891b72c58a --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DeleteFolderDialog } from "@app/components/filesPage/DeleteFolderDialog"; +import { createFolderId } from "@app/types/folder"; + +const mockFolder = { + id: createFolderId(), + name: "Invoices", + parentFolderId: null, + createdAt: Date.now(), + updatedAt: Date.now(), +}; + +const meta = { + title: "FilesPage/DeleteFolderDialog", + component: DeleteFolderDialog, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + folder: mockFolder, + fileCount: 0, + onClose: () => {}, + onConfirm: () => {}, + }, +}; + +export const WithFiles: Story = { + args: { + opened: true, + folder: mockFolder, + fileCount: 12, + onClose: () => {}, + onConfirm: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx new file mode 100644 index 0000000000..883e2fa8ba --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; +import type { FolderRecord } from "@app/types/folder"; + +const buildFileStub = ( + overrides: Partial = {}, +): StirlingFileStub => ({ + id: "file-1" as FileId, + name: "report.pdf", + type: "application/pdf", + size: 245_760, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "file-1" as FileId, + versionNumber: 1, + ...overrides, +}); + +const folder: FolderRecord = { + id: "folder-1" as FolderRecord["id"], + name: "Contracts", + parentFolderId: null, + createdAt: Date.now(), + updatedAt: Date.now(), +}; + +const meta = { + title: "FilesPage/FileDetailsPanel", + component: FileDetailsPanel, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const singleFile = buildFileStub(); +const fileMap = new Map([ + [singleFile.id, singleFile], +]); + +export const Default: Story = { + args: { + selectedFileIds: [singleFile.id], + fileMap, + currentFolder: null, + onClose: () => {}, + onAddToWorkspace: () => {}, + onMove: () => {}, + onRemove: () => {}, + }, +}; + +export const InFolder: Story = { + args: { + ...Default.args, + currentFolder: folder, + }, +}; + +export const MultiSelect: Story = { + args: (() => { + const fileA = buildFileStub({ id: "file-a" as FileId, name: "a.pdf" }); + const fileB = buildFileStub({ + id: "file-b" as FileId, + name: "b.pdf", + size: 102_400, + }); + return { + selectedFileIds: [fileA.id, fileB.id], + fileMap: new Map([ + [fileA.id, fileA], + [fileB.id, fileB], + ]), + currentFolder: null, + onClose: () => {}, + onAddToWorkspace: () => {}, + onMove: () => {}, + onRemove: () => {}, + }; + })(), +}; + +export const LocalOnlyWithSaveToServer: Story = { + args: { + ...Default.args, + onSaveToServer: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.stories.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.stories.tsx new file mode 100644 index 0000000000..d1cb9f32b6 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FileGrid.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ReactElement } from "react"; +import { + FileGrid, + type FilesPageEntry, +} from "@app/components/filesPage/FileGrid"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const buildFileStub = ( + overrides: Partial = {}, +): StirlingFileStub => ({ + id: "file-1" as FileId, + name: "report.pdf", + type: "application/pdf", + size: 1_240_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "file-1", + versionNumber: 1, + // Set so useLazyThumbnail short-circuits on the stored thumbnail instead of + // trying to read file bytes out of IndexedDB. + thumbnailUrl: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='160'%3E%3Crect width='120' height='160' fill='%23e9ecef'/%3E%3C/svg%3E", + ...overrides, +}); + +const localFile = buildFileStub(); +const cloudFile = buildFileStub({ + id: "file-2" as FileId, + name: "shared-invoice.pdf", + originalFileId: "file-2", + remoteStorageId: 42, + remoteOwnedByCurrentUser: true, +}); +const spreadsheetFile = buildFileStub({ + id: "file-3" as FileId, + name: "budget.xlsx", + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + originalFileId: "file-3", + thumbnailUrl: undefined, +}); + +const fileEntries: FilesPageEntry[] = [ + { kind: "file", file: localFile }, + { kind: "file", file: cloudFile }, + { kind: "file", file: spreadsheetFile }, +]; + +/** + * FileGrid renders file cards/rows via useLazyThumbnail, which reads + * IndexedDBContext + FileContext further up the tree - neither is part of + * the shared preview decorators, so FileContextProvider is stood up here. + * Folder entries are intentionally left out of these mocks: FolderCard / + * FolderRow call useFolders(), which needs a FolderProvider wired to + * IndexedDB/auth/app-config context this story doesn't stand up. + */ +function withFileContext(Story: () => ReactElement) { + return ( + + + + ); +} + +const meta = { + title: "FilesPage/FileGrid", + component: FileGrid, + decorators: [withFileContext], + args: { + entries: fileEntries, + selectedFileIds: new Set(), + viewMode: "grid", + onSelectFile: () => {}, + onOpenFolder: () => {}, + onOpenFile: () => {}, + onMoveFiles: () => {}, + onMoveFolder: () => {}, + onRenameFolder: () => {}, + onDeleteFolder: () => {}, + onChangeFolderAppearance: () => {}, + onRemoveFiles: () => {}, + onPromptMoveFiles: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const ListMode: Story = { + args: { + viewMode: "list", + }, +}; + +export const Loading: Story = { + args: { + entries: [], + loading: true, + }, +}; + +export const Empty: Story = { + args: { + entries: [], + loading: false, + currentTab: "all", + onEmptyUpload: () => {}, + onEmptyCreateFolder: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/FileOriginBadge.stories.tsx b/frontend/editor/src/core/components/filesPage/FileOriginBadge.stories.tsx new file mode 100644 index 0000000000..2f3ede8857 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FileOriginBadge.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FileOriginBadge } from "@app/components/filesPage/FileOriginBadge"; + +const meta: Meta = { + title: "FilesPage/FileOriginBadge", + component: FileOriginBadge, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + origin: "local", + }, +}; + +export const Cloud: Story = { + args: { + origin: "cloud", + }, +}; + +export const SharedWithMe: Story = { + args: { + origin: "shared-with-me", + }, +}; + +export const Compact: Story = { + args: { + origin: "cloud", + compact: true, + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx b/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx new file mode 100644 index 0000000000..4dc8cb3fd1 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx @@ -0,0 +1,46 @@ +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"; + +const folder: FolderRecord = { + id: "folder-1" as FolderRecord["id"], + name: "Contracts", + parentFolderId: null, + color: "#3b82f6", + icon: "star", + createdAt: Date.now(), + updatedAt: Date.now(), +}; + +const meta = { + title: "FilesPage/FolderAppearancePicker", + component: FolderAppearancePicker, + parameters: { layout: "padded" }, + args: { + folder, + onChange: fn(), + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const NoAppearanceSet: Story = { + args: { + folder: { + ...folder, + color: undefined, + icon: undefined, + }, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/FolderNameDialog.stories.tsx b/frontend/editor/src/core/components/filesPage/FolderNameDialog.stories.tsx new file mode 100644 index 0000000000..e059cda62a --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderNameDialog.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog"; + +const meta = { + title: "FilesPage/FolderNameDialog", + component: FolderNameDialog, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + title: "New folder", + submitLabel: "Create", + onClose: () => {}, + onSubmit: () => {}, + }, +}; + +export const Rename: Story = { + args: { + opened: true, + title: "Rename folder", + initialName: "Invoices 2026", + submitLabel: "Save", + onClose: () => {}, + onSubmit: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/FolderThumbnail.stories.tsx b/frontend/editor/src/core/components/filesPage/FolderThumbnail.stories.tsx new file mode 100644 index 0000000000..7208834119 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderThumbnail.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail"; + +const meta = { + title: "FilesPage/FolderThumbnail", + component: FolderThumbnail, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + color: "#6366f1", + fileCount: 12, + }, +}; + +export const RowSize: Story = { + args: { + color: "#22c55e", + fileCount: 3, + size: "row", + }, +}; + +export const WithIconGlyph: Story = { + args: { + color: "#f97316", + fileCount: 5, + iconGlyph: "📄", + }, +}; + +export const Empty: Story = { + args: { + size: "thumb", + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/FolderTreeSidebar.stories.tsx b/frontend/editor/src/core/components/filesPage/FolderTreeSidebar.stories.tsx new file mode 100644 index 0000000000..e06bf42685 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderTreeSidebar.stories.tsx @@ -0,0 +1,51 @@ +import type React from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FolderTreeSidebar } from "@app/components/filesPage/FolderTreeSidebar"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import { FolderProvider } from "@app/contexts/FolderContext"; +import { FilesPageProvider } from "@app/contexts/FilesPageContext"; +import { ROOT_FOLDER_ID } from "@app/types/folder"; + +/** + * FolderTreeSidebar reads the folder tree and active tab from FolderContext / + * FilesPageContext, neither of which is part of the shared preview + * decorators. Both providers also pull in IndexedDBContext (via + * FileContextProvider) further up the tree, so all three are stood up here. + * No folders are seeded into IndexedDB, so the tree renders with just the + * pinned "All files" / "Local" rows - an accurate empty state. + */ +function withFolderContexts(Story: () => React.JSX.Element) { + return ( + + + + + + + + ); +} + +const meta = { + title: "FilesPage/FolderTreeSidebar", + component: FolderTreeSidebar, + decorators: [withFolderContexts], + args: { + fileCounts: new Map([[ROOT_FOLDER_ID, 0]]), + onRequestNewFolder: () => {}, + onRenameFolder: () => {}, + onDeleteFolder: () => {}, + onMoveFilesIntoFolder: async () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithFileCounts: Story = { + args: { + fileCounts: new Map([[ROOT_FOLDER_ID, 12]]), + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx b/frontend/editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx new file mode 100644 index 0000000000..d01a697f04 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx @@ -0,0 +1,86 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { MoveToFolderDialog } from "@app/components/filesPage/MoveToFolderDialog"; +import { createFolderId, FolderRecord } from "@app/types/folder"; + +const workId = createFolderId(); +const invoicesId = createFolderId(); +const archivedId = createFolderId(); + +const folders: FolderRecord[] = [ + { + id: workId, + name: "Work", + parentFolderId: null, + color: "#3b82f6", + createdAt: Date.now(), + updatedAt: Date.now(), + }, + { + id: invoicesId, + name: "Invoices", + parentFolderId: workId, + color: "#10b981", + createdAt: Date.now(), + updatedAt: Date.now(), + }, + { + id: archivedId, + name: "Archived", + parentFolderId: null, + color: "#f59e0b", + createdAt: Date.now(), + updatedAt: Date.now(), + }, +]; + +const meta = { + title: "FilesPage/MoveToFolderDialog", + component: MoveToFolderDialog, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + onClose: () => {}, + folders, + onConfirm: () => {}, + }, +}; + +export const WithDisabledDescendant: Story = { + args: { + opened: true, + onClose: () => {}, + folders, + disabledFolderId: workId, + onConfirm: () => {}, + }, +}; + +export const WithCreateFolder: Story = { + args: { + opened: true, + onClose: () => {}, + folders, + onConfirm: () => {}, + onCreateFolder: async (name, parentFolderId) => ({ + id: createFolderId(), + name, + parentFolderId, + createdAt: Date.now(), + updatedAt: Date.now(), + }), + }, +}; + +export const Empty: Story = { + args: { + opened: true, + onClose: () => {}, + folders: [], + onConfirm: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/OpenInNewWindowMenuItem.stories.tsx b/frontend/editor/src/core/components/filesPage/OpenInNewWindowMenuItem.stories.tsx new file mode 100644 index 0000000000..14ba0755b2 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/OpenInNewWindowMenuItem.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { OpenInNewWindowMenuItem } from "@app/components/filesPage/OpenInNewWindowMenuItem"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const mockFile: StirlingFileStub = { + id: "file-1" as FileId, + name: "document.pdf", + type: "application/pdf", + size: 12345, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "file-1", + versionNumber: 1, +}; + +/** Core-flavor stub: renders nothing (desktop-only menu item). */ +const meta = { + title: "FilesPage/OpenInNewWindowMenuItem", + component: OpenInNewWindowMenuItem, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + file: mockFile, + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx b/frontend/editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx new file mode 100644 index 0000000000..1d5ce95e13 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx @@ -0,0 +1,64 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { VersionHistoryModal } from "@app/components/filesPage/VersionHistoryModal"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const mockFile: StirlingFileStub = { + id: "file-3" as FileId, + name: "report-watermarked.pdf", + type: "application/pdf", + size: 110592, + lastModified: 0, + isLeaf: true, + originalFileId: "file-1" as FileId, + versionNumber: 3, +}; + +/** + * The modal loads its version chain from IndexedDB (empty in Storybook) and + * dispatches file/navigation actions on add-to-workspace and remove, so it + * needs FileContext (also supplies IndexedDBContext) and the tool-registry- + * backed NavigationContext mounted above it. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + + + + + ); +} + +const meta = { + title: "FilesPage/VersionHistoryModal", + component: VersionHistoryModal, + decorators: [withProviders], + args: { + onClose: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** No prior versions exist in the (empty) storage, so the modal shows the empty state. */ +export const Default: Story = { + args: { + opened: true, + file: mockFile, + }, +}; + +export const NoFileSelected: Story = { + args: { + opened: true, + file: null, + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/VersionTimeline.stories.tsx b/frontend/editor/src/core/components/filesPage/VersionTimeline.stories.tsx new file mode 100644 index 0000000000..c86f7d8124 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/VersionTimeline.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { VersionTimeline } from "@app/components/filesPage/VersionTimeline"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId, ToolOperation } from "@app/types/file"; + +const buildFileStub = ( + overrides: Partial = {}, +): StirlingFileStub => ({ + id: "file-1" as FileId, + name: "report.pdf", + type: "application/pdf", + size: 1024, + lastModified: 0, + isLeaf: true, + originalFileId: "file-1", + versionNumber: 1, + ...overrides, +}); + +const toolOp = (toolId: ToolOperation["toolId"]): ToolOperation => ({ + toolId, + timestamp: 0, +}); + +const shortChain: StirlingFileStub[] = [ + buildFileStub({ + id: "file-1" as FileId, + name: "report.pdf", + size: 204800, + versionNumber: 1, + }), + buildFileStub({ + id: "file-2" as FileId, + name: "report-compressed.pdf", + size: 102400, + versionNumber: 2, + parentFileId: "file-1" as FileId, + toolHistory: [toolOp("compress")], + }), + buildFileStub({ + id: "file-3" as FileId, + name: "report-watermarked.pdf", + size: 110592, + versionNumber: 3, + parentFileId: "file-2" as FileId, + toolHistory: [toolOp("compress"), toolOp("watermark")], + }), +]; + +const longChain: StirlingFileStub[] = Array.from({ length: 9 }, (_, index) => { + const versionNumber = index + 1; + const toolHistory: ToolOperation[] = + versionNumber === 1 + ? [] + : [toolOp(versionNumber % 2 === 0 ? "compress" : "watermark")]; + return buildFileStub({ + id: `file-${versionNumber}` as FileId, + name: `report-v${versionNumber}.pdf`, + size: 100000 + versionNumber * 1024, + versionNumber, + parentFileId: + versionNumber > 1 ? (`file-${versionNumber - 1}` as FileId) : undefined, + toolHistory, + }); +}); + +const meta = { + title: "FilesPage/VersionTimeline", + component: VersionTimeline, + args: { + onAddToWorkspace: () => {}, + onRemove: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + chain: shortChain, + currentId: "file-3" as FileId, + }, +}; + +export const NoHeader: Story = { + args: { + chain: shortChain, + currentId: "file-3" as FileId, + hideHeader: true, + }, +}; + +export const LongChainCollapsed: Story = { + args: { + chain: longChain, + currentId: "file-9" as FileId, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx b/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx new file mode 100644 index 0000000000..7878980f0b --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx @@ -0,0 +1,79 @@ +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, +} from "@app/components/onboarding/OnboardingSlideShell"; + +const meta = { + title: "Onboarding/Slide Shell", + component: OnboardingSlideShell, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const BUTTONS: ShellButton[] = [ + { key: "back", back: true, action: "back" }, + { key: "skip", label: "Skip", action: "skip" }, + { key: "next", label: "Next", primary: true, action: "next" }, +]; + +/** A single standalone card — no step count, so no progress bar or pill. */ +export const Default: Story = { + args: { + hero: , + slideKey: "default", + title: "Welcome to Stirling PDF", + body: "Everything you need to view, edit and manage your PDFs in one place.", + stepIndex: 0, + stepCount: 1, + buttons: [{ key: "next", label: "Next", primary: true, action: "next" }], + onAction: () => {}, + onClose: () => {}, + }, +}; + +/** Mid-flow step: shows the step pill + progress bar, plus a back control. */ +export const SteppedWithBack: Story = { + args: { + hero: 2, + slideKey: "stepped", + title: "Choose your role", + body: "This helps us tailor the tools you see first.", + stepIndex: 2, + stepCount: 5, + buttons: BUTTONS, + onAction: () => {}, + onClose: () => {}, + }, +}; + +/** Dismiss (close button + escape-to-close) disabled — used for mandatory + * steps like a forced first-login password change. */ +export const NotDismissible: Story = { + args: { + hero: , + slideKey: "mandatory", + title: "Set a new password", + body: "You're using the default password — choose a new one to continue.", + stepIndex: 0, + stepCount: 3, + buttons: [ + { + key: "next", + label: "Continue", + primary: true, + action: "next", + disabled: true, + }, + ], + onAction: () => {}, + onClose: () => {}, + allowDismiss: false, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/OnboardingStepper.stories.tsx b/frontend/editor/src/core/components/onboarding/OnboardingStepper.stories.tsx new file mode 100644 index 0000000000..7784d7dfdf --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/OnboardingStepper.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { OnboardingStepper } from "@app/components/onboarding/OnboardingStepper"; + +const meta = { + title: "Onboarding/OnboardingStepper", + component: OnboardingStepper, + args: { totalSteps: 5, activeStep: 2 }, + argTypes: { + totalSteps: { control: { type: "number", min: 1, max: 10 } }, + activeStep: { control: { type: "number", min: 0, max: 9 } }, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: { totalSteps: 5, activeStep: 2 } }; + +export const FirstStep: Story = { args: { totalSteps: 5, activeStep: 0 } }; + +export const LastStep: Story = { args: { totalSteps: 5, activeStep: 4 } }; diff --git a/frontend/editor/src/core/components/onboarding/OnboardingTour.stories.tsx b/frontend/editor/src/core/components/onboarding/OnboardingTour.stories.tsx new file mode 100644 index 0000000000..d3dedcf848 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/OnboardingTour.stories.tsx @@ -0,0 +1,69 @@ +import { useTranslation } from "react-i18next"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { StepType } from "@reactour/tour"; +import OnboardingTour from "@app/components/onboarding/OnboardingTour"; +import "@app/components/onboarding/OnboardingTour.css"; + +/** + * Selectors go unused here since the target elements don't exist on the + * canvas, so reactour just centers the popover instead of anchoring to them. + */ +const SAMPLE_STEPS: StepType[] = [ + { + selector: "body", + content: "Welcome to Stirling PDF! Let's take a quick look around.", + }, + { + selector: "body", + content: "Here you can upload and manage your files.", + }, + { + selector: "body", + content: "This is the tools panel where you apply operations to a PDF.", + }, +]; + +function TourStage(props: { + tourType?: string; + isRTL?: boolean; + dimBackground?: boolean; +}) { + const { t } = useTranslation(); + return ( + { + const isLast = currentStep === (steps?.length ?? 0) - 1; + if (isLast) return; + setCurrentStep((prev) => prev + 1); + }} + onClose={({ setIsOpen }) => setIsOpen(false)} + dimBackground={props.dimBackground} + /> + ); +} + +const meta = { + title: "Onboarding/OnboardingTour", + component: TourStage, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** Default tour, mask dimmed to 70% opacity. */ +export const Default: Story = { args: {} }; + +/** Admin tour uses a dark mask class instead of the default dim mask. */ +export const AdminTour: Story = { args: { tourType: "admin" } }; + +/** RTL layout swaps the "next" arrow direction. */ +export const RTL: Story = { args: { isRTL: true } }; + +/** `dimBackground={false}` keeps the page fully visible behind the popover. */ +export const NoDim: Story = { args: { dimBackground: false } }; diff --git a/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.stories.tsx new file mode 100644 index 0000000000..46aa79429f --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.stories.tsx @@ -0,0 +1,105 @@ +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"; + +/** + * Renders the "interrupt" onboarding modals — slides shown outside the normal + * step flow (analytics consent, first-login password change, MFA setup, the + * external server-license notice) — each with dismissal disabled, since none + * of these can be skipped by the user. + */ +const meta = { + title: "Onboarding/Static Onboarding Slide", + component: StaticOnboardingSlide, + // Excluded from the automated (Vitest browser) test run: some slides fetch + // remote assets/endpoints (analytics, licensing) that aren't served in the + // headless scan, so they 404 and reject. Still renders in the Storybook UI + // for manual review. + tags: ["!test"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Opt-in analytics choice, shown once after first login. */ +export const AnalyticsChoice: Story = { + args: { + slideId: "analytics-choice", + runtimeState: DEFAULT_RUNTIME_STATE, + params: { analyticsError: null, analyticsLoading: false }, + onSkip: () => {}, + onAction: () => {}, + allowDismiss: false, + }, +}; + +/** Forced password change on first login with the default credentials. */ +export const FirstLogin: Story = { + args: { + slideId: "first-login", + runtimeState: { + ...DEFAULT_RUNTIME_STATE, + requiresPasswordChange: true, + firstLoginUsername: "admin", + usingDefaultCredentials: true, + }, + params: { + firstLoginUsername: "admin", + onPasswordChanged: () => {}, + usingDefaultCredentials: true, + }, + onSkip: () => {}, + onAction: () => {}, + allowDismiss: false, + }, +}; + +/** Two-factor setup, triggered when the account requires MFA. */ +export const MfaSetup: Story = { + args: { + slideId: "mfa-setup", + runtimeState: { ...DEFAULT_RUNTIME_STATE, requiresMfaSetup: true }, + params: { onMfaSetupComplete: () => {} }, + onSkip: () => {}, + onAction: () => {}, + allowDismiss: false, + }, +}; + +/** External license notice — the back button is stripped via + * `transformButtons` since there's no prior slide to return to. */ +export const ServerLicenseNotice: Story = { + args: { + slideId: "server-license", + transformButtons: (buttons) => + buttons.filter((btn) => btn.key !== "license-back"), + runtimeState: { + ...DEFAULT_RUNTIME_STATE, + licenseNotice: { + totalUsers: 12, + freeTierLimit: 5, + isOverLimit: true, + requiresLicense: true, + }, + }, + params: { + osOptions: [], + onDownloadUrlChange: () => {}, + licenseNotice: { + totalUsers: 12, + freeTierLimit: 5, + isOverLimit: true, + requiresLicense: true, + }, + loginEnabled: true, + }, + onSkip: () => {}, + onAction: () => {}, + allowDismiss: false, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.stories.tsx new file mode 100644 index 0000000000..3a91079712 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AnalyticsChoiceSlide from "@app/components/onboarding/slides/AnalyticsChoiceSlide"; + +// AnalyticsChoiceSlide is a slide-content factory (returns a SlideConfig), not +// a component, so wrap it to render its `body` the way the real onboarding +// shell does. +function AnalyticsChoiceSlideDemo({ + analyticsError = null, +}: { + analyticsError?: string | null; +}) { + const slide = AnalyticsChoiceSlide({ analyticsError }); + + return ( +
+

{slide.title}

+ {slide.body} +
+ ); +} + +const meta = { + title: "Onboarding/AnalyticsChoiceSlide", + component: AnalyticsChoiceSlideDemo, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: {} }; + +export const WithError: Story = { + args: { analyticsError: "Failed to save your analytics preference." }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.stories.tsx new file mode 100644 index 0000000000..d436942893 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground"; +import type { AnimatedCircleConfig } from "@app/types/types"; + +const circles: AnimatedCircleConfig[] = [ + { + size: 320, + color: "rgba(255, 255, 255, 0.4)", + position: "bottom-left", + blur: 40, + }, + { + size: 220, + color: "rgba(255, 255, 255, 0.3)", + position: "top-right", + opacity: 0.6, + blur: 30, + }, +]; + +const meta = { + title: "Onboarding/AnimatedSlideBackground", + component: AnimatedSlideBackground, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + gradientStops: ["#6E56CF", "#3B82F6"], + circles, + isActive: true, + slideKey: "welcome", + }, +}; + +export const Inactive: Story = { + args: { + ...Default.args, + isActive: false, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.stories.tsx new file mode 100644 index 0000000000..8618158336 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + DesktopInstallTitle, + type OSOption, +} from "@app/components/onboarding/slides/DesktopInstallTitle"; + +const OS_OPTIONS: OSOption[] = [ + { label: "macOS (Apple Silicon)", url: "#mac-arm", value: "mac-arm" }, + { label: "macOS (Intel)", url: "#mac-intel", value: "mac-intel" }, + { label: "Windows", url: "#windows", value: "windows" }, + { label: "Linux", url: "#linux", value: "linux" }, +]; + +const meta = { + title: "Onboarding/Slides/DesktopInstallTitle", + component: DesktopInstallTitle, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Multiple OS options: title plus a dropdown to switch the download target. */ +export const Default: Story = { + args: { + osLabel: "macOS (Apple Silicon)", + osUrl: "#mac-arm", + osOptions: OS_OPTIONS, + onDownloadUrlChange: () => {}, + }, +}; + +/** A single detected OS collapses to plain text — no dropdown affordance. */ +export const SingleOption: Story = { + args: { + osLabel: "Windows", + osUrl: "#windows", + osOptions: [{ label: "Windows", url: "#windows", value: "windows" }], + onDownloadUrlChange: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.stories.tsx new file mode 100644 index 0000000000..14b9b4009b --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FirstLoginSlide from "@app/components/onboarding/slides/FirstLoginSlide"; + +// FirstLoginSlide is a slide-content factory (returns a SlideConfig), not a +// component, so wrap it to render its `body` the way the real onboarding shell +// does. +function FirstLoginSlideDemo({ + username = "admin", + usingDefaultCredentials = false, +}: { + username?: string; + usingDefaultCredentials?: boolean; +}) { + const slide = FirstLoginSlide({ + username, + onPasswordChanged: () => {}, + usingDefaultCredentials, + }); + + return ( +
+

{slide.title}

+ {slide.body} +
+ ); +} + +const meta = { + title: "Onboarding/FirstLoginSlide", + component: FirstLoginSlideDemo, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: { username: "admin" } }; + +export const UsingDefaultCredentials: Story = { + args: { username: "admin", usingDefaultCredentials: true }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/PlanOverviewSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/PlanOverviewSlide.stories.tsx new file mode 100644 index 0000000000..2aafbaed68 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/PlanOverviewSlide.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PlanOverviewSlide from "@app/components/onboarding/slides/PlanOverviewSlide"; +import type { LicenseNotice } from "@app/types/types"; + +interface PlanOverviewStageProps { + isAdmin: boolean; + licenseNotice?: LicenseNotice; + loginEnabled?: boolean; +} + +// PlanOverviewSlide returns a SlideConfig (title/body nodes plus background +// config) rather than JSX, so this stage has to render those pieces itself. +function PlanOverviewStage({ + isAdmin, + licenseNotice, + loginEnabled, +}: PlanOverviewStageProps) { + const slide = PlanOverviewSlide({ isAdmin, licenseNotice, loginEnabled }); + return ( +
+

{slide.title}

+
{slide.body}
+
+ ); +} + +const meta = { + title: "Onboarding/Slides/PlanOverviewSlide", + component: PlanOverviewStage, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Regular user overview — no admin controls, no free-tier notice. */ +export const Default: Story = { + args: { isAdmin: false }, +}; + +/** Admin overview with login mode already enabled. */ +export const AdminLoginEnabled: Story = { + args: { + isAdmin: true, + loginEnabled: true, + licenseNotice: { + totalUsers: 3, + freeTierLimit: 5, + isOverLimit: false, + requiresLicense: false, + }, + }, +}; + +/** Admin overview before login mode is enabled — different body copy. */ +export const AdminLoginDisabled: Story = { + args: { + isAdmin: true, + loginEnabled: false, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.stories.tsx new file mode 100644 index 0000000000..b5e6ed4659 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ProcessorIntroSlide from "@app/components/onboarding/slides/ProcessorIntroSlide"; + +// ProcessorIntroSlide is a slide-content factory (returns a SlideConfig), not +// a component, so wrap it to render its `title`/`body` the way the real +// onboarding shell does. +function ProcessorIntroSlideDemo() { + const slide = ProcessorIntroSlide(); + + return ( +
+

{slide.title}

+
{slide.body}
+
+ ); +} + +const meta = { + title: "Onboarding/ProcessorIntroSlide", + component: ProcessorIntroSlideDemo, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: {} }; diff --git a/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.stories.tsx new file mode 100644 index 0000000000..73981e4bfb --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.stories.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SecurityCheckSlide from "@app/components/onboarding/slides/SecurityCheckSlide"; + +// SecurityCheckSlide is a slide-content factory (returns a SlideConfig), not a +// component, so wrap it to render its `body` the way the real onboarding shell +// does, with local state standing in for the modal's role selection state. +function SecurityCheckSlideDemo({ + initialRole = null, +}: { + initialRole?: "admin" | "user" | null; +}) { + const [selectedRole, setSelectedRole] = useState(initialRole); + const slide = SecurityCheckSlide({ + selectedRole, + onRoleSelect: setSelectedRole, + }); + + return ( +
+

{slide.title}

+ {slide.body} +
+ ); +} + +const meta = { + title: "Onboarding/SecurityCheckSlide", + component: SecurityCheckSlideDemo, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: {} }; + +export const AdminSelected: Story = { args: { initialRole: "admin" } }; + +export const UserSelected: Story = { args: { initialRole: "user" } }; diff --git a/frontend/editor/src/core/components/onboarding/slides/ServerLicenseSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/ServerLicenseSlide.stories.tsx new file mode 100644 index 0000000000..fae21c8748 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/ServerLicenseSlide.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ServerLicenseSlide from "@app/components/onboarding/slides/ServerLicenseSlide"; +import type { LicenseNotice } from "@app/types/types"; + +interface ServerLicenseStageProps { + licenseNotice?: LicenseNotice; +} + +// ServerLicenseSlide is a factory that returns a SlideConfig (title/body nodes +// plus background config), not JSX itself, so this stage renders those pieces +// directly to preview the slide in isolation. +function ServerLicenseStage({ licenseNotice }: ServerLicenseStageProps) { + const slide = ServerLicenseSlide({ licenseNotice }); + return ( +
+

{slide.title}

+
{slide.body}
+
+ ); +} + +const meta = { + title: "Onboarding/Slides/ServerLicenseSlide", + component: ServerLicenseStage, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Default free-tier notice — under the limit. */ +export const Default: Story = { + args: {}, +}; + +/** Under the free-tier limit with a known user count. */ +export const UnderLimit: Story = { + args: { + licenseNotice: { + totalUsers: 3, + freeTierLimit: 5, + isOverLimit: false, + requiresLicense: false, + }, + }, +}; + +/** Over the free-tier limit — prompts to upgrade with a different gradient. */ +export const OverLimit: Story = { + args: { + licenseNotice: { + totalUsers: 12, + freeTierLimit: 5, + isOverLimit: true, + requiresLicense: true, + }, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx new file mode 100644 index 0000000000..f3ae1a76d4 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide"; + +// WelcomeSlide is a slide-content factory (returns a SlideConfig), not +// a component, so wrap it to render its `title`/`body` the way the real +// onboarding shell does. +function WelcomeSlideDemo() { + const slide = WelcomeSlide(); + + return ( +
+

{slide.title}

+
{slide.body}
+
+ ); +} + +const meta = { + title: "Onboarding/WelcomeSlide", + component: WelcomeSlideDemo, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: {} }; diff --git a/frontend/editor/src/core/components/pageEditor/DragDropGrid.stories.tsx b/frontend/editor/src/core/components/pageEditor/DragDropGrid.stories.tsx new file mode 100644 index 0000000000..be7616f1f9 --- /dev/null +++ b/frontend/editor/src/core/components/pageEditor/DragDropGrid.stories.tsx @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import DragDropGrid from "@app/components/pageEditor/DragDropGrid"; + +interface MockGridItem { + id: string; + pageNumber?: number; + originalFileId?: string; +} + +const buildItems = (count: number): MockGridItem[] => + Array.from({ length: count }, (_, index) => ({ + id: `page-${index + 1}`, + pageNumber: index + 1, + originalFileId: "file-1", + })); + +const renderItem = ( + item: MockGridItem, + index: number, + refs: React.MutableRefObject>, + boxSelectedIds: string[], + clearBoxSelection: () => void, + activeDragIds: string[], + justMoved: boolean, + dragHandleProps?: any, + zoomLevel?: number, +) => { + const { ref: dndRef, ...restDragProps } = dragHandleProps ?? {}; + const isBoxSelected = boxSelectedIds.includes(item.id); + const isDragging = activeDragIds.includes(item.id); + + return ( +
{ + if (element) { + refs.current.set(item.id, element); + } else { + refs.current.delete(item.id); + } + dndRef?.(element); + }} + {...restDragProps} + onClick={clearBoxSelection} + style={{ + width: `calc(10rem * ${zoomLevel ?? 1})`, + height: `calc(13rem * ${zoomLevel ?? 1})`, + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: "0.5rem", + border: isBoxSelected + ? "2px solid var(--mantine-color-blue-6)" + : "1px solid var(--mantine-color-gray-4)", + background: isDragging + ? "var(--mantine-color-gray-1)" + : "var(--mantine-color-body)", + opacity: justMoved ? 0.7 : 1, + cursor: "grab", + }} + > + Page {item.pageNumber ?? index + 1} +
+ ); +}; + +const noopReorder = () => {}; + +const meta = { + title: "PageEditor/DragDropGrid", + component: DragDropGrid, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const ScrollDecorator = (StoryComponent: React.ComponentType) => ( +
+ +
+); + +export const Default: Story = { + args: { + items: buildItems(8), + onReorderPages: noopReorder, + renderItem, + }, + decorators: [ScrollDecorator], +}; + +export const Empty: Story = { + args: { + items: [], + onReorderPages: noopReorder, + renderItem, + }, + decorators: [ScrollDecorator], +}; + +export const Zoomed: Story = { + args: { + items: buildItems(6), + onReorderPages: noopReorder, + renderItem, + zoomLevel: 1.5, + }, + decorators: [ScrollDecorator], +}; diff --git a/frontend/editor/src/core/components/pageEditor/PageEditorControls.stories.tsx b/frontend/editor/src/core/components/pageEditor/PageEditorControls.stories.tsx new file mode 100644 index 0000000000..5838a6c7d4 --- /dev/null +++ b/frontend/editor/src/core/components/pageEditor/PageEditorControls.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageEditorControls from "@app/components/pageEditor/PageEditorControls"; + +const meta = { + title: "PageEditor/PageEditorControls", + component: PageEditorControls, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const baseArgs = { + onClosePdf: () => {}, + onUndo: () => {}, + onRedo: () => {}, + canUndo: true, + canRedo: true, + onRotate: () => {}, + onDelete: () => {}, + onSplit: () => {}, + onSplitAll: () => {}, + onPageBreak: () => {}, + onPageBreakAll: () => {}, + onExportAll: () => {}, + exportLoading: false, + selectionMode: true, + selectedPageIds: ["page-1", "page-2"], + displayDocument: { + pages: [ + { id: "page-1", pageNumber: 1 }, + { id: "page-2", pageNumber: 2 }, + { id: "page-3", pageNumber: 3 }, + ], + }, + splitPositions: new Set(), + totalPages: 3, +}; + +export const Default: Story = { + args: baseArgs, +}; + +export const NoSelection: Story = { + args: { + ...baseArgs, + selectedPageIds: [], + canUndo: false, + canRedo: false, + }, +}; + +export const WithExistingSplits: Story = { + args: { + ...baseArgs, + splitPositions: new Set(["page-1", "page-2"]), + }, +}; diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx new file mode 100644 index 0000000000..5f3f1138d5 --- /dev/null +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdvancedSelectionPanel from "@app/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel"; + +const meta = { + title: "PageEditor/BulkSelectionPanel/AdvancedSelectionPanel", + component: AdvancedSelectionPanel, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + csvInput: "", + setCsvInput: () => {}, + onUpdatePagesFromCSV: () => {}, + maxPages: 20, + advancedOpened: true, + }, +}; + +export const WithExpression: Story = { + args: { + csvInput: "1-5, odd", + setCsvInput: () => {}, + onUpdatePagesFromCSV: () => {}, + maxPages: 20, + advancedOpened: true, + }, +}; + +export const Closed: Story = { + args: { + csvInput: "", + setCsvInput: () => {}, + onUpdatePagesFromCSV: () => {}, + maxPages: 20, + advancedOpened: false, + }, +}; diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx new file mode 100644 index 0000000000..b913dbbde5 --- /dev/null +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import OperatorsSection from "@app/components/pageEditor/bulkSelectionPanel/OperatorsSection"; + +const meta = { + title: "PageEditor/BulkSelectionPanel/OperatorsSection", + component: OperatorsSection, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + csvInput: "1,2,3", + onInsertOperator: (op) => console.log("insert operator", op), + }, +}; + +export const EmptyInput: Story = { + args: { + csvInput: "", + onInsertOperator: (op) => console.log("insert operator", op), + }, +}; diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.stories.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.stories.tsx new file mode 100644 index 0000000000..3f3ff93a94 --- /dev/null +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SelectPages from "@app/components/pageEditor/bulkSelectionPanel/SelectPages"; + +const meta = { + title: "PageEditor/SelectPages", + component: SelectPages, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Select pages", + placeholder: "Page number", + onApply: (value: number) => console.log("apply", value), + maxPages: 10, + }, +}; + +export const WithValidation: Story = { + args: { + title: "Select pages", + placeholder: "Page number", + onApply: (value: number) => console.log("apply", value), + maxPages: 10, + validationFn: (value: number) => + value > 10 ? "Page number exceeds document length" : null, + }, +}; + +export const Range: Story = { + args: { + title: "Select page range", + placeholder: "Start page", + onApply: (value: number) => console.log("apply", value), + maxPages: 10, + isRange: true, + rangeEndValue: 5, + onRangeEndChange: (value: string | number) => + console.log("range end", value), + rangeEndPlaceholder: "End page", + }, +}; diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx new file mode 100644 index 0000000000..3b5d8421d1 --- /dev/null +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SelectedPagesDisplay from "@app/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay"; + +const displayDocument = { + pages: [ + { id: "page-1", pageNumber: 1 }, + { id: "page-2", pageNumber: 2 }, + { id: "page-3", pageNumber: 3 }, + { id: "page-4", pageNumber: 4 }, + ], +}; + +const meta = { + title: "PageEditor/SelectedPagesDisplay", + component: SelectedPagesDisplay, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + selectedPageIds: ["page-1", "page-3"], + displayDocument, + syntaxError: null, + }, +}; + +export const SyntaxError: Story = { + args: { + selectedPageIds: ["page-1", "page-3"], + displayDocument, + syntaxError: "Invalid page range: 1-abc", + }, +}; diff --git a/frontend/editor/src/core/components/shared/AllToolsNavButton.stories.tsx b/frontend/editor/src/core/components/shared/AllToolsNavButton.stories.tsx new file mode 100644 index 0000000000..a4912a750c --- /dev/null +++ b/frontend/editor/src/core/components/shared/AllToolsNavButton.stories.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AllToolsNavButton from "@app/components/shared/AllToolsNavButton"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; + +/** + * The button reads/writes tool selection and panel state via ToolWorkflowContext, + * and derives the home link href from NavigationContext plus the tool registry — + * all four providers must be present for it to render. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + + + + + + + ); +} + +const meta = { + title: "Shared/AllToolsNavButton", + component: AllToolsNavButton, + decorators: [withProviders], + args: { + activeButton: "tools", + setActiveButton: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Highlighted when it is the active quick-access button. */ +export const Default: Story = { + args: { + activeButton: "tools", + setActiveButton: () => {}, + }, +}; + +function InactiveDemo() { + const [activeButton, setActiveButton] = useState("home"); + return ( + + ); +} + +/** Not the active button — a different quick-access item is selected. */ +export const Inactive: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/AppSwitch.stories.tsx b/frontend/editor/src/core/components/shared/AppSwitch.stories.tsx new file mode 100644 index 0000000000..6d4449fe58 --- /dev/null +++ b/frontend/editor/src/core/components/shared/AppSwitch.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AppSwitch } from "@app/components/shared/AppSwitch"; + +/** The editor ⇄ processor app switcher rendered by both the editor and portal sidebars. */ +const meta: Meta = { + title: "Shared/AppSwitch", + component: AppSwitch, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Editor: Story = { + args: { + current: "editor", + theme: "light", + onSwitch: () => {}, + }, +}; + +export const Processor: Story = { + args: { + current: "processor", + theme: "light", + onSwitch: () => {}, + }, +}; + +export const DarkTheme: Story = { + args: { + current: "editor", + theme: "dark", + onSwitch: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/BulkShareModal.stories.tsx b/frontend/editor/src/core/components/shared/BulkShareModal.stories.tsx new file mode 100644 index 0000000000..cfa1f223ee --- /dev/null +++ b/frontend/editor/src/core/components/shared/BulkShareModal.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ReactElement } from "react"; +import BulkShareModal from "@app/components/shared/BulkShareModal"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const mockFiles: StirlingFileStub[] = [ + { + id: "story-file-1" as FileId, + name: "quarterly-report.pdf", + type: "application/pdf", + size: 2_400_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "story-file-1", + versionNumber: 1, + }, + { + id: "story-file-2" as FileId, + name: "cover-letter.pdf", + type: "application/pdf", + size: 180_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "story-file-2", + versionNumber: 1, + }, +]; + +/** + * BulkShareModal reads useFileActions() from FileContext, which isn't part of + * the shared preview decorators, so it's provided here for every story. + * AppConfig (gating share links on `storageShareLinksEnabled`) is added + * per-story instead, since Default and LinksEnabled need different values. + */ +function withFileContext(Story: () => ReactElement) { + return ( + + + + ); +} + +const meta = { + title: "Shared/BulkShareModal", + component: BulkShareModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + files: mockFiles, + }, + decorators: [withFileContext], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Share links disabled by server config — default when no config is loaded. */ +export const Default: Story = {}; + +/** Share links enabled — the role selector and "Generate Link" action are active. */ +export const LinksEnabled: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx b/frontend/editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx new file mode 100644 index 0000000000..0b800195f5 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ReactElement } from "react"; +import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const mockFiles: StirlingFileStub[] = [ + { + id: "file-1" as FileId, + name: "quarterly-report.pdf", + type: "application/pdf", + size: 2_400_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "file-1" as FileId, + versionNumber: 1, + }, + { + id: "file-2" as FileId, + name: "invoice-march.pdf", + type: "application/pdf", + size: 512_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "file-2" as FileId, + versionNumber: 1, + }, +]; + +/** + * The modal dispatches updateStirlingFileStub on upload, so it needs + * FileContext (also supplies IndexedDBContext) mounted above it. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + ); +} + +const meta = { + title: "Shared/BulkUploadToServerModal", + component: BulkUploadToServerModal, + decorators: [withProviders], + args: { + onClose: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + files: mockFiles, + }, +}; + +export const SingleFile: Story = { + args: { + opened: true, + files: [mockFiles[0]], + }, +}; diff --git a/frontend/editor/src/core/components/shared/ButtonToggle.stories.tsx b/frontend/editor/src/core/components/shared/ButtonToggle.stories.tsx new file mode 100644 index 0000000000..88183305d3 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ButtonToggle.stories.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + ButtonToggle, + ButtonToggleOption, +} from "@app/components/shared/ButtonToggle"; + +const meta: Meta = { + title: "Shared/ButtonToggle", + component: ButtonToggle, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +const options: ButtonToggleOption[] = [ + { + value: "automatic", + label: "Automatic", + description: "Detect and redact automatically", + }, + { value: "manual", label: "Manual", description: "Select regions yourself" }, +]; + +function ToggleDemo({ + disabled, + size, +}: { + disabled?: boolean; + size?: "xs" | "sm" | "md" | "lg"; +}) { + const [value, setValue] = useState("automatic"); + return ( + + ); +} + +/** Default toggle with two options, each carrying a label + description. */ +export const Default: Story = { render: () => }; + +/** Disabled state — the selected segment must still be legible. */ +export const Disabled: Story = { render: () => }; + +/** Small size variant. */ +export const Small: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/shared/CloudStorageIcons.stories.tsx b/frontend/editor/src/core/components/shared/CloudStorageIcons.stories.tsx new file mode 100644 index 0000000000..9d44784233 --- /dev/null +++ b/frontend/editor/src/core/components/shared/CloudStorageIcons.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + GoogleDriveIcon, + OneDriveIcon, + DropboxIcon, +} from "@app/components/shared/CloudStorageIcons"; + +/** Cloud storage brand icons with brand-color / muted current-color variants. */ +const meta: Meta = { + title: "Shared/CloudStorageIcons", + component: GoogleDriveIcon, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const GoogleDrive: Story = { + args: { colored: true }, +}; + +export const GoogleDriveMuted: Story = { + args: { colored: false }, +}; + +export const OneDrive: Story = { + render: (args) => , + args: { colored: true }, +}; + +export const Dropbox: Story = { + render: (args) => , + args: { colored: true }, +}; diff --git a/frontend/editor/src/core/components/shared/DropdownListWithFooter.stories.tsx b/frontend/editor/src/core/components/shared/DropdownListWithFooter.stories.tsx new file mode 100644 index 0000000000..c52f1c9298 --- /dev/null +++ b/frontend/editor/src/core/components/shared/DropdownListWithFooter.stories.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Button } from "@mantine/core"; +import DropdownListWithFooter, { + DropdownItem, +} from "@app/components/shared/DropdownListWithFooter"; + +const meta: Meta = { + title: "Shared/DropdownListWithFooter", + component: DropdownListWithFooter, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +const items: DropdownItem[] = [ + { value: "single", name: "Single page" }, + { value: "facing", name: "Facing pages" }, + { value: "book", name: "Book view" }, + { value: "continuous", name: "Continuous scroll", disabled: true }, +]; + +function SingleSelectDemo() { + const [value, setValue] = useState("single"); + return ( + setValue(v as string)} + /> + ); +} + +function MultiSelectDemo() { + const [value, setValue] = useState(["single"]); + return ( + setValue(v as string[])} + multiSelect + searchable + footer={ + + } + /> + ); +} + +/** Single-select dropdown with a disabled item. */ +export const Default: Story = { render: () => }; + +/** Multi-select with search box and a footer action. */ +export const MultiSelectWithFooter: Story = { + render: () => , +}; + +/** No items available — empty state message inside the dropdown. */ +export const Empty: Story = { + render: () => { + return ( + {}} + /> + ); + }, +}; diff --git a/frontend/editor/src/core/components/shared/EditableSecretField.stories.tsx b/frontend/editor/src/core/components/shared/EditableSecretField.stories.tsx new file mode 100644 index 0000000000..b49fbd2806 --- /dev/null +++ b/frontend/editor/src/core/components/shared/EditableSecretField.stories.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import EditableSecretField from "@app/components/shared/EditableSecretField"; + +const meta: Meta = { + title: "Shared/EditableSecretField", + component: EditableSecretField, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +function SecretFieldDemo({ + initialValue = "", + ...rest +}: { + initialValue?: string; + label?: string; + description?: string; + placeholder?: string; + disabled?: boolean; + error?: string; +}) { + const [value, setValue] = useState(initialValue); + return ( + + ); +} + +/** Empty value: renders a normal password input. */ +export const Default: Story = { + render: () => , +}; + +/** Backend returned a masked value (********): shows a read-only display + Edit button. */ +export const Masked: Story = { + render: () => , +}; + +/** Disabled state — the Edit button on a masked value must still read as inert. */ +export const MaskedDisabled: Story = { + render: () => , +}; + +/** Validation error surfaced under the password input. */ +export const WithError: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx b/frontend/editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx new file mode 100644 index 0000000000..dd83b6eece --- /dev/null +++ b/frontend/editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx @@ -0,0 +1,60 @@ +import { useState, type ComponentProps } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import EncryptedPdfUnlockModal from "@app/components/shared/EncryptedPdfUnlockModal"; + +const meta = { + title: "Shared/EncryptedPdfUnlockModal", + component: EncryptedPdfUnlockModal, + args: { + opened: true, + password: "", + isProcessing: false, + remainingCount: 0, + onPasswordChange: () => {}, + onUnlock: () => {}, + onUnlockAll: () => {}, + onSkip: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function UnlockDemo( + props: Partial>, +) { + const [password, setPassword] = useState(""); + return ( + {}} + onUnlockAll={() => {}} + onSkip={() => {}} + {...props} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const MultipleFilesRemaining: Story = { + render: () => , +}; + +export const IncorrectPassword: Story = { + render: () => ( + + ), +}; + +export const Processing: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/ErrorBoundary.stories.tsx b/frontend/editor/src/core/components/shared/ErrorBoundary.stories.tsx new file mode 100644 index 0000000000..22a5f06feb --- /dev/null +++ b/frontend/editor/src/core/components/shared/ErrorBoundary.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Text } from "@mantine/core"; +import ErrorBoundary from "@app/components/shared/ErrorBoundary"; + +const meta: Meta = { + title: "Shared/ErrorBoundary", + component: ErrorBoundary, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function ThrowingChild(): never { + throw new Error("Simulated render error for Storybook"); +} + +/** Normal path — children render untouched when nothing throws. */ +export const Default: Story = { + args: { + children: Protected content renders normally., + }, +}; + +/** A child throwing during render is caught, showing the default fallback with a retry button. */ +export const CaughtError: Story = { + args: { + children: , + }, +}; + +/** A custom fallback component receives the error and a retry callback. */ +export const CustomFallback: Story = { + args: { + children: , + fallback: ({ error, retry }) => ( + + Custom fallback: {error?.message} + + + ), + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileCard.stories.tsx b/frontend/editor/src/core/components/shared/FileCard.stories.tsx new file mode 100644 index 0000000000..48edd5854e --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileCard.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FileCard from "@app/components/shared/FileCard"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import { StirlingFileStub, FileId } from "@app/types/fileContext"; + +function makeFile(name: string, type = "application/pdf"): File { + return new File(["%PDF-1.4 storybook fixture"], name, { + type, + lastModified: Date.now(), + }); +} + +function makeStub(id: string): StirlingFileStub { + return { + id: id as FileId, + name: "Annual-Report-2026.pdf", + type: "application/pdf", + size: 245_760, + lastModified: Date.now(), + isLeaf: true, + originalFileId: id, + versionNumber: 1, + }; +} + +/** FileCard reads/writes files via FileContext + IndexedDB, so it needs a real provider tree. */ +const meta = { + title: "Shared/FileCard", + component: FileCard, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + file: makeFile("Annual-Report-2026.pdf"), + fileStub: makeStub("story-file-1"), + onRemove: () => {}, + onView: () => {}, + onEdit: () => {}, + }, +}; + +export const Selected: Story = { + args: { + ...Default.args, + isSelected: true, + onSelect: () => {}, + }, +}; + +export const Unsupported: Story = { + args: { + ...Default.args, + isSupported: false, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileDocIcon.stories.tsx b/frontend/editor/src/core/components/shared/FileDocIcon.stories.tsx new file mode 100644 index 0000000000..b42f7085a2 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileDocIcon.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FileDocIcon } from "@app/components/shared/FileDocIcon"; + +const meta = { + title: "Shared/FileDocIcon", + component: FileDocIcon, + parameters: { layout: "padded" }, + args: { variant: "pdf" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { variant: "pdf" }, +}; + +/** All file-type variants, each using its own default accent color. */ +export const AllVariants: Story = { + render: () => ( +
+ + + + + + + +
+ ), +}; + +/** Explicit `color` overrides the variant's default accent. */ +export const CustomColor: Story = { + args: { variant: "pdf", color: "#e64980" }, +}; diff --git a/frontend/editor/src/core/components/shared/FileDropdownMenu.stories.tsx b/frontend/editor/src/core/components/shared/FileDropdownMenu.stories.tsx new file mode 100644 index 0000000000..bb6b7d2670 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileDropdownMenu.stories.tsx @@ -0,0 +1,49 @@ +import type { CSSProperties } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FileDropdownMenu } from "@app/components/shared/FileDropdownMenu"; + +const viewOptionStyle: CSSProperties = { + display: "flex", + alignItems: "center", + gap: "0.25rem", + padding: "0.25rem 0.5rem", +}; + +const activeFiles = [ + { fileId: "file-1", name: "Contract-Draft-v1.pdf" }, + { fileId: "file-2", name: "Invoice-2026-04.pdf", versionNumber: 2 }, + { fileId: "file-3", name: "Scanned-Document-With-A-Very-Long-Name.pdf" }, +]; + +const meta: Meta = { + title: "Shared/FileDropdownMenu", + component: FileDropdownMenu, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + displayName: "Contract-Draft-v1.pdf", + activeFiles, + currentFileIndex: 0, + viewOptionStyle, + onFileSelect: () => {}, + onFileRemove: () => {}, + }, +}; + +export const Switching: Story = { + args: { + ...Default.args, + switchingTo: "viewer", + }, +}; + +export const NoRemove: Story = { + args: { + ...Default.args, + onFileRemove: undefined, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileGrid.stories.tsx b/frontend/editor/src/core/components/shared/FileGrid.stories.tsx new file mode 100644 index 0000000000..aea431852a --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileGrid.stories.tsx @@ -0,0 +1,104 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FileGrid from "@app/components/shared/FileGrid"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +/** + * FileGrid renders FileCard entries, which call useFileThumbnail -> + * useIndexedDBThumbnail. That hook reads IndexedDBContext + FileContext, + * neither of which is part of the shared preview decorators, so + * FileContextProvider (which wraps IndexedDBProvider internally) is stood up + * here. + */ +function withFileContext(Story: () => ReactElement) { + return ( + + + + ); +} + +const buildFile = (name: string, size: number, type: string): File => { + return new File([new Uint8Array(size)], name, { + type, + lastModified: Date.now(), + }); +}; + +const buildRecord = ( + id: string, + overrides: Partial = {}, +): StirlingFileStub => ({ + id: id as FileId, + name: overrides.name ?? "report.pdf", + type: overrides.type ?? "application/pdf", + size: overrides.size ?? 1_240_000, + lastModified: overrides.lastModified ?? Date.now(), + isLeaf: true, + originalFileId: id, + versionNumber: 1, + // Set so useLazyThumbnail short-circuits on the stored thumbnail instead of + // trying to read file bytes out of IndexedDB. + thumbnailUrl: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='160'%3E%3Crect width='120' height='160' fill='%23e9ecef'/%3E%3C/svg%3E", + ...overrides, +}); + +const files = [ + { + file: buildFile("report.pdf", 1_240_000, "application/pdf"), + record: buildRecord("file-1", { name: "report.pdf" }), + }, + { + file: buildFile("invoice.pdf", 540_000, "application/pdf"), + record: buildRecord("file-2", { + name: "invoice.pdf", + size: 540_000, + }), + }, + { + file: buildFile( + "budget.xlsx", + 82_000, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ), + record: buildRecord("file-3", { + name: "budget.xlsx", + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + size: 82_000, + thumbnailUrl: undefined, + }), + }, +]; + +const meta = { + title: "Shared/FileGrid", + component: FileGrid, + decorators: [withFileContext], + args: { + files, + onRemove: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const SearchAndSort: Story = { + args: { + showSearch: true, + showSort: true, + onDeleteAll: () => {}, + }, +}; + +export const Empty: Story = { + args: { + files: [], + showSearch: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FilePickerModal.stories.tsx b/frontend/editor/src/core/components/shared/FilePickerModal.stories.tsx new file mode 100644 index 0000000000..51eac29632 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FilePickerModal.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FilePickerModal from "@app/components/shared/FilePickerModal"; + +const mockStoredFiles = [ + { id: "file-1", name: "invoice.pdf", size: 245_000, thumbnail: null }, + { + id: "file-2", + name: "contract-draft.pdf", + size: 1_240_000, + thumbnail: null, + }, + { id: "file-3", name: "scanned-form.pdf", size: 3_400_000, thumbnail: null }, +]; + +const meta = { + title: "Shared/FilePickerModal", + component: FilePickerModal, + parameters: { layout: "padded" }, + args: { + opened: true, + onClose: () => {}, + onSelectFiles: () => {}, + storedFiles: mockStoredFiles, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Storage populated with a few files available to pick from. */ +export const Default: Story = {}; + +/** No files exist in storage yet — shows the empty-state message. */ +export const Empty: Story = { + args: { + storedFiles: [], + }, +}; diff --git a/frontend/editor/src/core/components/shared/FilePreview.stories.tsx b/frontend/editor/src/core/components/shared/FilePreview.stories.tsx new file mode 100644 index 0000000000..30e2fec884 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FilePreview.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FilePreview from "@app/components/shared/FilePreview"; +import { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const mockFile: StirlingFileStub = { + id: "file-1" as FileId, + name: "annual-report.pdf", + type: "application/pdf", + size: 245_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "file-1", + versionNumber: 1, +}; + +const meta = { + title: "Shared/FilePreview", + component: FilePreview, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + file: mockFile, + thumbnail: null, + }, +}; + +export const Empty: Story = { + args: { + file: null, + }, +}; + +export const WithNavigation: Story = { + args: { + file: mockFile, + thumbnail: null, + showStacking: true, + showHoverOverlay: true, + showNavigation: true, + totalFiles: 3, + onFileClick: () => {}, + onPrevious: () => {}, + onNext: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileSelectorPicker.stories.tsx b/frontend/editor/src/core/components/shared/FileSelectorPicker.stories.tsx new file mode 100644 index 0000000000..3d01996b99 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileSelectorPicker.stories.tsx @@ -0,0 +1,48 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FileSelectorPicker } from "@app/components/shared/FileSelectorPicker"; +import { FileContextProvider } from "@app/contexts/FileContext"; + +/** + * Reads from FileContext (workbench files) and IndexedDBContext (persisted + * saved files) further up the tree — neither is part of the shared preview + * decorators, so FileContextProvider (which also wraps IndexedDBProvider) is + * stood up here. The popover starts closed, so no IndexedDB read happens + * until a story interacts with it. + */ +function withFileContext(Story: () => ReactElement) { + return ( + +
+ +
+
+ ); +} + +const meta = { + title: "Shared/FileSelectorPicker", + component: FileSelectorPicker, + parameters: { layout: "padded" }, + args: { + onSelect: () => {}, + }, + decorators: [withFileContext], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const CustomPlaceholder: Story = { + args: { + placeholder: "Choose a comparison file", + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileUploadButton.stories.tsx b/frontend/editor/src/core/components/shared/FileUploadButton.stories.tsx new file mode 100644 index 0000000000..d0411f1be9 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileUploadButton.stories.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FileUploadButton from "@app/components/shared/FileUploadButton"; + +const meta: Meta = { + title: "Shared/FileUploadButton", + component: FileUploadButton, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +function UploadDemo({ + initialFile, + ...rest +}: { + initialFile?: File; + disabled?: boolean; + accept?: string; + placeholder?: string; +}) { + const [file, setFile] = useState(initialFile); + return ( + setFile(next ?? undefined)} + {...rest} + /> + ); +} + +/** No file chosen yet — shows the default "Choose File" placeholder. */ +export const Default: Story = { render: () => }; + +/** A file has already been selected — the button shows its name. */ +export const WithFileSelected: Story = { + render: () => ( + + ), +}; + +/** Disabled state — should still be legible but non-interactive. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/FirstLoginModal.stories.tsx b/frontend/editor/src/core/components/shared/FirstLoginModal.stories.tsx new file mode 100644 index 0000000000..30941579f4 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FirstLoginModal.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FirstLoginModal from "@app/components/shared/FirstLoginModal"; + +const meta = { + title: "Shared/FirstLoginModal", + component: FirstLoginModal, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + username: "jane.doe", + onPasswordChanged: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FitText.stories.tsx b/frontend/editor/src/core/components/shared/FitText.stories.tsx new file mode 100644 index 0000000000..63b93d0ed7 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FitText.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FitText from "@app/components/shared/FitText"; + +const meta: Meta = { + title: "Shared/FitText", + component: FitText, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** Single-line text that shrinks its font size to fit the available width. */ +export const Default: Story = { + args: { + text: "Invoice_2026_Quarterly_Report.pdf", + }, +}; + +/** Multi-line clamp with soft-break hints inserted after '/', '-' and '_'. */ +export const MultiLine: Story = { + args: { + text: "path/to/some-very/long_document/name_that_needs_multiple_lines.pdf", + lines: 3, + }, +}; + +/** Explicit font size (rem) with a lower minimum shrink scale. */ +export const CustomFontSize: Story = { + args: { + text: "Custom Sized Label", + fontSize: 1.5, + minimumFontScale: 0.5, + }, +}; diff --git a/frontend/editor/src/core/components/shared/Footer.stories.tsx b/frontend/editor/src/core/components/shared/Footer.stories.tsx new file mode 100644 index 0000000000..af1d4a5805 --- /dev/null +++ b/frontend/editor/src/core/components/shared/Footer.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import Footer from "@app/components/shared/Footer"; + +const meta = { + title: "Shared/Footer", + component: Footer, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Defaults: no overrides supplied, only the always-present links render. */ +export const Default: Story = { + args: {}, +}; + +/** All optional legal links populated, plus the cookie preferences button. */ +export const AllLinksAndCookieBanner: Story = { + args: { + privacyPolicy: "https://example.com/privacy", + termsAndConditions: "https://example.com/terms", + accessibilityStatement: "https://example.com/accessibility", + cookiePolicy: "https://example.com/cookies", + impressum: "https://example.com/impressum", + analyticsEnabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/HoverActionMenu.stories.tsx b/frontend/editor/src/core/components/shared/HoverActionMenu.stories.tsx new file mode 100644 index 0000000000..dc391d0e9a --- /dev/null +++ b/frontend/editor/src/core/components/shared/HoverActionMenu.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import HoverActionMenu, { + type HoverAction, +} from "@app/components/shared/HoverActionMenu"; +import { iconMap } from "@app/components/tools/automate/iconMap"; + +const { EditIcon, DeleteIcon, DownloadIcon } = iconMap; + +const actions: HoverAction[] = [ + { + id: "edit", + icon: , + label: "Edit", + onClick: () => {}, + }, + { + id: "download", + icon: , + label: "Download", + onClick: () => {}, + }, + { + id: "delete", + icon: , + label: "Delete", + onClick: () => {}, + color: "var(--text-error)", + }, +]; + +const meta: Meta = { + title: "Shared/HoverActionMenu", + component: HoverActionMenu, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** Visible menu with the standard edit/download/delete action set. */ +export const Default: Story = { + args: { + show: true, + actions, + }, +}; + +/** Hidden state (`show: false`) — menu stays mounted but faded/non-interactive. */ +export const Hidden: Story = { + args: { + show: false, + actions, + }, +}; + +/** One action disabled with a custom tooltip explaining why. */ +export const WithDisabledAction: Story = { + args: { + show: true, + actions: [ + actions[0], + actions[1], + { + ...actions[2], + disabled: true, + tooltip: "Deletion is restricted by policy", + }, + ], + }, +}; diff --git a/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx b/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx new file mode 100644 index 0000000000..5fad071d05 --- /dev/null +++ b/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { InfoBanner } from "@app/components/shared/InfoBanner"; + +const meta = { + title: "Shared/InfoBanner", + component: InfoBanner, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: "info-rounded", + title: "Heads up", + message: "This document contains form fields that will be flattened.", + }, +}; + +export const Warning: Story = { + args: { + tone: "warning", + icon: "warning-rounded", + title: "Action required", + message: "Some pages could not be processed and were skipped.", + buttonText: "Review", + onButtonClick: () => {}, + }, +}; + +export const Compact: Story = { + args: { + compact: true, + icon: "info-rounded", + message: "Autosave is enabled for this file.", + dismissible: false, + }, +}; diff --git a/frontend/editor/src/core/components/shared/LandingDocumentStack.stories.tsx b/frontend/editor/src/core/components/shared/LandingDocumentStack.stories.tsx new file mode 100644 index 0000000000..e51e068ff9 --- /dev/null +++ b/frontend/editor/src/core/components/shared/LandingDocumentStack.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LandingDocumentStack } from "@app/components/shared/LandingDocumentStack"; + +/** Decorative stack only: window dots + grey bars — no props, no i18n. */ +const meta: Meta = { + title: "Shared/LandingDocumentStack", + component: LandingDocumentStack, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/shared/LanguageSelector.stories.tsx b/frontend/editor/src/core/components/shared/LanguageSelector.stories.tsx new file mode 100644 index 0000000000..2ffcba96c1 --- /dev/null +++ b/frontend/editor/src/core/components/shared/LanguageSelector.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LanguageSelector from "@app/components/shared/LanguageSelector"; + +const meta = { + title: "Shared/LanguageSelector", + component: LanguageSelector, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Compact: Story = { + args: { + compact: true, + tooltip: "Change language", + }, +}; + +export const TopStartPosition: Story = { + args: { + position: "top-start", + offset: 4, + }, +}; diff --git a/frontend/editor/src/core/components/shared/LoadingFallback.stories.tsx b/frontend/editor/src/core/components/shared/LoadingFallback.stories.tsx new file mode 100644 index 0000000000..1ce948a671 --- /dev/null +++ b/frontend/editor/src/core/components/shared/LoadingFallback.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LoadingFallback } from "@app/components/shared/LoadingFallback"; + +/** Full-screen splash shown while i18next Suspense is loading translations. */ +const meta: Meta = { + title: "Shared/LoadingFallback", + component: LoadingFallback, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/shared/LocalIcon.stories.tsx b/frontend/editor/src/core/components/shared/LocalIcon.stories.tsx new file mode 100644 index 0000000000..5f38ef168b --- /dev/null +++ b/frontend/editor/src/core/components/shared/LocalIcon.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LocalIcon from "@app/components/shared/LocalIcon"; + +const meta: Meta = { + title: "Shared/LocalIcon", + component: LocalIcon, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: "description", + width: "2rem", + height: "2rem", + }, +}; + +export const NumericSize: Story = { + args: { + icon: "download", + width: 32, + }, +}; + +export const WithFullCollectionPrefix: Story = { + args: { + icon: "material-symbols:error-rounded", + width: "1.5rem", + }, +}; diff --git a/frontend/editor/src/core/components/shared/LoginAgreementModal.stories.tsx b/frontend/editor/src/core/components/shared/LoginAgreementModal.stories.tsx new file mode 100644 index 0000000000..d8b160692a --- /dev/null +++ b/frontend/editor/src/core/components/shared/LoginAgreementModal.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LoginAgreementModal from "@app/components/shared/LoginAgreementModal"; + +/** + * Renders nothing by default: the modal only opens after fetching + * `/api/v1/config/login-disclaimer` and finding it enabled, which requires a + * live AppConfigProvider/backend. In Storybook (no providers configured) the + * config stays null, so the effect bails out and the component stays hidden. + */ +const meta: Meta = { + title: "Shared/LoginAgreementModal", + component: LoginAgreementModal, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/shared/MobileUploadModal.stories.tsx b/frontend/editor/src/core/components/shared/MobileUploadModal.stories.tsx new file mode 100644 index 0000000000..58e7e86aa4 --- /dev/null +++ b/frontend/editor/src/core/components/shared/MobileUploadModal.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import MobileUploadModal from "@app/components/shared/MobileUploadModal"; + +const meta: Meta = { + title: "Shared/MobileUploadModal", + component: MobileUploadModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + onFilesReceived: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** QR code + instructions for scanning a file upload session from a phone. */ +export const Default: Story = {}; + +/** Closed state — modal renders nothing visible. */ +export const Closed: Story = { + args: { + opened: false, + }, +}; diff --git a/frontend/editor/src/core/components/shared/MultiSelectControls.stories.tsx b/frontend/editor/src/core/components/shared/MultiSelectControls.stories.tsx new file mode 100644 index 0000000000..cb8281399b --- /dev/null +++ b/frontend/editor/src/core/components/shared/MultiSelectControls.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import MultiSelectControls from "@app/components/shared/MultiSelectControls"; + +const meta: Meta = { + title: "Shared/MultiSelectControls", + component: MultiSelectControls, + parameters: { layout: "padded" }, + args: { + selectedCount: 3, + onClearSelection: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** Only the always-present "Clear files" action, since none of the optional handlers are passed. */ +export const Default: Story = {}; + +/** All optional actions supplied — every button in the group renders. */ +export const AllActions: Story = { + args: { + onAddToUpload: () => {}, + onOpenInFileEditor: () => {}, + onOpenInPageEditor: () => {}, + onDeleteAll: () => {}, + }, +}; + +/** Renders nothing when no files are selected. */ +export const NoSelection: Story = { + args: { + selectedCount: 0, + }, +}; diff --git a/frontend/editor/src/core/components/shared/NavigationWarningModal.stories.tsx b/frontend/editor/src/core/components/shared/NavigationWarningModal.stories.tsx new file mode 100644 index 0000000000..ac6636125e --- /dev/null +++ b/frontend/editor/src/core/components/shared/NavigationWarningModal.stories.tsx @@ -0,0 +1,103 @@ +import { useEffect, type ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import NavigationWarningModal from "@app/components/shared/NavigationWarningModal"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { + NavigationProvider, + useNavigationGuard, + type NavigationWarningHandlers, +} from "@app/contexts/NavigationContext"; + +/** + * The modal renders nothing until NavigationContext has unsaved changes AND a + * pending navigation to warn about, so this drives both into place on mount — + * mirroring what a real editor does when it calls requestNavigation() while + * hasUnsavedChanges is true. It also registers any warning handlers the story + * supplies, since the modal only shows the "Apply & Leave"/"Export & Leave" + * buttons when a handler for them is present. + */ +function TriggerWarning({ + children, + handlers, +}: { + children: React.ReactNode; + handlers?: NavigationWarningHandlers; +}) { + const { + hasUnsavedChanges, + setHasUnsavedChanges, + requestNavigation, + registerNavigationWarningHandlers, + } = useNavigationGuard(); + + useEffect(() => { + setHasUnsavedChanges(true); + }, [setHasUnsavedChanges]); + + useEffect(() => { + if (handlers) { + registerNavigationWarningHandlers(handlers); + } + }, [handlers, registerNavigationWarningHandlers]); + + useEffect(() => { + if (hasUnsavedChanges) { + requestNavigation(() => {}); + } + }, [hasUnsavedChanges, requestNavigation]); + + return <>{children}; +} + +function withProviders( + Story: () => ReactElement, + context: { parameters: { navigationHandlers?: NavigationWarningHandlers } }, +) { + return ( + + + + + + + + ); +} + +const meta = { + title: "Shared/NavigationWarningModal", + component: NavigationWarningModal, + decorators: [withProviders], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Unsaved changes plus a pending navigation trigger the confirmation dialog. */ +export const Default: Story = {}; + +/** + * When the active tool registers an "apply and continue" handler (e.g. a + * pending edit that can be committed before leaving), the modal adds a third + * action alongside "Keep Working" and "Discard Changes". + */ +export const WithApplyAndContinue: Story = { + parameters: { + navigationHandlers: { + onApplyAndContinue: async () => {}, + } satisfies NavigationWarningHandlers, + }, +}; + +/** + * When the active tool registers an "export and continue" handler (e.g. a + * conversion tool that can export its result before leaving), the modal adds + * an "Export & Leave" action instead. + */ +export const WithExportAndContinue: Story = { + parameters: { + navigationHandlers: { + onExportAndContinue: async () => {}, + } satisfies NavigationWarningHandlers, + }, +}; diff --git a/frontend/editor/src/core/components/shared/ObscuredOverlay.stories.tsx b/frontend/editor/src/core/components/shared/ObscuredOverlay.stories.tsx new file mode 100644 index 0000000000..4b8d936cc5 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ObscuredOverlay.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ObscuredOverlay from "@app/components/shared/ObscuredOverlay"; + +const meta: Meta = { + title: "Shared/ObscuredOverlay", + component: ObscuredOverlay, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +const Content = () => ( +
+ Underlying content that gets obscured. +
+); + +export const Unobscured: Story = { + args: { + obscured: false, + children: , + }, +}; + +export const Obscured: Story = { + args: { + obscured: true, + overlayMessage: "This feature requires an upgrade", + buttonText: "Upgrade", + onButtonClick: () => {}, + children: , + }, +}; + +export const RoundedCorners: Story = { + args: { + obscured: true, + overlayMessage: "Locked", + borderRadius: "0.5rem", + children: , + }, +}; diff --git a/frontend/editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx b/frontend/editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx new file mode 100644 index 0000000000..e14d9dc030 --- /dev/null +++ b/frontend/editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageSelectionSyntaxHint from "@app/components/shared/PageSelectionSyntaxHint"; + +const meta = { + title: "Shared/PageSelectionSyntaxHint", + component: PageSelectionSyntaxHint, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Valid syntax ("1-3,5") renders nothing — no hint shown. */ +export const Default: Story = { + args: { + input: "1-3,5", + maxPages: 10, + }, +}; + +/** Malformed expression falls back to CSV parsing and shows the panel-style hint. */ +export const SyntaxError: Story = { + args: { + input: "abc", + maxPages: 10, + }, +}; + +/** Same malformed input, compact variant used inline within a tool panel. */ +export const CompactSyntaxError: Story = { + args: { + input: "abc", + maxPages: 10, + variant: "compact", + }, +}; diff --git a/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx b/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx new file mode 100644 index 0000000000..81969bf615 --- /dev/null +++ b/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PolicyBadges } from "@app/components/shared/PolicyBadges"; +import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges"; + +const mockPolicies: FileItemPolicyRef[] = [ + { id: "policy-1", name: "Redact PII", accentColor: "#e03131", recent: true }, + { id: "policy-2", name: "Sanitize", accentColor: "#2f9e44", recent: false }, + { id: "policy-3", name: "Watermark", accentColor: "#4263eb", recent: false }, +]; + +const meta = { + title: "Shared/PolicyBadges", + component: PolicyBadges, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + policies: mockPolicies, + }, +}; + +export const Enforcing: Story = { + args: { + policies: [ + { + id: "policy-1", + name: "Redact PII", + accentColor: "#e03131", + recent: false, + enforcing: true, + }, + ...mockPolicies.slice(1), + ], + }, +}; + +export const Empty: Story = { + args: { + policies: [], + }, +}; diff --git a/frontend/editor/src/core/components/shared/PrivateContent.stories.tsx b/frontend/editor/src/core/components/shared/PrivateContent.stories.tsx new file mode 100644 index 0000000000..3103eb3962 --- /dev/null +++ b/frontend/editor/src/core/components/shared/PrivateContent.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PrivateContent } from "@app/components/shared/PrivateContent"; + +/** Layout-invisible wrapper that tags sensitive content with 'ph-no-capture' to exclude it from analytics. */ +const meta: Meta = { + title: "Shared/PrivateContent", + component: PrivateContent, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: "sensitive-filename.pdf", + }, +}; + +export const WithCustomClassName: Story = { + args: { + children: "sensitive-filename.pdf", + className: "custom-class", + }, +}; diff --git a/frontend/editor/src/core/components/shared/ShareFileModal.stories.tsx b/frontend/editor/src/core/components/shared/ShareFileModal.stories.tsx new file mode 100644 index 0000000000..a616480d36 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ShareFileModal.stories.tsx @@ -0,0 +1,63 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ShareFileModal from "@app/components/shared/ShareFileModal"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const mockFile: StirlingFileStub = { + id: "story-file-1" as FileId, + name: "quarterly-report.pdf", + type: "application/pdf", + size: 2_400_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "story-file-1", + versionNumber: 1, +}; + +/** + * ShareFileModal reads useFileActions() from FileContext (stood up here since + * it isn't part of the shared preview decorators) and useAppConfig() to gate + * share links on `storageShareLinksEnabled` — wrapped per-story to show both + * the disabled and enabled states. + */ +function withFileContext(Story: () => ReactElement) { + return ( + + + + ); +} + +const meta = { + title: "Shared/ShareFileModal", + component: ShareFileModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + file: mockFile, + }, + decorators: [withFileContext], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Share links disabled by server config — default when no config is loaded. */ +export const Default: Story = {}; + +/** Share links enabled — the role selector and "Generate Link" action are active. */ +export const LinksEnabled: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/ShareManagementModal.stories.tsx b/frontend/editor/src/core/components/shared/ShareManagementModal.stories.tsx new file mode 100644 index 0000000000..3fff68cbf3 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ShareManagementModal.stories.tsx @@ -0,0 +1,66 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ShareManagementModal from "@app/components/shared/ShareManagementModal"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const mockFile: StirlingFileStub = { + id: "story-file-1" as FileId, + name: "quarterly-report.pdf", + type: "application/pdf", + size: 2_400_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "story-file-1", + versionNumber: 1, +}; + +/** + * ShareManagementModal reads useFileActions() from FileContext (stood up here + * since it isn't part of the shared preview decorators) and useAppConfig() to + * gate share links on `storageShareLinksEnabled` — wrapped per-story to show + * both the disabled and enabled states. + */ +function withFileContext(Story: () => ReactElement) { + return ( + + + + ); +} + +const meta = { + title: "Shared/ShareManagementModal", + component: ShareManagementModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + file: mockFile, + }, + decorators: [withFileContext], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Share links disabled by server config — default when no config is loaded. */ +export const Default: Story = {}; + +/** Share links enabled — the role selector, link generation and activity panel are active. */ +export const LinksEnabled: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/SkeletonLoader.stories.tsx b/frontend/editor/src/core/components/shared/SkeletonLoader.stories.tsx new file mode 100644 index 0000000000..50c754a3cf --- /dev/null +++ b/frontend/editor/src/core/components/shared/SkeletonLoader.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SkeletonLoader from "@app/components/shared/SkeletonLoader"; + +const meta: Meta = { + title: "Shared/SkeletonLoader", + component: SkeletonLoader, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const PageGrid: Story = { + args: { type: "pageGrid", count: 4 }, +}; + +export const FileGrid: Story = { + args: { type: "fileGrid", count: 4 }, +}; + +export const Controls: Story = { + args: { type: "controls" }, +}; + +export const Viewer: Story = { + args: { type: "viewer" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; + +export const Block: Story = { + args: { type: "block", width: 120, height: 20 }, +}; diff --git a/frontend/editor/src/core/components/shared/ToolChain.stories.tsx b/frontend/editor/src/core/components/shared/ToolChain.stories.tsx new file mode 100644 index 0000000000..b8ef29f9b5 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ToolChain.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToolChain from "@app/components/shared/ToolChain"; +import { ToolOperation } from "@app/types/file"; + +function op(toolId: ToolOperation["toolId"], timestamp: number): ToolOperation { + return { toolId, timestamp }; +} + +const shortChain: ToolOperation[] = [op("watermark", 1), op("ocr", 2)]; + +const longChain: ToolOperation[] = [ + op("split", 1), + op("merge", 2), + op("watermark", 3), + op("ocr", 4), + op("rotate", 5), +]; + +const meta = { + title: "Shared/ToolChain", + component: ToolChain, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Default text style, short chain (no truncation needed). */ +export const Default: Story = { + args: { + toolChain: shortChain, + }, +}; + +/** Text style with a long chain — truncates to first → +N → last, with a tooltip for the full chain. */ +export const TextTruncated: Story = { + args: { + toolChain: longChain, + displayStyle: "text", + }, +}; + +/** Badge style — shows up to 3 badges, with "..." + final badge and a tooltip when longer. */ +export const Badges: Story = { + args: { + toolChain: longChain, + displayStyle: "badges", + }, +}; + +/** Compact style — collapses to a tool count once more than one tool is present. */ +export const Compact: Story = { + args: { + toolChain: longChain, + displayStyle: "compact", + }, +}; diff --git a/frontend/editor/src/core/components/shared/ToolIcon.stories.tsx b/frontend/editor/src/core/components/shared/ToolIcon.stories.tsx new file mode 100644 index 0000000000..8994f34157 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ToolIcon.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { iconMap } from "@app/components/tools/automate/iconMap"; +import { ToolIcon } from "@app/components/shared/ToolIcon"; + +const { PictureAsPdfIcon } = iconMap; + +const meta = { + title: "Shared/ToolIcon", + component: ToolIcon, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: , + }, +}; + +/** Visually unavailable state, for tools the user can't run. */ +export const ReducedOpacity: Story = { + args: { + icon: , + opacity: 0.25, + }, +}; + +/** No right margin, for inline placement. */ +export const NoMargin: Story = { + args: { + icon: , + marginRight: "0", + }, +}; diff --git a/frontend/editor/src/core/components/shared/ToolPanelHeader.stories.tsx b/frontend/editor/src/core/components/shared/ToolPanelHeader.stories.tsx new file mode 100644 index 0000000000..bfcaedba4f --- /dev/null +++ b/frontend/editor/src/core/components/shared/ToolPanelHeader.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SettingsIcon from "@mui/icons-material/Settings"; +import { ToolPanelHeader } from "@app/components/shared/ToolPanelHeader"; + +const meta: Meta = { + title: "Shared/ToolPanelHeader", + component: ToolPanelHeader, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: , + title: "Split", + }, +}; + +/** Trailing close button only renders when `onClose` is supplied. */ +export const WithCloseButton: Story = { + args: { + icon: , + title: "Split", + onClose: () => {}, + closeLabel: "Close tool panel", + }, +}; diff --git a/frontend/editor/src/core/components/shared/UpdateModal.stories.tsx b/frontend/editor/src/core/components/shared/UpdateModal.stories.tsx new file mode 100644 index 0000000000..9d7626edbf --- /dev/null +++ b/frontend/editor/src/core/components/shared/UpdateModal.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import UpdateModal from "@app/components/shared/UpdateModal"; +import type { UpdateSummary, MachineInfo } from "@app/services/updateService"; + +const UPDATE_SUMMARY: UpdateSummary = { + latest_version: "2.5.0", + latest_stable_version: "2.5.0", + max_priority: "normal", + recommended_action: "This update contains important fixes and improvements.", + any_breaking: false, + migration_guides: [ + { + version: "2.5.0", + notes: "Config file format changed for custom watermark presets.", + url: "https://docs.stirlingpdf.com/migration/2.5.0", + }, + ], +}; + +const MACHINE_INFO: MachineInfo = { + machineType: "Client-win", + activeSecurity: false, + licenseType: "NORMAL", +}; + +const meta = { + title: "Shared/UpdateModal", + component: UpdateModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + currentVersion: "2.4.0", + updateSummary: UPDATE_SUMMARY, + machineInfo: MACHINE_INFO, + downloadSizeBytes: 235_000_000, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Web/server build: no Tauri updater, so the footer offers a plain download link. */ +export const Default: Story = {}; + +/** Desktop app: an update has finished installing and is waiting for a restart. */ +export const DesktopInstallReadyToRestart: Story = { + args: { + desktopInstall: { + state: "ready-to-restart", + progress: null, + errorMessage: null, + actions: { + startInstall: async () => true, + restartApp: async () => {}, + }, + }, + }, +}; + +/** Desktop app on a non-admin machine: install probe reported it can't write to + * the install directory, so Install Now is disabled and the docs alert shows. */ +export const DesktopInstallBlocked: Story = { + args: { + desktopInstall: { + state: "idle", + progress: null, + errorMessage: null, + actions: { + startInstall: async () => true, + restartApp: async () => {}, + }, + canInstall: { + canInstall: false, + reason: "Install directory is not writable without elevation.", + }, + }, + }, +}; diff --git a/frontend/editor/src/core/components/shared/UpdateStartupPopup.stories.tsx b/frontend/editor/src/core/components/shared/UpdateStartupPopup.stories.tsx new file mode 100644 index 0000000000..4abfdece22 --- /dev/null +++ b/frontend/editor/src/core/components/shared/UpdateStartupPopup.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { UpdateStartupPopup } from "@app/components/shared/UpdateStartupPopup"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +/** + * Startup update-check popup — renders null until an update is detected, so + * every story here shows an empty canvas. The stories exercise the gating + * logic (`isUpdatePopupAllowed`) rather than the (invisible) update-found UI, + * which additionally requires a real startup delay + network round trip. + */ +const meta = { + title: "Shared/UpdateStartupPopup", + component: UpdateStartupPopup, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** No app config resolved yet — gate is closed, renders nothing. */ +export const Default: Story = {}; + +/** Config resolved but `shouldShowUpdate` is false — gate stays closed. */ +export const UpdatesDisabled: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** + * Gate is open (`shouldShowUpdate: true`), so the startup timer would fire and + * check for an update — but the modal itself only appears once that check + * resolves with a newer version, well after the 15s startup delay. + */ +export const UpdatesEnabled: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/UploadToServerModal.stories.tsx b/frontend/editor/src/core/components/shared/UploadToServerModal.stories.tsx new file mode 100644 index 0000000000..50f1e9e0ad --- /dev/null +++ b/frontend/editor/src/core/components/shared/UploadToServerModal.stories.tsx @@ -0,0 +1,62 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import UploadToServerModal from "@app/components/shared/UploadToServerModal"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const mockFile: StirlingFileStub = { + id: "file-1" as FileId, + name: "quarterly-report.pdf", + type: "application/pdf", + size: 2_400_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "file-1" as FileId, + versionNumber: 1, +}; + +const mockUploadedFile: StirlingFileStub = { + ...mockFile, + id: "file-2" as FileId, + originalFileId: "file-2" as FileId, + remoteStorageId: 2, +}; + +/** + * The modal dispatches updateStirlingFileStub on upload, so it needs + * FileContext (also supplies IndexedDBContext) mounted above it. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + ); +} + +const meta = { + title: "Shared/UploadToServerModal", + component: UploadToServerModal, + decorators: [withProviders], + args: { + onClose: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + file: mockFile, + }, +}; + +export const AlreadyUploaded: Story = { + args: { + opened: true, + file: mockUploadedFile, + }, +}; diff --git a/frontend/editor/src/core/components/shared/UserSelector.stories.tsx b/frontend/editor/src/core/components/shared/UserSelector.stories.tsx new file mode 100644 index 0000000000..760eb0f5ee --- /dev/null +++ b/frontend/editor/src/core/components/shared/UserSelector.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import UserSelector from "@app/components/shared/UserSelector"; + +/** + * Fetches `/api/v1/user/users` on mount — unmocked here, so stories render + * whatever the fetch settles to (loader, then the "no users" empty state). + */ +const meta = { + title: "Shared/UserSelector", + component: UserSelector, + parameters: { layout: "padded" }, + args: { + value: [], + onChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Disabled: Story = { + args: { disabled: true }, +}; + +export const CustomPlaceholder: Story = { + args: { placeholder: "Add collaborators..." }, +}; diff --git a/frontend/editor/src/core/components/shared/ZipWarningModal.stories.tsx b/frontend/editor/src/core/components/shared/ZipWarningModal.stories.tsx new file mode 100644 index 0000000000..4f43ee145c --- /dev/null +++ b/frontend/editor/src/core/components/shared/ZipWarningModal.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ZipWarningModal from "@app/components/shared/ZipWarningModal"; + +const meta: Meta = { + title: "Shared/ZipWarningModal", + component: ZipWarningModal, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + fileCount: 42, + zipFileName: "large-archive.zip", + onConfirm: () => {}, + onCancel: () => {}, + }, +}; + +export const SingleFile: Story = { + args: { + opened: true, + fileCount: 1, + zipFileName: "small-archive.zip", + onConfirm: () => {}, + onCancel: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/LoginRequiredBanner.stories.tsx b/frontend/editor/src/core/components/shared/config/LoginRequiredBanner.stories.tsx new file mode 100644 index 0000000000..7544729824 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/LoginRequiredBanner.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner"; + +const meta = { + title: "Shared/Config/LoginRequiredBanner", + component: LoginRequiredBanner, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + show: true, + }, +}; + +export const Hidden: Story = { + args: { + show: false, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/OverviewHeader.stories.tsx b/frontend/editor/src/core/components/shared/config/OverviewHeader.stories.tsx new file mode 100644 index 0000000000..0dc2b09db3 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/OverviewHeader.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { OverviewHeader } from "@app/components/shared/config/OverviewHeader"; + +const meta = { + title: "Shared/Config/OverviewHeader", + component: OverviewHeader, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/shared/config/PendingBadge.stories.tsx b/frontend/editor/src/core/components/shared/config/PendingBadge.stories.tsx new file mode 100644 index 0000000000..85df5f4243 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/PendingBadge.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PendingBadge from "@app/components/shared/config/PendingBadge"; + +const meta = { + title: "Shared/Config/PendingBadge", + component: PendingBadge, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + show: true, + }, +}; + +export const Hidden: Story = { + args: { + show: false, + }, +}; + +export const LargeSize: Story = { + args: { + show: true, + size: "lg", + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx b/frontend/editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx new file mode 100644 index 0000000000..d9f250da6e --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal"; + +const meta = { + title: "Shared/Config/RestartConfirmationModal", + component: RestartConfirmationModal, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + onClose: () => {}, + onRestart: () => {}, + }, +}; + +export const Closed: Story = { + args: { + opened: false, + onClose: () => {}, + onRestart: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx new file mode 100644 index 0000000000..28d5a1c3e0 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar"; +import type { ConfigNavSection } from "@app/components/shared/config/configNavSections"; + +const mockConfigNavSections: ConfigNavSection[] = [ + { + title: "Preferences", + items: [ + { + key: "general", + label: "General", + icon: "settings-rounded", + component: null, + }, + { + key: "hotkeys", + label: "Keyboard Shortcuts", + icon: "keyboard-rounded", + component: null, + }, + ], + }, + { + title: "Workspace", + items: [ + { + key: "people", + label: "People", + icon: "group-rounded", + component: null, + }, + { + key: "teams", + label: "Teams", + icon: "groups-rounded", + component: null, + disabled: true, + }, + ], + }, +]; + +const meta = { + title: "Shared/Config/SettingsSearchBar", + component: SettingsSearchBar, + parameters: { layout: "padded" }, + args: { + configNavSections: mockConfigNavSections, + onNavigate: async () => {}, + isMobile: false, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Mobile: Story = { + args: { + isMobile: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx b/frontend/editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx new file mode 100644 index 0000000000..ae94dd7117 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter"; + +const meta = { + title: "Shared/Config/SettingsStickyFooter", + component: SettingsStickyFooter, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isDirty: true, + saving: false, + loginEnabled: true, + onSave: () => {}, + onDiscard: () => {}, + }, +}; + +export const Saving: Story = { + args: { + ...Default.args, + saving: true, + }, +}; + +export const Hidden: Story = { + args: { + ...Default.args, + isDirty: false, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx new file mode 100644 index 0000000000..08047afb17 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import GeneralSection from "@app/components/shared/config/configSections/GeneralSection"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ThemeProvider } from "@app/components/shared/ThemeProvider"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +// Reads theme/tool-panel preferences via usePreferences()/useTheme() and server +// config via useAppConfig() — none of which the Storybook preview's own provider +// tree supplies (those are the portal contexts), so wrap here. AppConfigProvider +// uses autoFetch off so stories render a fixed config instead of hitting the API. +const meta = { + title: "Shared/Config/ConfigSections/GeneralSection", + component: GeneralSection, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( + + + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Login enabled, no backend version known yet — Software Updates section and admin banner stay hidden. */ +export const Default: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** Backend version known — shows the Software Updates section with version info. */ +export const WithBackendVersion: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** Login disabled — the "For System Administrators" banner shows, prompting the env vars to enable it. */ +export const AdminBanner: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx new file mode 100644 index 0000000000..d7ad738458 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import HelpSection from "@app/components/shared/config/configSections/HelpSection"; + +const meta = { + title: "Shared/Config/ConfigSections/HelpSection", + component: HelpSection, + parameters: { layout: "padded" }, + args: { + onRequestClose: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isAdmin: false, + }, +}; + +export const Admin: Story = { + args: { + isAdmin: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx new file mode 100644 index 0000000000..94420ad5cd --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx @@ -0,0 +1,40 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; +import { HotkeyProvider } from "@app/contexts/HotkeyContext"; + +/** + * HotkeyContext reads the tool registry and selection state off + * ToolWorkflowContext, so both providers must wrap the story. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + + + + + + + + + ); +} + +const meta = { + title: "Shared/Config/ConfigSections/HotkeysSection", + component: HotkeysSection, + decorators: [withProviders], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Full tool list with default keyboard shortcuts assigned. */ +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx new file mode 100644 index 0000000000..6b0c958725 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LegalSection from "@app/components/shared/config/configSections/LegalSection"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +// Reads legal document links and the analytics flag via useAppConfig() (the +// preview's own provider tree doesn't supply this — that's the portal +// context), so wrap here. AppConfigProvider uses autoFetch off so stories +// render a fixed config instead of hitting the API. +const meta = { + title: "Shared/Config/ConfigSections/LegalSection", + component: LegalSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** All optional legal documents configured, analytics disabled — no Cookie Preferences card. */ +export const Default: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** Analytics enabled — adds the Cookie Preferences card with its "Manage" button. */ +export const WithAnalyticsEnabled: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** No legal documents configured — only Privacy Policy and Terms show, using the stirling.com fallback links. */ +export const MinimalLinks: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/Overview.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/Overview.stories.tsx new file mode 100644 index 0000000000..9b04d088e9 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/Overview.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import Overview from "@app/components/shared/config/configSections/Overview"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +// Reads config via useAppConfig() — no props. Wrap in AppConfigProvider with +// autoFetch off so stories render a fixed config instead of hitting the API. +const meta = { + title: "Shared/Config/Overview", + component: Overview, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** No config resolved yet (default context state) — shows the loading spinner. */ +export const Default: Story = {}; + +/** Config loaded — renders the basic/security/system/integration sections. */ +export const Loaded: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** Config resolved but carrying a server-reported warning. */ +export const WithWarning: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx new file mode 100644 index 0000000000..0d7a44c38f --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ProviderCard from "@app/components/shared/config/configSections/ProviderCard"; +import type { Provider } from "@app/components/shared/config/configSections/providerDefinitions"; + +const mockProvider: Provider = { + id: "google", + name: "Google", + icon: "key-rounded", + type: "oauth2", + scope: "Sign-in authentication", + documentationUrl: "https://docs.stirlingpdf.com/Configuration/OAuth", + fields: [ + { + key: "clientId", + type: "text", + label: "Client ID", + description: "The OAuth2 client ID from Google Cloud Console", + placeholder: "your-client-id.apps.googleusercontent.com", + }, + { + key: "clientSecret", + type: "password", + label: "Client Secret", + description: "The OAuth2 client secret from Google Cloud Console", + }, + { + key: "scopes", + type: "tags", + label: "Scopes", + description: "OAuth2 scopes to request", + defaultValue: ["email", "profile"], + }, + { + key: "autoProvision", + type: "switch", + label: "Auto Provision Users", + description: "Automatically create accounts for new sign-ins", + defaultValue: false, + }, + ], +}; + +const meta = { + title: "Shared/Config/ConfigSections/ProviderCard", + component: ProviderCard, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + provider: mockProvider, + isConfigured: false, + }, +}; + +export const Configured: Story = { + args: { + provider: mockProvider, + isConfigured: true, + settings: { + clientId: "example-client-id.apps.googleusercontent.com", + scopes: ["email", "profile"], + autoProvision: true, + }, + }, +}; + +export const ReadOnly: Story = { + args: { + provider: mockProvider, + isConfigured: true, + readOnly: true, + settings: { + clientId: "example-client-id.apps.googleusercontent.com", + scopes: ["email", "profile"], + }, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx index 93999ce438..c24178635c 100644 --- a/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx +++ b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx @@ -32,10 +32,16 @@ interface ProviderCardProps { readOnly?: boolean; } +// Shared default so an omitted `settings` prop keeps the same identity across +// renders. An inline `settings = {}` would allocate a new object every render, +// and the sync effect below lists `settings` as a dependency — so it would +// re-run and setState on every render, looping until React bails out. +const NO_SETTINGS: Record = {}; + export default function ProviderCard({ provider, isConfigured, - settings = {}, + settings = NO_SETTINGS, onSave, onDisconnect, onChange, diff --git a/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx new file mode 100644 index 0000000000..7d149b2b3b --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ThirdPartyLicensesSection, { + FrontendThirdPartyLicensesSection, +} from "@app/components/shared/config/configSections/ThirdPartyLicensesSection"; + +const meta = { + title: "Shared/Config/ConfigSections/ThirdPartyLicensesSection", + component: ThirdPartyLicensesSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Frontend: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/filePreview/DocumentStack.stories.tsx b/frontend/editor/src/core/components/shared/filePreview/DocumentStack.stories.tsx new file mode 100644 index 0000000000..f76bc47831 --- /dev/null +++ b/frontend/editor/src/core/components/shared/filePreview/DocumentStack.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Box } from "@mantine/core"; +import DocumentStack from "@app/components/shared/filePreview/DocumentStack"; + +const meta = { + title: "Shared/FilePreview/DocumentStack", + component: DocumentStack, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const previewContent = ( + +); + +export const SingleFile: Story = { + args: { + totalFiles: 1, + children: previewContent, + }, +}; + +export const TwoFiles: Story = { + args: { + totalFiles: 2, + children: previewContent, + }, +}; + +export const ManyFiles: Story = { + args: { + totalFiles: 5, + children: previewContent, + }, +}; diff --git a/frontend/editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx b/frontend/editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx new file mode 100644 index 0000000000..d410c3af78 --- /dev/null +++ b/frontend/editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail"; + +const mockFile = new File(["dummy content"], "sample-report.pdf", { + type: "application/pdf", +}); + +const meta = { + title: "Shared/FilePreview/DocumentThumbnail", + component: DocumentThumbnail, + parameters: { layout: "padded" }, + args: { + file: mockFile, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithThumbnail: Story = { + args: { + thumbnail: + "data:image/svg+xml;utf8," + + encodeURIComponent( + '', + ), + }, +}; + +export const Encrypted: Story = { + args: { + isEncrypted: true, + }, +}; + +export const Loading: Story = { + args: { + isLoading: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/filePreview/HoverOverlay.stories.tsx b/frontend/editor/src/core/components/shared/filePreview/HoverOverlay.stories.tsx new file mode 100644 index 0000000000..560d129843 --- /dev/null +++ b/frontend/editor/src/core/components/shared/filePreview/HoverOverlay.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Box, Text } from "@mantine/core"; +import HoverOverlay from "@app/components/shared/filePreview/HoverOverlay"; + +const meta = { + title: "Shared/FilePreview/HoverOverlay", + component: HoverOverlay, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( + + Page thumbnail + + ), + }, +}; diff --git a/frontend/editor/src/core/components/shared/filePreview/NavigationArrows.stories.tsx b/frontend/editor/src/core/components/shared/filePreview/NavigationArrows.stories.tsx new file mode 100644 index 0000000000..76a6fbc99e --- /dev/null +++ b/frontend/editor/src/core/components/shared/filePreview/NavigationArrows.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import NavigationArrows from "@app/components/shared/filePreview/NavigationArrows"; + +const meta = { + title: "Shared/FilePreview/NavigationArrows", + component: NavigationArrows, + parameters: { layout: "padded" }, + args: { + onPrevious: () => {}, + onNext: () => {}, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children:
Page 1 of 5
, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + children:
Page 1 of 1
, + }, +}; diff --git a/frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessButton.stories.tsx b/frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessButton.stories.tsx new file mode 100644 index 0000000000..cc984b007f --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessButton.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import HomeIcon from "@mui/icons-material/HomeRounded"; +import QuickAccessButton from "@app/components/shared/quickAccessBar/QuickAccessButton"; + +const meta = { + title: "Shared/QuickAccessBar/QuickAccessButton", + component: QuickAccessButton, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: , + label: "Home", + isActive: false, + ariaLabel: "Home", + }, +}; + +export const Active: Story = { + args: { + icon: , + label: "Home", + isActive: true, + ariaLabel: "Home", + }, +}; + +export const Disabled: Story = { + args: { + icon: , + label: "Home", + isActive: false, + ariaLabel: "Home", + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx b/frontend/editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx new file mode 100644 index 0000000000..9de692e597 --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CreateSessionFlow } from "@app/components/shared/signing/CreateSessionFlow"; +import type { FileState } from "@app/types/file"; + +const mockFile: FileState = { + name: "contract.pdf", + size: 245_760, +}; + +function CreateSessionFlowDemo({ + initialFiles, +}: { + initialFiles: FileState[]; +}) { + const [selectedUserIds, setSelectedUserIds] = useState([]); + const [dueDate, setDueDate] = useState(""); + + return ( + {}} + /> + ); +} + +const meta = { + title: "Shared/Signing/CreateSessionFlow", + component: CreateSessionFlow, + parameters: { layout: "padded" }, + args: { + selectedFiles: [mockFile], + selectedUserIds: [], + onSelectedUserIdsChange: () => {}, + dueDate: "", + onDueDateChange: () => {}, + creating: false, + onSubmit: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** A single file is selected, so step 1 shows its picker instead of the "no file" message. */ +export const Default: Story = { + render: () => , +}; + +/** No file selected yet: step 1 shows the empty-state prompt instead of the document picker. */ +export const NoFileSelected: Story = { + render: () => , +}; + +/** Session creation in flight: the review step's submit action is disabled. */ +export const Creating: Story = { + args: { + selectedFiles: [mockFile], + selectedUserIds: [1, 2], + onSelectedUserIdsChange: () => {}, + dueDate: "2026-08-01", + onDueDateChange: () => {}, + creating: true, + onSubmit: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx b/frontend/editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx new file mode 100644 index 0000000000..ae08d23771 --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx @@ -0,0 +1,104 @@ +import type React from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import SharedSigningLauncher from "@app/components/shared/signing/SharedSigningLauncher"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; +import type { SignRequestSummary } from "@app/types/signingSession"; + +/** + * SharedSigningLauncher reads server config via AppConfigContext (whether + * group signing is enabled) and tool selection via ToolWorkflowContext (the + * "Open shared signing" click target), so both must wrap it here. + */ +function withProviders(groupSigningEnabled: boolean) { + return function Decorator(Story: () => React.JSX.Element) { + return ( + + + + + + + + + + + + ); + }; +} + +const meta = { + title: "Shared/Signing/SharedSigningLauncher", + component: SharedSigningLauncher, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const noSignRequests: SignRequestSummary[] = []; + +const pendingSignRequests: SignRequestSummary[] = [ + { + sessionId: "session-1", + documentName: "NDA-acme-corp.pdf", + ownerUsername: "alex", + createdAt: "2026-07-01T09:00:00Z", + dueDate: "2026-07-20T00:00:00Z", + myStatus: "PENDING", + }, + { + sessionId: "session-2", + documentName: "vendor-agreement.pdf", + ownerUsername: "jordan", + createdAt: "2026-07-05T14:30:00Z", + dueDate: "2026-07-22T00:00:00Z", + myStatus: "VIEWED", + }, +]; + +/** Group signing enabled, no sign requests awaiting the user's action. */ +export const Default: Story = { + decorators: [withProviders(true)], + parameters: { + msw: { + handlers: [ + http.get("/api/v1/security/cert-sign/sign-requests", () => + HttpResponse.json(noSignRequests), + ), + http.get("/api/v1/security/cert-sign/sessions", () => + HttpResponse.json([]), + ), + ], + }, + }, +}; + +/** Two sign requests awaiting this user — the count badge appears on the button. */ +export const PendingRequests: Story = { + decorators: [withProviders(true)], + parameters: { + msw: { + handlers: [ + http.get("/api/v1/security/cert-sign/sign-requests", () => + HttpResponse.json(pendingSignRequests), + ), + http.get("/api/v1/security/cert-sign/sessions", () => + HttpResponse.json([]), + ), + ], + }, + }, +}; + +/** Group signing disabled on the server — the component renders nothing. */ +export const Disabled: Story = { + decorators: [withProviders(false)], +}; diff --git a/frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx b/frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx new file mode 100644 index 0000000000..b33c1e37b5 --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ConfigureSignatureDefaultsStep } from "@app/components/shared/signing/steps/ConfigureSignatureDefaultsStep"; +import { SignatureSettings } from "@app/components/tools/certSign/SignatureSettingsInput"; + +const meta = { + title: "Shared/Signing/Steps/ConfigureSignatureDefaultsStep", + component: ConfigureSignatureDefaultsStep, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], + args: { + settings: {}, + onSettingsChange: () => {}, + onBack: () => {}, + onNext: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function ConfigureDefaultsDemo({ + disabled, + initial, +}: { + disabled?: boolean; + initial: SignatureSettings; +}) { + const [settings, setSettings] = useState(initial); + return ( + {}} + onNext={() => {}} + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => ( + + ), +}; + +export const InvisibleSignature: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx b/frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx new file mode 100644 index 0000000000..60cacfd9ea --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ReviewSessionStep } from "@app/components/shared/signing/steps/ReviewSessionStep"; +import type { FileState } from "@app/types/file"; +import type { SignatureSettings } from "@app/components/tools/certSign/SignatureSettingsInput"; + +const selectedFile: FileState = { + name: "contract-agreement.pdf", + size: 2.4 * 1024 * 1024, +}; + +const signatureSettings: SignatureSettings = { + showSignature: true, + pageNumber: 1, + reason: "Approval of contract terms", + location: "London, UK", + showLogo: true, +}; + +const meta = { + title: "Shared/Signing/Steps/ReviewSessionStep", + component: ReviewSessionStep, + parameters: { layout: "padded" }, + args: { + selectedFile, + participantCount: 3, + signatureSettings, + dueDate: "2026-08-01", + onDueDateChange: () => {}, + onBack: () => {}, + onSubmit: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const InvisibleSignature: Story = { + args: { + signatureSettings: { + showSignature: false, + }, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx b/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx new file mode 100644 index 0000000000..f8fbfacc1d --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SelectDocumentStep } from "@app/components/shared/signing/steps/SelectDocumentStep"; +import type { FileState } from "@app/types/file"; + +const meta = { + title: "Shared/Signing/SelectDocumentStep", + component: SelectDocumentStep, + parameters: { layout: "padded" }, + args: { + selectedFiles: [] as FileState[], + onNext: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const mockFile: FileState = { + name: "contract-agreement.pdf", + size: 2.4 * 1024 * 1024, +}; + +export const NoFileSelected: Story = {}; + +export const Default: Story = { + args: { + selectedFiles: [mockFile], + }, +}; + +export const MultipleFilesSelected: Story = { + args: { + selectedFiles: [mockFile, { name: "addendum.pdf", size: 512 * 1024 }], + }, +}; diff --git a/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx b/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx new file mode 100644 index 0000000000..7e74abb11c --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SelectParticipantsStep } from "@app/components/shared/signing/steps/SelectParticipantsStep"; + +const meta = { + title: "Shared/Signing/Steps/SelectParticipantsStep", + component: SelectParticipantsStep, + parameters: { layout: "padded" }, + args: { + selectedUserIds: [], + onSelectedUserIdsChange: () => {}, + onBack: () => {}, + onNext: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithSelection: Story = { + args: { selectedUserIds: [1, 2] }, +}; + +export const Disabled: Story = { + args: { disabled: true }, +}; diff --git a/frontend/editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx b/frontend/editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx new file mode 100644 index 0000000000..78f1710e69 --- /dev/null +++ b/frontend/editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SliderWithInput from "@app/components/shared/sliderWithInput/SliderWithInput"; + +/** Reproduces the compression "Quality" slider used in tool settings panels. */ +const meta: Meta = { + title: "Shared/SliderWithInput", + component: SliderWithInput, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +function SliderDemo({ + disabled, + initial = 60, +}: { + disabled?: boolean; + initial?: number; +}) { + const [value, setValue] = useState(initial); + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/tooltip/TooltipContent.stories.tsx b/frontend/editor/src/core/components/shared/tooltip/TooltipContent.stories.tsx new file mode 100644 index 0000000000..8f06789bac --- /dev/null +++ b/frontend/editor/src/core/components/shared/tooltip/TooltipContent.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TooltipContent } from "@app/components/shared/tooltip/TooltipContent"; +import type { TooltipTip } from "@app/types/tips"; + +const meta = { + title: "Shared/Tooltip/TooltipContent", + component: TooltipContent, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const singleTip: TooltipTip[] = [ + { + title: "Tip", + description: + "Choose a page range before running the split to keep sections in order.", + bullets: [ + "Ranges use commas, e.g. 1-3,5", + "Leave blank to include all pages", + ], + }, +]; + +const multipleTips: TooltipTip[] = [ + { + title: "Step 1", + description: "Select the pages you want to extract.", + }, + { + title: "Step 2", + description: "Confirm the output order matches your expectations.", + bullets: ["Drag to reorder", "Remove any page with the trash icon"], + }, +]; + +/** Plain text content with no structured tips. */ +export const Default: Story = { + args: { + content: "Drag and drop files here, or click to browse your computer.", + }, +}; + +/** A single tip with a title, description, and bullet list. */ +export const SingleTip: Story = { + args: { + tips: singleTip, + }, +}; + +/** Multiple tips rendered as separate sections, each with its own spacing. */ +export const MultipleTips: Story = { + args: { + tips: multipleTips, + }, +}; diff --git a/frontend/editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx b/frontend/editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx new file mode 100644 index 0000000000..1a4d260d08 --- /dev/null +++ b/frontend/editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DrawSignatureCanvas } from "@app/components/shared/wetSignature/DrawSignatureCanvas"; + +const meta = { + title: "Shared/WetSignature/DrawSignatureCanvas", + component: DrawSignatureCanvas, + parameters: { layout: "padded" }, + args: { + signature: null, + onChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +function DrawSignatureCanvasDemo( + props: Partial>, +) { + const [signature, setSignature] = useState(null); + + return ( + + ); +} + +/** Empty canvas ready for the user to draw a signature. */ +export const Default: Story = { render: () => }; + +/** Disabled state — drawing and clearing are both blocked. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.stories.tsx b/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.stories.tsx new file mode 100644 index 0000000000..0c6cf99dcf --- /dev/null +++ b/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.stories.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + SignatureTypeSelector, + type SignatureType, +} from "@app/components/shared/wetSignature/SignatureTypeSelector"; + +const meta: Meta = { + title: "Shared/WetSignature/SignatureTypeSelector", + component: SignatureTypeSelector, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function SignatureTypeSelectorDemo({ + initialValue = "draw", + disabled, +}: { + initialValue?: SignatureType; + disabled?: boolean; +}) { + const [value, setValue] = useState(initialValue); + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx b/frontend/editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx new file mode 100644 index 0000000000..b3996cbaa4 --- /dev/null +++ b/frontend/editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TypeSignatureText } from "@app/components/shared/wetSignature/TypeSignatureText"; + +const meta = { + title: "Shared/WetSignature/TypeSignatureText", + component: TypeSignatureText, + parameters: { layout: "padded" }, + args: { + text: "Jane Doe", + fontFamily: "Arial", + fontSize: 40, + color: "#000000", + onTextChange: () => {}, + onFontFamilyChange: () => {}, + onFontSizeChange: () => {}, + onColorChange: () => {}, + onSignatureChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function TypeSignatureTextDemo( + props: Partial>, +) { + const [text, setText] = useState(props.text ?? "Jane Doe"); + const [fontFamily, setFontFamily] = useState(props.fontFamily ?? "Arial"); + const [fontSize, setFontSize] = useState(props.fontSize ?? 40); + const [color, setColor] = useState(props.color ?? "#000000"); + + return ( + {})} + /> + ); +} + +/** Typed signature with text, font, size and colour controls plus a live preview. */ +export const Default: Story = { + render: () => , +}; + +/** No text entered yet, so the preview is hidden. */ +export const Empty: Story = { + render: () => , +}; + +/** All controls disabled, e.g. while the signature is being submitted. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx b/frontend/editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx new file mode 100644 index 0000000000..4612e37442 --- /dev/null +++ b/frontend/editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { UploadSignatureImage } from "@app/components/shared/wetSignature/UploadSignatureImage"; + +const meta = { + title: "Shared/WetSignature/UploadSignatureImage", + component: UploadSignatureImage, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + args: { + signature: null, + onChange: () => {}, + }, +}; + +export const WithSignature: Story = { + args: { + signature: + "data:image/svg+xml;base64," + + btoa( + 'Jane Doe', + ), + onChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + signature: null, + onChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/toast/ToastRenderer.stories.tsx b/frontend/editor/src/core/components/toast/ToastRenderer.stories.tsx new file mode 100644 index 0000000000..ffed473833 --- /dev/null +++ b/frontend/editor/src/core/components/toast/ToastRenderer.stories.tsx @@ -0,0 +1,102 @@ +import { useEffect } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToastRenderer from "@app/components/toast/ToastRenderer"; +import { ToastProvider, useToast } from "@app/components/toast/ToastContext"; +import type { ToastOptions } from "@app/components/toast/types"; + +// ToastRenderer takes no props — it renders whatever is in ToastContext. The +// only way to exercise it is to seed toasts through the same provider/show() +// API the app uses, then let the renderer subscribe to that context. +function SeedToasts({ + toasts, + children, +}: { + toasts: ToastOptions[]; + children: React.ReactNode; +}) { + const { show } = useToast(); + useEffect(() => { + toasts.forEach((toast) => show(toast)); + }, []); + return <>{children}; +} + +const meta = { + title: "Toast/ToastRenderer", + component: ToastRenderer, + parameters: { layout: "fullscreen" }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const WithProgress: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const WithActionButton: Story = { + decorators: [ + (Story) => ( + {}, + }, + ]} + > + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/tools/ToolLoadingFallback.stories.tsx b/frontend/editor/src/core/components/tools/ToolLoadingFallback.stories.tsx new file mode 100644 index 0000000000..4135be2c79 --- /dev/null +++ b/frontend/editor/src/core/components/tools/ToolLoadingFallback.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToolLoadingFallback from "@app/components/tools/ToolLoadingFallback"; + +const meta: Meta = { + title: "Tools/ToolLoadingFallback", + component: ToolLoadingFallback, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const WithToolName: Story = { + args: { + toolName: "Merge PDF", + }, +}; diff --git a/frontend/editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx b/frontend/editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx new file mode 100644 index 0000000000..2290784b00 --- /dev/null +++ b/frontend/editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx @@ -0,0 +1,51 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; + +/** + * ToolWorkflowProvider reads navigation and tool-registry state on mount, so + * NavigationProvider and ToolRegistryProvider must wrap it; PreferencesProvider + * backs the persisted tool-panel-mode choice. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + + + + + + + ); +} + +const meta = { + title: "Tools/ToolPanelModePrompt", + component: ToolPanelModePrompt, + decorators: [withProviders], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Orchestrator controls visibility directly via `forceOpen`. */ +export const Default: Story = { + args: { + forceOpen: true, + onComplete: () => {}, + }, +}; + +/** Closed — nothing renders on top of the story canvas. */ +export const Closed: Story = { + args: { + forceOpen: false, + onComplete: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/ToolRenderer.stories.tsx b/frontend/editor/src/core/components/tools/ToolRenderer.stories.tsx new file mode 100644 index 0000000000..fbca2dfc4f --- /dev/null +++ b/frontend/editor/src/core/components/tools/ToolRenderer.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ReactElement } from "react"; +import ToolRenderer from "@app/components/tools/ToolRenderer"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; + +/** + * ToolWorkflowContext reads the tool registry, preferences, and navigation + * state, so all three providers must be present above it for ToolRenderer + * to resolve a tool. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + + + + + + + ); +} + +const meta = { + title: "Tools/ToolRenderer", + component: ToolRenderer, + decorators: [withProviders], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** A registered tool with a component renders its lazy-loaded settings UI. */ +export const Default: Story = { + args: { + selectedToolKey: "compress", + onPreviewFile: () => {}, + onComplete: () => {}, + onError: () => {}, + }, +}; + +/** An unknown tool key falls back to the "Tool not found" message. */ +export const ToolNotFound: Story = { + args: { + selectedToolKey: "not-a-real-tool" as Story["args"]["selectedToolKey"], + onPreviewFile: () => {}, + onComplete: () => {}, + onError: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx new file mode 100644 index 0000000000..c4dcc8a202 --- /dev/null +++ b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageNumberPreview from "@app/components/tools/addPageNumbers/PageNumberPreview"; +import { defaultParameters } from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters"; + +const meta = { + title: "Tools/AddPageNumbers/PageNumberPreview", + component: PageNumberPreview, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const WithQuickGrid: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + showQuickGrid: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/addPassword/AddPasswordSettings.stories.tsx b/frontend/editor/src/core/components/tools/addPassword/AddPasswordSettings.stories.tsx new file mode 100644 index 0000000000..be16c2037c --- /dev/null +++ b/frontend/editor/src/core/components/tools/addPassword/AddPasswordSettings.stories.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AddPasswordSettings from "@app/components/tools/addPassword/AddPasswordSettings"; +import { AddPasswordParameters } from "@app/hooks/tools/addPassword/useAddPasswordParameters"; + +const meta = { + title: "Tools/AddPassword/AddPasswordSettings", + component: AddPasswordSettings, + args: { + parameters: { password: "", ownerPassword: "", keyLength: 128 }, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the password/key-length inputs interactive in the canvas. +const AddPasswordSettingsDemo = (props: { + initialParameters: AddPasswordParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const Filled: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/addStamp/StampPreview.stories.tsx b/frontend/editor/src/core/components/tools/addStamp/StampPreview.stories.tsx new file mode 100644 index 0000000000..74a8c95690 --- /dev/null +++ b/frontend/editor/src/core/components/tools/addStamp/StampPreview.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import StampPreview from "@app/components/tools/addStamp/StampPreview"; +import { defaultParameters } from "@app/components/tools/addStamp/useAddStampParameters"; + +const meta = { + title: "Tools/AddStamp/StampPreview", + component: StampPreview, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const WithText: Story = { + args: { + parameters: { + ...defaultParameters, + stampText: "CONFIDENTIAL", + }, + onParameterChange: () => {}, + }, +}; + +export const WithQuickGrid: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + showQuickGrid: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx b/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx new file mode 100644 index 0000000000..b50df8c70f --- /dev/null +++ b/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import StampSetupSettings from "@app/components/tools/addStamp/StampSetupSettings"; +import { + AddStampParameters, + defaultParameters, +} from "@app/components/tools/addStamp/useAddStampParameters"; + +const meta = { + title: "Tools/AddStamp/StampSetupSettings", + component: StampSetupSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function Demo({ + initialParameters = defaultParameters, + disabled, + filename, +}: { + initialParameters?: AddStampParameters; + disabled?: boolean; + filename?: string; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + filename={filename} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const TextStampWithPreview: Story = { + render: () => ( + + ), +}; + +export const ImageStamp: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx new file mode 100644 index 0000000000..1476e951e5 --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AddWatermarkSingleStepSettings from "@app/components/tools/addWatermark/AddWatermarkSingleStepSettings"; +import { + AddWatermarkParameters, + defaultParameters, +} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +const meta = { + title: "Tools/AddWatermark/AddWatermarkSingleStepSettings", + component: AddWatermarkSingleStepSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, + showFlatten, + textOnly, +}: { + initialParameters?: AddWatermarkParameters; + disabled?: boolean; + showFlatten?: boolean; + textOnly?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + showFlatten={showFlatten} + textOnly={textOnly} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const TextWatermark: Story = { + render: () => ( + + ), +}; + +export const TextOnly: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx new file mode 100644 index 0000000000..6e9e099c5d --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WatermarkFormatting from "@app/components/tools/addWatermark/WatermarkFormatting"; +import { defaultParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +const meta = { + title: "Tools/AddWatermark/WatermarkFormatting", + component: WatermarkFormatting, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: { ...defaultParameters, watermarkType: "text" }, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; + +export const WithoutFlattenOption: Story = { + args: { + ...Default.args, + showFlatten: false, + }, +}; diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx new file mode 100644 index 0000000000..b5568e1779 --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WatermarkImageFile from "@app/components/tools/addWatermark/WatermarkImageFile"; +import { + AddWatermarkParameters, + defaultParameters, +} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +const meta = { + title: "Tools/AddWatermark/WatermarkImageFile", + component: WatermarkImageFile, + args: { + parameters: { ...defaultParameters, watermarkType: "image" }, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const makeImageFile = (name: string, sizeBytes: number): File => + new File([new Uint8Array(sizeBytes)], name, { type: "image/png" }); + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the file picker interaction working in the canvas. +const WatermarkImageFileDemo = (props: { + initialParameters: AddWatermarkParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const WithSelectedImage: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx new file mode 100644 index 0000000000..316e377511 --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WatermarkStyleSettings from "@app/components/tools/addWatermark/WatermarkStyleSettings"; +import { + AddWatermarkParameters, + defaultParameters, +} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +const meta: Meta = { + title: "Tools/AddWatermark/WatermarkStyleSettings", + component: WatermarkStyleSettings, +}; +export default meta; +type Story = StoryObj; + +function WatermarkStyleSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + watermarkType: "text", + watermarkText: "CONFIDENTIAL", + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx new file mode 100644 index 0000000000..5bee6d393a --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx @@ -0,0 +1,41 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WatermarkTextStyle from "@app/components/tools/addWatermark/WatermarkTextStyle"; +import { + AddWatermarkParameters, + defaultParameters, +} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +const meta: Meta = { + title: "AddWatermark/WatermarkTextStyle", + component: WatermarkTextStyle, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function WatermarkTextStyleDemo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: AddWatermarkParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkTypeSettings.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkTypeSettings.stories.tsx new file mode 100644 index 0000000000..9ce7f4ff4b --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkTypeSettings.stories.tsx @@ -0,0 +1,28 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WatermarkTypeSettings from "@app/components/tools/addWatermark/WatermarkTypeSettings"; + +const meta: Meta = { + title: "Tools/AddWatermark/WatermarkTypeSettings", + component: WatermarkTypeSettings, +}; +export default meta; +type Story = StoryObj; + +function WatermarkTypeSettingsDemo({ disabled }: { disabled?: boolean }) { + const [watermarkType, setWatermarkType] = useState<"text" | "image">("text"); + + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkWording.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkWording.stories.tsx new file mode 100644 index 0000000000..c95c2f37f1 --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkWording.stories.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WatermarkWording from "@app/components/tools/addWatermark/WatermarkWording"; +import { + AddWatermarkParameters, + defaultParameters, +} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +const meta: Meta = { + title: "AddWatermark/WatermarkWording", + component: WatermarkWording, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function WatermarkWordingDemo({ + initialText = "", + disabled, +}: { + initialText?: string; + disabled?: boolean; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + watermarkText: initialText, + }); + + const handleParameterChange = ( + key: K, + value: AddWatermarkParameters[K], + ) => { + setParameters((prev) => ({ ...prev, [key]: value })); + }; + + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Filled: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx new file mode 100644 index 0000000000..c4b4ecef9b --- /dev/null +++ b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdjustContrastBasicSettings from "@app/components/tools/adjustContrast/AdjustContrastBasicSettings"; +import { + AdjustContrastParameters, + defaultParameters, +} from "@app/hooks/tools/adjustContrast/useAdjustContrastParameters"; + +const meta = { + title: "Tools/AdjustContrast/AdjustContrastBasicSettings", + component: AdjustContrastBasicSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function Demo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: AdjustContrastParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Adjusted: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx new file mode 100644 index 0000000000..13155ae524 --- /dev/null +++ b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdjustContrastColorSettings from "@app/components/tools/adjustContrast/AdjustContrastColorSettings"; +import { + AdjustContrastParameters, + defaultParameters, +} from "@app/hooks/tools/adjustContrast/useAdjustContrastParameters"; + +const meta = { + title: "Tools/AdjustContrast/AdjustContrastColorSettings", + component: AdjustContrastColorSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function Demo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: AdjustContrastParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Adjusted: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastPreview.stories.tsx b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastPreview.stories.tsx new file mode 100644 index 0000000000..e1d605188f --- /dev/null +++ b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastPreview.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdjustContrastPreview from "@app/components/tools/adjustContrast/AdjustContrastPreview"; +import { defaultParameters } from "@app/hooks/tools/adjustContrast/useAdjustContrastParameters"; + +const meta = { + title: "Tools/AdjustContrast/AdjustContrastPreview", + component: AdjustContrastPreview, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// No file selected yet: the component shows the obscured "select a PDF" state +// without attempting any thumbnail generation, which needs a live PDF worker. +export const Default: Story = { + args: { + file: null, + parameters: defaultParameters, + }, +}; diff --git a/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx new file mode 100644 index 0000000000..6352b0fe15 --- /dev/null +++ b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdjustContrastSingleStepSettings from "@app/components/tools/adjustContrast/AdjustContrastSingleStepSettings"; +import { + AdjustContrastParameters, + defaultParameters, +} from "@app/hooks/tools/adjustContrast/useAdjustContrastParameters"; + +const meta = { + title: "Tools/AdjustContrast/AdjustContrastSingleStepSettings", + component: AdjustContrastSingleStepSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function Demo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: AdjustContrastParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Adjusted: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.stories.tsx b/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.stories.tsx new file mode 100644 index 0000000000..00d3aaba85 --- /dev/null +++ b/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdjustPageScaleSettings from "@app/components/tools/adjustPageScale/AdjustPageScaleSettings"; +import { + AdjustPageScaleParameters, + PageSize, +} from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters"; + +const meta = { + title: "Tools/AdjustPageScale/AdjustPageScaleSettings", + component: AdjustPageScaleSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const defaultParameters: AdjustPageScaleParameters = { + scaleFactor: 1.0, + pageSize: PageSize.KEEP, + orientation: "PORTRAIT", +}; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const CustomPageSize: Story = { + args: { + parameters: { + scaleFactor: 2.5, + pageSize: PageSize.A4, + orientation: "LANDSCAPE", + }, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/autoRename/AutoRenameSettings.stories.tsx b/frontend/editor/src/core/components/tools/autoRename/AutoRenameSettings.stories.tsx new file mode 100644 index 0000000000..6e632564c1 --- /dev/null +++ b/frontend/editor/src/core/components/tools/autoRename/AutoRenameSettings.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AutoRenameSettings from "@app/components/tools/autoRename/AutoRenameSettings"; +import { AutoRenameParameters } from "@app/hooks/tools/autoRename/useAutoRenameParameters"; + +const meta = { + title: "Tools/AutoRename/AutoRenameSettings", + component: AutoRenameSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const baseParameters: AutoRenameParameters = { + useFirstTextAsFallback: false, +}; + +export const Default: Story = { + args: { + parameters: baseParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: baseParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/automate/AutomationCreation.stories.tsx b/frontend/editor/src/core/components/tools/automate/AutomationCreation.stories.tsx new file mode 100644 index 0000000000..8299f7222c --- /dev/null +++ b/frontend/editor/src/core/components/tools/automate/AutomationCreation.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AutomationCreation from "@app/components/tools/automate/AutomationCreation"; +import { AutomationMode } from "@app/types/automation"; +import type { AutomationConfig } from "@app/types/automation"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; + +const emptyToolRegistry: Partial = {}; + +const existingAutomation: AutomationConfig = { + id: "automation-1", + name: "Weekly Cleanup", + description: "Compress and flatten incoming PDFs.", + icon: "CompressIcon", + operations: [ + { operation: "compress", parameters: {} }, + { operation: "flatten", parameters: {} }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +const meta = { + title: "Tools/Automate/AutomationCreation", + component: AutomationCreation, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + mode: AutomationMode.CREATE, + onBack: () => {}, + onComplete: () => {}, + toolRegistry: emptyToolRegistry, + }, +}; + +export const EditExisting: Story = { + args: { + mode: AutomationMode.EDIT, + existingAutomation, + onBack: () => {}, + onComplete: () => {}, + toolRegistry: emptyToolRegistry, + }, +}; + +export const EmbeddedHideMetadata: Story = { + args: { + mode: AutomationMode.CREATE, + hideMetadata: true, + nameOverride: "Watched Folder Automation", + onBack: () => {}, + onComplete: () => {}, + onSaveFailed: () => {}, + toolRegistry: emptyToolRegistry, + }, +}; diff --git a/frontend/editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx b/frontend/editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx new file mode 100644 index 0000000000..b3fea378df --- /dev/null +++ b/frontend/editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AutomationImportModal from "@app/components/tools/automate/AutomationImportModal"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; + +const emptyToolRegistry: Partial = {}; + +const meta = { + title: "Tools/Automate/AutomationImportModal", + component: AutomationImportModal, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + toolRegistry: emptyToolRegistry, + onCancel: () => {}, + onImport: () => {}, + }, +}; + +export const Closed: Story = { + args: { + opened: false, + toolRegistry: emptyToolRegistry, + onCancel: () => {}, + onImport: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/automate/IconSelector.stories.tsx b/frontend/editor/src/core/components/tools/automate/IconSelector.stories.tsx new file mode 100644 index 0000000000..1a8fdadcd1 --- /dev/null +++ b/frontend/editor/src/core/components/tools/automate/IconSelector.stories.tsx @@ -0,0 +1,28 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import IconSelector from "@app/components/tools/automate/IconSelector"; + +const meta = { + title: "Automate/IconSelector", + component: IconSelector, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function IconSelectorDemo({ size }: { size?: "sm" | "md" | "lg" }) { + const [value, setValue] = useState("SettingsIcon"); + return ; +} + +export const Default: Story = { + render: () => , +}; + +export const Medium: Story = { + render: () => , +}; + +export const Large: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx b/frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx new file mode 100644 index 0000000000..81aa48e300 --- /dev/null +++ b/frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TextInput } from "@mantine/core"; +import ToolConfigurationModal from "@app/components/tools/automate/ToolConfigurationModal"; +import { + ToolRegistry, + ToolCategoryId, + SubcategoryId, +} from "@app/data/toolsTaxonomy"; +import { + ToolAutomationSettingsProps, + ErasedToolParams, +} from "@app/hooks/tools/shared/toolOperationTypes"; + +const meta = { + title: "Tools/Automate/ToolConfigurationModal", + component: ToolConfigurationModal, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Registry entry with no automationSettings: the modal falls back to a "no settings" message. */ +export const Default: Story = { + args: { + opened: true, + tool: { + id: "1", + operation: "autoRename", + name: "Auto Rename", + }, + onSave: () => {}, + onCancel: () => {}, + toolRegistry: {}, + }, +}; + +function DemoSettings({ + parameters, + onParameterChange, + disabled, +}: ToolAutomationSettingsProps) { + return ( + + onParameterChange("prefix", event.currentTarget.value) + } + disabled={disabled} + /> + ); +} + +const registryWithSettings: Partial = { + autoRename: { + icon: null, + name: "Auto Rename", + component: null, + description: "Automatically rename files.", + categoryId: ToolCategoryId.STANDARD_TOOLS, + subcategoryId: SubcategoryId.GENERAL, + automationSettings: DemoSettings, + }, +}; + +/** Registry entry with a settings component: renders the tool's own configuration fields. */ +export const WithSettings: Story = { + args: { + opened: true, + tool: { + id: "1", + operation: "autoRename", + name: "Auto Rename", + parameters: { prefix: "invoice-" }, + }, + onSave: () => {}, + onCancel: () => {}, + toolRegistry: registryWithSettings, + }, +}; diff --git a/frontend/editor/src/core/components/tools/automate/ToolList.stories.tsx b/frontend/editor/src/core/components/tools/automate/ToolList.stories.tsx new file mode 100644 index 0000000000..f9c83419c8 --- /dev/null +++ b/frontend/editor/src/core/components/tools/automate/ToolList.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToolList from "@app/components/tools/automate/ToolList"; +import type { AutomationTool } from "@app/types/automation"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; + +// Kept empty: ToolSelector resolves `tool.operation` against this registry, and an +// unmatched operation falls back to the search-input display rather than ToolButton +// (which needs Hotkey/ToolWorkflow context this story doesn't mount). +const emptyToolRegistry: Partial = {}; + +const configuredTools: AutomationTool[] = [ + { + id: "step-1", + operation: "compress", + name: "Compress", + configured: true, + parameters: {}, + }, + { + id: "step-2", + operation: "flatten", + name: "Flatten", + configured: false, + parameters: {}, + }, +]; + +const meta = { + title: "Tools/Automate/ToolList", + component: ToolList, + args: { + toolRegistry: emptyToolRegistry, + onToolUpdate: () => {}, + onToolRemove: () => {}, + onToolConfigure: () => {}, + onToolAdd: () => {}, + getToolName: (operation: string) => operation, + getToolDefaultParameters: () => ({}), + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + tools: configuredTools, + }, +}; + +export const Empty: Story = { + args: { + tools: [], + }, +}; diff --git a/frontend/editor/src/core/components/tools/automate/ToolSelector.stories.tsx b/frontend/editor/src/core/components/tools/automate/ToolSelector.stories.tsx new file mode 100644 index 0000000000..504fed0b1e --- /dev/null +++ b/frontend/editor/src/core/components/tools/automate/ToolSelector.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToolSelector from "@app/components/tools/automate/ToolSelector"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; + +// ToolSelector only mounts ToolButton once a tool is selected or the dropdown +// is opened with matches, and ToolButton needs Hotkey/ToolWorkflow/AppConfig +// context the shared preview doesn't mount. Leaving `selectedValue` unset and +// the registry empty keeps the story on the closed search-input display. +const emptyToolRegistry: Partial = {}; + +const meta = { + title: "Tools/Automate/ToolSelector", + component: ToolSelector, + args: { + onSelect: () => {}, + toolRegistry: emptyToolRegistry, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Closed search input, showing the default "Add a tool..." placeholder. */ +export const Default: Story = {}; + +/** Custom placeholder text, e.g. when embedded in a different flow. */ +export const CustomPlaceholder: Story = { + args: { + placeholder: "Choose a step...", + }, +}; diff --git a/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx b/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx new file mode 100644 index 0000000000..7d1dc55e4d --- /dev/null +++ b/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import BookletImpositionSettings from "@app/components/tools/bookletImposition/BookletImpositionSettings"; +import { + BookletImpositionParameters, + defaultParameters, +} from "@app/hooks/tools/bookletImposition/useBookletImpositionParameters"; + +const meta = { + title: "Tools/BookletImposition/BookletImpositionSettings", + component: BookletImpositionSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const manualDuplexParameters: BookletImpositionParameters = { + ...defaultParameters, + doubleSided: false, + duplexPass: "FIRST", +}; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const ManualDuplex: Story = { + args: { + parameters: manualDuplexParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx b/frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx new file mode 100644 index 0000000000..449ca5bf6a --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CertSignAutomationSettings from "@app/components/tools/certSign/CertSignAutomationSettings"; +import { + CertSignParameters, + defaultParameters, +} from "@app/hooks/tools/certSign/useCertSignParameters"; + +const meta = { + title: "Tools/CertSign/CertSignAutomationSettings", + component: CertSignAutomationSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: CertSignParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const AutoSignMode: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx new file mode 100644 index 0000000000..137fc8272a --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CertificateFilesSettings from "@app/components/tools/certSign/CertificateFilesSettings"; +import { + CertSignParameters, + defaultParameters, +} from "@app/hooks/tools/certSign/useCertSignParameters"; + +const meta = { + title: "Tools/CertSign/CertificateFilesSettings", + component: CertificateFilesSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: CertSignParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => ( + + ), +}; + +export const Pkcs12: Story = { + render: () => ( + + ), +}; + +export const Jks: Story = { + render: () => ( + + ), +}; + +export const AutoSignMode: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx new file mode 100644 index 0000000000..02a30d6382 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CertificateFormatSettings from "@app/components/tools/certSign/CertificateFormatSettings"; +import { defaultParameters } from "@app/hooks/tools/certSign/useCertSignParameters"; + +const meta = { + title: "Tools/CertSign/CertificateFormatSettings", + component: CertificateFormatSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const Selected: Story = { + args: { + parameters: { ...defaultParameters, certType: "PKCS12" }, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx new file mode 100644 index 0000000000..8caa8726f5 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + CertificateSelector, + CertificateType, + UploadFormat, +} from "@app/components/tools/certSign/CertificateSelector"; + +const meta = { + title: "Tools/CertSign/CertificateSelector", + component: CertificateSelector, + parameters: { layout: "padded" }, + args: { + certType: "UPLOAD", + onCertTypeChange: () => {}, + uploadFormat: "PKCS12", + onUploadFormatChange: () => {}, + p12File: null, + onP12FileChange: () => {}, + privateKeyFile: null, + onPrivateKeyFileChange: () => {}, + certFile: null, + onCertFileChange: () => {}, + jksFile: null, + onJksFileChange: () => {}, + password: "", + onPasswordChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SelectorDemo({ + initialCertType = "UPLOAD", + initialUploadFormat = "PKCS12", + disabled, +}: { + initialCertType?: CertificateType; + initialUploadFormat?: UploadFormat; + disabled?: boolean; +}) { + const [certType, setCertType] = useState(initialCertType); + const [uploadFormat, setUploadFormat] = + useState(initialUploadFormat); + const [p12File, setP12File] = useState(null); + const [privateKeyFile, setPrivateKeyFile] = useState(null); + const [certFile, setCertFile] = useState(null); + const [jksFile, setJksFile] = useState(null); + const [password, setPassword] = useState(""); + + return ( + + ); +} + +export const Default: Story = { + render: () => , +}; + +export const PemFormat: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx new file mode 100644 index 0000000000..2ad8f660a0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CertificateTypeSettings from "@app/components/tools/certSign/CertificateTypeSettings"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { + CertSignParameters, + defaultParameters, +} from "@app/hooks/tools/certSign/useCertSignParameters"; + +const meta = { + title: "Tools/CertSign/CertificateTypeSettings", + component: CertificateTypeSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, + serverCertificateEnabled = false, + hardwareSigningAvailable = false, +}: { + initialParameters?: CertSignParameters; + disabled?: boolean; + serverCertificateEnabled?: boolean; + hardwareSigningAvailable?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + + ); +} + +/** No server certificate or hardware signing available — just the informational message. */ +export const Default: Story = { + render: () => , +}; + +/** Server certificate and on-device signing both available alongside upload. */ +export const AllSourcesAvailable: Story = { + render: () => ( + + ), +}; + +/** Server certificate selected as the active source. */ +export const ServerSelected: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.stories.tsx b/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.stories.tsx new file mode 100644 index 0000000000..2e34f1e2a5 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.stories.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import HardwareCertificateSettings from "@app/components/tools/certSign/HardwareCertificateSettings"; +import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParameters"; + +const baseParameters: CertSignParameters = { + signMode: "DEVICE", + certType: "WINDOWS_STORE", + password: "", + showSignature: false, + reason: "", + location: "", + name: "", + pageNumber: 1, + showLogo: true, +}; + +const meta = { + title: "Tools/CertSign/HardwareCertificateSettings", + component: HardwareCertificateSettings, + args: { + parameters: baseParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the kind toggle / driver / PIN inputs interactive +// in the canvas. Capability + certificate lookups hit the backend and are +// expected to fail in Storybook - the component treats that as best-effort +// and still renders the picker. +const HardwareCertificateSettingsDemo = (props: { + initialParameters: CertSignParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const Pkcs11: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx b/frontend/editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx new file mode 100644 index 0000000000..13c1e333f6 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignatureAppearanceSettings from "@app/components/tools/certSign/SignatureAppearanceSettings"; +import { + CertSignParameters, + defaultParameters, +} from "@app/hooks/tools/certSign/useCertSignParameters"; + +const meta = { + title: "Tools/CertSign/SignatureAppearanceSettings", + component: SignatureAppearanceSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: CertSignParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const VisibleSignature: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx b/frontend/editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx new file mode 100644 index 0000000000..cd41aa7ca1 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignatureSettingsDisplay from "@app/components/tools/certSign/SignatureSettingsDisplay"; + +const meta = { + title: "CertSign/SignatureSettingsDisplay", + component: SignatureSettingsDisplay, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + showSignature: true, + pageNumber: 1, + reason: "Document approval", + location: "New York, USA", + showLogo: true, + }, +}; + +export const Invisible: Story = { + args: { + showSignature: false, + pageNumber: null, + reason: null, + location: null, + showLogo: false, + }, +}; + +export const MinimalDetails: Story = { + args: { + showSignature: true, + pageNumber: null, + reason: null, + location: null, + showLogo: false, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx b/frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx new file mode 100644 index 0000000000..87b4127647 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignatureSettingsInput, { + SignatureSettings, +} from "@app/components/tools/certSign/SignatureSettingsInput"; + +const meta = { + title: "Tools/CertSign/SignatureSettingsInput", + component: SignatureSettingsInput, + parameters: { layout: "padded" }, + args: { + value: {}, + onChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialValue = {}, + disabled, +}: { + initialValue?: SignatureSettings; + disabled?: boolean; +}) { + const [value, setValue] = useState(initialValue); + + return ( + + ); +} + +export const Default: Story = { + render: () => , +}; + +export const VisibleSignature: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx b/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx new file mode 100644 index 0000000000..12a1ef08fe --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WetSignatureInput from "@app/components/tools/certSign/WetSignatureInput"; + +const meta = { + title: "Tools/CertSign/WetSignatureInput", + component: WetSignatureInput, + parameters: { layout: "padded" }, + args: { + onSignatureDataChange: () => {}, + onSignatureTypeChange: () => {}, + onCertTypeChange: () => {}, + onP12FileChange: () => {}, + onPasswordChange: () => {}, + certType: "USER_CERT", + p12File: null, + password: "", + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function WetSignatureInputDemo( + props: Partial>, +) { + const [certType, setCertType] = useState<"SERVER" | "USER_CERT" | "UPLOAD">( + props.certType ?? "USER_CERT", + ); + const [p12File, setP12File] = useState(props.p12File ?? null); + const [password, setPassword] = useState(props.password ?? ""); + + return ( + {}} + onSignatureTypeChange={() => {}} + onP12FileChange={setP12File} + onPasswordChange={setPassword} + {...props} + certType={certType} + onCertTypeChange={setCertType} + p12File={p12File} + password={password} + /> + ); +} + +/** Default state: personal certificate selected, canvas signature type. */ +export const Default: Story = { + render: () => , +}; + +/** Upload-certificate flow, revealing the P12 file and password fields. */ +export const UploadCertificate: Story = { + render: () => , +}; + +/** All controls disabled. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx b/frontend/editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx new file mode 100644 index 0000000000..316ad9d8b0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AddParticipantsFlow } from "@app/components/tools/certSign/modals/AddParticipantsFlow"; + +const meta = { + title: "Tools/CertSign/Modals/AddParticipantsFlow", + component: AddParticipantsFlow, + parameters: { layout: "padded" }, + args: { + opened: true, + onClose: () => {}, + onSubmit: async () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Closed: Story = { + args: { opened: false }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx b/frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx new file mode 100644 index 0000000000..2962196a74 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CertificateConfigModal } from "@app/components/tools/certSign/modals/CertificateConfigModal"; + +const meta = { + title: "Tools/CertSign/Modals/CertificateConfigModal", + component: CertificateConfigModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + onSign: async () => {}, + signatureCount: 1, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const MultipleSignatures: Story = { + args: { signatureCount: 3 }, +}; + +export const Disabled: Story = { + args: { disabled: true }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx b/frontend/editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx new file mode 100644 index 0000000000..28f2948249 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SelectSignatureModal } from "@app/components/tools/certSign/modals/SelectSignatureModal"; + +const meta = { + title: "Tools/CertSign/Modals/SelectSignatureModal", + component: SelectSignatureModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + onSignatureSelected: () => {}, + onCreateNew: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx b/frontend/editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx new file mode 100644 index 0000000000..fc4df3c7c8 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ParticipantListPanel } from "@app/components/tools/certSign/panels/ParticipantListPanel"; +import type { ParticipantInfo } from "@app/types/signingSession"; + +const meta = { + title: "CertSign/ParticipantListPanel", + component: ParticipantListPanel, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const participants: ParticipantInfo[] = [ + { + id: 1, + userId: 101, + email: "alice@example.com", + name: "Alice Anderson", + status: "SIGNED", + lastUpdated: "2026-07-10T12:00:00Z", + }, + { + id: 2, + userId: 102, + email: "bob@example.com", + name: "Bob Brown", + status: "PENDING", + lastUpdated: "2026-07-11T09:00:00Z", + }, + { + id: 3, + userId: 103, + email: "carol@example.com", + name: "Carol Clark", + status: "DECLINED", + lastUpdated: "2026-07-12T15:30:00Z", + }, +]; + +export const Default: Story = { + args: { + participants, + finalized: false, + onRemove: () => {}, + }, +}; + +export const Finalized: Story = { + args: { + participants, + finalized: true, + onRemove: () => {}, + }, +}; + +export const Empty: Story = { + args: { + participants: [], + finalized: false, + onRemove: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx new file mode 100644 index 0000000000..95575dab64 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SessionActionsPanel } from "@app/components/tools/certSign/panels/SessionActionsPanel"; +import type { SessionDetail } from "@app/types/signingSession"; + +const baseSession: SessionDetail = { + sessionId: "session-1", + documentName: "Contract.pdf", + ownerEmail: "owner@example.com", + message: "Please review and sign by end of week.", + dueDate: "2026-08-01", + createdAt: "2026-07-01T09:00:00Z", + updatedAt: "2026-07-10T09:00:00Z", + finalized: false, + participants: [ + { + id: 1, + userId: 1, + email: "alice@example.com", + name: "Alice", + status: "SIGNED", + lastUpdated: "2026-07-05T09:00:00Z", + }, + { + id: 2, + userId: 2, + email: "bob@example.com", + name: "Bob", + status: "PENDING", + lastUpdated: "2026-07-01T09:00:00Z", + }, + ], +}; + +const meta = { + title: "Tools/CertSign/Panels/SessionActionsPanel", + component: SessionActionsPanel, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + session: baseSession, + onAddParticipants: () => {}, + onFinalize: () => {}, + onLoadSignedPdf: () => {}, + finalizing: false, + loadingPdf: false, + }, +}; + +export const AllSigned: Story = { + args: { + ...Default.args, + session: { + ...baseSession, + participants: baseSession.participants.map((p) => ({ + ...p, + status: "SIGNED", + })), + }, + }, +}; + +export const Finalized: Story = { + args: { + ...Default.args, + session: { ...baseSession, finalized: true }, + loadingPdf: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx new file mode 100644 index 0000000000..fcc489a6b1 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SessionDetailPanel } from "@app/components/tools/certSign/panels/SessionDetailPanel"; +import type { SigningDetailData } from "@app/hooks/signing/useSigningSessionController"; +import type { SessionDetail } from "@app/types/signingSession"; + +const baseSession: SessionDetail = { + sessionId: "session-1", + documentName: "Employment-Contract.pdf", + ownerEmail: "owner@example.com", + message: "Please review and sign by the due date.", + dueDate: "2026-08-01T00:00:00Z", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-10T00:00:00Z", + finalized: false, + participants: [ + { + id: 1, + userId: 101, + email: "alice@example.com", + name: "Alice Johnson", + status: "SIGNED", + lastUpdated: "2026-07-05T00:00:00Z", + }, + { + id: 2, + userId: 102, + email: "bob@example.com", + name: "Bob Smith", + status: "PENDING", + lastUpdated: "2026-07-01T00:00:00Z", + }, + ], +}; + +function buildData(session: SessionDetail): SigningDetailData { + return { + session, + pdfFile: null, + onFinalize: async () => {}, + onLoadSignedPdf: async () => {}, + onAddParticipants: async () => {}, + onRemoveParticipant: async () => {}, + onDelete: async () => {}, + onBack: () => {}, + onRefresh: async () => {}, + }; +} + +const meta = { + title: "CertSign/SessionDetailPanel", + component: SessionDetailPanel, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + data: buildData(baseSession), + }, +}; + +export const Finalized: Story = { + args: { + data: buildData({ + ...baseSession, + finalized: true, + participants: baseSession.participants.map((p) => ({ + ...p, + status: "SIGNED", + })), + }), + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx new file mode 100644 index 0000000000..2a1792b5c3 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignControlsPanel from "@app/components/tools/certSign/panels/SignControlsPanel"; +import type { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; + +const meta = { + title: "CertSign/SignControlsPanel", + component: SignControlsPanel, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const textSignatureConfig: SignParameters = { + signatureType: "text", + signerName: "Alice Anderson", + fontFamily: "Helvetica", + fontSize: 16, + textColor: "#000000", + signatureData: "Alice Anderson", +}; + +export const Default: Story = { + args: { + placementMode: false, + onPlacementModeChange: () => {}, + onSignatureSelected: () => {}, + onComplete: () => {}, + canComplete: true, + signatureConfig: textSignatureConfig, + hasSelectedAnnotation: true, + onDeleteSelected: () => {}, + }, +}; + +export const PlacingNoSelection: Story = { + args: { + ...Default.args, + placementMode: true, + canComplete: false, + hasSelectedAnnotation: false, + }, +}; + +export const NoSignatureChosen: Story = { + args: { + ...Default.args, + signatureConfig: { signatureType: "canvas" }, + hasSelectedAnnotation: false, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx b/frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx new file mode 100644 index 0000000000..5da3dbd5ea --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AddSignaturesStep } from "@app/components/tools/certSign/steps/AddSignaturesStep"; + +const meta = { + title: "Tools/CertSign/Steps/AddSignaturesStep", + component: AddSignaturesStep, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onRequestPlacement: () => {}, + placementMode: false, + }, +}; + +export const PlacementMode: Story = { + args: { + onRequestPlacement: () => {}, + onCancelPlacement: () => {}, + placementMode: true, + }, +}; + +export const Disabled: Story = { + args: { + onRequestPlacement: () => {}, + placementMode: false, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx b/frontend/editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx new file mode 100644 index 0000000000..025ea7d574 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CertificateSelectionStep } from "@app/components/tools/certSign/steps/CertificateSelectionStep"; +import { + CertificateType, + UploadFormat, +} from "@app/components/tools/certSign/CertificateSelector"; + +const meta = { + title: "Tools/CertSign/CertificateSelectionStep", + component: CertificateSelectionStep, + parameters: { layout: "padded" }, + args: { + certType: "UPLOAD", + onCertTypeChange: () => {}, + uploadFormat: "PKCS12", + onUploadFormatChange: () => {}, + p12File: null, + onP12FileChange: () => {}, + privateKeyFile: null, + onPrivateKeyFileChange: () => {}, + certFile: null, + onCertFileChange: () => {}, + jksFile: null, + onJksFileChange: () => {}, + password: "", + onPasswordChange: () => {}, + onBack: () => {}, + onNext: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function StepDemo({ + initialCertType = "UPLOAD", + initialUploadFormat = "PKCS12", + withUploadedFile = false, + disabled, +}: { + initialCertType?: CertificateType; + initialUploadFormat?: UploadFormat; + withUploadedFile?: boolean; + disabled?: boolean; +}) { + const [certType, setCertType] = useState(initialCertType); + const [uploadFormat, setUploadFormat] = + useState(initialUploadFormat); + const [p12File, setP12File] = useState( + withUploadedFile + ? new File(["mock"], "certificate.p12", { + type: "application/x-pkcs12", + }) + : null, + ); + const [privateKeyFile, setPrivateKeyFile] = useState(null); + const [certFile, setCertFile] = useState(null); + const [jksFile, setJksFile] = useState(null); + const [password, setPassword] = useState(withUploadedFile ? "secret" : ""); + + return ( + {}} + onNext={() => {}} + disabled={disabled} + /> + ); +} + +/** Upload flow with no file/password yet — "Continue" stays disabled. */ +export const Default: Story = { + render: () => , +}; + +/** Upload flow with a certificate + password already provided — "Continue" is enabled. */ +export const UploadReady: Story = { + render: () => , +}; + +/** Pre-installed user certificate — always valid, no upload fields required. */ +export const UserCertificate: Story = { + render: () => , +}; + +/** Whole step disabled (e.g. while a request is in flight). */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx b/frontend/editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx new file mode 100644 index 0000000000..76e920b0db --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ReviewSignatureStep } from "@app/components/tools/certSign/steps/ReviewSignatureStep"; +import type { SignRequestDetail } from "@app/types/signingSession"; + +const signRequest: SignRequestDetail = { + sessionId: "session-1", + documentName: "Contract.pdf", + ownerUsername: "owner@example.com", + message: "Please sign the attached contract.", + dueDate: "2026-08-01T00:00:00Z", + createdAt: "2026-07-15T00:00:00Z", + myStatus: "VIEWED", + showSignature: true, + pageNumber: 1, + reason: "Contract approval", + location: "London, UK", +}; + +const meta = { + title: "CertSign/ReviewSignatureStep", + component: ReviewSignatureStep, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + signatureCount: 1, + certType: "USER_CERT", + uploadFormat: "PKCS12", + p12File: null, + signRequest, + onBack: () => {}, + onSign: () => {}, + onDecline: () => {}, + }, +}; + +export const MultipleSignatures: Story = { + args: { + ...Default.args, + signatureCount: 3, + certType: "SERVER", + }, +}; + +export const UploadedCertificateDisabled: Story = { + args: { + ...Default.args, + certType: "UPLOAD", + uploadFormat: "PFX", + p12File: new File(["dummy"], "my-cert.pfx"), + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx b/frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx new file mode 100644 index 0000000000..c99f9a0fa9 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SignatureCreationStep } from "@app/components/tools/certSign/steps/SignatureCreationStep"; + +const meta = { + title: "CertSign/Steps/SignatureCreationStep", + component: SignatureCreationStep, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + signatureType: "draw", + onSignatureTypeChange: () => {}, + signature: null, + onSignatureChange: () => {}, + signatureText: "", + fontFamily: "Helvetica", + fontSize: 32, + textColor: "#000000", + onSignatureTextChange: () => {}, + onFontFamilyChange: () => {}, + onFontSizeChange: () => {}, + onTextColorChange: () => {}, + onNext: () => {}, + }, +}; + +export const WithSignature: Story = { + args: { + ...Default.args, + signature: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + }, +}; + +export const TypeMode: Story = { + args: { + ...Default.args, + signatureType: "type", + signatureText: "Jane Doe", + signature: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx b/frontend/editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx new file mode 100644 index 0000000000..685d06b89f --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SignaturePlacementStep } from "@app/components/tools/certSign/steps/SignaturePlacementStep"; + +const meta = { + title: "Tools/CertSign/SignaturePlacementStep", + component: SignaturePlacementStep, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + isPlaced: false, + placementInfo: null, + onBack: () => {}, + onNext: () => {}, + children: ( +
+ ), + }, +}; + +export const Placed: Story = { + args: { + isPlaced: true, + placementInfo: { page: 2, x: 120, y: 340 }, + onBack: () => {}, + onNext: () => {}, + children: ( +
+ ), + }, +}; + +export const Disabled: Story = { + args: { + isPlaced: true, + placementInfo: { page: 1, x: 50, y: 50 }, + onBack: () => {}, + onNext: () => {}, + disabled: true, + children: ( +
+ ), + }, +}; diff --git a/frontend/editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx b/frontend/editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx new file mode 100644 index 0000000000..ce15dd5922 --- /dev/null +++ b/frontend/editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdvancedOptionsStep from "@app/components/tools/changeMetadata/steps/AdvancedOptionsStep"; +import { defaultParameters } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; +import { TrappedStatus } from "@app/types/metadata"; + +const meta = { + title: "Tools/ChangeMetadata/Steps/AdvancedOptionsStep", + component: AdvancedOptionsStep, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + addCustomMetadata: () => {}, + removeCustomMetadata: () => {}, + updateCustomMetadata: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithCustomMetadata: Story = { + args: { + parameters: { + ...defaultParameters, + trapped: TrappedStatus.TRUE, + customMetadata: [{ id: "1", key: "CustomField", value: "CustomValue" }], + }, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx b/frontend/editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx new file mode 100644 index 0000000000..4ba366f972 --- /dev/null +++ b/frontend/editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CustomMetadataStep from "@app/components/tools/changeMetadata/steps/CustomMetadataStep"; +import { + ChangeMetadataParameters, + defaultParameters, + createCustomMetadataFunctions, +} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; +import { CustomMetadataEntry } from "@app/types/metadata"; + +const meta = { + title: "Tools/ChangeMetadata/CustomMetadataStep", + component: CustomMetadataStep, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + addCustomMetadata: () => {}, + removeCustomMetadata: () => {}, + updateCustomMetadata: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function CustomMetadataStepDemo({ + disabled, + customMetadata = [], +}: { + disabled?: boolean; + customMetadata?: CustomMetadataEntry[]; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + customMetadata, + }); + + const onParameterChange = ( + key: K, + value: ChangeMetadataParameters[K], + ) => setParameters((prev) => ({ ...prev, [key]: value })); + + const { addCustomMetadata, removeCustomMetadata, updateCustomMetadata } = + createCustomMetadataFunctions(parameters, onParameterChange); + + return ( + + ); +} + +export const Default: Story = { + render: () => , +}; + +export const WithEntries: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/changeMetadata/steps/DeleteAllStep.stories.tsx b/frontend/editor/src/core/components/tools/changeMetadata/steps/DeleteAllStep.stories.tsx new file mode 100644 index 0000000000..d03e977157 --- /dev/null +++ b/frontend/editor/src/core/components/tools/changeMetadata/steps/DeleteAllStep.stories.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import DeleteAllStep from "@app/components/tools/changeMetadata/steps/DeleteAllStep"; +import { + ChangeMetadataParameters, + defaultParameters, +} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; + +const meta = { + title: "Tools/ChangeMetadata/DeleteAllStep", + component: DeleteAllStep, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function DeleteAllStepDemo({ + disabled, + deleteAll = false, +}: { + disabled?: boolean; + deleteAll?: boolean; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + deleteAll, + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Checked: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx b/frontend/editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx new file mode 100644 index 0000000000..d6b8de2337 --- /dev/null +++ b/frontend/editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import DocumentDatesStep from "@app/components/tools/changeMetadata/steps/DocumentDatesStep"; +import { + ChangeMetadataParameters, + defaultParameters, +} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; + +const meta = { + title: "Tools/ChangeMetadata/DocumentDatesStep", + component: DocumentDatesStep, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function DocumentDatesStepDemo({ + disabled, + creationDate = null, + modificationDate = null, +}: { + disabled?: boolean; + creationDate?: Date | null; + modificationDate?: Date | null; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + creationDate, + modificationDate, + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Filled: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/changeMetadata/steps/StandardMetadataStep.stories.tsx b/frontend/editor/src/core/components/tools/changeMetadata/steps/StandardMetadataStep.stories.tsx new file mode 100644 index 0000000000..34dacc7d41 --- /dev/null +++ b/frontend/editor/src/core/components/tools/changeMetadata/steps/StandardMetadataStep.stories.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import StandardMetadataStep from "@app/components/tools/changeMetadata/steps/StandardMetadataStep"; +import { + ChangeMetadataParameters, + defaultParameters, +} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; + +const meta = { + title: "Tools/ChangeMetadata/StandardMetadataStep", + component: StandardMetadataStep, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function StandardMetadataStepDemo({ + disabled, + filled = false, +}: { + disabled?: boolean; + filled?: boolean; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + ...(filled + ? { + title: "Annual Report 2026", + author: "Jane Doe", + subject: "Financial Summary", + keywords: "finance, report, annual", + creator: "Stirling PDF", + producer: "Stirling PDF", + } + : {}), + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Filled: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/changePermissions/ChangePermissionsSettings.stories.tsx b/frontend/editor/src/core/components/tools/changePermissions/ChangePermissionsSettings.stories.tsx new file mode 100644 index 0000000000..8a05186886 --- /dev/null +++ b/frontend/editor/src/core/components/tools/changePermissions/ChangePermissionsSettings.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ChangePermissionsSettings from "@app/components/tools/changePermissions/ChangePermissionsSettings"; +import { ChangePermissionsParameters } from "@app/hooks/tools/changePermissions/useChangePermissionsParameters"; + +const mockParameters: ChangePermissionsParameters = { + preventAssembly: false, + preventExtractContent: false, + preventExtractForAccessibility: false, + preventFillInForm: false, + preventModify: false, + preventModifyAnnotations: false, + preventPrinting: false, + preventPrintingFaithful: false, +}; + +const meta: Meta = { + title: "Tools/ChangePermissions/ChangePermissionsSettings", + component: ChangePermissionsSettings, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: mockParameters, + onParameterChange: () => {}, + }, +}; + +export const SomeRestricted: Story = { + args: { + parameters: { + ...mockParameters, + preventPrinting: true, + preventModify: true, + }, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: mockParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/compare/CompareDocumentPane.stories.tsx b/frontend/editor/src/core/components/tools/compare/CompareDocumentPane.stories.tsx new file mode 100644 index 0000000000..09d27d5ad5 --- /dev/null +++ b/frontend/editor/src/core/components/tools/compare/CompareDocumentPane.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CompareDocumentPane from "@app/components/tools/compare/CompareDocumentPane"; +import type { PagePreview } from "@app/types/compare"; + +// 1x1 transparent PNG so the pane's resolves without a network request. +const PLACEHOLDER_PAGE_IMAGE = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +const buildPages = (count: number): PagePreview[] => + Array.from({ length: count }, (_, index) => ({ + pageNumber: index + 1, + width: 612, + height: 792, + rotation: 0, + url: PLACEHOLDER_PAGE_IMAGE, + })); + +const meta = { + title: "Tools/Compare/CompareDocumentPane", + component: CompareDocumentPane, + // Excluded from the automated (Vitest browser) test run: mounting several of + // these panes in one page exhausts the headless browser's memory and the page + // is dropped mid-run, which fails the whole file rather than this component. + // It still renders in the Storybook UI. + tags: ["!test"], + args: { + pane: "base", + layout: "side-by-side", + scrollRef: { current: null }, + peerScrollRef: { current: null }, + handleScrollSync: () => {}, + handleWheelZoom: () => {}, + handleWheelOverscroll: () => {}, + onTouchStart: () => {}, + onTouchMove: () => {}, + onTouchEnd: () => {}, + isPanMode: false, + zoom: 1, + title: "original-document.pdf", + changes: [ + { value: "change-1", label: "Paragraph 1 change", pageNumber: 1 }, + { value: "change-2", label: "Paragraph 2 change", pageNumber: 2 }, + ], + onNavigateChange: () => {}, + isLoading: false, + processingMessage: "Processing...", + pages: buildPages(2), + pairedPages: buildPages(2), + getRowHeightPx: () => 792, + wordHighlightMap: new Map(), + metaIndexToGroupId: new Map(), + documentLabel: "Original", + pageLabel: "Page", + altLabel: "Document page preview", + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Loading: Story = { + args: { + isLoading: true, + pages: [], + pairedPages: [], + }, +}; + +export const NoChanges: Story = { + args: { + changes: [], + dropdownPlaceholder: "No changes", + }, +}; diff --git a/frontend/editor/src/core/components/tools/compare/CompareNavigationDropdown.stories.tsx b/frontend/editor/src/core/components/tools/compare/CompareNavigationDropdown.stories.tsx new file mode 100644 index 0000000000..0e8f59f40e --- /dev/null +++ b/frontend/editor/src/core/components/tools/compare/CompareNavigationDropdown.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CompareNavigationDropdown from "@app/components/tools/compare/CompareNavigationDropdown"; + +const meta = { + title: "Compare/CompareNavigationDropdown", + component: CompareNavigationDropdown, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const sampleChanges = [ + { + value: "change-1", + label: "Introduction paragraph reworded", + pageNumber: 1, + }, + { value: "change-2", label: "Budget table updated", pageNumber: 1 }, + { value: "change-3", label: "New clause added", pageNumber: 2 }, + { value: "change-4", label: "Signature date changed", pageNumber: 3 }, +]; + +export const Default: Story = { + args: { + changes: sampleChanges, + placeholder: "Jump to change", + onNavigate: (value, pageNumber) => { + console.log("navigate", value, pageNumber); + }, + renderedPageNumbers: new Set([1, 2, 3]), + }, +}; + +export const Empty: Story = { + args: { + changes: [], + placeholder: "Jump to change", + onNavigate: () => {}, + }, +}; + +export const RenderingInProgress: Story = { + args: { + changes: sampleChanges, + placeholder: "Jump to change", + onNavigate: () => {}, + renderedPageNumbers: new Set([1]), + }, +}; diff --git a/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx b/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx new file mode 100644 index 0000000000..14d47c6761 --- /dev/null +++ b/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ComparePixelWorkbenchView from "@app/components/tools/compare/ComparePixelWorkbenchView"; +import type { CompareResultPixelData } from "@app/types/compare"; + +// A tiny transparent PNG data URI so the elements have something valid to +// load without reaching out to a real file or network resource. +const PLACEHOLDER_IMAGE = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +const baseResult: CompareResultPixelData = { + mode: "pixel", + base: { fileId: "base-file", fileName: "contract-v1.pdf" }, + comparison: { fileId: "comparison-file", fileName: "contract-v2.pdf" }, + pages: [ + { + pageNumber: 1, + width: 612, + height: 792, + baseImageUrl: PLACEHOLDER_IMAGE, + comparisonImageUrl: PLACEHOLDER_IMAGE, + diffImageUrl: PLACEHOLDER_IMAGE, + diffPixels: 1200, + totalPixels: 484704, + diffRatio: 0.0025, + sizeMismatch: false, + }, + { + pageNumber: 2, + width: 612, + height: 792, + baseImageUrl: PLACEHOLDER_IMAGE, + comparisonImageUrl: PLACEHOLDER_IMAGE, + diffImageUrl: PLACEHOLDER_IMAGE, + diffPixels: 0, + totalPixels: 484704, + diffRatio: 0, + sizeMismatch: false, + }, + { + pageNumber: 3, + width: 612, + height: 792, + baseImageUrl: PLACEHOLDER_IMAGE, + comparisonImageUrl: PLACEHOLDER_IMAGE, + diffImageUrl: PLACEHOLDER_IMAGE, + diffPixels: 96940, + totalPixels: 484704, + diffRatio: 0.2, + sizeMismatch: true, + missingComparison: true, + }, + ], + totals: { + diffPixels: 98140, + totalPixels: 1454112, + diffRatio: 0.0675, + pagesWithChanges: 2, + durationMs: 842, + processedAt: 1752300000000, + }, + warnings: [], + settings: { + dpi: 150, + threshold: 10, + }, +}; + +const meta = { + title: "Tools/Compare/ComparePixelWorkbenchView", + component: ComparePixelWorkbenchView, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + result: baseResult, + }, +}; + +export const NoDifferences: Story = { + args: { + result: { + ...baseResult, + pages: baseResult.pages.map((page) => ({ + ...page, + diffPixels: 0, + diffRatio: 0, + sizeMismatch: false, + missingBase: undefined, + missingComparison: undefined, + })), + totals: { + ...baseResult.totals, + diffPixels: 0, + diffRatio: 0, + pagesWithChanges: 0, + }, + }, + }, +}; + +export const WithWarnings: Story = { + args: { + result: { + ...baseResult, + warnings: [ + "Page 3 could not be rendered at the requested DPI and was downscaled.", + ], + }, + }, +}; diff --git a/frontend/editor/src/core/components/tools/compress/CompressSettings.stories.tsx b/frontend/editor/src/core/components/tools/compress/CompressSettings.stories.tsx new file mode 100644 index 0000000000..672f436e56 --- /dev/null +++ b/frontend/editor/src/core/components/tools/compress/CompressSettings.stories.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CompressSettings from "@app/components/tools/compress/CompressSettings"; +import { + CompressParameters, + defaultParameters, +} from "@app/hooks/tools/compress/useCompressParameters"; + +const meta = { + title: "Tools/Compress/CompressSettings", + component: CompressSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the sliders/inputs interactive in the canvas. +const CompressSettingsDemo = (props: { + initialParameters: CompressParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => , +}; + +export const FileSizeMethod: Story = { + render: () => ( + + ), +}; + +export const LineArtEnabled: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromCbrSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromCbrSettings.stories.tsx new file mode 100644 index 0000000000..0f9f386add --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromCbrSettings.stories.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromCbrSettings from "@app/components/tools/convert/ConvertFromCbrSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/Convert/ConvertFromCbrSettings", + component: ConvertFromCbrSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the checkbox interactive in the canvas. +const ConvertFromCbrSettingsDemo = (props: { + initialParameters: ConvertParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const OptimizeForEbookEnabled: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromCbzSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromCbzSettings.stories.tsx new file mode 100644 index 0000000000..abec19fcc2 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromCbzSettings.stories.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromCbzSettings from "@app/components/tools/convert/ConvertFromCbzSettings"; +import { + defaultParameters, + ConvertParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/ConvertFromCbzSettings", + component: ConvertFromCbzSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function CbzSettingsDemo({ + disabled, + initialOptimize = false, +}: { + disabled?: boolean; + initialOptimize?: boolean; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + cbzOptions: { optimizeForEbook: initialOptimize }, + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const OptimizedForEbook: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx new file mode 100644 index 0000000000..e0eafa1835 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromEbookSettings from "@app/components/tools/convert/ConvertFromEbookSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/Convert/ConvertFromEbookSettings", + component: ConvertFromEbookSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +function ConvertFromEbookSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = + useState(defaultParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx new file mode 100644 index 0000000000..4e860948f9 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromEmailSettings from "@app/components/tools/convert/ConvertFromEmailSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/Convert/ConvertFromEmailSettings", + component: ConvertFromEmailSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function ConvertFromEmailSettingsDemo( + props: Partial>, +) { + const [parameters, setParameters] = useState( + props.parameters ?? defaultParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + /> + ); +} + +/** Default state: attachments included, so the max-size input is visible. */ +export const Default: Story = { + render: () => , +}; + +/** Attachments excluded, hiding the max-attachment-size input. */ +export const WithoutAttachments: Story = { + render: () => ( + + ), +}; + +/** All controls disabled, e.g. while a conversion is running. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromImageSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromImageSettings.stories.tsx new file mode 100644 index 0000000000..2392e13ec3 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromImageSettings.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta: Meta = { + title: "Tools/Convert/ConvertFromImageSettings", + component: ConvertFromImageSettings, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function ConvertFromImageSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = + useState(defaultParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx new file mode 100644 index 0000000000..8ca41ba2bb --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromSvgSettings from "@app/components/tools/convert/ConvertFromSvgSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta: Meta = { + title: "Tools/Convert/ConvertFromSvgSettings", + component: ConvertFromSvgSettings, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function ConvertFromSvgSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = + useState(defaultParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx new file mode 100644 index 0000000000..ddc756f723 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromWebSettings from "@app/components/tools/convert/ConvertFromWebSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta: Meta = { + title: "Tools/Convert/ConvertFromWebSettings", + component: ConvertFromWebSettings, +}; +export default meta; +type Story = StoryObj; + +function ConvertFromWebSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + fromExtension: "html", + toExtension: "pdf", + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToCbrSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToCbrSettings.stories.tsx new file mode 100644 index 0000000000..3a202e95fa --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToCbrSettings.stories.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertToCbrSettings from "@app/components/tools/convert/ConvertToCbrSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/Convert/ConvertToCbrSettings", + component: ConvertToCbrSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the DPI input interactive in the canvas. +const ConvertToCbrSettingsDemo = (props: { + initialParameters: ConvertParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToCbzSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToCbzSettings.stories.tsx new file mode 100644 index 0000000000..0d31069b86 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToCbzSettings.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertToCbzSettings from "@app/components/tools/convert/ConvertToCbzSettings"; +import { defaultParameters } from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/Convert/ConvertToCbzSettings", + component: ConvertToCbzSettings, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx new file mode 100644 index 0000000000..c2087cf00f --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx @@ -0,0 +1,54 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertToEpubSettings from "@app/components/tools/convert/ConvertToEpubSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta: Meta = { + title: "Tools/Convert/ConvertToEpubSettings", + component: ConvertToEpubSettings, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function ConvertToEpubSettingsDemo({ + toExtension = "epub", + disabled, +}: { + toExtension?: string; + disabled?: boolean; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + fromExtension: "docx", + toExtension, + }); + + const handleParameterChange = ( + key: K, + value: ConvertParameters[K], + ) => { + setParameters((prev) => ({ ...prev, [key]: value })); + }; + + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Azw3Output: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToImageSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToImageSettings.stories.tsx new file mode 100644 index 0000000000..242c0c631d --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToImageSettings.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImageSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta: Meta = { + title: "Tools/Convert/ConvertToImageSettings", + component: ConvertToImageSettings, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function ConvertToImageSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = + useState(defaultParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx new file mode 100644 index 0000000000..2bffbe1391 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertToPdfaSettings from "@app/components/tools/convert/ConvertToPdfaSettings"; +import { defaultParameters } from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/Convert/ConvertToPdfaSettings", + component: ConvertToPdfaSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + selectedFiles: [], + disabled: false, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const StrictMode: Story = { + args: { + parameters: { + ...defaultParameters, + pdfaOptions: { outputFormat: "pdfa-1", strict: true }, + }, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToPdfxSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToPdfxSettings.stories.tsx new file mode 100644 index 0000000000..30d59e42fb --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToPdfxSettings.stories.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertToPdfxSettings from "@app/components/tools/convert/ConvertToPdfxSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; +import { StirlingFile } from "@app/types/fileContext"; + +const meta = { + title: "Tools/Convert/ConvertToPdfxSettings", + component: ConvertToPdfxSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + selectedFiles: [], + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component renders no UI — it only reconciles the "outputFormat" field +// on mount — so the shim just proves it mounts and updates parameters without +// throwing. +const ConvertToPdfxSettingsDemo = (props: { + initialParameters: ConvertParameters; + selectedFiles?: StirlingFile[]; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + selectedFiles={props.selectedFiles ?? []} + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.stories.tsx b/frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.stories.tsx new file mode 100644 index 0000000000..4d1c029da0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.stories.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import GroupedFormatDropdown from "@app/components/tools/convert/GroupedFormatDropdown"; + +const documentOptions = [ + { value: "pdf", label: "PDF", group: "Document" }, + { value: "docx", label: "Word", group: "Document" }, + { value: "odt", label: "OpenDocument", group: "Document" }, + { value: "png", label: "PNG", group: "Image" }, + { value: "jpg", label: "JPEG", group: "Image" }, + { value: "epub", label: "EPUB", group: "Ebook", usesCloud: true }, + { value: "mobi", label: "MOBI", group: "Ebook", usesCloud: true }, + { value: "cbr", label: "CBR", group: "Comic", enabled: false }, +]; + +const meta = { + title: "Tools/Convert/GroupedFormatDropdown", + component: GroupedFormatDropdown, + args: { + options: documentOptions, + onChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the selected format interactive in the canvas. +const GroupedFormatDropdownDemo = ( + props: Partial>, +) => { + const [value, setValue] = useState(props.value); + + return ( + + ); +}; + +export const Default: Story = { + render: () => , +}; + +export const Selected: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/crop/CropAreaSelector.stories.tsx b/frontend/editor/src/core/components/tools/crop/CropAreaSelector.stories.tsx new file mode 100644 index 0000000000..fbe30e65f5 --- /dev/null +++ b/frontend/editor/src/core/components/tools/crop/CropAreaSelector.stories.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Box, MantineProvider } from "@mantine/core"; +import CropAreaSelector from "@app/components/tools/crop/CropAreaSelector"; +import { Rectangle, PDFBounds } from "@app/utils/cropCoordinates"; +import { mantineTheme } from "@app/theme/mantineTheme"; + +// CropAreaSelector reads theme.other.crop (overlay/handle colors), which only +// the core app's Mantine theme defines. Nesting it here supplies that value +// without disturbing whatever theme wraps the story globally, since Mantine +// merges nested providers with their parent. +const pdfBounds: PDFBounds = { + actualWidth: 595.28, + actualHeight: 841.89, + thumbnailWidth: 300, + thumbnailHeight: 424, + offsetX: 0, + offsetY: 0, + scale: 300 / 595.28, +}; + +const meta = { + title: "Tools/Crop/CropAreaSelector", + component: CropAreaSelector, + parameters: { layout: "padded" }, + // Every story below supplies its own `render`, which ignores these args, but + // Storybook's types still require `args` to satisfy CropAreaSelector's + // required props. + args: { + pdfBounds, + cropArea: { x: 50, y: 50, width: 300, height: 400 }, + onCropAreaChange: () => {}, + children: null, + }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function Demo({ + initialCropArea = { x: 50, y: 50, width: 300, height: 400 }, + disabled, +}: { + initialCropArea?: Rectangle; + disabled?: boolean; +}) { + const [cropArea, setCropArea] = useState(initialCropArea); + + return ( + + + + + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx b/frontend/editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx new file mode 100644 index 0000000000..dfb1aede62 --- /dev/null +++ b/frontend/editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CropAutomationSettings from "@app/components/tools/crop/CropAutomationSettings"; +import { + CropParameters, + defaultParameters, +} from "@app/hooks/tools/crop/useCropParameters"; + +const meta = { + title: "Tools/Crop/CropAutomationSettings", + component: CropAutomationSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: CropParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const CustomArea: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/crop/CropCoordinateInputs.stories.tsx b/frontend/editor/src/core/components/tools/crop/CropCoordinateInputs.stories.tsx new file mode 100644 index 0000000000..fbf034b7e7 --- /dev/null +++ b/frontend/editor/src/core/components/tools/crop/CropCoordinateInputs.stories.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CropCoordinateInputs from "@app/components/tools/crop/CropCoordinateInputs"; +import { Rectangle, PDFBounds } from "@app/utils/cropCoordinates"; + +const meta = { + title: "Tools/Crop/CropCoordinateInputs", + component: CropCoordinateInputs, + parameters: { layout: "padded" }, + args: { + cropArea: { x: 50, y: 50, width: 300, height: 400 }, + onCoordinateChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const pdfBounds: PDFBounds = { + actualWidth: 595.28, + actualHeight: 841.89, + thumbnailWidth: 300, + thumbnailHeight: 424, + offsetX: 0, + offsetY: 0, + scale: 300 / 595.28, +}; + +function Demo({ + initialCropArea = { x: 50, y: 50, width: 300, height: 400 }, + disabled, + showAutomationInfo, + withBounds = true, +}: { + initialCropArea?: Rectangle; + disabled?: boolean; + showAutomationInfo?: boolean; + withBounds?: boolean; +}) { + const [cropArea, setCropArea] = useState(initialCropArea); + + return ( + + setCropArea((prev) => ({ + ...prev, + [field]: typeof value === "number" ? value : Number(value) || 0, + })) + } + disabled={disabled} + pdfBounds={withBounds ? pdfBounds : undefined} + showAutomationInfo={showAutomationInfo} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const AutomationInfo: Story = { + render: () => , +}; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx b/frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx new file mode 100644 index 0000000000..945bd29745 --- /dev/null +++ b/frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import BookmarkEditor from "@app/components/tools/editTableOfContents/BookmarkEditor"; +import { createBookmarkNode } from "@app/utils/editTableOfContents"; + +const meta = { + title: "Tools/EditTableOfContents/BookmarkEditor", + component: BookmarkEditor, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + bookmarks: [ + createBookmarkNode({ + title: "Chapter 1: Introduction", + pageNumber: 1, + children: [ + createBookmarkNode({ + title: "Section 1.1: Background", + pageNumber: 2, + }), + createBookmarkNode({ title: "Section 1.2: Scope", pageNumber: 4 }), + ], + }), + createBookmarkNode({ title: "Chapter 2: Methodology", pageNumber: 8 }), + ], + onChange: () => {}, + }, +}; + +export const Empty: Story = { + args: { + bookmarks: [], + onChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx new file mode 100644 index 0000000000..a9daf70b4e --- /dev/null +++ b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import EditTableOfContentsSettings from "@app/components/tools/editTableOfContents/EditTableOfContentsSettings"; +import { BookmarkNode } from "@app/utils/editTableOfContents"; + +const sampleBookmarks: BookmarkNode[] = [ + { + id: "1", + title: "Chapter 1", + pageNumber: 1, + expanded: true, + children: [ + { + id: "1.1", + title: "Section 1.1", + pageNumber: 2, + expanded: false, + children: [], + }, + ], + }, + { + id: "2", + title: "Chapter 2", + pageNumber: 5, + expanded: true, + children: [], + }, +]; + +const meta = { + title: "Tools/EditTableOfContents/EditTableOfContentsSettings", + component: EditTableOfContentsSettings, + args: { + bookmarks: sampleBookmarks, + replaceExisting: true, + onReplaceExistingChange: () => {}, + onSelectFiles: () => {}, + onLoadFromPdf: () => {}, + onImportJson: () => {}, + onImportClipboard: () => {}, + onExportJson: () => {}, + onExportClipboard: () => {}, + isLoading: false, + loadError: null, + canReadClipboard: true, + canWriteClipboard: true, + disabled: false, + selectedFileName: "document.pdf", + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const NoFileSelected: Story = { + args: { + selectedFileName: undefined, + bookmarks: [], + }, +}; + +export const LoadingWithError: Story = { + args: { + isLoading: true, + loadError: "Failed to read bookmarks from the selected PDF.", + }, +}; diff --git a/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx new file mode 100644 index 0000000000..265d7c0786 --- /dev/null +++ b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx @@ -0,0 +1,86 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import EditTableOfContentsWorkbenchView, { + type EditTableOfContentsWorkbenchViewData, +} from "@app/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView"; +import { createBookmarkNode } from "@app/utils/editTableOfContents"; + +const meta = { + title: "Tools/EditTableOfContents/EditTableOfContentsWorkbenchView", + component: EditTableOfContentsWorkbenchView, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const sampleFile = new File(["%PDF-1.4"], "annual-report.pdf", { + type: "application/pdf", +}); + +const sampleBookmarks = [ + createBookmarkNode({ + title: "Introduction", + pageNumber: 1, + }), + createBookmarkNode({ + title: "Chapter 1: Overview", + pageNumber: 3, + children: [ + createBookmarkNode({ title: "Background", pageNumber: 4 }), + createBookmarkNode({ title: "Scope", pageNumber: 6 }), + ], + }), + createBookmarkNode({ + title: "Conclusion", + pageNumber: 20, + }), +]; + +const baseData: EditTableOfContentsWorkbenchViewData = { + bookmarks: sampleBookmarks, + selectedFileName: sampleFile.name, + disabled: false, + files: [sampleFile], + thumbnails: [undefined], + downloadUrl: null, + downloadFilename: null, + errorMessage: null, + isGeneratingThumbnails: false, + isExecuteDisabled: false, + isExecuting: false, + onClearError: () => {}, + onBookmarksChange: () => {}, + onExecute: () => {}, + onUndo: () => {}, + onFileClick: () => {}, +}; + +export const Default: Story = { + args: { + data: baseData, + }, +}; + +export const Empty: Story = { + args: { + data: null, + }, +}; + +export const WithResults: Story = { + args: { + data: { + ...baseData, + downloadUrl: "blob:https://example.com/annual-report-toc.pdf", + downloadFilename: "annual-report-toc.pdf", + }, + }, +}; + +export const WithError: Story = { + args: { + data: { + ...baseData, + errorMessage: "Failed to apply the table of contents.", + }, + }, +}; diff --git a/frontend/editor/src/core/components/tools/extractImages/ExtractImagesSettings.stories.tsx b/frontend/editor/src/core/components/tools/extractImages/ExtractImagesSettings.stories.tsx new file mode 100644 index 0000000000..e0f7c0a014 --- /dev/null +++ b/frontend/editor/src/core/components/tools/extractImages/ExtractImagesSettings.stories.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ExtractImagesSettings from "@app/components/tools/extractImages/ExtractImagesSettings"; +import { + ExtractImagesParameters, + defaultParameters, +} from "@app/hooks/tools/extractImages/useExtractImagesParameters"; + +const meta = { + title: "Tools/ExtractImages/ExtractImagesSettings", + component: ExtractImagesSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the dropdown interactive in the canvas. +const ExtractImagesSettingsDemo = (props: { + initialParameters: ExtractImagesParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const JpgFormat: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/extractPages/ExtractPagesSettings.stories.tsx b/frontend/editor/src/core/components/tools/extractPages/ExtractPagesSettings.stories.tsx new file mode 100644 index 0000000000..0ebf63c149 --- /dev/null +++ b/frontend/editor/src/core/components/tools/extractPages/ExtractPagesSettings.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ExtractPagesSettings from "@app/components/tools/extractPages/ExtractPagesSettings"; +import { ExtractPagesParameters } from "@app/hooks/tools/extractPages/useExtractPagesParameters"; + +const meta = { + title: "Tools/ExtractPages/ExtractPagesSettings", + component: ExtractPagesSettings, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const buildParameters = ( + overrides: Partial = {}, +): ExtractPagesParameters => ({ + pageNumbers: "", + ...overrides, +}); + +export const Default: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + }, +}; + +export const Filled: Story = { + args: { + parameters: buildParameters({ pageNumbers: "1,3,5-8" }), + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: buildParameters({ pageNumbers: "1-10" }), + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx b/frontend/editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx new file mode 100644 index 0000000000..9f5e84b3b4 --- /dev/null +++ b/frontend/editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FlattenSettings from "@app/components/tools/flatten/FlattenSettings"; +import { FlattenParameters } from "@app/hooks/tools/flatten/useFlattenParameters"; + +const buildParameters = ( + overrides: Partial = {}, +): FlattenParameters => ({ + flattenOnlyForms: false, + renderDpi: undefined, + ...overrides, +}); + +const meta = { + title: "Tools/Flatten/FlattenSettings", + component: FlattenSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + }, +}; + +export const FlattenOnlyForms: Story = { + args: { + parameters: buildParameters({ flattenOnlyForms: true }), + onParameterChange: () => {}, + }, +}; + +export const CustomRenderDpi: Story = { + args: { + parameters: buildParameters({ renderDpi: 300 }), + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx b/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx new file mode 100644 index 0000000000..cd443e248a --- /dev/null +++ b/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx @@ -0,0 +1,103 @@ +import type React from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import DetailedToolItem from "@app/components/tools/fullscreen/DetailedToolItem"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { + ToolWorkflowProvider, + useToolWorkflow, +} from "@app/contexts/ToolWorkflowContext"; +import { HotkeyProvider } from "@app/contexts/HotkeyContext"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import type { ToolId } from "@app/types/toolId"; +import { + ToolCategoryId, + SubcategoryId, + type ToolRegistryEntry, +} from "@app/data/toolsTaxonomy"; + +// DetailedToolItem reads hotkeys/favourites/availability via useToolMeta, which +// pulls from HotkeyContext, ToolWorkflowContext and AppConfigContext, so every +// provider here must be present or those reads fail. AppConfigProvider uses +// autoFetch={false} to skip the network fetch and render synchronously instead +// of showing a loading state. +function withProviders(Story: () => React.JSX.Element) { + return ( + + + + + + + + + + + + + + ); +} + +// Pulls a real entry out of the same registry the component reads internally +// (via useToolMeta), so the icon/description/availability match a real render. +function ToolItemDemo({ + toolId, + isSelected = false, +}: { + toolId: ToolId; + isSelected?: boolean; +}) { + const { toolRegistry } = useToolWorkflow(); + const tool = toolRegistry[toolId]; + if (!tool) return null; + + return ( + {}} + /> + ); +} + +// Meta-level args are inherited by every story below. The stories themselves +// use `render` to swap in ToolItemDemo (which pulls a real registry entry), +// so these values never actually reach DetailedToolItem — they only exist to +// satisfy the required-props type on StoryAnnotations. +const mockTool: ToolRegistryEntry = { + icon: null, + name: "Split", + component: null, + description: "Split a PDF into multiple files", + categoryId: ToolCategoryId.STANDARD_TOOLS, + subcategoryId: SubcategoryId.GENERAL, + automationSettings: null, +}; + +const meta = { + title: "Tools/Fullscreen/DetailedToolItem", + component: DetailedToolItem, + decorators: [withProviders], + args: { + id: "split", + tool: mockTool, + isSelected: false, + onClick: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** An available tool rendered in its default, unselected state. */ +export const Default: Story = { + render: () => , +}; + +/** The active tool in the panel — highlighted selected state. */ +export const Selected: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx new file mode 100644 index 0000000000..08352aaab6 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import GetPdfInfoReportView from "@app/components/tools/getPdfInfo/GetPdfInfoReportView"; +import type { PdfInfoReportData } from "@app/types/getPdfInfo"; + +const filledData: PdfInfoReportData = { + generatedAt: Date.now(), + entries: [ + { + fileId: "file-1", + fileName: "annual-report.pdf", + fileSize: 245_760, + lastModified: Date.now(), + thumbnailUrl: null, + error: null, + data: { + Metadata: { + Title: "Annual Report 2025", + Author: "Stirling PDF", + Subject: "Financials", + Keywords: "annual, report, finance", + Creator: "Stirling PDF", + Producer: "Stirling PDF", + CreationDate: "2025-01-15T10:00:00Z", + ModificationDate: "2025-02-01T08:30:00Z", + }, + BasicInfo: { + FileSizeInBytes: 245_760, + WordCount: 12_400, + ParagraphCount: 320, + CharacterCount: 78_000, + Compression: true, + CompressionType: "Flate", + Language: "en-US", + "Number of pages": 24, + TotalImages: 6, + }, + DocumentInfo: { + "PDF version": "1.7", + Trapped: "False", + "Page Mode": "UseOutlines", + }, + Encryption: { + IsEncrypted: false, + }, + Permissions: { + Printing: "Allowed", + Modifying: "Not Allowed", + "Extracting Content": "Allowed", + }, + Compliancy: { + "IsPDF/ACompliant": true, + "PDF/AConformanceLevel": "2B", + }, + "Bookmarks/Outline/TOC": [ + { Title: "Introduction" }, + { Title: "Financial Summary" }, + { Title: "Appendix" }, + ], + Other: { + Attachments: [], + EmbeddedFiles: [], + JavaScript: [], + }, + PerPageInfo: { + "Page 1": { + Rotation: 0, + "Page Orientation": "Portrait", + }, + }, + SummaryData: { + encrypted: false, + restrictedPermissions: ["Modifying"], + restrictedPermissionsCount: 1, + Compliance: [ + { + Standard: "PDF/A", + Compliant: true, + Summary: "Fully compliant with PDF/A-2B.", + }, + ], + }, + }, + summaryGeneratedAt: Date.now(), + }, + ], +}; + +const emptyData: PdfInfoReportData = { + generatedAt: Date.now(), + entries: [], +}; + +const meta = { + title: "Tools/GetPdfInfo/GetPdfInfoReportView", + component: GetPdfInfoReportView, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + data: filledData, + }, +}; + +export const NoData: Story = { + args: { + data: emptyData, + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx new file mode 100644 index 0000000000..92d8f82377 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx @@ -0,0 +1,103 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import GetPdfInfoResults from "@app/components/tools/getPdfInfo/GetPdfInfoResults"; +import type { GetPdfInfoOperationHook } from "@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation"; +import type { PdfInfoReportEntry } from "@app/types/getPdfInfo"; + +const mockEntry: PdfInfoReportEntry = { + fileId: "file-1", + fileName: "sample.pdf", + fileSize: 245_760, + lastModified: Date.now(), + thumbnailUrl: null, + data: {}, + error: null, + summaryGeneratedAt: Date.now(), +}; + +const baseOperation: GetPdfInfoOperationHook = { + files: [], + thumbnails: [], + isGeneratingThumbnails: false, + downloadUrl: null, + downloadFilename: "", + isLoading: false, + status: "", + errorMessage: null, + progress: null, + executeOperation: async () => {}, + resetResults: () => {}, + clearError: () => {}, + cancelOperation: () => {}, + undoOperation: async () => {}, + results: [], +}; + +const meta = { + title: "Tools/GetPdfInfo/GetPdfInfoResults", + component: GetPdfInfoResults, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + operation: { + ...baseOperation, + results: [mockEntry], + files: [ + new File([JSON.stringify(mockEntry.data)], "response.json", { + type: "application/json", + }), + ], + }, + isLoading: false, + errorMessage: null, + }, +}; + +export const Loading: Story = { + args: { + operation: { + ...baseOperation, + results: [], + }, + isLoading: true, + errorMessage: null, + }, +}; + +export const Empty: Story = { + args: { + operation: { + ...baseOperation, + results: [], + }, + isLoading: false, + errorMessage: null, + }, +}; + +export const PartialError: Story = { + args: { + operation: { + ...baseOperation, + results: [ + mockEntry, + { + ...mockEntry, + fileId: "file-2", + fileName: "broken.pdf", + error: "Could not read file", + }, + ], + files: [ + new File([JSON.stringify(mockEntry.data)], "response.json", { + type: "application/json", + }), + ], + }, + isLoading: false, + errorMessage: "Some files could not be processed.", + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx new file mode 100644 index 0000000000..b2cb29b81d --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ComplianceSection from "@app/components/tools/getPdfInfo/sections/ComplianceSection"; + +const meta = { + title: "Tools/GetPdfInfo/ComplianceSection", + component: ComplianceSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + anchorId: "compliance", + complianceSummary: [ + { + Standard: "pdfa-2b", + Compliant: true, + Summary: "Document conforms to PDF/A-2B requirements", + }, + { + Standard: "pdfua-1", + Compliant: false, + Summary: + "Document is missing required tagging structure for accessibility", + }, + ], + legacyCompliance: { + "IsPDF/SECCompliant": true, + }, + }, +}; + +export const AllPassed: Story = { + args: { + anchorId: "compliance-passed", + complianceSummary: [ + { + Standard: "pdfa-3b", + Compliant: true, + Summary: "Document conforms to PDF/A-3B requirements", + }, + { + Standard: "pdfua-1", + Compliant: true, + Summary: "Document meets PDF/UA-1 accessibility requirements", + }, + ], + legacyCompliance: null, + }, +}; + +export const Empty: Story = { + args: { + anchorId: "compliance-empty", + complianceSummary: [], + legacyCompliance: null, + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx new file mode 100644 index 0000000000..75b2dd679d --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import KeyValueSection from "@app/components/tools/getPdfInfo/sections/KeyValueSection"; + +const meta = { + title: "Tools/GetPdfInfo/KeyValueSection", + component: KeyValueSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Document Info", + anchorId: "document-info", + obj: { + Title: "Sample Document", + Author: "Jane Doe", + Producer: "Stirling-PDF", + CreationDate: "2026-01-15", + }, + }, +}; + +export const Empty: Story = { + args: { + title: "Custom Metadata", + anchorId: "custom-metadata", + obj: {}, + emptyLabel: "No custom metadata found", + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx new file mode 100644 index 0000000000..f087e0e8f7 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import OtherSection from "@app/components/tools/getPdfInfo/sections/OtherSection"; +import type { PdfOtherInfo } from "@app/types/getPdfInfo"; + +const populatedOther: PdfOtherInfo = { + Attachments: [ + { Name: "invoice.xlsx", Description: "Original invoice", FileSize: 24576 }, + ], + EmbeddedFiles: [ + { + Name: "font-data.bin", + FileSize: 10240, + MimeType: "application/octet-stream", + CreationDate: "2026-01-01", + ModificationDate: "2026-02-01", + }, + ], + JavaScript: [{ "JS Name": "AutoPrint", "JS Script Length": 42 }], + Layers: [{ Name: "Watermark" }], + StructureTree: [{ Type: "Document" }], + XMPMetadata: "...", +}; + +const meta = { + title: "Tools/GetPdfInfo/Sections/OtherSection", + component: OtherSection, + parameters: { layout: "padded" }, + args: { + anchorId: "other", + other: populatedOther, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Empty: Story = { + args: { + other: {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.stories.tsx new file mode 100644 index 0000000000..3cf0bbc9d7 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PerPageSection from "@app/components/tools/getPdfInfo/sections/PerPageSection"; +import type { PdfPerPageInfo } from "@app/types/getPdfInfo"; + +const perPage: PdfPerPageInfo = { + "Page 1": { + Size: { + "Width (px)": "612", + "Height (px)": "792", + "Width (in)": "8.5", + "Height (in)": "11", + "Standard Page": "Letter", + }, + Rotation: 0, + "Page Orientation": "Portrait", + MediaBox: "[0.0, 0.0, 612.0, 792.0]", + CropBox: "[0.0, 0.0, 612.0, 792.0]", + "Text Characters Count": 1284, + Annotations: { + AnnotationsCount: 2, + SubtypeCount: 1, + ContentsCount: 1, + }, + Images: [{ Name: "Im0", Width: 400, Height: 300, ColorSpace: "DeviceRGB" }], + Links: [{ URI: "https://stirlingpdf.com" }], + Fonts: [ + { Name: "Helvetica", IsEmbedded: true, Subtype: "Type1" }, + { Name: "Times-Roman", IsEmbedded: false, Subtype: "Type1" }, + ], + XObjectCounts: { Image: 1, Form: 0, Other: 0 }, + Multimedia: [], + }, + "Page 2": { + Size: { + "Width (px)": "612", + "Height (px)": "792", + "Standard Page": "Letter", + }, + Rotation: 90, + "Page Orientation": "Landscape", + MediaBox: "[0.0, 0.0, 612.0, 792.0]", + "Text Characters Count": 0, + Images: [], + Links: [], + Fonts: [], + Multimedia: [], + }, +}; + +const meta = { + title: "GetPdfInfo/PerPageSection", + component: PerPageSection, + parameters: { layout: "padded" }, + args: { + anchorId: "per-page-info", + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + perPage, + }, +}; + +export const Empty: Story = { + args: { + perPage: null, + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx new file mode 100644 index 0000000000..464ef92491 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SummarySection from "@app/components/tools/getPdfInfo/sections/SummarySection"; +import type { ParsedPdfSections } from "@app/types/getPdfInfo"; + +const fullSections: ParsedPdfSections = { + basicInfo: { + "Number of pages": 12, + FileSizeInBytes: 245_760, + TotalImages: 4, + Language: "en-US", + }, + documentInfo: { + "PDF version": "1.7", + }, + metadata: { + Title: "Annual Report 2026", + Author: "Jane Doe", + CreationDate: "2026-01-10", + ModificationDate: "2026-02-01", + }, + encryption: { + IsEncrypted: false, + }, + permissions: { + Printing: "Allowed", + Modifying: "Allowed", + "Extracting Content": "Allowed", + "Document Assembly": "Allowed", + }, + summaryData: { + restrictedPermissionsCount: 0, + Compliance: [ + { Standard: "pdfa-2b", Compliant: true, Summary: "Passed" }, + { Standard: "pdfua-1", Compliant: false, Summary: "Failed" }, + ], + }, + other: { + EmbeddedFiles: [], + JavaScript: [], + Layers: [], + }, + perPage: { + "Page 1": { + Fonts: [ + { Name: "Helvetica", IsEmbedded: true }, + { Name: "Arial", IsEmbedded: false }, + ], + Multimedia: [], + }, + }, + formFields: {}, + toc: [], +}; + +const emptySections: ParsedPdfSections = { + basicInfo: {}, + documentInfo: {}, + metadata: {}, + encryption: {}, + permissions: {}, + summaryData: {}, + other: {}, + perPage: {}, + formFields: {}, + toc: [], +}; + +const meta = { + title: "Tools/GetPdfInfo/SummarySection", + component: SummarySection, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + sections: fullSections, + }, +}; + +export const Empty: Story = { + args: { + sections: emptySections, + }, +}; + +export const HiddenTitle: Story = { + args: { + sections: fullSections, + hideSectionTitle: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx new file mode 100644 index 0000000000..9645e7d1ec --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import TableOfContentsSection from "@app/components/tools/getPdfInfo/sections/TableOfContentsSection"; +import type { PdfTocEntry } from "@app/types/getPdfInfo"; + +const tocArray: PdfTocEntry[] = [ + { Title: "Chapter 1: Introduction" }, + { Title: "Chapter 2: Getting Started" }, + { Title: "Chapter 3: Advanced Topics" }, +]; + +const meta = { + title: "Tools/GetPdfInfo/TableOfContentsSection", + component: TableOfContentsSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + anchorId: "table-of-contents", + tocArray, + }, +}; + +export const Empty: Story = { + args: { + anchorId: "table-of-contents-empty", + tocArray: [], + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx new file mode 100644 index 0000000000..6c3bed6e0f --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import KeyValueList from "@app/components/tools/getPdfInfo/shared/KeyValueList"; + +const meta = { + title: "Tools/GetPdfInfo/KeyValueList", + component: KeyValueList, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + obj: { + Title: "Sample Document", + Author: "Jane Doe", + Producer: "Stirling-PDF", + CreationDate: "2026-01-15", + }, + }, +}; + +export const Empty: Story = { + args: { + obj: {}, + emptyLabel: "No custom metadata found", + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx new file mode 100644 index 0000000000..30ff2ccb19 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ScrollableCodeBlock from "@app/components/tools/getPdfInfo/shared/ScrollableCodeBlock"; + +const meta = { + title: "Tools/GetPdfInfo/Shared/ScrollableCodeBlock", + component: ScrollableCodeBlock, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + content: ` + + + + Sample Document + Stirling PDF + + + +`, + }, +}; + +export const Empty: Story = { + args: { + content: null, + }, +}; + +export const CustomEmptyMessage: Story = { + args: { + content: undefined, + emptyMessage: "No structure tree found in this document", + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/shared/SectionBlock.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/shared/SectionBlock.stories.tsx new file mode 100644 index 0000000000..efd32402a9 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/shared/SectionBlock.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SectionBlock from "@app/components/tools/getPdfInfo/shared/SectionBlock"; +import KeyValueList from "@app/components/tools/getPdfInfo/shared/KeyValueList"; + +const meta = { + title: "Tools/GetPdfInfo/Shared/SectionBlock", + component: SectionBlock, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Document Info", + anchorId: "document-info", + children: ( + + ), + }, +}; diff --git a/frontend/editor/src/core/components/tools/merge/MergeFileSorter.stories.tsx b/frontend/editor/src/core/components/tools/merge/MergeFileSorter.stories.tsx new file mode 100644 index 0000000000..d919d3ef3a --- /dev/null +++ b/frontend/editor/src/core/components/tools/merge/MergeFileSorter.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import MergeFileSorter from "@app/components/tools/merge/MergeFileSorter"; + +const meta = { + title: "Tools/Merge/MergeFileSorter", + component: MergeFileSorter, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onSortFiles: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + onSortFiles: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/merge/MergeSettings.stories.tsx b/frontend/editor/src/core/components/tools/merge/MergeSettings.stories.tsx new file mode 100644 index 0000000000..689e210612 --- /dev/null +++ b/frontend/editor/src/core/components/tools/merge/MergeSettings.stories.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import MergeSettings from "@app/components/tools/merge/MergeSettings"; +import { MergeParameters } from "@app/hooks/tools/merge/useMergeParameters"; + +const meta = { + title: "tools/merge/MergeSettings", + component: MergeSettings, + args: { + parameters: { + removeDigitalSignature: false, + generateTableOfContents: false, + }, + onParameterChange: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function MergeSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = useState({ + removeDigitalSignature: false, + generateTableOfContents: false, + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/ocr/AdvancedOCRSettings.stories.tsx b/frontend/editor/src/core/components/tools/ocr/AdvancedOCRSettings.stories.tsx new file mode 100644 index 0000000000..863f730d7a --- /dev/null +++ b/frontend/editor/src/core/components/tools/ocr/AdvancedOCRSettings.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdvancedOCRSettings from "@app/components/tools/ocr/AdvancedOCRSettings"; + +const meta = { + title: "Tools/Ocr/AdvancedOCRSettings", + component: AdvancedOCRSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + advancedOptions: [], + ocrRenderType: "hocr", + onParameterChange: () => {}, + }, +}; + +export const OptionsSelected: Story = { + args: { + advancedOptions: ["sidecar", "deskew"], + ocrRenderType: "hocr", + onParameterChange: () => {}, + }, +}; + +export const CompatibilityMode: Story = { + args: { + advancedOptions: [], + ocrRenderType: "sandwich", + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + advancedOptions: ["clean"], + ocrRenderType: "hocr", + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx b/frontend/editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx new file mode 100644 index 0000000000..3ca16b9aba --- /dev/null +++ b/frontend/editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import LanguagePicker from "@app/components/tools/ocr/LanguagePicker"; + +const meta = { + title: "Tools/OCR/LanguagePicker", + component: LanguagePicker, + args: { + value: [], + onChange: () => {}, + }, + parameters: { + msw: { + handlers: [ + http.get("/api/v1/ui-data/ocr-pdf", () => + HttpResponse.json({ + languages: ["eng", "fra", "deu", "spa", "ita", "por"], + }), + ), + ], + }, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Controlled wrapper so selecting/removing languages actually updates the picker. */ +function LanguagePickerDemo({ + initialValue = [], + disabled, +}: { + initialValue?: string[]; + disabled?: boolean; +}) { + const [value, setValue] = useState(initialValue); + return ( + + ); +} + +/** No languages selected yet, backend list loaded via MSW. */ +export const Default: Story = { + render: () => , +}; + +/** One language already selected. */ +export const WithSelection: Story = { + render: () => , +}; + +/** Disabled — e.g. while OCR is running elsewhere in the tool panel. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/ocr/OCRSettings.stories.tsx b/frontend/editor/src/core/components/tools/ocr/OCRSettings.stories.tsx new file mode 100644 index 0000000000..8839cfda29 --- /dev/null +++ b/frontend/editor/src/core/components/tools/ocr/OCRSettings.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import OCRSettings from "@app/components/tools/ocr/OCRSettings"; +import { OCRParameters } from "@app/hooks/tools/ocr/useOCRParameters"; + +const buildParameters = ( + overrides: Partial = {}, +): OCRParameters => ({ + languages: [], + ocrType: "skip-text", + ocrRenderType: "hocr", + additionalOptions: [], + ...overrides, +}); + +const meta = { + title: "Tools/OCR/OCRSettings", + component: OCRSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + }, +}; + +export const LanguagesSelected: Story = { + args: { + parameters: buildParameters({ languages: ["eng", "fra"] }), + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/pageLayout/LayoutPreview.stories.tsx b/frontend/editor/src/core/components/tools/pageLayout/LayoutPreview.stories.tsx new file mode 100644 index 0000000000..fdac5554d2 --- /dev/null +++ b/frontend/editor/src/core/components/tools/pageLayout/LayoutPreview.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LayoutPreview from "@app/components/tools/pageLayout/LayoutPreview"; +import { defaultParameters } from "@app/hooks/tools/pageLayout/usePageLayoutParameters"; + +const meta = { + title: "Tools/PageLayout/LayoutPreview", + component: LayoutPreview, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + }, +}; + +export const CustomGridLandscape: Story = { + args: { + parameters: { + ...defaultParameters, + mode: "CUSTOM", + rows: 2, + cols: 3, + orientation: "LANDSCAPE", + addBorder: true, + }, + }, +}; + +export const RightToLeftReading: Story = { + args: { + parameters: { + ...defaultParameters, + mode: "CUSTOM", + rows: 2, + cols: 2, + arrangement: "BY_ROWS", + readingDirection: "RTL", + addBorder: true, + }, + }, +}; diff --git a/frontend/editor/src/core/components/tools/pageLayout/PageLayoutAdvancedSettings.stories.tsx b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutAdvancedSettings.stories.tsx new file mode 100644 index 0000000000..712ca94741 --- /dev/null +++ b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutAdvancedSettings.stories.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageLayoutAdvancedSettings from "@app/components/tools/pageLayout/PageLayoutAdvancedSettings"; +import { + PageLayoutParameters, + defaultParameters, +} from "@app/hooks/tools/pageLayout/usePageLayoutParameters"; + +const meta = { + title: "Tools/PageLayout/PageLayoutAdvancedSettings", + component: PageLayoutAdvancedSettings, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function AdvancedSettingsDemo({ + disabled, + initialParameters, +}: { + disabled?: boolean; + initialParameters?: PageLayoutParameters; +}) { + const [parameters, setParameters] = useState( + initialParameters ?? defaultParameters, + ); + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; + +export const LandscapeRTL: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/pageLayout/PageLayoutMarginsBordersSettings.stories.tsx b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutMarginsBordersSettings.stories.tsx new file mode 100644 index 0000000000..67d9f06388 --- /dev/null +++ b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutMarginsBordersSettings.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageLayoutMarginsBordersSettings from "@app/components/tools/pageLayout/PageLayoutMarginsBordersSettings"; +import { + PageLayoutParameters, + defaultParameters, +} from "@app/hooks/tools/pageLayout/usePageLayoutParameters"; + +const meta = { + title: "Tools/PageLayout/PageLayoutMarginsBordersSettings", + component: PageLayoutMarginsBordersSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const withMarginsParameters: PageLayoutParameters = { + ...defaultParameters, + topMargin: 20, + bottomMargin: 20, + leftMargin: 15, + rightMargin: 15, + innerMargin: 5, +}; + +const withBorderParameters: PageLayoutParameters = { + ...defaultParameters, + addBorder: true, + borderWidth: 2, +}; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const WithMargins: Story = { + args: { + parameters: withMarginsParameters, + onParameterChange: () => {}, + }, +}; + +export const WithBorder: Story = { + args: { + parameters: withBorderParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/pageLayout/PageLayoutPreview.stories.tsx b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutPreview.stories.tsx new file mode 100644 index 0000000000..e3c2a66183 --- /dev/null +++ b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutPreview.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageLayoutPreview from "@app/components/tools/pageLayout/PageLayoutPreview"; +import { defaultParameters } from "@app/hooks/tools/pageLayout/usePageLayoutParameters"; + +const meta = { + title: "Tools/PageLayout/PageLayoutPreview", + component: PageLayoutPreview, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + }, +}; + +export const CustomGridWithBorder: Story = { + args: { + parameters: { + ...defaultParameters, + mode: "CUSTOM", + rows: 2, + cols: 3, + addBorder: true, + borderWidth: 2, + }, + }, +}; + +export const Landscape: Story = { + args: { + parameters: { + ...defaultParameters, + orientation: "LANDSCAPE", + pagesPerSheet: 2, + }, + }, +}; diff --git a/frontend/editor/src/core/components/tools/pageLayout/PageLayoutSettings.stories.tsx b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutSettings.stories.tsx new file mode 100644 index 0000000000..4ed2a6219f --- /dev/null +++ b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutSettings.stories.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageLayoutSettings from "@app/components/tools/pageLayout/PageLayoutSettings"; +import { + PageLayoutParameters, + defaultParameters, +} from "@app/hooks/tools/pageLayout/usePageLayoutParameters"; + +const meta: Meta = { + title: "Tools/PageLayout/PageLayoutSettings", + component: PageLayoutSettings, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initial, + disabled, +}: { + initial: PageLayoutParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = useState(initial); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +/** Default mode: pages-per-sheet select plus its description banner. */ +export const Default: Story = { + render: () => , +}; + +/** Custom mode: rows/columns number inputs instead of the sheet-count select. */ +export const CustomMode: Story = { + render: () => ( + + ), +}; + +/** Disabled: all controls locked, e.g. while no file is selected. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/redact/RedactAdvancedSettings.stories.tsx b/frontend/editor/src/core/components/tools/redact/RedactAdvancedSettings.stories.tsx new file mode 100644 index 0000000000..70c858cb65 --- /dev/null +++ b/frontend/editor/src/core/components/tools/redact/RedactAdvancedSettings.stories.tsx @@ -0,0 +1,40 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RedactAdvancedSettings from "@app/components/tools/redact/RedactAdvancedSettings"; +import { + RedactParameters, + defaultParameters, +} from "@app/hooks/tools/redact/useRedactParameters"; + +const meta = { + title: "Tools/Redact/RedactAdvancedSettings", + component: RedactAdvancedSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function AdvancedSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = + useState(defaultParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/redact/RedactModeSelector.stories.tsx b/frontend/editor/src/core/components/tools/redact/RedactModeSelector.stories.tsx new file mode 100644 index 0000000000..03ca1abe12 --- /dev/null +++ b/frontend/editor/src/core/components/tools/redact/RedactModeSelector.stories.tsx @@ -0,0 +1,54 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RedactModeSelector from "@app/components/tools/redact/RedactModeSelector"; +import type { RedactMode } from "@app/hooks/tools/redact/useRedactParameters"; + +const meta = { + title: "Tools/Redact/RedactModeSelector", + component: RedactModeSelector, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], + args: { + mode: "automatic", + onModeChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function ModeDemo({ + disabled, + hasAnyFiles, +}: { + disabled?: boolean; + hasAnyFiles?: boolean; +}) { + const [mode, setMode] = useState("automatic"); + return ( + + ); +} + +/** Files present: both Automatic and Manual are selectable. */ +export const Default: Story = { render: () => }; + +/** No files uploaded yet: both options disabled with a tooltip on Automatic. */ +export const NoFiles: Story = { + render: () => , +}; + +/** Selector disabled entirely (e.g. while an operation is running). */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx b/frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx new file mode 100644 index 0000000000..e34cc33924 --- /dev/null +++ b/frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RedactSingleStepSettings from "@app/components/tools/redact/RedactSingleStepSettings"; +import { + RedactParameters, + defaultParameters, +} from "@app/hooks/tools/redact/useRedactParameters"; + +const meta = { + title: "Tools/Redact/RedactSingleStepSettings", + component: RedactSingleStepSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: RedactParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const AutomaticWithWords: Story = { + render: () => ( + + ), +}; + +export const ManualMode: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx b/frontend/editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx new file mode 100644 index 0000000000..ec6d2b8492 --- /dev/null +++ b/frontend/editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WordsToRedactInput from "@app/components/tools/redact/WordsToRedactInput"; + +const meta = { + title: "Tools/Redact/WordsToRedactInput", + component: WordsToRedactInput, + args: { + wordsToRedact: [], + onWordsChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function WordsToRedactInputDemo({ + initialWords = [], + disabled, +}: { + initialWords?: string[]; + disabled?: boolean; +}) { + const [words, setWords] = useState(initialWords); + return ( + + ); +} + +export const Default: Story = { + render: () => , +}; + +export const WithWords: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/removeAnnotations/RemoveAnnotationsSettings.stories.tsx b/frontend/editor/src/core/components/tools/removeAnnotations/RemoveAnnotationsSettings.stories.tsx new file mode 100644 index 0000000000..d0461743ad --- /dev/null +++ b/frontend/editor/src/core/components/tools/removeAnnotations/RemoveAnnotationsSettings.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RemoveAnnotationsSettings from "@app/components/tools/removeAnnotations/RemoveAnnotationsSettings"; + +const meta = { + title: "Tools/RemoveAnnotations/RemoveAnnotationsSettings", + component: RemoveAnnotationsSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx b/frontend/editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx new file mode 100644 index 0000000000..946dc5074b --- /dev/null +++ b/frontend/editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RemoveBlanksSettings from "@app/components/tools/removeBlanks/RemoveBlanksSettings"; +import { RemoveBlanksParameters } from "@app/hooks/tools/removeBlanks/useRemoveBlanksParameters"; + +const buildParameters = ( + overrides: Partial = {}, +): RemoveBlanksParameters => ({ + threshold: 10, + whitePercent: 99.9, + includeBlankPages: false, + ...overrides, +}); + +const meta = { + title: "Tools/RemoveBlanks/RemoveBlanksSettings", + component: RemoveBlanksSettings, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + }, +}; + +export const IncludeBlankPages: Story = { + args: { + parameters: buildParameters({ includeBlankPages: true }), + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/removeCertificateSign/RemoveCertificateSignSettings.stories.tsx b/frontend/editor/src/core/components/tools/removeCertificateSign/RemoveCertificateSignSettings.stories.tsx new file mode 100644 index 0000000000..4af89e165f --- /dev/null +++ b/frontend/editor/src/core/components/tools/removeCertificateSign/RemoveCertificateSignSettings.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RemoveCertificateSignSettings from "@app/components/tools/removeCertificateSign/RemoveCertificateSignSettings"; +import { RemoveCertificateSignParameters } from "@app/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters"; + +const baseParameters: RemoveCertificateSignParameters = {}; + +const meta = { + title: "Tools/RemoveCertificateSign/RemoveCertificateSignSettings", + component: RemoveCertificateSignSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: baseParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: baseParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/removePages/RemovePagesSettings.stories.tsx b/frontend/editor/src/core/components/tools/removePages/RemovePagesSettings.stories.tsx new file mode 100644 index 0000000000..92db55641e --- /dev/null +++ b/frontend/editor/src/core/components/tools/removePages/RemovePagesSettings.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RemovePagesSettings from "@app/components/tools/removePages/RemovePagesSettings"; +import { RemovePagesParameters } from "@app/hooks/tools/removePages/useRemovePagesParameters"; + +const meta = { + title: "Tools/RemovePages/RemovePagesSettings", + component: RemovePagesSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const baseParameters: RemovePagesParameters = { + pageNumbers: "", +}; + +export const Default: Story = { + args: { + parameters: baseParameters, + onParameterChange: () => {}, + }, +}; + +export const FilledValid: Story = { + args: { + parameters: { pageNumbers: "1,3,5-8,10" }, + onParameterChange: () => {}, + }, +}; + +export const InvalidInput: Story = { + args: { + parameters: { pageNumbers: "abc" }, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: baseParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/removePassword/RemovePasswordSettings.stories.tsx b/frontend/editor/src/core/components/tools/removePassword/RemovePasswordSettings.stories.tsx new file mode 100644 index 0000000000..5997482e5d --- /dev/null +++ b/frontend/editor/src/core/components/tools/removePassword/RemovePasswordSettings.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; + +import RemovePasswordSettings from "@app/components/tools/removePassword/RemovePasswordSettings"; +import { RemovePasswordParameters } from "@app/hooks/tools/removePassword/useRemovePasswordParameters"; + +const parameters: RemovePasswordParameters = { + password: "", +}; + +const meta = { + title: "Tools/RemovePassword/RemovePasswordSettings", + component: RemovePasswordSettings, + parameters: { layout: "padded" }, + args: { + parameters, + onParameterChange: fn(), + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Filled: Story = { + args: { + parameters: { + ...parameters, + password: "correct-horse-battery-staple", + }, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.stories.tsx b/frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.stories.tsx new file mode 100644 index 0000000000..07586069b9 --- /dev/null +++ b/frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.stories.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ReorganizePagesSettings from "@app/components/tools/reorganizePages/ReorganizePagesSettings"; +import { + defaultReorganizePagesParameters, + ReorganizePagesParameters, +} from "@app/hooks/tools/reorganizePages/useReorganizePagesParameters"; + +const meta: Meta = { + title: "Tools/ReorganizePages/ReorganizePagesSettings", + component: ReorganizePagesSettings, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initial, + disabled, +}: { + initial?: Partial; + disabled?: boolean; +}) { + const [parameters, setParameters] = useState({ + ...defaultReorganizePagesParameters, + ...initial, + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +/** Custom order mode (default): the page order text input is shown. */ +export const Default: Story = { render: () => }; + +/** A preset mode (e.g. reverse) that doesn't require a page order input. */ +export const PresetMode: Story = { + render: () => , +}; + +/** Disabled state, e.g. while no files are loaded or processing is in progress. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/repair/RepairSettings.stories.tsx b/frontend/editor/src/core/components/tools/repair/RepairSettings.stories.tsx new file mode 100644 index 0000000000..4111f5882b --- /dev/null +++ b/frontend/editor/src/core/components/tools/repair/RepairSettings.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RepairSettings from "@app/components/tools/repair/RepairSettings"; +import { RepairParameters } from "@app/hooks/tools/repair/useRepairParameters"; + +const parameters: RepairParameters = {}; + +const meta = { + title: "Tools/Repair/RepairSettings", + component: RepairSettings, + args: { + parameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx b/frontend/editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx new file mode 100644 index 0000000000..ff11addc1e --- /dev/null +++ b/frontend/editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ReplaceColorSettings from "@app/components/tools/replaceColor/ReplaceColorSettings"; +import { + ReplaceColorParameters, + defaultParameters, +} from "@app/hooks/tools/replaceColor/useReplaceColorParameters"; + +const meta = { + title: "Tools/ReplaceColor/ReplaceColorSettings", + component: ReplaceColorSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const CustomColor: Story = { + args: { + parameters: { + ...defaultParameters, + replaceAndInvertOption: "CUSTOM_COLOR", + } satisfies ReplaceColorParameters, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/rotate/RotateAutomationSettings.stories.tsx b/frontend/editor/src/core/components/tools/rotate/RotateAutomationSettings.stories.tsx new file mode 100644 index 0000000000..e967671741 --- /dev/null +++ b/frontend/editor/src/core/components/tools/rotate/RotateAutomationSettings.stories.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RotateAutomationSettings from "@app/components/tools/rotate/RotateAutomationSettings"; +import { RotateParameters } from "@app/hooks/tools/rotate/useRotateParameters"; + +const meta = { + title: "Tools/Rotate/RotateAutomationSettings", + component: RotateAutomationSettings, + parameters: { layout: "padded" }, + args: { + parameters: { angle: 0 }, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function RotateDemo({ + disabled, + initialAngle = 0, +}: { + disabled?: boolean; + initialAngle?: number; +}) { + const [parameters, setParameters] = useState({ + angle: initialAngle, + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Rotated90: Story = { + render: () => , +}; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx b/frontend/editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx new file mode 100644 index 0000000000..c5463cbfc0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SanitizeSettings from "@app/components/tools/sanitize/SanitizeSettings"; +import { defaultParameters } from "@app/hooks/tools/sanitize/useSanitizeParameters"; + +const meta = { + title: "Tools/Sanitize/SanitizeSettings", + component: SanitizeSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const AllSelected: Story = { + args: { + parameters: { + removeJavaScript: true, + removeEmbeddedFiles: true, + removeXMPMetadata: true, + removeMetadata: true, + removeLinks: true, + removeFonts: true, + }, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx b/frontend/editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx new file mode 100644 index 0000000000..05f4a41cf4 --- /dev/null +++ b/frontend/editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ScannerImageSplitSettings from "@app/components/tools/scannerImageSplit/ScannerImageSplitSettings"; +import { ScannerImageSplitParameters } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitParameters"; + +const buildParameters = ( + overrides: Partial = {}, +): ScannerImageSplitParameters => ({ + angle_threshold: 10, + tolerance: 30, + min_area: 10000, + min_contour_area: 500, + border_size: 1, + ...overrides, +}); + +const meta = { + title: "Tools/ScannerImageSplit/ScannerImageSplitSettings", + component: ScannerImageSplitSettings, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/ErrorNotification.stories.tsx b/frontend/editor/src/core/components/tools/shared/ErrorNotification.stories.tsx new file mode 100644 index 0000000000..81f6c658bc --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/ErrorNotification.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ErrorNotification from "@app/components/tools/shared/ErrorNotification"; + +const meta = { + title: "ToolsShared/ErrorNotification", + component: ErrorNotification, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + error: "Something went wrong while processing the file.", + onClose: () => {}, + }, +}; + +export const CustomTitle: Story = { + args: { + error: "The uploaded file could not be read.", + onClose: () => {}, + title: "Upload failed", + color: "orange", + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/FileMetadata.stories.tsx b/frontend/editor/src/core/components/tools/shared/FileMetadata.stories.tsx new file mode 100644 index 0000000000..750d751e6d --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/FileMetadata.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FileMetadata from "@app/components/tools/shared/FileMetadata"; + +const buildFile = (name = "report.pdf", type = "application/pdf"): File => + new File(["%PDF-1.4 mock content"], name, { + type, + lastModified: new Date("2026-01-15T10:30:00Z").getTime(), + }); + +const meta = { + title: "ToolsShared/FileMetadata", + component: FileMetadata, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + file: buildFile(), + }, +}; + +export const UnknownType: Story = { + args: { + file: buildFile("data.bin", ""), + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/NavigationControls.stories.tsx b/frontend/editor/src/core/components/tools/shared/NavigationControls.stories.tsx new file mode 100644 index 0000000000..e34b1cb329 --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/NavigationControls.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import NavigationControls from "@app/components/tools/shared/NavigationControls"; + +const meta = { + title: "ToolsShared/NavigationControls", + component: NavigationControls, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + currentIndex: 0, + totalFiles: 5, + onPrevious: () => {}, + onNext: () => {}, + }, +}; + +export const LastFile: Story = { + args: { + currentIndex: 4, + totalFiles: 5, + onPrevious: () => {}, + onNext: () => {}, + }, +}; + +export const SingleFile: Story = { + args: { + currentIndex: 0, + totalFiles: 1, + onPrevious: () => {}, + onNext: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/NoToolsFound.stories.tsx b/frontend/editor/src/core/components/tools/shared/NoToolsFound.stories.tsx new file mode 100644 index 0000000000..6ac9f9a3b0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/NoToolsFound.stories.tsx @@ -0,0 +1,11 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import NoToolsFound from "@app/components/tools/shared/NoToolsFound"; + +const meta = { + title: "Tools/Shared/NoToolsFound", + component: NoToolsFound, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx b/frontend/editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx new file mode 100644 index 0000000000..b6e1d8280f --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import NumberInputWithUnit from "@app/components/tools/shared/NumberInputWithUnit"; + +const meta = { + title: "Tools/Shared/NumberInputWithUnit", + component: NumberInputWithUnit, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + label: "Margin", + value: 10, + onChange: () => {}, + unit: "px", + }, +}; + +export const WithMinMax: Story = { + args: { + ...Default.args, + label: "Opacity", + value: 50, + unit: "%", + min: 0, + max: 100, + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/ResultsPreview.stories.tsx b/frontend/editor/src/core/components/tools/shared/ResultsPreview.stories.tsx new file mode 100644 index 0000000000..174a192613 --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/ResultsPreview.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ResultsPreview, { + ReviewFile, +} from "@app/components/tools/shared/ResultsPreview"; + +function makeFile(name: string, type: string, size: number): File { + return new File([new Uint8Array(size)], name, { type }); +} + +const files: ReviewFile[] = [ + { file: makeFile("contract-final.pdf", "application/pdf", 245_760) }, + { file: makeFile("scan-001.pdf", "application/pdf", 1_048_576) }, + { file: makeFile("invoice-march.pdf", "application/pdf", 51_200) }, +]; + +const meta = { + title: "Tools/Shared/ResultsPreview", + component: ResultsPreview, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + files, + }, +}; + +export const SingleFile: Story = { + args: { + files: [files[0]], + }, +}; + +export const Loading: Story = { + args: { + files: [], + isGeneratingThumbnails: true, + }, +}; + +export const Empty: Story = { + args: { + files: [], + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/SubcategoryHeader.stories.tsx b/frontend/editor/src/core/components/tools/shared/SubcategoryHeader.stories.tsx new file mode 100644 index 0000000000..634841969a --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/SubcategoryHeader.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SubcategoryHeader from "@app/components/tools/shared/SubcategoryHeader"; + +const meta = { + title: "Tools/Shared/SubcategoryHeader", + component: SubcategoryHeader, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + label: "Page organization", + }, +}; + +export const CustomSpacing: Story = { + args: { + label: "Security", + mt: "2rem", + mb: "1rem", + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/ToolStep.stories.tsx b/frontend/editor/src/core/components/tools/shared/ToolStep.stories.tsx new file mode 100644 index 0000000000..e3c10425f0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/ToolStep.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Text } from "@mantine/core"; +import ToolStep from "@app/components/tools/shared/ToolStep"; + +const meta = { + title: "Tools/Shared/ToolStep", + component: ToolStep, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Select pages", + children: Step content goes here., + }, +}; + +export const WithHelpTextAndNumber: Story = { + args: { + title: "Choose output format", + helpText: "Pick the format your converted file should use.", + showNumber: true, + _stepNumber: 2, + children: Step content goes here., + }, +}; + +export const Collapsed: Story = { + args: { + title: "Advanced settings", + isCollapsed: true, + onCollapsedClick: () => {}, + children: Step content goes here., + }, +}; diff --git a/frontend/editor/src/core/components/tools/showJS/ShowJSView.stories.tsx b/frontend/editor/src/core/components/tools/showJS/ShowJSView.stories.tsx new file mode 100644 index 0000000000..fcb29ed009 --- /dev/null +++ b/frontend/editor/src/core/components/tools/showJS/ShowJSView.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ShowJSView from "@app/components/tools/showJS/ShowJSView"; + +const SAMPLE_SCRIPT = `function greet(name) { + // say hello + if (!name) { + return "Hello, stranger!"; + } + return "Hello, " + name + "!"; +} + +for (let i = 0; i < 3; i++) { + console.log(greet("World")); +} +`; + +const meta = { + title: "Tools/ShowJS/ShowJSView", + component: ShowJSView, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + data: SAMPLE_SCRIPT, + }, +}; + +export const WithDownload: Story = { + args: { + data: { + scriptText: SAMPLE_SCRIPT, + downloadUrl: "blob:mock-download-url", + downloadFilename: "extracted.js", + }, + }, +}; + +export const Empty: Story = { + args: { + data: "", + }, +}; diff --git a/frontend/editor/src/core/components/tools/sign/PenSizeSelector.stories.tsx b/frontend/editor/src/core/components/tools/sign/PenSizeSelector.stories.tsx new file mode 100644 index 0000000000..658d4ebb6e --- /dev/null +++ b/frontend/editor/src/core/components/tools/sign/PenSizeSelector.stories.tsx @@ -0,0 +1,36 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PenSizeSelector from "@app/components/tools/sign/PenSizeSelector"; + +const meta = { + title: "Tools/Sign/PenSizeSelector", + component: PenSizeSelector, + args: { + value: 5, + inputValue: "5", + onValueChange: () => {}, + onInputChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function PenSizeSelectorDemo({ disabled }: { disabled?: boolean }) { + const [value, setValue] = useState(5); + const [inputValue, setInputValue] = useState("5"); + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx b/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx new file mode 100644 index 0000000000..9079bb08ab --- /dev/null +++ b/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SavedSignaturesSection from "@app/components/tools/sign/SavedSignaturesSection"; +import type { SavedSignature } from "@app/hooks/tools/sign/useSavedSignatures"; + +const mockSignatures: SavedSignature[] = [ + { + id: "sig-1", + label: "My signature", + scope: "personal", + type: "text", + dataUrl: "", + signerName: "Jordan Lee", + fontFamily: "cursive", + fontSize: 32, + textColor: "#1a1a1a", + createdAt: Date.now(), + updatedAt: Date.now(), + }, + { + id: "sig-2", + label: "Company stamp", + scope: "shared", + type: "image", + dataUrl: + "data:image/svg+xml;base64," + + btoa( + 'Approved', + ), + createdAt: Date.now(), + updatedAt: Date.now(), + }, + { + id: "sig-3", + label: "Quick draw", + scope: "localStorage", + type: "canvas", + dataUrl: + "data:image/svg+xml;base64," + + btoa( + '', + ), + createdAt: Date.now(), + updatedAt: Date.now(), + }, +]; + +const meta = { + title: "Tools/Sign/SavedSignaturesSection", + component: SavedSignaturesSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + signatures: mockSignatures, + isAtCapacity: false, + maxLimit: 5, + onUseSignature: () => {}, + onDeleteSignature: () => {}, + onRenameSignature: () => {}, + }, +}; + +export const Empty: Story = { + args: { + signatures: [], + isAtCapacity: false, + maxLimit: 5, + onUseSignature: () => {}, + onDeleteSignature: () => {}, + onRenameSignature: () => {}, + }, +}; + +export const AtCapacity: Story = { + args: { + signatures: mockSignatures, + isAtCapacity: true, + maxLimit: 3, + onUseSignature: () => {}, + onDeleteSignature: () => {}, + onRenameSignature: () => {}, + }, +}; + +export const AdminWithSharedDelete: Story = { + args: { + signatures: mockSignatures, + isAtCapacity: false, + maxLimit: 5, + isAdmin: true, + onUseSignature: () => {}, + onDeleteSignature: () => {}, + onRenameSignature: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/singleLargePage/SingleLargePageSettings.stories.tsx b/frontend/editor/src/core/components/tools/singleLargePage/SingleLargePageSettings.stories.tsx new file mode 100644 index 0000000000..9433f691af --- /dev/null +++ b/frontend/editor/src/core/components/tools/singleLargePage/SingleLargePageSettings.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SingleLargePageSettings from "@app/components/tools/singleLargePage/SingleLargePageSettings"; +import type { SingleLargePageParameters } from "@app/hooks/tools/singleLargePage/useSingleLargePageParameters"; + +const parameters: SingleLargePageParameters = {}; + +const meta = { + title: "Tools/SingleLargePage/SingleLargePageSettings", + component: SingleLargePageSettings, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/split/SplitAutomationSettings.stories.tsx b/frontend/editor/src/core/components/tools/split/SplitAutomationSettings.stories.tsx new file mode 100644 index 0000000000..a3b12a58e4 --- /dev/null +++ b/frontend/editor/src/core/components/tools/split/SplitAutomationSettings.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SplitAutomationSettings from "@app/components/tools/split/SplitAutomationSettings"; +import { + defaultParameters, + SplitParameters, +} from "@app/hooks/tools/split/useSplitParameters"; +import { SPLIT_METHODS } from "@app/constants/splitConstants"; + +const meta = { + title: "Tools/Split/SplitAutomationSettings", + component: SplitAutomationSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const MethodSelected: Story = { + args: { + parameters: { + ...defaultParameters, + method: SPLIT_METHODS.BY_PAGES, + pages: "1,3,5", + } satisfies SplitParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: { + ...defaultParameters, + method: SPLIT_METHODS.BY_PAGES, + pages: "1,3,5", + } satisfies SplitParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/split/SplitSettings.stories.tsx b/frontend/editor/src/core/components/tools/split/SplitSettings.stories.tsx new file mode 100644 index 0000000000..a3411db819 --- /dev/null +++ b/frontend/editor/src/core/components/tools/split/SplitSettings.stories.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SplitSettings from "@app/components/tools/split/SplitSettings"; +import { SPLIT_METHODS } from "@app/constants/splitConstants"; +import { + defaultParameters, + SplitParameters, +} from "@app/hooks/tools/split/useSplitParameters"; + +const meta = { + title: "Tools/Split/SplitSettings", + component: SplitSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SplitSettingsDemo({ + initialParameters, + disabled, +}: { + initialParameters: SplitParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +/** No method chosen yet — shows the "select a method first" placeholder. */ +export const NoMethodSelected: Story = { + render: () => , +}; + +/** Split by pages: a single page-range text input. */ +export const ByPages: Story = { + render: () => ( + + ), +}; + +/** Split by sections: divisions, split mode, and merge checkbox. */ +export const BySections: Story = { + render: () => ( + + ), +}; + +/** Split by poster print: page size + division factors + orientation. */ +export const ByPoster: Story = { + render: () => ( + + ), +}; + +/** Disabled state: all inputs are non-interactive. */ +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx b/frontend/editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx new file mode 100644 index 0000000000..8a86d24755 --- /dev/null +++ b/frontend/editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import TimestampPdfSettings from "@app/components/tools/timestampPdf/TimestampPdfSettings"; +import { defaultParameters } from "@app/hooks/tools/timestampPdf/useTimestampPdfParameters"; + +const meta = { + title: "Tools/TimestampPdf/TimestampPdfSettings", + component: TimestampPdfSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx b/frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx new file mode 100644 index 0000000000..51e1d08e09 --- /dev/null +++ b/frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar"; + +const meta = { + title: "Tools/ToolPicker/FavoriteStar", + component: FavoriteStar, + parameters: { layout: "centered" }, + args: { + isFavorite: false, + onToggle: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Favorited: Story = { + args: { + isFavorite: true, + }, +}; + +export const Sizes: Story = { + render: () => ( +
+ {(["xs", "sm", "md", "lg", "xl"] as const).map((size) => ( + {}} size={size} /> + ))} +
+ ), +}; diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx b/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx new file mode 100644 index 0000000000..c7dfd1b4eb --- /dev/null +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx @@ -0,0 +1,82 @@ +import { useState, type ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToolSearch from "@app/components/tools/toolPicker/ToolSearch"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { + ToolWorkflowProvider, + useToolWorkflow, +} from "@app/contexts/ToolWorkflowContext"; + +// ToolSearchDemo below sources toolRegistry via useToolWorkflow(), so +// ToolWorkflowProvider must be present — and it in turn needs +// ToolRegistryProvider and NavigationProvider as ancestors to build a real registry. +function withProviders(Story: () => ReactElement) { + return ( + + + + + + + + + + ); +} + +function ToolSearchDemo({ + mode = "filter", + initialValue = "", +}: { + mode?: "filter" | "dropdown" | "unstyled"; + initialValue?: string; +}) { + const { toolRegistry } = useToolWorkflow(); + const [value, setValue] = useState(initialValue); + + return ( + + ); +} + +const meta = { + title: "Tools/ToolPicker/ToolSearch", + component: ToolSearch, + decorators: [withProviders], + args: { + value: "", + onChange: () => {}, + toolRegistry: {}, + mode: "filter", + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Filter mode: plain input, no dropdown, used inline above a tool grid. */ +export const Default: Story = { + render: () => , +}; + +/** + * Dropdown mode with a query pre-filled. The results panel itself only opens + * in response to a user typing (internal `dropdownOpen` state), so this + * renders the closed input — type in it to see the fuzzy-matched list. + */ +export const DropdownMode: Story = { + render: () => , +}; + +/** Unstyled mode: bare input with no wrapping container, for embedding elsewhere. */ +export const Unstyled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/unlockPdfForms/UnlockPdfFormsSettings.stories.tsx b/frontend/editor/src/core/components/tools/unlockPdfForms/UnlockPdfFormsSettings.stories.tsx new file mode 100644 index 0000000000..2e88b16eec --- /dev/null +++ b/frontend/editor/src/core/components/tools/unlockPdfForms/UnlockPdfFormsSettings.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import UnlockPdfFormsSettings from "@app/components/tools/unlockPdfForms/UnlockPdfFormsSettings"; +import { UnlockPdfFormsParameters } from "@app/hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters"; + +const defaultParameters: UnlockPdfFormsParameters = {}; + +const meta = { + title: "Tools/UnlockPdfForms/UnlockPdfFormsSettings", + component: UnlockPdfFormsSettings, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx new file mode 100644 index 0000000000..1203f3bee1 --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx @@ -0,0 +1,140 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ValidateSignatureReportView from "@app/components/tools/validateSignature/ValidateSignatureReportView"; +import type { + SignatureValidationReportData, + SignatureValidationSignature, +} from "@app/types/validateSignature"; + +const baseSignature: SignatureValidationSignature = { + id: "sig-1", + valid: true, + chainValid: true, + trustValid: true, + chainValidationError: null, + certPathLength: 2, + notExpired: true, + coversEntireDocument: true, + revocationChecked: true, + revocationStatus: "good", + validationTimeSource: "signing-time", + signerName: "Jane Doe", + signatureDate: "2026-05-12T10:30:00Z", + reason: "Approved", + location: "London, UK", + issuerDN: "CN=Example CA, O=Example Corp", + subjectDN: "CN=Jane Doe, O=Example Corp", + serialNumber: "1A2B3C4D5E", + validFrom: "2025-01-01T00:00:00Z", + validUntil: "2027-01-01T00:00:00Z", + signatureAlgorithm: "SHA256withRSA", + keySize: 2048, + version: "1", + keyUsages: ["digitalSignature", "nonRepudiation"], + selfSigned: false, + errorMessage: null, +}; + +const validData: SignatureValidationReportData = { + generatedAt: Date.parse("2026-05-12T11:00:00Z"), + entries: [ + { + fileId: "file-1", + fileName: "contract.pdf", + fileSize: 245_760, + lastModified: Date.parse("2026-05-12T10:30:00Z"), + thumbnailUrl: null, + createdAtLabel: "12 May 2026", + signatures: [baseSignature], + }, + ], +}; + +const multiSignatureData: SignatureValidationReportData = { + generatedAt: Date.parse("2026-05-12T11:00:00Z"), + entries: [ + { + fileId: "file-1", + fileName: "agreement.pdf", + fileSize: 512_000, + lastModified: Date.parse("2026-05-12T10:30:00Z"), + thumbnailUrl: null, + createdAtLabel: "12 May 2026", + signatures: [ + baseSignature, + { + ...baseSignature, + id: "sig-2", + signerName: "John Smith", + valid: false, + chainValid: false, + trustValid: false, + chainValidationError: "Certificate chain could not be verified", + errorMessage: "Untrusted certificate", + }, + ], + }, + ], +}; + +const noSignaturesData: SignatureValidationReportData = { + generatedAt: Date.parse("2026-05-12T11:00:00Z"), + entries: [ + { + fileId: "file-2", + fileName: "unsigned-report.pdf", + fileSize: 128_000, + lastModified: Date.parse("2026-05-12T10:30:00Z"), + thumbnailUrl: null, + createdAtLabel: "12 May 2026", + signatures: [], + }, + ], +}; + +const errorData: SignatureValidationReportData = { + generatedAt: Date.parse("2026-05-12T11:00:00Z"), + entries: [ + { + fileId: "file-3", + fileName: "corrupted.pdf", + fileSize: 64_000, + lastModified: Date.parse("2026-05-12T10:30:00Z"), + thumbnailUrl: null, + createdAtLabel: "12 May 2026", + signatures: [], + error: "Unable to parse document signatures", + }, + ], +}; + +const meta = { + title: "Tools/ValidateSignature/ValidateSignatureReportView", + component: ValidateSignatureReportView, + parameters: { layout: "padded" }, + args: { + data: validData, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const MultipleSignatures: Story = { + args: { + data: multiSignatureData, + }, +}; + +export const NoSignatures: Story = { + args: { + data: noSignaturesData, + }, +}; + +export const Error: Story = { + args: { + data: errorData, + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx new file mode 100644 index 0000000000..3d8b1baa1e --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ValidateSignatureSettings from "@app/components/tools/validateSignature/ValidateSignatureSettings"; +import { ValidateSignatureParameters } from "@app/hooks/tools/validateSignature/useValidateSignatureParameters"; + +const meta = { + title: "Tools/ValidateSignature/ValidateSignatureSettings", + component: ValidateSignatureSettings, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const buildParameters = ( + overrides: Partial = {}, +): ValidateSignatureParameters => ({ + certFile: null, + ...overrides, +}); + +export const Default: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + }, +}; + +export const WithCertFile: Story = { + args: { + parameters: buildParameters({ + certFile: new File(["cert-data"], "trusted-root.crt", { + type: "application/x-x509-ca-cert", + }), + }), + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx new file mode 100644 index 0000000000..499b76e393 --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FieldBlock from "@app/components/tools/validateSignature/reportView/FieldBlock"; + +// FieldBlock is a plain function that returns a JSX element (called directly +// as `FieldBlock(label, value)`), not a React component consumed via JSX +// props, so every story renders it through a `render` override instead of +// `args`. +const meta = { + title: "Tools/ValidateSignature/ReportView/FieldBlock", +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => FieldBlock("Signer Name", "Jane Doe"), +}; + +export const EmptyValue: Story = { + render: () => FieldBlock("Reason", ""), +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx new file mode 100644 index 0000000000..066510a0ef --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FileSummaryHeader from "@app/components/tools/validateSignature/reportView/FileSummaryHeader"; + +const meta = { + title: "Tools/ValidateSignature/ReportView/FileSummaryHeader", + component: FileSummaryHeader, + args: { + fileSize: 2_456_789, + createdAt: "2026-01-15T09:30:00Z", + totalSignatures: 2, + lastSignatureDate: "2026-03-04T14:12:00Z", + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const NoSignatures: Story = { + args: { + totalSignatures: 0, + lastSignatureDate: null, + }, +}; + +export const MissingMetadata: Story = { + args: { + fileSize: null, + createdAt: null, + totalSignatures: 1, + lastSignatureDate: null, + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx new file mode 100644 index 0000000000..7c8b8edc1f --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignatureSection from "@app/components/tools/validateSignature/reportView/SignatureSection"; +import type { SignatureValidationSignature } from "@app/types/validateSignature"; + +const buildSignature = ( + overrides: Partial = {}, +): SignatureValidationSignature => ({ + id: "sig-1", + valid: true, + chainValid: true, + trustValid: true, + chainValidationError: null, + certPathLength: 2, + notExpired: true, + coversEntireDocument: true, + revocationChecked: true, + revocationStatus: "good", + validationTimeSource: "signing-time", + signerName: "Jane Doe", + signatureDate: "2026-06-01T10:00:00Z", + reason: "Approved", + location: "London, UK", + issuerDN: "CN=Example CA, O=Example Corp", + subjectDN: "CN=Jane Doe, O=Example Corp", + serialNumber: "0x4F2A9C", + validFrom: "2025-01-01T00:00:00Z", + validUntil: "2027-01-01T00:00:00Z", + signatureAlgorithm: "SHA256withRSA", + keySize: 2048, + version: "3", + keyUsages: ["digitalSignature", "nonRepudiation"], + selfSigned: false, + errorMessage: null, + ...overrides, +}); + +const meta = { + title: "Tools/ValidateSignature/SignatureSection", + component: SignatureSection, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + signature: buildSignature(), + index: 0, + }, +}; + +export const InvalidWithError: Story = { + args: { + signature: buildSignature({ + valid: false, + chainValid: false, + trustValid: false, + notExpired: false, + errorMessage: "Certificate has expired", + }), + index: 1, + }, +}; + +export const SelfSignedMinimalData: Story = { + args: { + signature: buildSignature({ + signerName: "", + reason: "", + location: "", + issuerDN: "", + subjectDN: "", + serialNumber: "", + keySize: null, + version: "", + keyUsages: [], + selfSigned: true, + }), + index: 2, + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx new file mode 100644 index 0000000000..aba78f9f2c --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignatureStatusBadge from "@app/components/tools/validateSignature/reportView/SignatureStatusBadge"; +import type { SignatureValidationSignature } from "@app/types/validateSignature"; + +const baseSignature: SignatureValidationSignature = { + id: "sig-1", + valid: true, + chainValid: true, + trustValid: true, + chainValidationError: null, + certPathLength: 2, + notExpired: true, + coversEntireDocument: true, + revocationChecked: true, + revocationStatus: "good", + validationTimeSource: "signing-time", + signerName: "Jane Doe", + signatureDate: "2026-06-01T12:00:00Z", + reason: "Document approval", + location: "London, UK", + issuerDN: "CN=Stirling PDF CA", + subjectDN: "CN=Jane Doe", + serialNumber: "0123456789ABCDEF", + validFrom: "2025-01-01T00:00:00Z", + validUntil: "2027-01-01T00:00:00Z", + signatureAlgorithm: "SHA256withRSA", + keySize: 2048, + version: "2", + keyUsages: ["digitalSignature", "nonRepudiation"], + selfSigned: false, + errorMessage: null, +}; + +const meta = { + title: "Tools/ValidateSignature/SignatureStatusBadge", + component: SignatureStatusBadge, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + signature: baseSignature, + }, +}; + +export const UntrustedSigner: Story = { + args: { + signature: { + ...baseSignature, + id: "sig-2", + trustValid: false, + chainValid: false, + selfSigned: true, + }, + }, +}; + +export const Invalid: Story = { + args: { + signature: { + ...baseSignature, + id: "sig-3", + valid: false, + errorMessage: "Signature does not match document contents", + }, + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/reportView/ThumbnailPreview.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/reportView/ThumbnailPreview.stories.tsx new file mode 100644 index 0000000000..feccc477d3 --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/reportView/ThumbnailPreview.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ThumbnailPreview from "@app/components/tools/validateSignature/reportView/ThumbnailPreview"; + +const meta = { + title: "Tools/ValidateSignature/ReportView/ThumbnailPreview", + component: ThumbnailPreview, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const SAMPLE_THUMBNAIL = + "data:image/svg+xml;utf8," + + encodeURIComponent( + '', + ); + +export const Default: Story = { + args: { + thumbnailUrl: SAMPLE_THUMBNAIL, + fileName: "signed-contract.pdf", + }, +}; + +export const NoThumbnail: Story = { + args: { + thumbnailUrl: null, + fileName: "signed-contract.pdf", + }, +}; diff --git a/frontend/editor/src/core/components/viewer/AnnotationMenuButtons.stories.tsx b/frontend/editor/src/core/components/viewer/AnnotationMenuButtons.stories.tsx new file mode 100644 index 0000000000..65f57faa9d --- /dev/null +++ b/frontend/editor/src/core/components/viewer/AnnotationMenuButtons.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + DeleteButton, + EditTextButton, + AttachCommentButton, + CommentButton, + LinkButton, +} from "@app/components/viewer/AnnotationMenuButtons"; + +// This module has no single default-exported component -- it's a set of small +// action-icon buttons shared by the annotation menu. Document each separately. +const meta: Meta = { + title: "Viewer/AnnotationMenuButtons", + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Delete: Story = { + render: () => {}} />, +}; + +export const EditText: Story = { + render: () => {}} />, +}; + +export const AttachCommentAdd: Story = { + render: () => ( + {}} + onAdd={() => {}} + /> + ), +}; + +export const AttachCommentView: Story = { + render: () => ( + {}} + onAdd={() => {}} + /> + ), +}; + +export const CommentEmpty: Story = { + render: () => {}} />, +}; + +export const CommentWithContent: Story = { + render: () => {}} />, +}; + +export const LinkAdd: Story = { + render: () => ( + {}} + onAddLink={() => {}} + /> + ), +}; + +export const LinkGoTo: Story = { + render: () => ( + {}} + onAddLink={() => {}} + /> + ), +}; diff --git a/frontend/editor/src/core/components/viewer/AnnotationSelectionMenu.stories.tsx b/frontend/editor/src/core/components/viewer/AnnotationSelectionMenu.stories.tsx new file mode 100644 index 0000000000..bd9d86f0c6 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/AnnotationSelectionMenu.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AnnotationSelectionMenu } from "@app/components/viewer/AnnotationSelectionMenu"; + +// AnnotationSelectionMenu reads the active document from ActiveDocumentContext, which +// defaults to `null` outside of a live EmbedPDF document-manager session (not something the +// shared preview can stub). With no active document it short-circuits and renders nothing, +// so this story only exercises that no-active-document mount path without throwing. +const meta = { + title: "Viewer/AnnotationSelectionMenu", + component: AnnotationSelectionMenu, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + selected: false, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/AnnotationTypeButtons.stories.tsx b/frontend/editor/src/core/components/viewer/AnnotationTypeButtons.stories.tsx new file mode 100644 index 0000000000..860f743a99 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/AnnotationTypeButtons.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AnnotationTypeButtons } from "@app/components/viewer/AnnotationTypeButtons"; + +const meta = { + title: "Viewer/AnnotationTypeButtons", + component: AnnotationTypeButtons, + parameters: { layout: "centered" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const baseArgs = { + annotation: undefined, + documentId: "doc-1", + pageIndex: 0, + annotationId: "annotation-1", + menuWidth: 260, + obj: undefined, + firstLinkTarget: null, + hasCommentContent: false, + isInSidebar: false, + currentColor: "#000000", + strokeColor: "#000000", + fillColor: "#0000ff", + backgroundColor: "#ffffff", + textColor: "#000000", + currentOpacity: 100, + currentWidth: 2, + onDelete: () => {}, + onEdit: () => {}, + onColorChange: () => {}, + onOpacityChange: () => {}, + onWidthChange: () => {}, + onPropertiesUpdate: () => {}, + onGoToLink: () => {}, + onAddLink: () => {}, + onAddToSidebar: () => {}, + onViewComment: () => {}, + onCommentColorChange: () => {}, +}; + +export const TextMarkup: Story = { + args: { + ...baseArgs, + annotationType: "textMarkup", + }, +}; + +export const Ink: Story = { + args: { + ...baseArgs, + annotationType: "ink", + }, +}; + +export const Comment: Story = { + args: { + ...baseArgs, + annotationType: "comment", + hasCommentContent: true, + }, +}; + +export const Shape: Story = { + args: { + ...baseArgs, + annotationType: "shape", + }, +}; diff --git a/frontend/editor/src/core/components/viewer/CustomSearchLayer.stories.tsx b/frontend/editor/src/core/components/viewer/CustomSearchLayer.stories.tsx new file mode 100644 index 0000000000..940ceabb47 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/CustomSearchLayer.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CustomSearchLayer } from "@app/components/viewer/CustomSearchLayer"; + +// CustomSearchLayer reads its highlight rects from the EmbedPDF search plugin +// context (useSearch/useDocumentState). Outside a mounted PDFContext.Provider +// those hooks fall back to their default (empty) state, so the layer renders +// nothing — this still exercises the component mounting without throwing. +const meta = { + title: "Viewer/CustomSearchLayer", + component: CustomSearchLayer, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + documentId: "doc-1", + pageIndex: 0, + scale: 1, + highlightColor: "rgba(255, 220, 0, 0.4)", + activeHighlightColor: "rgba(255, 140, 0, 0.6)", + opacity: 1, + padding: 2, + borderRadius: 4, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/DocumentReadyWrapper.stories.tsx b/frontend/editor/src/core/components/viewer/DocumentReadyWrapper.stories.tsx new file mode 100644 index 0000000000..cb0576dfb2 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/DocumentReadyWrapper.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Center, Text } from "@mantine/core"; +import { DocumentReadyWrapper } from "@app/components/viewer/DocumentReadyWrapper"; + +/** + * Outside of a live `` tree the document-manager plugin never + * finishes loading, so this always renders its `fallback` — the same state + * shown in the real viewer while the PDF engine is still initializing. + */ +const meta = { + title: "Viewer/DocumentReadyWrapper", + component: DocumentReadyWrapper, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + fallback: ( +
+ + Loading document… + +
+ ), + children: (documentId: string) => Document ready: {documentId}, + }, +}; + +export const NoFallback: Story = { + args: { + children: (documentId: string) => Document ready: {documentId}, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/LinkLayer.stories.tsx b/frontend/editor/src/core/components/viewer/LinkLayer.stories.tsx new file mode 100644 index 0000000000..376cdca0df --- /dev/null +++ b/frontend/editor/src/core/components/viewer/LinkLayer.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LinkLayer } from "@app/components/viewer/LinkLayer"; + +// LinkLayer reads its link annotations from EmbedPDF's react context +// (useDocumentState/useScroll/useAnnotation). Outside of a live +// provider those hooks resolve to their documented empty defaults (no +// annotations, scale 1), so the layer renders null — this still exercises +// the component's mount path without needing a real PDF engine. +const meta = { + title: "Viewer/LinkLayer", + component: LinkLayer, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + documentId: "storybook-doc", + pageIndex: 0, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx new file mode 100644 index 0000000000..8880c58afa --- /dev/null +++ b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF"; + +const meta = { + title: "Viewer/LocalEmbedPDF", + component: LocalEmbedPDF, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// No file/url supplied — renders the "No PDF provided" empty state without +// touching the pdfium engine or blob URL setup. +export const Empty: Story = { + args: {}, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +// A non-PDF file surfaces the "cannot preview" guard instead of attempting to +// load the pdfium engine. +export const UnsupportedFile: Story = { + args: { + file: new File(["not a pdf"], "notes.txt", { type: "text/plain" }), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/frontend/editor/src/core/components/viewer/NonPdfViewer.stories.tsx b/frontend/editor/src/core/components/viewer/NonPdfViewer.stories.tsx new file mode 100644 index 0000000000..4f10d49303 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/NonPdfViewer.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ReactElement } from "react"; +import { NonPdfViewer } from "@app/components/viewer/NonPdfViewer"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; + +/** + * NonPdfViewer reads tool availability (for the "Convert to PDF" action) via + * ToolWorkflowContext, which depends on the tool registry, preferences, and + * navigation providers being mounted above it. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + + + + + + + ); +} + +function makeFile(name: string, type: string, contents: string): File { + return new File([contents], name, { type }); +} + +const meta = { + title: "Viewer/NonPdfViewer", + component: NonPdfViewer, + parameters: { layout: "fullscreen" }, + decorators: [withProviders], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** CSV preview: parsed into a scrollable table. */ +export const Csv: Story = { + args: { + sidebarsVisible: true, + setSidebarsVisible: () => {}, + file: makeFile( + "invoice.csv", + "text/csv", + "Item,Qty,Price\nWidget,3,9.99\nGadget,1,19.99\n", + ), + }, +}; + +/** JSON preview: syntax-highlighted / pretty-printed. */ +export const Json: Story = { + args: { + sidebarsVisible: true, + setSidebarsVisible: () => {}, + file: makeFile( + "config.json", + "application/json", + JSON.stringify({ name: "Stirling PDF", version: 1 }, null, 2), + ), + }, +}; + +/** Unsupported file type: falls back to the "Preview not available" state. */ +export const Unsupported: Story = { + args: { + sidebarsVisible: true, + setSidebarsVisible: () => {}, + file: makeFile("archive.zip", "application/zip", "binary-ish-content"), + }, +}; diff --git a/frontend/editor/src/core/components/viewer/RedactionPendingTracker.stories.tsx b/frontend/editor/src/core/components/viewer/RedactionPendingTracker.stories.tsx new file mode 100644 index 0000000000..40062505f8 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/RedactionPendingTracker.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RedactionPendingTracker } from "@app/components/viewer/RedactionPendingTracker"; + +// RedactionPendingTracker reads the active document from ActiveDocumentContext, which +// defaults to `null` outside of a live EmbedPDF document-manager session (not something the +// shared preview can stub). With no active document it short-circuits and renders nothing, +// so this story only exercises that no-active-document mount path without throwing. +const meta = { + title: "Viewer/RedactionPendingTracker", + component: RedactionPendingTracker, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/viewer/RedactionSelectionMenu.stories.tsx b/frontend/editor/src/core/components/viewer/RedactionSelectionMenu.stories.tsx new file mode 100644 index 0000000000..af01665228 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/RedactionSelectionMenu.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RedactionSelectionMenu } from "@app/components/viewer/RedactionSelectionMenu"; + +// RedactionSelectionMenu renders only when there's an active document ID +// (ActiveDocumentContext) and a selected redaction annotation from the live +// EmbedPDF redaction plugin. Neither exists in Storybook, so the component's +// own guard clause renders nothing here - that's its real empty state. +const meta = { + title: "Viewer/RedactionSelectionMenu", + component: RedactionSelectionMenu, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/viewer/SearchInterface.stories.tsx b/frontend/editor/src/core/components/viewer/SearchInterface.stories.tsx new file mode 100644 index 0000000000..1e1a255465 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/SearchInterface.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SearchInterface } from "@app/components/viewer/SearchInterface"; + +// SearchInterface reads ViewerContext via useContext with optional chaining +// throughout, so it renders fine without a ViewerProvider mounted — search +// state simply resolves to its empty/no-results defaults. +const meta = { + title: "Viewer/SearchInterface", + component: SearchInterface, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + visible: true, + onClose: () => {}, + }, +}; + +export const Hidden: Story = { + args: { + visible: false, + onClose: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/SignatureFieldOverlay.stories.tsx b/frontend/editor/src/core/components/viewer/SignatureFieldOverlay.stories.tsx new file mode 100644 index 0000000000..5dc1e73380 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/SignatureFieldOverlay.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignatureFieldOverlay from "@app/components/viewer/SignatureFieldOverlay"; + +// With no pdfSource the overlay resolves zero fields and renders null — this +// is the state it's mounted in until the host viewer has a document loaded. +const meta = { + title: "Viewer/SignatureFieldOverlay", + component: SignatureFieldOverlay, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + pageIndex: 0, + pdfSource: null, + documentId: "doc-1", + pageWidth: 612, + pageHeight: 792, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/SignaturePlacementOverlay.stories.tsx b/frontend/editor/src/core/components/viewer/SignaturePlacementOverlay.stories.tsx new file mode 100644 index 0000000000..c6e4551333 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/SignaturePlacementOverlay.stories.tsx @@ -0,0 +1,94 @@ +import { useEffect, useRef } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SignaturePlacementOverlay } from "@app/components/viewer/SignaturePlacementOverlay"; +import { SignatureProvider } from "@app/contexts/SignatureContext"; +import type { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; + +const textSignature: SignParameters = { + signatureType: "text", + signerName: "Jordan Blake", + fontFamily: "Helvetica", + fontSize: 32, + textColor: "#1e293b", + textAlign: "left", +}; + +// The overlay positions itself relative to containerRef and only paints once a +// mousemove has been observed inside that element, so the harness supplies a +// sized, positioned container and fires a synthetic mousemove after mount to +// simulate the cursor already being over the page. +function PlacementHarness({ + signatureConfig, +}: { + signatureConfig: SignParameters | null; +}) { + const containerRef = useRef(null); + + useEffect(() => { + const element = containerRef.current; + if (!element) return; + const rect = element.getBoundingClientRect(); + element.dispatchEvent( + new MouseEvent("mousemove", { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + bubbles: true, + }), + ); + }, [signatureConfig]); + + return ( +
+ +
+ ); +} + +const noopContainerRef = { current: null }; + +const meta = { + title: "Viewer/SignaturePlacementOverlay", + component: SignaturePlacementOverlay, + // SignaturePlacementOverlay reads useSignature() to report its preview size + // back up — that context isn't mounted by the shared preview, so stub it here. + decorators: [ + (Story) => ( + + + + ), + ], + // Stories below override render with PlacementHarness, but Storybook's types + // still require args to satisfy the component's required props. + args: { + containerRef: noopContainerRef, + isActive: true, + signatureConfig: textSignature, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Cursor-following preview of a text signature over the page. */ +export const Default: Story = { + render: () => , +}; + +/** No signature configured yet — the overlay renders nothing. */ +export const NoSignatureConfigured: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/viewer/SignaturePreviewLayer.stories.tsx b/frontend/editor/src/core/components/viewer/SignaturePreviewLayer.stories.tsx new file mode 100644 index 0000000000..a5580dd505 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/SignaturePreviewLayer.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SignaturePreviewLayer } from "@app/components/viewer/SignaturePreviewLayer"; +import type { SignaturePreview } from "@app/components/viewer/viewerTypes"; + +// A 1x1 transparent PNG — enough to satisfy the src without a real signature asset. +const PLACEHOLDER_SIGNATURE_DATA = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +const SAMPLE_PREVIEWS: SignaturePreview[] = [ + { + id: "sig-1", + pageIndex: 0, + x: 0.2, + y: 0.6, + width: 0.25, + height: 0.1, + signatureData: PLACEHOLDER_SIGNATURE_DATA, + signatureType: "image", + participantName: "Jane Doe", + }, +]; + +// SignaturePreviewLayer reads pause/resume from EmbedPDF's interaction-manager +// react context (useInteractionManagerCapability). Outside a mounted PDFContext +// provider that hook resolves to its documented empty default (no capability), +// so drag/resize handlers no-op — this still exercises the component's mount +// and render path without needing a real PDF engine. +const meta = { + title: "Viewer/SignaturePreviewLayer", + component: SignaturePreviewLayer, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + pageIndex: 0, + pageWidth: 600, + pageHeight: 800, + previews: SAMPLE_PREVIEWS, + readOnly: false, + placementMode: false, + onChange: () => {}, + }, +}; + +export const ReadOnly: Story = { + args: { + ...Default.args, + readOnly: true, + }, +}; + +export const PlacementMode: Story = { + args: { + ...Default.args, + previews: [], + placementMode: true, + placementData: PLACEHOLDER_SIGNATURE_DATA, + placementType: "image", + }, +}; diff --git a/frontend/editor/src/core/components/viewer/StampPlacementOverlay.stories.tsx b/frontend/editor/src/core/components/viewer/StampPlacementOverlay.stories.tsx new file mode 100644 index 0000000000..e2a7f64fc7 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/StampPlacementOverlay.stories.tsx @@ -0,0 +1,95 @@ +import { createRef, useEffect, useRef } from "react"; +import type { Meta, StoryObj, Decorator } from "@storybook/react-vite"; +import { StampPlacementOverlay } from "@app/components/viewer/StampPlacementOverlay"; +import { SignatureProvider } from "@app/contexts/SignatureContext"; +import type { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; + +// StampPlacementOverlay reads/writes SignatureContext (useSignature, for +// placementPreviewSize), which isn't mounted by the shared preview. +const withProviders: Decorator = (Story) => ( + + + +); + +const textSignature: SignParameters = { + signatureType: "text", + signerName: "Jane Doe", + fontFamily: "Helvetica", + fontSize: 32, + textColor: "#1e3a5f", + textAlign: "left", +}; + +// StampPlacementOverlay tracks the mouse over containerRef and only renders a +// preview once it has both a built signature image and a cursor position, so +// the demo dispatches a synthetic mousemove after mount to show it in place. +function StampPlacementOverlayDemo( + props: Partial>, +) { + const containerRef = useRef(null); + + useEffect(() => { + const element = containerRef.current; + if (!element) return; + const rect = element.getBoundingClientRect(); + element.dispatchEvent( + new MouseEvent("mousemove", { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + bubbles: true, + }), + ); + }, []); + + return ( +
+ +
+ ); +} + +const meta = { + title: "Viewer/StampPlacementOverlay", + component: StampPlacementOverlay, + parameters: { layout: "padded" }, + decorators: [withProviders], + // Actual rendering is handled by StampPlacementOverlayDemo's own containerRef; + // these are just placeholder values to satisfy the component's required props. + args: { + containerRef: createRef(), + isActive: true, + signatureConfig: textSignature, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Active placement mode with a text signature preview following the cursor. */ +export const Default: Story = { + render: () => , +}; + +/** Inactive placement mode — the overlay renders nothing (returns null). */ +export const Inactive: Story = { + render: () => , +}; + +/** No signature configured yet — nothing to preview, so it renders nothing. */ +export const NoSignatureConfig: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/viewer/TextSelectionMenu.stories.tsx b/frontend/editor/src/core/components/viewer/TextSelectionMenu.stories.tsx new file mode 100644 index 0000000000..1321fe7ea8 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/TextSelectionMenu.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { SelectionSelectionMenuProps } from "@embedpdf/plugin-selection/react"; +import { TextSelectionMenu } from "@app/components/viewer/TextSelectionMenu"; + +function baseProps( + overrides: Partial = {}, +): SelectionSelectionMenuProps { + return { + rect: { origin: { x: 0, y: 0 }, size: { width: 120, height: 20 } }, + menuWrapperProps: { style: {}, ref: () => {} }, + selected: true, + placement: { suggestTop: true }, + context: { type: "selection", pageIndex: 0 }, + ...overrides, + }; +} + +const meta = { + title: "Viewer/TextSelectionMenu", + component: TextSelectionMenu, + parameters: { layout: "centered" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: baseProps(), +}; + +export const BelowSelection: Story = { + args: baseProps({ placement: { suggestTop: false } }), +}; + +export const NotSelected: Story = { + args: baseProps({ selected: false }), +}; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx b/frontend/editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx new file mode 100644 index 0000000000..eb88d5f548 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CsvViewer } from "@app/components/viewer/nonpdf/CsvViewer"; + +const buildCsvFile = (contents: string, name = "data.csv"): File => + new File([contents], name, { type: "text/csv" }); + +const SAMPLE_CSV = [ + "Name,Age,City", + "Alice,30,New York", + "Bob,25,Los Angeles", + "Charlie,35,Chicago", +].join("\n"); + +const SAMPLE_TSV = [ + "Name\tAge\tCity", + "Alice\t30\tNew York", + "Bob\t25\tLos Angeles", +].join("\n"); + +const meta = { + title: "Viewer/NonPdf/CsvViewer", + component: CsvViewer, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + file: buildCsvFile(SAMPLE_CSV), + isTsv: false, + }, +}; + +export const Tsv: Story = { + args: { + file: buildCsvFile(SAMPLE_TSV, "data.tsv"), + isTsv: true, + }, +}; + +export const Empty: Story = { + args: { + file: buildCsvFile(""), + isTsv: false, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.stories.tsx b/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.stories.tsx new file mode 100644 index 0000000000..173fc38903 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { HtmlViewer } from "@app/components/viewer/nonpdf/HtmlViewer"; + +const sampleHtmlFile = new File( + ["

Sample document

Preview content.

"], + "sample.html", + { type: "text/html" }, +); + +const meta = { + title: "Viewer/Nonpdf/HtmlViewer", + component: HtmlViewer, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + file: sampleHtmlFile, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/ImageViewer.stories.tsx b/frontend/editor/src/core/components/viewer/nonpdf/ImageViewer.stories.tsx new file mode 100644 index 0000000000..53d0329733 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/nonpdf/ImageViewer.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ImageViewer } from "@app/components/viewer/nonpdf/ImageViewer"; + +// 2x2 red PNG, used so the component's URL.createObjectURL(file) has real image bytes to render. +const PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFUlEQVR42mNk+M9QDwAEZ" + + "AGvRy0PbwAAAABJRU5ErkJggg=="; + +function makeImageFile(name: string): File { + const bytes = atob(PNG_BASE64); + const buffer = new Uint8Array(bytes.length); + for (let i = 0; i < bytes.length; i++) { + buffer[i] = bytes.charCodeAt(i); + } + return new File([buffer], name, { type: "image/png" }); +} + +const meta = { + title: "Viewer/NonPdf/ImageViewer", + component: ImageViewer, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + file: makeImageFile("sample.png"), + fileName: "sample.png", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/JsonViewer.stories.tsx b/frontend/editor/src/core/components/viewer/nonpdf/JsonViewer.stories.tsx new file mode 100644 index 0000000000..7261e24ad6 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/nonpdf/JsonViewer.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { JsonViewer } from "@app/components/viewer/nonpdf/JsonViewer"; + +function jsonFile(name: string, contents: string) { + return new File([contents], name, { type: "application/json" }); +} + +const meta = { + title: "Viewer/NonPdf/JsonViewer", + component: JsonViewer, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + file: jsonFile( + "config.json", + JSON.stringify( + { + name: "Stirling PDF", + version: "2.0.0", + features: ["merge", "split", "compress"], + settings: { theme: "dark", locale: "en-US" }, + }, + null, + 2, + ), + ), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const InvalidJson: Story = { + args: { + file: jsonFile("broken.json", "{ this is not valid json "), + }, + decorators: Default.decorators, +}; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.stories.tsx b/frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.stories.tsx new file mode 100644 index 0000000000..d37267484c --- /dev/null +++ b/frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { NonPdfBanner } from "@app/components/viewer/nonpdf/NonPdfBanner"; + +const meta = { + title: "Viewer/NonPdf/NonPdfBanner", + component: NonPdfBanner, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onConvertToPdf: () => {}, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const Hidden: Story = { + args: { + onConvertToPdf: undefined, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx b/frontend/editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx new file mode 100644 index 0000000000..6b006b8327 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TextViewer } from "@app/components/viewer/nonpdf/TextViewer"; + +const meta = { + title: "Viewer/NonPdf/TextViewer", + component: TextViewer, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const plainTextFile = new File( + [ + Array.from( + { length: 20 }, + (_, i) => `Line ${i + 1}: the quick brown fox jumps over the lazy dog.`, + ).join("\n"), + ], + "notes.txt", + { type: "text/plain" }, +); + +const markdownFile = new File( + [ + [ + "# Sample document", + "", + "This is a **markdown** file rendered by the text viewer.", + "", + "- item one", + "- item two", + "", + "> A blockquote for good measure.", + ].join("\n"), + ], + "README.md", + { type: "text/markdown" }, +); + +export const Default: Story = { + args: { + file: plainTextFile, + isMarkdown: false, + }, +}; + +export const Markdown: Story = { + args: { + file: markdownFile, + isMarkdown: true, + }, +}; diff --git a/frontend/editor/src/core/ui/FilePicker.stories.tsx b/frontend/editor/src/core/ui/FilePicker.stories.tsx new file mode 100644 index 0000000000..53fa1ef2a0 --- /dev/null +++ b/frontend/editor/src/core/ui/FilePicker.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FilePicker } from "@app/ui/FilePicker"; + +const meta = { + title: "Primitives/FilePicker", + component: FilePicker, + parameters: { layout: "centered" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + onChange: () => {}, + children: "Choose file", + }, +}; + +export const AcceptPdf: Story = { + args: { + onChange: () => {}, + accept: "application/pdf", + children: "Choose PDF", + }, +}; + +export const Multiple: Story = { + args: { + onChange: () => {}, + multiple: true, + children: "Choose files", + }, +}; + +export const Disabled: Story = { + args: { + onChange: () => {}, + disabled: true, + children: "Choose file", + }, +}; diff --git a/frontend/editor/src/portal/components/AssistantButton.stories.tsx b/frontend/editor/src/portal/components/AssistantButton.stories.tsx new file mode 100644 index 0000000000..cb6c83c043 --- /dev/null +++ b/frontend/editor/src/portal/components/AssistantButton.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useEffect } from "react"; +import { AssistantButton } from "@portal/components/AssistantButton"; +import { useUI } from "@portal/contexts/UIContext"; + +function ForceOpen() { + const { openAssistant } = useUI(); + useEffect(() => { + openAssistant(); + }, [openAssistant]); + return null; +} + +const meta: Meta = { + title: "Portal/Assistant/AssistantButton", + component: AssistantButton, +}; +export default meta; +type Story = StoryObj; + +export const Closed: Story = {}; + +export const Open: Story = { + decorators: [ + (S) => ( + <> + + + + ), + ], +}; diff --git a/frontend/editor/src/portal/components/BrandMarks.stories.tsx b/frontend/editor/src/portal/components/BrandMarks.stories.tsx new file mode 100644 index 0000000000..e25fde2b5d --- /dev/null +++ b/frontend/editor/src/portal/components/BrandMarks.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { BrandMark } from "@portal/components/BrandMarks"; + +const meta = { + title: "Portal/BrandMarks", + component: BrandMark, + parameters: { layout: "padded" }, + args: { + id: "s3", + size: 24, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const CloudProvider: Story = { args: { id: "googledrive" } }; + +export const Neutral: Story = { args: { id: "sftp" } }; + +export const UnknownFallback: Story = { + args: { id: "some-unrecognised-source" }, +}; diff --git a/frontend/editor/src/portal/components/DownloadEditorModal.stories.tsx b/frontend/editor/src/portal/components/DownloadEditorModal.stories.tsx new file mode 100644 index 0000000000..6a9166c3b2 --- /dev/null +++ b/frontend/editor/src/portal/components/DownloadEditorModal.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; + +const meta: Meta = { + title: "Portal/DownloadEditorModal", + component: DownloadEditorModal, + parameters: { layout: "fullscreen" }, + args: { open: true, onClose: () => {} }, +}; +export default meta; +type Story = StoryObj; + +/** Landing list of desktop + self-hosted install options. */ +export const Open: Story = {}; diff --git a/frontend/editor/src/portal/components/ErrorBoundary.stories.tsx b/frontend/editor/src/portal/components/ErrorBoundary.stories.tsx new file mode 100644 index 0000000000..a680039046 --- /dev/null +++ b/frontend/editor/src/portal/components/ErrorBoundary.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ErrorBoundary } from "@portal/components/ErrorBoundary"; + +function Boom(): never { + throw new Error("kaboom"); +} + +const meta = { + title: "Portal/ErrorBoundary", + component: ErrorBoundary, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** No error: children render straight through. */ +export const Default: Story = { + args: { + children:
Everything is fine.
, + }, +}; + +/** A child throws during render; the boundary contains it with the default + * fallback card instead of taking down the rest of the page. */ +export const CaughtError: Story = { + args: { + children: , + }, +}; + +/** A custom fallback receives the boundary's reset function so it can offer + * its own retry affordance. */ +export const CustomFallback: Story = { + args: { + children: , + fallback: (reset) => ( +
+

Custom error UI.

+ +
+ ), + }, +}; diff --git a/frontend/editor/src/portal/components/HomeGreeting.stories.tsx b/frontend/editor/src/portal/components/HomeGreeting.stories.tsx new file mode 100644 index 0000000000..e2cca6dbfb --- /dev/null +++ b/frontend/editor/src/portal/components/HomeGreeting.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { HomeGreeting } from "@portal/components/HomeGreeting"; + +const meta = { + title: "Portal/Home/HomeGreeting", + component: HomeGreeting, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Time-of-day greeting + today's date, shown above the paid-tier home hero. */ +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/HomeHero.stories.tsx b/frontend/editor/src/portal/components/HomeHero.stories.tsx new file mode 100644 index 0000000000..dd1ea07c04 --- /dev/null +++ b/frontend/editor/src/portal/components/HomeHero.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { HomeHero } from "@portal/components/HomeHero"; + +const meta = { + title: "Portal/Home/HomeHero", + component: HomeHero, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Pay-as-you-go tier: welcome header + setup checklist until onboarding completes. */ +export const Default: Story = { + args: { tier: "pro" }, +}; + +/** Free tier renders the same welcome-header composition as pro. */ +export const FreeTier: Story = { + args: { tier: "free" }, +}; + +/** Enterprise tier hides the status chips — the procurement deal hero owns the invite step. */ +export const EnterpriseTier: Story = { + args: { tier: "enterprise" }, +}; diff --git a/frontend/editor/src/portal/components/LinkAccountFooterItem.stories.tsx b/frontend/editor/src/portal/components/LinkAccountFooterItem.stories.tsx new file mode 100644 index 0000000000..b65041164a --- /dev/null +++ b/frontend/editor/src/portal/components/LinkAccountFooterItem.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; + +const meta: Meta = { + title: "Portal/LinkAccountFooterItem", + component: LinkAccountFooterItem, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Unlinked org — the "Link Stirling account" CTA appears in the sidebar footer. */ +export const Unlinked: Story = { + globals: { linkState: "unlinked" }, +}; + +/** Linked org — the CTA hides itself (renders nothing), since the state is + * already communicated elsewhere. */ +export const Linked: Story = { + globals: { linkState: "linked-subscribed" }, +}; diff --git a/frontend/editor/src/portal/components/LoginScreen.stories.tsx b/frontend/editor/src/portal/components/LoginScreen.stories.tsx new file mode 100644 index 0000000000..63db811b71 --- /dev/null +++ b/frontend/editor/src/portal/components/LoginScreen.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LoginScreen } from "@portal/components/LoginScreen"; + +const meta: Meta = { + title: "Portal/LoginScreen", + component: LoginScreen, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/PortalChrome.stories.tsx b/frontend/editor/src/portal/components/PortalChrome.stories.tsx new file mode 100644 index 0000000000..b491fee325 --- /dev/null +++ b/frontend/editor/src/portal/components/PortalChrome.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PortalChrome } from "@portal/components/PortalChrome"; + +const meta = { + title: "Portal/Shell/PortalChrome", + component: PortalChrome, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx b/frontend/editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx new file mode 100644 index 0000000000..99735e657f --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { AccountLinkProvider } from "@portal/contexts/AccountLinkContext"; +import { AccountLinkPanel } from "@portal/components/account-link/AccountLinkPanel"; +import { listInstances } from "@portal/mocks/link"; +import "@portal/views/AccountLink.css"; + +// AccountLinkPanel reads the shared account-link instance from context (the +// preview only supplies LinkProvider), so wrap it in AccountLinkProvider here. +const meta: Meta = { + title: "Portal/AccountLink/AccountLinkPanel", + component: AccountLinkPanel, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( + + + + ), + ], +}; +export default meta; +type Story = StoryObj; + +/** + * The Settings account-link surface: the status badge (driven by the Link + * toolbar global), the LinkAccountCard for this instance, and — once linked — + * the team-wide linked-instances table. + */ +export const Default: Story = { + parameters: { + msw: { + handlers: [ + http.get("/api/v1/account-link/status", () => + HttpResponse.json({ linked: true, name: "prod-eu-gateway" }), + ), + http.get("*/api/v1/account-link/instances", () => + HttpResponse.json(listInstances()), + ), + ], + }, + }, +}; + +/** Not linked — the instances table is hidden until this instance links its org. */ +export const NotLinked: Story = { + parameters: { + msw: { + handlers: [ + http.get("/api/v1/account-link/status", () => + HttpResponse.json({ linked: false, name: null }), + ), + ], + }, + }, +}; + +/** Linked, but the signed-in admin isn't the team owner — the instances table fails to load. */ +export const LoadForbidden: Story = { + parameters: { + msw: { + handlers: [ + http.get("/api/v1/account-link/status", () => + HttpResponse.json({ linked: true, name: "prod-eu-gateway" }), + ), + http.get("*/api/v1/account-link/instances", () => + HttpResponse.json({ detail: "Forbidden" }, { status: 403 }), + ), + ], + }, + }, +}; diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx new file mode 100644 index 0000000000..8228e3b1bd --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal"; + +const meta: Meta = { + title: "Portal/AccountLink/LinkAccountModal", + component: LinkAccountModal, + parameters: { layout: "fullscreen" }, + args: { + open: true, + onClose: () => {}, + onLinked: async () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** Default "link" mode — sign in to register this instance against a Stirling account. */ +export const Default: Story = {}; + +/** "reauth" mode — an already-linked instance's session expired and needs a fresh sign-in. */ +export const Reauth: Story = { + args: { mode: "reauth" }, +}; diff --git a/frontend/editor/src/portal/components/account-link/LinkGate.stories.tsx b/frontend/editor/src/portal/components/account-link/LinkGate.stories.tsx deleted file mode 100644 index 60d9a9962c..0000000000 --- a/frontend/editor/src/portal/components/account-link/LinkGate.stories.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { Card } from "@app/ui"; -import { LinkGate } from "@portal/components/account-link/LinkGate"; - -const meta: Meta = { - title: "Portal/AccountLink/LinkGate", - component: LinkGate, - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -/** - * Gating follows the Link toolbar global: "Unlinked" shows the lock prompt, - * any linked state renders the feature. - */ -export const Default: Story = { - args: { - feature: "AI extraction", - children: ( - A billable feature, unlocked once linked. - ), - }, -}; diff --git a/frontend/editor/src/portal/components/billing/CardPlaceholder.stories.tsx b/frontend/editor/src/portal/components/billing/CardPlaceholder.stories.tsx new file mode 100644 index 0000000000..5d035007f9 --- /dev/null +++ b/frontend/editor/src/portal/components/billing/CardPlaceholder.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CardPlaceholder } from "@portal/components/billing/CardPlaceholder"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/CardPlaceholder", + component: CardPlaceholder, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Stand-in card form shown when Stripe isn't mounted (no publishable key / preview). */ +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/billing/InvoicesList.stories.tsx b/frontend/editor/src/portal/components/billing/InvoicesList.stories.tsx new file mode 100644 index 0000000000..90aba9a4fe --- /dev/null +++ b/frontend/editor/src/portal/components/billing/InvoicesList.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { InvoicesList } from "@portal/components/billing/InvoicesList"; +import type { Invoice } from "@portal/api/billing"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/InvoicesList", + component: InvoicesList, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function invoice(overrides: Partial & { id: string }): Invoice { + return { + number: null, + status: "paid", + totalMinor: 4900, + currency: "usd", + createdAt: "2026-06-01T00:00:00Z", + periodStart: "2026-05-01T00:00:00Z", + periodEnd: "2026-06-01T00:00:00Z", + hostedInvoiceUrl: "https://invoice.stripe.com/i/mock", + invoicePdf: "https://invoice.stripe.com/i/mock/pdf", + description: "Stirling Processor Plan", + pdfsProcessed: 1204, + ...overrides, + }; +} + +const SEVEN_INVOICES: Invoice[] = Array.from({ length: 7 }, (_, i) => + invoice({ + id: `in_mock_${i}`, + number: `INV-${1000 + i}`, + status: i === 0 ? "open" : "paid", + createdAt: `2026-0${(i % 6) + 1}-01T00:00:00Z`, + }), +); + +/** A handful of recent invoices — the common case. */ +export const Default: Story = { + parameters: { + msw: { + handlers: [ + http.get("*/api/v1/payg/invoices", () => + HttpResponse.json(SEVEN_INVOICES), + ), + ], + }, + }, +}; + +/** No invoices yet — free team or a subscription with no closed cycle. */ +export const Empty: Story = { + parameters: { + msw: { + handlers: [ + http.get("*/api/v1/payg/invoices", () => HttpResponse.json([])), + ], + }, + }, +}; + +/** Fetch fails — the inline error message renders instead of the table. */ +export const LoadError: Story = { + parameters: { + msw: { + handlers: [ + http.get( + "*/api/v1/payg/invoices", + () => new HttpResponse(null, { status: 500 }), + ), + ], + }, + }, +}; diff --git a/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx new file mode 100644 index 0000000000..a8a3d64d0d --- /dev/null +++ b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PrepaidCapacityCard } from "@portal/components/billing/PrepaidCapacityCard"; +import { + subscribedWallet, + prepaidWallet, +} from "@portal/components/billing/walletFixtures"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/PrepaidCapacityCard", + component: PrepaidCapacityCard, + parameters: { layout: "padded" }, + args: { + wallet: subscribedWallet, + onBuy: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** No bundle yet — the "12 months for the price of 10" offer nudge (leader view). */ +export const OfferNudge: Story = { + args: { + wallet: subscribedWallet, + }, +}; + +/** Bundle held, plenty of capacity left — meter + "Top up" action (leader view). */ +export const BundleHealthy: Story = { + args: { + wallet: prepaidWallet, + }, +}; + +/** Bundle nearly drawn down — meter crosses into the "Running low" band. */ +export const BundleLow: Story = { + args: { + wallet: { + ...prepaidWallet, + prepaidUnitsRemaining: 10_000, + }, + }, +}; diff --git a/frontend/editor/src/portal/components/billing/PrepayModalHeader.stories.tsx b/frontend/editor/src/portal/components/billing/PrepayModalHeader.stories.tsx new file mode 100644 index 0000000000..d91d9f754e --- /dev/null +++ b/frontend/editor/src/portal/components/billing/PrepayModalHeader.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PrepayModalHeader } from "@portal/components/billing/PrepayModalHeader"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/PrepayModalHeader", + component: PrepayModalHeader, + args: { + step: 1, + total: 3, + title: "Choose an amount", + onClose: () => console.log("close"), + }, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Prepaid wizard, step 1 of 3 — badge + 3-segment progress bar. */ +export const StepOfThree: Story = {}; + +/** Metered checkout, step 2 of 2 — badge + 2-segment progress bar. */ +export const StepOfTwo: Story = { + args: { step: 2, total: 2, title: "Add a payment method" }, +}; + +/** No step supplied — badge and progress bar are hidden (e.g. a terminal confirmation screen). */ +export const NoSteps: Story = { + args: { step: undefined, title: "You're all set" }, +}; diff --git a/frontend/editor/src/portal/components/infrastructure/AuditTab.stories.tsx b/frontend/editor/src/portal/components/infrastructure/AuditTab.stories.tsx index 3bb2d6dc3c..3733b2aca9 100644 --- a/frontend/editor/src/portal/components/infrastructure/AuditTab.stories.tsx +++ b/frontend/editor/src/portal/components/infrastructure/AuditTab.stories.tsx @@ -42,7 +42,13 @@ export const Empty: Story = { handlers: [ http.get("*/api/v1/proprietary/ui-data/infrastructure/audit-log", () => HttpResponse.json({ - summary: { totalEvents: 0, processing: 0, elevation: 0, config: 0 }, + summary: { + totalEvents: 0, + policy: 0, + processing: 0, + elevation: 0, + config: 0, + }, events: [], fullServer: true, }), @@ -74,7 +80,13 @@ export const TeamLeadScoped: Story = { handlers: [ http.get("*/api/v1/proprietary/ui-data/infrastructure/audit-log", () => HttpResponse.json({ - summary: { totalEvents: 4, processing: 2, elevation: 0, config: 1 }, + summary: { + totalEvents: 4, + policy: 0, + processing: 2, + elevation: 0, + config: 1, + }, events: [ { id: "9102", diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx new file mode 100644 index 0000000000..d2bd6bc6f2 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; +import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation"; +import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings"; + +/** Stand-in for a tool's real automation settings UI. */ +function MockCompressSettings({ + parameters, + onParameterChange, +}: { + parameters: Record; + onParameterChange: (key: string, value: unknown) => void; +}) { + return ( + + ); +} + +const editableStep = { + support: "editable", + toolId: "compress", + params: { level: 5 }, +} as unknown as WorkingToolStep; + +const noSettingsStep = { + support: "noSettings", + toolId: "flatten", + params: {}, +} as unknown as WorkingToolStep; + +const unsupportedStep = { + support: "unsupported", + toolId: "convert", + params: {}, +} as unknown as WorkingToolStep; + +const registry = { + compress: { automationSettings: MockCompressSettings }, +} as unknown as Partial; + +const meta = { + title: "Portal/Pipelines/PipelineStepSettings", + component: PipelineStepSettings, + args: { + step: editableStep, + registry, + onChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** A tool with an editable settings UI, rendered via the tool's own component. */ +export const Editable: Story = {}; + +/** A migrated tool with no configurable parameters — shows an informational note. */ +export const NoSettings: Story = { + args: { + step: noSettingsStep, + }, +}; + +/** A tool not yet migrated to the automation mapper seam — shows a fallback warning. */ +export const Unsupported: Story = { + args: { + step: unsupportedStep, + registry: {}, + }, +}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelinesTable.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelinesTable.stories.tsx new file mode 100644 index 0000000000..3f18b8e53f --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelinesTable.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { PipelineView } from "@portal/api/pipelines"; +import { PipelinesTable } from "@portal/components/pipelines/PipelinesTable"; + +const PIPELINES: PipelineView[] = [ + { + id: "pipe-intake", + name: "Claims intake", + enabled: true, + status: "active", + trigger: "folder-watch", + sources: [{ id: "src-claims", name: "Claims intake" }], + steps: ["redact", "sanitize", "watermark"], + output: "folder", + owner: "jane@stirlingpdf.com", + }, + { + id: "pipe-archive", + name: "Archive reprocess", + enabled: false, + status: "paused", + trigger: "manual", + sources: [], + steps: [], + output: "inline", + owner: "jane@stirlingpdf.com", + }, +]; + +const meta: Meta = { + title: "Portal/Pipelines/PipelinesTable", + component: PipelinesTable, + parameters: { layout: "padded" }, + args: { pipelines: PIPELINES, onRowClick: () => {} }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Empty: Story = { + args: { pipelines: [] }, +}; diff --git a/frontend/editor/src/portal/components/pipelines/ToolPicker.stories.tsx b/frontend/editor/src/portal/components/pipelines/ToolPicker.stories.tsx new file mode 100644 index 0000000000..3035a9b343 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/ToolPicker.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SubcategoryId } from "@app/data/toolsTaxonomy"; +import { type ExecutableTool } from "@app/hooks/tools/shared/toolAutomation"; +import { ToolPicker } from "@portal/components/pipelines/ToolPicker"; + +const tools: ExecutableTool[] = [ + { + toolId: "merge", + name: "Merge", + icon: "🧩", + subcategoryId: SubcategoryId.GENERAL, + endpoint: "/api/v1/general/merge-pdfs", + support: "noSettings", + }, + { + toolId: "split", + name: "Split", + icon: "✂️", + subcategoryId: SubcategoryId.GENERAL, + endpoint: "/api/v1/general/split-pages", + support: "editable", + }, + { + toolId: "watermark", + name: "Watermark", + icon: "💧", + subcategoryId: SubcategoryId.DOCUMENT_SECURITY, + endpoint: "/api/v1/security/add-watermark", + support: "editable", + }, + { + toolId: "removePassword", + name: "Remove password", + icon: "🔓", + subcategoryId: SubcategoryId.DOCUMENT_SECURITY, + endpoint: "/api/v1/security/remove-password", + support: "editable", + }, + { + toolId: "ocr", + name: "OCR", + icon: "🔍", + subcategoryId: SubcategoryId.EXTRACTION, + endpoint: "/api/v1/misc/ocr-pdf", + support: "editable", + }, +]; + +const meta = { + title: "Portal/Pipelines/ToolPicker", + component: ToolPicker, + parameters: { layout: "padded" }, + args: { + tools, + onPick: () => {}, + onClose: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Full tool list grouped by category, as offered when adding a pipeline step. */ +export const Default: Story = {}; + +/** No tools match the registry (or the search filter), showing the empty state. */ +export const NoMatches: Story = { + args: { + tools: [], + }, +}; diff --git a/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.stories.tsx b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.stories.tsx new file mode 100644 index 0000000000..1586f9cc45 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ClassificationLabelsSection } from "@portal/components/policies/ClassificationLabelsSection"; + +const meta: Meta = { + title: "Portal/Policies/ClassificationLabelsSection", + component: ClassificationLabelsSection, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx b/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx new file mode 100644 index 0000000000..627be84936 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + PolicyExternalApiConfig, + type ExternalApiParams, +} from "@portal/components/policies/PolicyExternalApiConfig"; +import { + buildStepParameters, + operationById, +} from "@portal/components/policies/stepOperations"; + +const meta: Meta = { + title: "Portal/Policies/PolicyExternalApiConfig", + component: PolicyExternalApiConfig, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const EMPTY_PARAMS: ExternalApiParams = { + connectionId: "", + path: "", + method: "", + bodyMode: "", + fileFieldName: "", + responseMode: "", + resultUrlPath: "", + resultUrlHeader: "", + responseSelect: "", + requireTrue: "", + fields: "", + headers: "", + bodyTemplate: "", + includeContext: "", + includeFile: "", + operationId: "", + operationValues: "", +}; + +/** Keeps the parameters in local state, exercising onChange like the real editor does. */ +function Controlled({ initial }: { initial: ExternalApiParams }) { + const [parameters, setParameters] = useState(initial); + return ( + + ); +} + +export const OperationPicker: Story = { + render: () => , +}; + +const slackNotify = operationById("slackNotify")!; +const slackParams = buildStepParameters(slackNotify, "1", { + message: "{{run.policyName}} processed {{document.filename}}", +}); + +export const NotifyConfigured: Story = { + render: () => , +}; + +const customApiCall = operationById("customApiCall")!; +const customParams = buildStepParameters(customApiCall, "", {}); + +export const CustomApiEscapeHatch: Story = { + render: () => , +}; diff --git a/frontend/editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx b/frontend/editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx new file mode 100644 index 0000000000..fa1854415d --- /dev/null +++ b/frontend/editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + PolicyPurviewConfig, + type PurviewLabelParams, +} from "@portal/components/policies/PolicyPurviewConfig"; + +const meta: Meta = { + title: "Portal/Policies/PolicyPurviewConfig", + component: PolicyPurviewConfig, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Renders the config and keeps its parameters in local state, exercising onChange. */ +function Controlled({ parameters }: { parameters: PurviewLabelParams }) { + const [value, setValue] = useState(parameters); + return ; +} + +export const Empty: Story = { + render: () => ( + + ), +}; + +export const Configured: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx b/frontend/editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx new file mode 100644 index 0000000000..434fb5f7cb --- /dev/null +++ b/frontend/editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx @@ -0,0 +1,32 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + PolicyPurviewReadConfig, + type PurviewReadParams, +} from "@portal/components/policies/PolicyPurviewReadConfig"; + +const meta: Meta = { + title: "Portal/Policies/PolicyPurviewReadConfig", + component: PolicyPurviewReadConfig, + parameters: { layout: "padded" }, + args: { + parameters: { connectionId: "" }, + onChange: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** Renders the config and keeps its parameters in local state, exercising onChange. */ +function Controlled({ parameters }: { parameters: PurviewReadParams }) { + const [value, setValue] = useState(parameters); + return ; +} + +export const Empty: Story = { + render: () => , +}; + +export const ConnectionSelected: Story = { + render: () => , +}; diff --git a/frontend/editor/src/portal/components/procurement/CalendlyInline.stories.tsx b/frontend/editor/src/portal/components/procurement/CalendlyInline.stories.tsx new file mode 100644 index 0000000000..dd00672de2 --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/CalendlyInline.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CalendlyInline } from "@portal/components/procurement/CalendlyInline"; +import "@portal/views/Procurement.css"; + +const meta: Meta = { + title: "Portal/Procurement/CalendlyInline", + component: CalendlyInline, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +// Calendly's widget.js can't reach the network in Storybook, so this renders the +// "unable to load" fallback link rather than the live embed. +export const Default: Story = {}; + +export const WithPrefilledEmail: Story = { + args: { email: "buyer@example.com" }, +}; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx new file mode 100644 index 0000000000..a6ca8dfcf5 --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ProcurementAgreement } from "@portal/components/procurement/ProcurementAgreement"; +import type { QuoteResult } from "@portal/api/procurement"; +import "@portal/views/Procurement.css"; + +const quote: QuoteResult = { + quoteId: 488, + quoteNumber: "Q-2026-0488", + status: "sent", + currency: "USD", + annualNetMinor: 8_400_000, + tcvMinor: 25_200_000, + renewalAnnualNetMinor: 8_652_000, + cpiRatePct: 3, + lineItems: [ + { + key: "platform", + label: "Platform subscription", + kind: "RECURRING", + amountMinor: 6_000_000, + }, + { + key: "support", + label: "Premium support", + kind: "RECURRING", + amountMinor: 1_800_000, + }, + { + key: "onboarding", + label: "Onboarding services", + kind: "ONE_TIME", + amountMinor: 500_000, + }, + { + key: "loyalty", + label: "Multi-year loyalty discount", + kind: "DISCOUNT", + amountMinor: -300_000, + }, + { key: "sso", label: "SSO / SCIM", kind: "INCLUDED", amountMinor: 0 }, + ], + validUntil: "2026-08-15", + stripeQuoteId: "qt_1NWnd488", + invoiceUrl: null, + invoicePdf: null, + config: { + volume: 500, + users: 25, + intensity: 4, + sizeMult: 1.4, + deployment: "cloud", + termYears: 3, + serviceLevel: "standard", + indemnification: true, + training: false, + qbr: true, + businessName: "Northwind Logistics", + }, +}; + +/** + * The final agreement (security) step: the combined Master Service Agreement + Order Form + EULA + + * DPA, gated behind an "I agree" checkbox before the buyer can accept the quote. + */ +const meta: Meta = { + title: "Portal/Procurement/ProcurementAgreement", + component: ProcurementAgreement, + parameters: { layout: "padded" }, + args: { + quote, + busy: false, + downloading: false, + onAgree: () => {}, + onDownload: () => {}, + onEdit: () => {}, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +// Agreeing: the primary CTA shows its loading state while the accept call is in flight. +export const Agreeing: Story = { + args: { busy: true }, +}; + +// Downloading: the secondary action shows its loading state instead. +export const Downloading: Story = { + args: { downloading: true }, +}; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementBanner.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementBanner.stories.tsx new file mode 100644 index 0000000000..28e9d2f99a --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementBanner.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ProcurementBanner } from "@portal/components/procurement/ProcurementBanner"; +import type { ProcurementController } from "@portal/components/procurement/useProcurement"; +import type { ProcurementSnapshot } from "@portal/api/procurement"; + +const snapshot: ProcurementSnapshot = { + dealId: 1, + stage: "trial", + deployment: "cloud", + seats: 250, + trialStartedAt: "2026-06-25T00:00:00Z", + trialEndsAt: "2026-07-09T00:00:00Z", + trialExtensionsUsed: 0, + licensed: false, + licenseKey: null, + latestQuote: null, +}; + +function makeController( + overrides: Partial = {}, +): ProcurementController { + return { + isLinked: true, + loading: false, + data: null, + started: false, + stage: undefined, + latest: null, + isIssued: false, + isDraft: true, + busy: false, + downloading: false, + downloadingLicense: false, + error: null, + setError: () => {}, + open: false, + setOpen: () => {}, + editing: false, + setEditing: () => {}, + extra: null, + setExtra: () => {}, + invoicePdf: null, + onStartTrial: () => {}, + onConfirmSetup: () => {}, + onExtendTrial: () => {}, + onReset: () => {}, + onGenerate: () => {}, + onAgree: () => {}, + onDownloadPdf: async () => {}, + onDownloadOfflineLicense: async () => {}, + ...overrides, + }; +} + +/** Deal-status hero once a deal is underway, otherwise the enterprise on-ramp. */ +const meta: Meta = { + title: "Portal/Procurement/ProcurementBanner", + component: ProcurementBanner, + parameters: { layout: "padded" }, +}; +export default meta; + +type Story = StoryObj; + +/** No deal yet: the enterprise on-ramp upsell. */ +export const Upsell: Story = { + args: { controller: makeController() }, +}; + +/** A deal is underway: the wired deal-status hero. */ +export const DealUnderway: Story = { + args: { + controller: makeController({ + started: true, + data: snapshot, + stage: snapshot.stage, + }), + }, +}; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx new file mode 100644 index 0000000000..06361da844 --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ProcurementSnapshot } from "@portal/api/procurement"; +import { + LicenseModal, + ScheduleCallModal, + TrialSetupModal, + TrialManageModal, +} from "@portal/components/procurement/ProcurementExtras"; +import "@portal/views/Procurement.css"; + +/** + * The small centred dialogs that hang off the deal-status hero's quick actions: the licence key, + * schedule-a-call, trial setup, and trial management. Grouped in one story file since none is + * substantial enough to warrant its own. + */ +const meta: Meta = { + title: "Portal/Procurement/ProcurementExtras", + parameters: { layout: "fullscreen" }, +}; +export default meta; + +type Story = StoryObj; + +const SNAPSHOT: ProcurementSnapshot = { + dealId: 42, + stage: "trial", + deployment: "cloud", + seats: 12, + trialStartedAt: "2026-06-15T00:00:00Z", + trialEndsAt: "2026-07-29T00:00:00Z", + trialExtensionsUsed: 0, + licensed: false, + licenseKey: null, + latestQuote: null, +}; + +// Licence key with the offline add-on available. +export const License: Story = { + render: () => ( + {}} + licenseKey="MOCK-ENTERPRISE-KEY-0001" + offlineAvailable + downloadingLicense={false} + onDownloadOffline={() => {}} + /> + ), +}; + +// Still on the trial licence: warns that the offline file must be re-downloaded once the +// agreement is in place. +export const LicenseTrial: Story = { + render: () => ( + {}} + licenseKey="MOCK-TRIAL-KEY-0001" + offlineAvailable + downloadingLicense={false} + onDownloadOffline={() => {}} + trial + /> + ), +}; + +// Calendly's widget.js can't reach the network in Storybook, so this renders the +// "unable to load" fallback link rather than the live embed. +export const ScheduleCall: Story = { + render: () => ( + {}} email="buyer@example.com" /> + ), +}; + +// Deployment + seat count captured before the trial starts. +export const TrialSetup: Story = { + render: () => ( + {}} + busy={false} + onConfirm={() => {}} + /> + ), +}; + +// Trial in progress with extensions still available. +export const TrialManage: Story = { + render: () => ( + {}} + snapshot={SNAPSHOT} + busy={false} + onExtend={() => {}} + onCancel={() => {}} + /> + ), +}; + +// Both extensions used: the extend action is disabled in favour of a "contact us" message. +export const TrialManageMaxed: Story = { + render: () => ( + {}} + snapshot={{ ...SNAPSHOT, trialExtensionsUsed: 2 }} + busy={false} + onExtend={() => {}} + onCancel={() => {}} + /> + ), +}; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementFlow.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementFlow.stories.tsx new file mode 100644 index 0000000000..da5777f316 --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementFlow.stories.tsx @@ -0,0 +1,143 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow"; +import type { ProcurementController } from "@portal/components/procurement/useProcurement"; +import type { ProcurementSnapshot, QuoteResult } from "@portal/api/procurement"; +import "@portal/views/Procurement.css"; + +const quote: QuoteResult = { + quoteId: 488, + quoteNumber: "Q-2026-0488", + status: "sent", + currency: "USD", + annualNetMinor: 8_400_000, + tcvMinor: 25_200_000, + renewalAnnualNetMinor: 8_652_000, + cpiRatePct: 3, + lineItems: [ + { + key: "platform", + label: "Platform subscription", + kind: "RECURRING", + amountMinor: 6_000_000, + }, + { + key: "support", + label: "Premium support", + kind: "RECURRING", + amountMinor: 1_800_000, + }, + ], + validUntil: "2026-08-15", + stripeQuoteId: "qt_1NWnd488", + invoiceUrl: null, + invoicePdf: null, + config: { + volume: 500, + users: 25, + intensity: 4, + sizeMult: 1.4, + deployment: "cloud", + termYears: 3, + serviceLevel: "standard", + indemnification: true, + training: false, + qbr: true, + businessName: "Northwind Logistics", + }, +}; + +const snapshot: ProcurementSnapshot = { + dealId: 42, + stage: "quote", + deployment: "cloud", + seats: 25, + trialStartedAt: "2026-05-01T00:00:00Z", + trialEndsAt: "2026-06-01T00:00:00Z", + trialExtensionsUsed: 0, + licensed: false, + licenseKey: null, + latestQuote: quote, +}; + +/** Builds a fully-populated controller so callers only need to override the fields a story cares about. */ +function makeController( + overrides: Partial = {}, +): ProcurementController { + return { + isLinked: true, + loading: false, + data: snapshot, + started: true, + stage: snapshot.stage ?? undefined, + latest: quote, + isIssued: true, + isDraft: false, + busy: false, + downloading: false, + downloadingLicense: false, + error: null, + setError: () => {}, + open: true, + setOpen: () => {}, + editing: false, + setEditing: () => {}, + extra: null, + setExtra: () => {}, + invoicePdf: null, + onStartTrial: () => {}, + onConfirmSetup: () => {}, + onExtendTrial: () => {}, + onReset: () => {}, + onGenerate: () => {}, + onAgree: () => {}, + onDownloadPdf: async () => {}, + onDownloadOfflineLicense: async () => {}, + ...overrides, + }; +} + +/** + * The procurement takeover flow: quote & agreement, payment, and live stages, driven by a + * plain ProcurementController object (no live backend needed to render each stage). + */ +const meta: Meta = { + title: "Portal/Procurement/ProcurementFlow", + component: ProcurementFlow, + parameters: { layout: "fullscreen" }, +}; +export default meta; + +type Story = StoryObj; + +// Quote issued and ready to agree to: the combined quote + agreement step. +export const Default: Story = { + args: { controller: makeController() }, +}; + +// No linked account yet: the modal shows the "link your account" empty state. +export const Unlinked: Story = { + args: { + controller: makeController({ + isLinked: false, + data: null, + started: false, + stage: undefined, + latest: null, + isIssued: false, + isDraft: true, + }), + }, +}; + +// Snapshot still loading: the skeleton placeholder shows instead of a stage. +export const Loading: Story = { + args: { + controller: makeController({ + loading: true, + data: null, + started: false, + stage: undefined, + latest: null, + }), + }, +}; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementStages.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementStages.stories.tsx new file mode 100644 index 0000000000..22cc7b2bc3 --- /dev/null +++ b/frontend/editor/src/portal/components/procurement/ProcurementStages.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + PaymentStageCard, + LiveStageCard, + LicensePanel, +} from "@portal/components/procurement/ProcurementStages"; +import "@portal/views/Procurement.css"; + +/** + * The three small presentational pieces used inside the procurement takeover + * modal once a quote exists: the payment step, the live confirmation, and the + * licence panel shown alongside them. Grouped in one story file since none is + * substantial enough to warrant its own. + */ +const meta: Meta = { + title: "Portal/Procurement/ProcurementStages", + parameters: { layout: "padded" }, +}; +export default meta; + +type Story = StoryObj; + +// Subscription created: both invoice link and PDF download are offered. +export const Payment: Story = { + render: () => ( + + ), +}; + +// Invoice not yet issued: no action row renders. +export const PaymentPending: Story = { + render: () => , +}; + +// Deal is active: terminal confirmation card. +export const Live: Story = { + render: () => , +}; + +// Licence key with the offline add-on available. +export const License: Story = { + render: () => ( + {}} + /> + ), +}; + +// Offline add-on not purchased: only the copy action is offered. +export const LicenseOnlineOnly: Story = { + render: () => ( + {}} + /> + ), +}; + +// Offline licence file is being generated. +export const LicenseDownloading: Story = { + render: () => ( + {}} + /> + ), +}; diff --git a/frontend/editor/src/portal/components/sources/ConnectionForm.stories.tsx b/frontend/editor/src/portal/components/sources/ConnectionForm.stories.tsx new file mode 100644 index 0000000000..c5a9e69cac --- /dev/null +++ b/frontend/editor/src/portal/components/sources/ConnectionForm.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ConnectionForm } from "@portal/components/sources/ConnectionForm"; +import { + CREATABLE_CONNECTION_TYPES, + emptyConnectionValues, +} from "@portal/components/sources/connectionTypes"; + +const s3Type = CREATABLE_CONNECTION_TYPES.find((type) => type.id === "s3")!; +const apiType = CREATABLE_CONNECTION_TYPES.find((type) => type.id === "api")!; +const purviewType = CREATABLE_CONNECTION_TYPES.find( + (type) => type.id === "purview", +)!; + +const meta = { + title: "Portal/Sources/ConnectionForm", + component: ConnectionForm, + parameters: { layout: "padded" }, + args: { + type: s3Type, + values: emptyConnectionValues(s3Type), + onChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +// The custom API preset exercises `visibleWhen`: picking a select value reveals extra fields +// (username/password for BASIC auth) that stay hidden for the other auth types. +export const ConditionalFieldsRevealed: Story = { + args: { + type: apiType, + values: { + ...emptyConnectionValues(apiType), + name: "Internal API", + authType: "BASIC", + }, + }, +}; + +// A partially filled preset, showing helper text and required-field markers together. +export const Filled: Story = { + args: { + type: purviewType, + values: { + ...emptyConnectionValues(purviewType), + name: "Purview", + tenantId: "00000000-0000-0000-0000-000000000000", + }, + }, +}; diff --git a/frontend/editor/src/portal/components/sources/ConnectionModal.stories.tsx b/frontend/editor/src/portal/components/sources/ConnectionModal.stories.tsx new file mode 100644 index 0000000000..e901a1ec55 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/ConnectionModal.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { IntegrationConfig } from "@portal/api/integrations"; +import { ConnectionModal } from "@portal/components/sources/ConnectionModal"; + +const EDIT_CONNECTION: IntegrationConfig = { + id: 1, + integrationType: "S3", + name: "Claims intake bucket", + scope: "TEAM", + ownerUserId: null, + ownerTeamId: 1, + enabled: true, + locked: false, + defaultAccess: "EXPLICIT_ONLY", + config: { + bucket: "acme-claims-inbox", + region: "eu-west-2", + accessKeyId: "AKIAIOSFODNN7EXAMPLE", + secretAccessKey: "********", + }, + canManage: true, + createdAt: "2026-07-02T09:12:00", + updatedAt: "2026-07-02T09:12:00", +}; + +const meta: Meta = { + title: "Portal/Sources/ConnectionModal", + component: ConnectionModal, + parameters: { layout: "fullscreen" }, + args: { + open: true, + capabilities: { customApi: true }, + onClose: () => {}, + onSaved: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** Creating a new connection starts on the type picker, grouped by category. */ +export const Picker: Story = {}; + +/** A pinned slot (e.g. the S3 field on a source) skips the picker and goes straight to the form. */ +export const FixedType: Story = { + args: { fixedTypeId: "s3" }, +}; + +/** Editing a stored connection reuses the form of the preset it was created from. */ +export const Edit: Story = { + args: { connection: EDIT_CONNECTION }, +}; diff --git a/frontend/editor/src/portal/components/sources/ConnectionPicker.stories.tsx b/frontend/editor/src/portal/components/sources/ConnectionPicker.stories.tsx new file mode 100644 index 0000000000..712ec1f4f6 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/ConnectionPicker.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { ConnectionPicker } from "@portal/components/sources/ConnectionPicker"; + +const meta: Meta = { + title: "Portal/Sources/ConnectionPicker", + component: ConnectionPicker, + parameters: { layout: "padded" }, + args: { + value: "", + onChange: () => {}, + integrationType: "S3", + createTypeId: "s3", + }, +}; +export default meta; +type Story = StoryObj; + +/** Filtered to S3 connections; the global mock data has one bucket to pick. */ +export const Default: Story = {}; + +/** A preset-scoped slot (e.g. a Jira step): only Jira connections and the free-form + * custom API connection are offered, since the latter can point anywhere. */ +export const PresetScoped: Story = { + args: { + integrationType: "API", + createTypeId: "api", + presetId: "jira", + }, +}; + +/** Embedded inside a host modal (e.g. the source builder), which offers its own + * create surface instead of stacking the shared connection modal on top. */ +export const DelegatedCreate: Story = { + args: { + onCreateNew: () => {}, + }, +}; + +/** The connections list fails to load; the picker still renders empty with an + * inline error banner rather than blocking the field. */ +export const LoadError: Story = { + parameters: { + msw: { + handlers: [ + http.get("*/api/v1/integrations", () => + HttpResponse.json( + { detail: "Could not reach the backend" }, + { status: 500 }, + ), + ), + ], + }, + }, +}; diff --git a/frontend/editor/src/portal/components/sources/ConnectionTypePicker.stories.tsx b/frontend/editor/src/portal/components/sources/ConnectionTypePicker.stories.tsx new file mode 100644 index 0000000000..9f7a87d62c --- /dev/null +++ b/frontend/editor/src/portal/components/sources/ConnectionTypePicker.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ConnectionTypePicker } from "@portal/components/sources/ConnectionTypePicker"; +import { + CREATABLE_CONNECTION_TYPES, + presetConnectionTypes, +} from "@portal/components/sources/connectionTypes"; + +const meta = { + title: "Portal/Sources/ConnectionTypePicker", + component: ConnectionTypePicker, + parameters: { layout: "padded" }, + args: { + types: presetConnectionTypes(), + onPick: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const IncludingCustomApi: Story = { + args: { types: CREATABLE_CONNECTION_TYPES }, +}; + +export const NoResults: Story = { + args: { types: [] }, +}; diff --git a/frontend/editor/src/portal/components/sources/S3ConnectionPicker.stories.tsx b/frontend/editor/src/portal/components/sources/S3ConnectionPicker.stories.tsx new file mode 100644 index 0000000000..2cca577460 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/S3ConnectionPicker.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker"; + +const meta = { + title: "Portal/Sources/S3ConnectionPicker", + component: S3ConnectionPicker, + parameters: { layout: "padded" }, + args: { + value: "", + onChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Selected: Story = { args: { value: "1" } }; diff --git a/frontend/editor/src/portal/components/sources/SourceModal.stories.tsx b/frontend/editor/src/portal/components/sources/SourceModal.stories.tsx new file mode 100644 index 0000000000..b71d689442 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/SourceModal.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { SourceModal } from "@portal/components/sources/SourceModal"; + +// SourceModal calls useQueryClient() to invalidate source queries after a save; +// the global preview doesn't set up react-query, so provide a client here. +const queryClient = new QueryClient(); + +const meta: Meta = { + title: "Portal/Sources/SourceModal", + component: SourceModal, + parameters: { layout: "fullscreen" }, + args: { + open: true, + onClose: () => {}, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; +export default meta; +type Story = StoryObj; + +/** No `sourceId`: the connector catalogue (including greyed-out coming-soon entries). */ +export const ChooseType: Story = {}; + +/** A `folder` source: the configure stage, seeded from the mocked record. */ +export const EditFolder: Story = { + args: { sourceId: "src-claims" }, +}; + +/** A `webhook` source: configure stage plus the delivery-URL row (secret stays masked). */ +export const EditWebhook: Story = { + args: { sourceId: "src-webhook" }, +}; diff --git a/frontend/editor/src/portal/components/sources/SourceTypeIcon.stories.tsx b/frontend/editor/src/portal/components/sources/SourceTypeIcon.stories.tsx new file mode 100644 index 0000000000..ea136cb143 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/SourceTypeIcon.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SourceTypeIcon } from "@portal/components/sources/SourceTypeIcon"; + +const meta: Meta = { + title: "Portal/Sources/SourceTypeIcon", + component: SourceTypeIcon, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Folder: Story = { + args: { type: "folder" }, +}; + +export const S3: Story = { + args: { type: "s3" }, +}; + +export const Editor: Story = { + args: { type: "editor" }, +}; + +/** Unknown source types fall back to the neutral document glyph. */ +export const Unknown: Story = { + args: { type: "unrecognised" }, +}; diff --git a/frontend/editor/src/portal/components/users/PendingInvitations.stories.tsx b/frontend/editor/src/portal/components/users/PendingInvitations.stories.tsx new file mode 100644 index 0000000000..fa5d44e8bd --- /dev/null +++ b/frontend/editor/src/portal/components/users/PendingInvitations.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PendingInvitations } from "@portal/components/users/PendingInvitations"; +import type { PendingInvitation } from "@portal/api/users"; + +const INVITATIONS: PendingInvitation[] = [ + { + id: 1, + email: "priya@stirlingpdf.com", + invitedBy: "tom@stirlingpdf.com", + expiresAt: new Date(Date.now() + 3 * 86400000).toISOString(), + }, + { + id: 2, + email: "lars@stirlingpdf.com", + invitedBy: "dana@stirlingpdf.com", + expiresAt: new Date(Date.now() + 86400000).toISOString(), + }, + { + id: 3, + email: "legal@meridian-partners.com", + }, +]; + +const meta: Meta = { + title: "Portal/Users/PendingInvitations", + component: PendingInvitations, + parameters: { layout: "padded" }, + args: { + invitations: INVITATIONS, + onCancel: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** A mix of invites: one expiring today, one expiring soon, one with no expiry. */ +export const Default: Story = {}; + +/** Single invite, expiring today. */ +export const ExpiresToday: Story = { + args: { + invitations: [ + { + id: 1, + email: "priya@stirlingpdf.com", + invitedBy: "tom@stirlingpdf.com", + expiresAt: new Date(Date.now() + 3 * 3600000).toISOString(), + }, + ], + }, +}; + +/** No pending invites: header still renders with a zero count. */ +export const Empty: Story = { + args: { invitations: [] }, +}; diff --git a/frontend/editor/src/portal/views/Integrations.stories.tsx b/frontend/editor/src/portal/views/Integrations.stories.tsx new file mode 100644 index 0000000000..f25c355c53 --- /dev/null +++ b/frontend/editor/src/portal/views/Integrations.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { Integrations } from "@portal/views/Integrations"; + +const meta: Meta = { + title: "Portal/Views/Integrations", + component: Integrations, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Seeded mock data: connected vendors (expandable groups) plus the available catalogue. */ +export const Default: Story = {}; + +/** + * No stored connections yet. Only the "Available" catalogue and coming-soon + * source connectors are left to show. + */ +export const NoConnections: Story = { + parameters: { + msw: { + handlers: [ + http.get("*/api/v1/integrations", () => HttpResponse.json([])), + ], + }, + }, +}; diff --git a/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx b/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx new file mode 100644 index 0000000000..95d2987e52 --- /dev/null +++ b/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Route, Routes } from "react-router-dom"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { PipelineBuilder } from "@portal/views/PipelineBuilder"; + +/** + * Renders the builder at a specific path so its `:id` route param resolves the + * same way it does in the app, without nesting a second Router inside the + * preview's MemoryRouter. + */ +function withRoute(path: string) { + return function RouteDecorator(Story: () => React.ReactElement) { + return ( + + } /> + } /> + + ); + }; +} + +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. + decorators: [ + (Story) => ( + + + + ), + ], +}; +export default meta; +type Story = StoryObj; + +/** A new, unsaved pipeline: empty operation chain, no sources selected yet. */ +export const Default: Story = { + decorators: [withRoute("/processor/pipelines/new")], +}; + +/** Editing a seeded pipeline: pre-filled name, sources, trigger and steps. */ +export const Edit: Story = { + decorators: [withRoute("/processor/pipelines/plc-redaction")], +}; diff --git a/frontend/editor/src/proprietary/auth/ui/AuthShell.stories.tsx b/frontend/editor/src/proprietary/auth/ui/AuthShell.stories.tsx new file mode 100644 index 0000000000..b5abb76c96 --- /dev/null +++ b/frontend/editor/src/proprietary/auth/ui/AuthShell.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AuthShell } from "@app/auth/ui/AuthShell"; + +/** + * The login card shell shared by the editor and the portal: a centered card + * that expands to two columns (form + right panel) on wide/tall viewports. + */ +const meta = { + title: "Auth/Auth Shell", + component: AuthShell, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( +
+

Sign in

+

Enter your credentials to continue.

+
+ ), + }, +}; + +export const WithRightPanel: Story = { + args: { + children: ( +
+
+

Sign in

+

Enter your credentials to continue.

+
+
+

Why Stirling PDF?

+

Fast, secure, self-hostable PDF tooling.

+
+
+ ), + }, +}; + +export const WithFooter: Story = { + args: { + children: ( +
+

Sign in

+

Enter your credentials to continue.

+
+ ), + footer: ( +
+ © Stirling PDF +
+ ), + }, +}; diff --git a/frontend/editor/src/proprietary/auth/ui/EmailPasswordForm.stories.tsx b/frontend/editor/src/proprietary/auth/ui/EmailPasswordForm.stories.tsx new file mode 100644 index 0000000000..84e085725d --- /dev/null +++ b/frontend/editor/src/proprietary/auth/ui/EmailPasswordForm.stories.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import EmailPasswordForm from "@app/auth/ui/EmailPasswordForm"; +import "@app/auth/ui/auth.css"; + +/** + * The email/password/MFA fields shared by the login and signup auth forms + */ +const meta: Meta = { + title: "Auth/Email Password Form", + component: EmailPasswordForm, + parameters: { layout: "centered" }, + render: (args) => { + const [email, setEmail] = useState(args.email); + const [password, setPassword] = useState(args.password); + const [mfaCode, setMfaCode] = useState(args.mfaCode ?? ""); + return ( +
+ +
+ ); + }, + args: { + email: "", + password: "", + setEmail: () => {}, + setPassword: () => {}, + onSubmit: () => {}, + isSubmitting: false, + submitButtonText: "Sign in", + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithMfa: Story = { + args: { + showMfaField: true, + requiresMfa: true, + email: "user@example.com", + password: "hunter2", + }, +}; + +export const WithErrors: Story = { + args: { + fieldErrors: { + email: "Enter a valid email address.", + password: "Password is required.", + }, + }, +}; + +export const Submitting: Story = { + args: { + email: "user@example.com", + password: "hunter2", + isSubmitting: true, + }, +}; diff --git a/frontend/editor/src/proprietary/auth/ui/ErrorMessage.stories.tsx b/frontend/editor/src/proprietary/auth/ui/ErrorMessage.stories.tsx new file mode 100644 index 0000000000..9647417990 --- /dev/null +++ b/frontend/editor/src/proprietary/auth/ui/ErrorMessage.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import ErrorMessage from "@app/auth/ui/ErrorMessage"; +import "@app/auth/ui/auth.css"; + +/** + * The inline error banner the auth forms render when a submission fails + */ +const meta: Meta = { + title: "Auth/Error Message", + component: ErrorMessage, + parameters: { layout: "centered" }, + args: { + error: "Invalid email or password.", + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const LongMessage: Story = { + args: { + error: + "We couldn't sign you in because your account has been temporarily locked after too many failed attempts. Please try again in a few minutes.", + }, +}; + +export const Empty: Story = { + args: { + error: null, + }, +}; diff --git a/frontend/editor/src/proprietary/auth/ui/SpringLoginForm.stories.tsx b/frontend/editor/src/proprietary/auth/ui/SpringLoginForm.stories.tsx new file mode 100644 index 0000000000..270c60c3aa --- /dev/null +++ b/frontend/editor/src/proprietary/auth/ui/SpringLoginForm.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SpringLoginForm from "@app/auth/ui/SpringLoginForm"; +import type { SpringLoginState } from "@app/auth/ui/useSpringLogin"; +import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg"; +import "@app/auth/ui/auth.css"; + +/** Builds a mock useSpringLogin() state; stories override just the fields they need. */ +function mockState( + overrides: Partial = {}, +): SpringLoginState { + return { + email: "", + setEmail: () => {}, + password: "", + setPassword: () => {}, + mfaCode: "", + setMfaCode: () => {}, + requiresMfa: false, + error: null, + setError: () => {}, + isSubmitting: false, + providers: [], + loginMethod: "all", + isUserPassAllowed: true, + hasProviders: false, + signInWithEmail: async () => {}, + signInWithProvider: async () => {}, + ...overrides, + }; +} + +/** + * The shared Spring login form body: logo, error, OAuth buttons, divider, and + * the email/password form. Rendered by both the editor and the portal inside + * their own auth shells. + */ +const meta = { + title: "Auth/Spring Login Form", + component: SpringLoginForm, + parameters: { layout: "centered" }, + args: { + state: mockState(), + logoSrc: loginHeader, + logoAlt: "Stirling PDF", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( +
+ +
+ ), +}; + +export const WithOAuthProviders: Story = { + args: { + state: mockState({ + providers: ["google", "github"], + hasProviders: true, + }), + }, + render: (args) => ( +
+ +
+ ), +}; + +export const WithError: Story = { + args: { + state: mockState({ + email: "user@example.com", + error: "Invalid email or password.", + }), + }, + render: (args) => ( +
+ +
+ ), +}; + +export const RequiresMfa: Story = { + args: { + state: mockState({ + email: "user@example.com", + requiresMfa: true, + }), + }, + render: (args) => ( +
+ +
+ ), +}; diff --git a/frontend/editor/src/proprietary/auth/ui/SupabaseLoginForm.stories.tsx b/frontend/editor/src/proprietary/auth/ui/SupabaseLoginForm.stories.tsx new file mode 100644 index 0000000000..9f24d2f971 --- /dev/null +++ b/frontend/editor/src/proprietary/auth/ui/SupabaseLoginForm.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import SupabaseLoginForm from "@app/auth/ui/SupabaseLoginForm"; +import type { SupabaseLoginState } from "@app/auth/ui/useSupabaseLogin"; +import "@app/auth/ui/auth.css"; + +/** + * Login form combining SSO buttons and email/password, driven by a + * {@link SupabaseLoginState} from useSupabaseLogin. + */ +const meta: Meta = { + title: "Auth/Supabase Login Form", + component: SupabaseLoginForm, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +function makeState( + overrides: Partial = {}, +): SupabaseLoginState { + return { + email: "", + setEmail: () => {}, + password: "", + setPassword: () => {}, + error: null, + setError: () => {}, + isSubmitting: false, + providers: ["google", "github"], + hasProviders: true, + signInWithEmail: async () => {}, + signInWithProvider: async () => {}, + ...overrides, + }; +} + +export const Default: Story = { + args: { + state: makeState(), + }, +}; + +export const NoProviders: Story = { + args: { + state: makeState({ providers: [], hasProviders: false }), + }, +}; + +export const WithError: Story = { + args: { + state: makeState({ + email: "user@example.com", + error: "Invalid email or password.", + }), + }, +}; + +export const Submitting: Story = { + args: { + state: makeState({ + email: "user@example.com", + password: "hunter2", + isSubmitting: true, + }), + }, +}; diff --git a/frontend/editor/src/proprietary/components/agents/StirlingLogoAnimated.stories.tsx b/frontend/editor/src/proprietary/components/agents/StirlingLogoAnimated.stories.tsx new file mode 100644 index 0000000000..2355f88810 --- /dev/null +++ b/frontend/editor/src/proprietary/components/agents/StirlingLogoAnimated.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { StirlingLogoAnimated } from "@app/components/agents/StirlingLogoAnimated"; + +/** + * Animated Stirling logo mark, used as a "thinking" indicator in the chat panel. + */ +const meta: Meta = { + title: "Agents/StirlingLogoAnimated", + component: StirlingLogoAnimated, + parameters: { layout: "padded" }, + args: { + size: 20, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Large: Story = { + args: { size: 64 }, +}; diff --git a/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx b/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx new file mode 100644 index 0000000000..3192b69303 --- /dev/null +++ b/frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline"; + +const meta = { + title: "Agents/StirlingLogoOutline", + component: StirlingLogoOutline, + parameters: { layout: "centered" }, + args: { + size: 20, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Large: Story = { + args: { + size: 64, + }, +}; diff --git a/frontend/editor/src/proprietary/components/policies/PolicyPiiField.stories.tsx b/frontend/editor/src/proprietary/components/policies/PolicyPiiField.stories.tsx new file mode 100644 index 0000000000..12c764dd4f --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyPiiField.stories.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PolicyPiiField } from "@app/components/policies/PolicyPiiField"; +import { PII_PRESETS } from "@app/data/policyDefinitions"; +import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters"; + +const BASE_PARAMETERS: RedactParameters = { + mode: "automatic", + wordsToRedact: [], + useRegex: false, + wholeWordSearch: false, + redactColor: "#000000", + customPadding: 0.1, + convertPDFToImage: true, +}; + +// The field only owns the preset selection; the story holds parameters state +// so selecting/clearing presets is live. +function Harness({ + initialParameters, + disabled, +}: { + initialParameters: RedactParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + return ( +
+ +
+ ); +} + +const meta = { + title: "Policies/PolicyPiiField", + component: Harness, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** No presets selected yet. */ +export const Default: Story = { + args: { + initialParameters: BASE_PARAMETERS, + }, +}; + +/** SSN and credit card presets pre-selected. */ +export const WithSelection: Story = { + args: { + initialParameters: { + ...BASE_PARAMETERS, + useRegex: true, + wordsToRedact: [PII_PRESETS[0].pattern, PII_PRESETS[1].pattern], + }, + }, +}; + +/** Disabled state. */ +export const Disabled: Story = { + args: { + initialParameters: BASE_PARAMETERS, + disabled: true, + }, +}; diff --git a/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.stories.tsx b/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.stories.tsx new file mode 100644 index 0000000000..2befe6971b --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.stories.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig"; +import { PII_PRESETS } from "@app/data/policyDefinitions"; +import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters"; + +const BASE_PARAMETERS: RedactParameters = { + mode: "automatic", + wordsToRedact: [], + useRegex: true, + wholeWordSearch: false, + redactColor: "#000000", + customPadding: 0.1, + convertPDFToImage: true, +}; + +// onChange must be wired up for real, since the component normalises its +// params via an onChange call on mount. +function Harness({ + initialParameters, + disabled, +}: { + initialParameters: RedactParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + return ( +
+ +
+ ); +} + +const meta = { + title: "Policies/PolicyRedactConfig", + component: Harness, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** No PII presets selected yet. */ +export const Default: Story = { + args: { + initialParameters: BASE_PARAMETERS, + }, +}; + +/** SSN and credit card presets pre-selected. */ +export const WithSelection: Story = { + args: { + initialParameters: { + ...BASE_PARAMETERS, + wordsToRedact: [PII_PRESETS[0].pattern, PII_PRESETS[1].pattern], + }, + }, +}; + +/** Locked, e.g. when the policy step is view-only. */ +export const Disabled: Story = { + args: { + initialParameters: BASE_PARAMETERS, + disabled: true, + }, +}; diff --git a/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx b/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx new file mode 100644 index 0000000000..e990de30ea --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig"; +import { + defaultParameters, + type AddWatermarkParameters, +} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +// The parent policy form owns the parameters; the story holds that state so edits are live. +function Harness({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = + useState(defaultParameters); + return ( +
+ +
+ ); +} + +const meta: Meta = { + title: "Policies/PolicyWatermarkConfig", + component: Harness, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Text-only watermark settings for a policy, with flatten forced on. */ +export const Default: Story = {}; + +/** All fields locked for read-only policy review. */ +export const Disabled: Story = { + args: { disabled: true }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx b/frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx new file mode 100644 index 0000000000..613f8cacc7 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal"; +import type { User } from "@app/services/userManagementService"; + +const USER: User = { + id: 1, + username: "alice@example.com", + roleName: "adminUserSettings.admin", + enabled: true, +}; + +const meta = { + title: "Shared/ChangeUserPasswordModal", + component: ChangeUserPasswordModal, + args: { + opened: true, + user: USER, + mailEnabled: true, + onClose: () => {}, + onSuccess: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** When SMTP notifications aren't configured, the email checkboxes are disabled. */ +export const MailDisabled: Story = { + args: { mailEnabled: false }, +}; + +/** A username that isn't a valid email address also disables the email checkboxes. */ +export const NonEmailUsername: Story = { + args: { user: { ...USER, username: "alice" } }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/DividerWithText.stories.tsx b/frontend/editor/src/proprietary/components/shared/DividerWithText.stories.tsx new file mode 100644 index 0000000000..fe677d26d6 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/DividerWithText.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import DividerWithText from "@app/components/shared/DividerWithText"; + +/** + * Horizontal rule, optionally with a centered text label (e.g. "or"). + */ +const meta = { + title: "Shared/DividerWithText", + component: DividerWithText, + parameters: { layout: "padded" }, + args: { + text: "or", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** With no `text`, renders a plain horizontal rule. */ +export const PlainRule: Story = { + args: { text: undefined }, +}; + +export const Subcategory: Story = { + args: { + text: "Advanced options", + variant: "subcategory", + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/ManageBillingButton.stories.tsx b/frontend/editor/src/proprietary/components/shared/ManageBillingButton.stories.tsx new file mode 100644 index 0000000000..f860767b43 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/ManageBillingButton.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ManageBillingButton } from "@app/components/shared/ManageBillingButton"; + +/** + * Button that opens the Stripe billing portal for the current license. + */ +const meta = { + title: "Shared/ManageBillingButton", + component: ManageBillingButton, + parameters: { layout: "centered" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** Custom return URL to redirect back to once the billing portal session ends. */ +export const CustomReturnUrl: Story = { + args: { + returnUrl: "https://stirlingpdf.com/account", + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx b/frontend/editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx new file mode 100644 index 0000000000..ec61584fa6 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import UpdateSeatsModal from "@app/components/shared/UpdateSeatsModal"; + +/** + * Modal for adjusting enterprise seat count, redirecting to the Stripe billing portal on confirm. + */ +const meta = { + title: "Shared/UpdateSeatsModal", + component: UpdateSeatsModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + currentSeats: 10, + minimumSeats: 5, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** Minimum seats equal to current seats, e.g. a fully-utilized license. */ +export const AtMinimum: Story = { + args: { + currentSeats: 5, + minimumSeats: 5, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/EnterpriseRequiredBanner.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/EnterpriseRequiredBanner.stories.tsx new file mode 100644 index 0000000000..2ff341d6f9 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/EnterpriseRequiredBanner.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import EnterpriseRequiredBanner from "@app/components/shared/config/EnterpriseRequiredBanner"; + +/** + * Banner explaining that an enterprise-only feature is running in demo mode. + */ +const meta: Meta = { + title: "Config/EnterpriseRequiredBanner", + component: EnterpriseRequiredBanner, + parameters: { layout: "padded" }, + args: { + show: true, + featureName: "Audit Logs", + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** When `show` is false the banner renders nothing. */ +export const Hidden: Story = { + args: { show: false }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx new file mode 100644 index 0000000000..2c8fb2aa3b --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import GeneralWithLoginLanding from "@app/components/shared/config/GeneralWithLoginLanding"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ThemeProvider } from "@app/components/shared/ThemeProvider"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +// Wraps GeneralSection, which reads theme/tool-panel preferences via +// usePreferences()/useTheme() and server config via useAppConfig() — none of +// which the Storybook preview's own provider tree supplies (those are the +// portal contexts), so wrap here. AppConfigProvider uses autoFetch off so +// stories render a fixed config instead of hitting the API. The login-landing +// control itself stays hidden (loginLandingMode() defaults to non-"dynamic" +// in this build), so only the General section is visible. +const meta = { + title: "Config/GeneralWithLoginLanding", + component: GeneralWithLoginLanding, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( + + + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Login enabled, no backend version known yet — Software Updates section and admin banner stay hidden. */ +export const Default: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** Backend version known — shows the Software Updates section with version info. */ +export const WithBackendVersion: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** Login disabled — the "For System Administrators" banner shows, prompting the env vars to enable it. */ +export const AdminBanner: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx new file mode 100644 index 0000000000..a5d57a1a73 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { OverviewHeader } from "@app/components/shared/config/OverviewHeader"; +import { AuthContext } from "@app/auth/context"; +import type { AuthContextValue } from "@app/auth/types"; + +/** + * Header for the application configuration page: title, description, and + * (when signed in) the current user's email plus a log-out button. + */ +const meta: Meta = { + title: "Config/OverviewHeader", + component: OverviewHeader, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const signedInAuth: AuthContextValue = { + session: null, + user: { + id: "user-1", + email: "jane.doe@example.com", + username: "jane.doe", + role: "ROLE_USER", + }, + displayName: "jane.doe", + isAnonymous: false, + isAdmin: false, + portalAccess: false, + role: "ROLE_USER", + loading: false, + error: null, + signOut: async () => {}, + refreshSession: async () => {}, +}; + +/** Signed out: no email line, no log-out button. */ +export const Default: Story = {}; + +/** Signed in: shows the current user's email and a log-out button. */ +export const SignedIn: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx new file mode 100644 index 0000000000..213df8951c --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AccountSection from "@app/components/shared/config/configSections/AccountSection"; +import { AuthContext } from "@app/auth/context"; +import type { AuthContextValue } from "@app/auth/types"; +import { accountService } from "@app/services/accountService"; + +/** + * Account settings panel shown inside the app config modal: password / + * username management and two-factor authentication setup. + */ +const meta: Meta = { + title: "Config/AccountSection", + component: AccountSection, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +// AccountSection gets its user from useAuth() and its MFA/account data from +// accountService on mount rather than via props, so stories set those directly. +const standardAuth: AuthContextValue = { + session: null, + user: { + id: "user-1", + email: "jane.doe@example.com", + username: "jane.doe", + role: "ROLE_USER", + }, + displayName: "jane.doe", + isAnonymous: false, + isAdmin: false, + portalAccess: false, + role: "ROLE_USER", + loading: false, + error: null, + signOut: async () => {}, + refreshSession: async () => {}, +}; + +const ssoAuth: AuthContextValue = { + ...standardAuth, + user: { + ...standardAuth.user!, + email: "jane.doe@acme-corp.com", + authenticationType: "sso", + }, +}; + +accountService.getAccountData = async () => ({ + username: "jane.doe", + role: "ROLE_USER", + settings: "{}", + changeCredsFlag: false, + oAuth2Login: false, + saml2Login: false, + mfaEnabled: false, +}); + +/** Standard account with password/username management and 2FA available to enable. */ +export const Default: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; + +/** Two-factor authentication already enabled: shows the disable action instead. */ +export const MfaEnabled: Story = { + decorators: [ + (Story) => { + accountService.getAccountData = async () => ({ + username: "jane.doe", + role: "ROLE_USER", + settings: "{}", + changeCredsFlag: false, + oAuth2Login: false, + saml2Login: false, + mfaEnabled: true, + }); + return ( + + + + ); + }, + ], +}; + +/** SSO-managed account: password/username changes and 2FA are hidden behind identity-provider notices. */ +export const SsoUser: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiDocumentsSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiDocumentsSection.stories.tsx new file mode 100644 index 0000000000..c0ba0c34cb --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiDocumentsSection.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse, delay } from "msw"; +import AdminAiDocumentsSection from "@app/components/shared/config/configSections/AdminAiDocumentsSection"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext"; +import { + AiEngineApiResponse, + MASKED_SECRET, +} from "@app/components/shared/config/configSections/aiEngineSettings"; + +// AdminAiDocumentsSection takes no props: it fetches the shared aiEngine settings +// blob via apiClient on mount and reads login state through useAppConfig(), so +// each variant mocks the GET response and wraps in a fixed AppConfigProvider +// (autoFetch off, so stories never hit the real API). It also tracks dirty state +// through useSettingsDirty()/useUnsavedChanges(), which isn't part of Storybook's +// global provider stack, so that needs wrapping here too or the hook throws. +const baseSettings: AiEngineApiResponse = { + rag: { + embeddingProvider: "voyageai", + embeddingModel: "voyage-4", + embeddingApiKey: MASKED_SECRET, + topK: 5, + maxSearches: 3, + }, +}; + +const handlerFor = (data: AiEngineApiResponse) => + http.get("*/api/v1/admin/settings/section/aiEngine", () => + HttpResponse.json(data), + ); + +const meta = { + title: "Config/AdminAiDocumentsSection", + component: AdminAiDocumentsSection, + parameters: { + layout: "padded", + msw: { + handlers: [ + handlerFor(baseSettings), + http.put("*/api/v1/admin/settings", () => HttpResponse.json({})), + http.put("*/api/v1/admin/settings/section/aiEngine", () => + HttpResponse.json({}), + ), + ], + }, + }, + decorators: [ + (Story) => ( + + + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** VoyageAI embeddings with a saved (masked) API key and default retrieval settings. */ +export const Default: Story = {}; + +/** Ollama needs a base URL instead of an API key: the key field is hidden. */ +export const OllamaProvider: Story = { + parameters: { + msw: { + handlers: [ + handlerFor({ + rag: { + embeddingProvider: "ollama", + embeddingModel: "nomic-embed-text", + embeddingBaseUrl: "http://ollama:11434/v1", + topK: 5, + maxSearches: 3, + }, + }), + ], + }, + }, +}; + +/** While the settings request is in flight, a centered loader replaces the form. */ +export const Loading: Story = { + parameters: { + msw: { + handlers: [ + http.get("*/api/v1/admin/settings/section/aiEngine", async () => { + await delay("infinite"); + return HttpResponse.json(baseSettings); + }), + ], + }, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.stories.tsx new file mode 100644 index 0000000000..4061f52fd8 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import AdminAiGeneralSection from "@app/components/shared/config/configSections/AdminAiGeneralSection"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext"; +import type { AiEngineApiResponse } from "@app/components/shared/config/configSections/aiEngineSettings"; + +// AdminAiGeneralSection fetches the shared aiEngine settings blob on mount via +// apiClient (no service seam to stub), so each variant supplies its own MSW +// handler for GET .../admin/settings/section/aiEngine. Saves PUT back to the +// same section plus the generic /admin/settings delta endpoint; the smoke +// test never clicks Save, so those aren't stubbed here. +const baseSettings: AiEngineApiResponse = { + enabled: true, + url: "http://stirling-pdf-engine:5001", + timeoutSeconds: 30, + longRunningTimeoutSeconds: 120, + streamTimeoutSeconds: 60, + features: { + chat: true, + documentQuestions: true, + createPdf: false, + mathAuditor: false, + pdfComment: false, + classify: true, + }, +}; + +const handlerFor = (data: AiEngineApiResponse) => + http.get("*/api/v1/admin/settings/section/aiEngine", () => + HttpResponse.json(data), + ); + +const meta = { + title: "Config/AdminAiGeneralSection", + component: AdminAiGeneralSection, + parameters: { + layout: "padded", + msw: { handlers: [handlerFor(baseSettings)] }, + }, + // useSettingsDirty() reads UnsavedChangesContext and the section reads login + // state via useAppConfig(), neither of which the global preview supplies. + decorators: [ + (Story) => ( + + + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** AI enabled, connected to an engine, with a mix of capabilities toggled on. */ +export const Default: Story = {}; + +/** Master switch off: the URL, timeouts, and capability switches are all disabled. */ +export const Disabled: Story = { + parameters: { + msw: { handlers: [handlerFor({ ...baseSettings, enabled: false })] }, + }, +}; + +/** The engine URL was changed and saved but awaits a restart to take effect. */ +export const PendingRestart: Story = { + parameters: { + msw: { + handlers: [ + handlerFor({ + ...baseSettings, + _pending: { url: "http://stirling-pdf-engine:6001" }, + }), + ], + }, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiLimitsSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiLimitsSection.stories.tsx new file mode 100644 index 0000000000..3caae46a25 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiLimitsSection.stories.tsx @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse, delay } from "msw"; +import AdminAiLimitsSection from "@app/components/shared/config/configSections/AdminAiLimitsSection"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext"; +import type { AiEngineApiResponse } from "@app/components/shared/config/configSections/aiEngineSettings"; + +// AdminAiLimitsSection takes no props: it fetches the shared aiEngine settings +// blob via apiClient on mount and reads login state through useAppConfig(), so +// each variant mocks the GET response and wraps in a fixed AppConfigProvider +// (autoFetch off, so stories never hit the real API). +const baseSettings: AiEngineApiResponse = { + limits: { + maxPages: 50, + maxCharacters: 200000, + modelMaxConcurrency: 4, + }, +}; + +const meta = { + title: "Config/AdminAiLimitsSection", + component: AdminAiLimitsSection, + parameters: { + layout: "padded", + msw: { + handlers: [ + http.get("/api/v1/admin/settings/section/aiEngine", () => + HttpResponse.json(baseSettings), + ), + http.put("/api/v1/admin/settings", () => HttpResponse.json({})), + http.put("/api/v1/admin/settings/section/aiEngine", () => + HttpResponse.json({}), + ), + ], + }, + }, + decorators: [ + (Story) => ( + + + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Loaded limits with login enabled, so the sticky footer can appear once edited. */ +export const Default: Story = {}; + +/** While the settings request is in flight, a centered loader replaces the form. */ +export const Loading: Story = { + parameters: { + msw: { + handlers: [ + http.get("/api/v1/admin/settings/section/aiEngine", async () => { + await delay("infinite"); + return HttpResponse.json(baseSettings); + }), + ], + }, + }, +}; + +/** A pending (restart-required) change on max pages shows the pending badge next to its label. */ +export const PendingChange: Story = { + parameters: { + msw: { + handlers: [ + http.get("/api/v1/admin/settings/section/aiEngine", () => + HttpResponse.json({ + ...baseSettings, + _pending: { limits: { maxPages: 100 } }, + } satisfies AiEngineApiResponse), + ), + ], + }, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiModelsSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiModelsSection.stories.tsx new file mode 100644 index 0000000000..2a1a5f96eb --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiModelsSection.stories.tsx @@ -0,0 +1,106 @@ +import type React from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import AdminAiModelsSection from "@app/components/shared/config/configSections/AdminAiModelsSection"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext"; +import { + AiEngineApiResponse, + MASKED_SECRET, +} from "@app/components/shared/config/configSections/aiEngineSettings"; + +/** + * AdminAiModelsSection fetches its settings via apiClient on mount (through + * useAdminSettings) rather than taking props, so each story mocks the GET + * with MSW. It also reads useAppConfig()/useSettingsDirty(), which throw + * without their providers, so every story wraps in both. + */ +function withProviders(Story: () => React.JSX.Element) { + return ( + + + + + + ); +} + +function aiEngineHandler(response: AiEngineApiResponse) { + return http.get("/api/v1/admin/settings/section/aiEngine", () => + HttpResponse.json(response), + ); +} + +const meta = { + title: "Config/AdminAiModelsSection", + component: AdminAiModelsSection, + parameters: { layout: "padded" }, + decorators: [withProviders], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Anthropic provider with a saved (masked) API key and no base URL field. */ +export const Default: Story = { + parameters: { + msw: { + handlers: [ + aiEngineHandler({ + models: { + provider: "anthropic", + smartModel: "claude-sonnet-5", + fastModel: "claude-haiku-4-5", + smartMaxTokens: 8192, + fastMaxTokens: 2048, + apiKey: MASKED_SECRET, + }, + }), + ], + }, + }, +}; + +/** Ollama provider: no API key field, but a base URL input and an SSRF warning banner instead. */ +export const OllamaProvider: Story = { + parameters: { + msw: { + handlers: [ + aiEngineHandler({ + models: { + provider: "ollama", + smartModel: "llama3.1", + fastModel: "qwen2.5", + smartMaxTokens: 4096, + fastMaxTokens: 1024, + baseUrl: "http://ollama:11434/v1", + }, + }), + ], + }, + }, +}; + +/** Some fields are pending a config-management rollout — each shows a PendingBadge next to its label. */ +export const PendingChanges: Story = { + parameters: { + msw: { + handlers: [ + aiEngineHandler({ + models: { + provider: "anthropic", + smartModel: "claude-sonnet-5", + fastModel: "claude-haiku-4-5", + smartMaxTokens: 8192, + fastMaxTokens: 2048, + apiKey: MASKED_SECRET, + }, + _pending: { + models: { + smartModel: "claude-opus-4-8", + }, + }, + }), + ], + }, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx new file mode 100644 index 0000000000..2959cbb033 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx @@ -0,0 +1,100 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdminAuditSection from "@app/components/shared/config/configSections/AdminAuditSection"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import auditService from "@app/services/auditService"; + +// AdminAuditSection reads its config through useAppConfig()/useLoginRequired() +// rather than taking props, so each variant wraps it in an AppConfigProvider +// with a fixed initialConfig (autoFetch off, so stories never hit the API). +// Without login enabled + an ENTERPRISE license the component falls back to +// hardcoded demo data on its own; the "fully enabled" and "disabled" variants +// below supply that config, which makes the component call +// auditService.getSystemStatus() on mount, so each stubs that call directly +// (the module exports a plain object, the seam the component itself calls +// through) inside its own decorator so the two variants don't depend on +// render order. +const meta = { + title: "Config/AdminAuditSection", + component: AdminAuditSection, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** + * No login configured yet: falls back to demo data, shows the enterprise + * banner, and the dashboard tabs are disabled. + */ +export const Default: Story = {}; + +/** Login is disabled entirely: both the login and enterprise banners show. */ +export const LoginDisabled: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; + +/** + * Login enabled with an ENTERPRISE license: both banners are hidden, the + * dashboard tabs are active, and the fetched (mocked) status backs the page + * instead of the built-in demo data. + */ +export const Enabled: Story = { + decorators: [ + (Story) => { + auditService.getSystemStatus = async () => ({ + enabled: true, + level: "INFO", + retentionDays: 90, + totalEvents: 15234, + pdfMetadataEnabled: true, + captureFileHash: true, + capturePdfAuthor: true, + captureOperationResults: true, + }); + return ( + + + + ); + }, + ], +}; + +/** Audit logging itself is turned off server-side: shows the disabled notice instead of the tabs. */ +export const AuditLoggingDisabled: Story = { + decorators: [ + (Story) => { + auditService.getSystemStatus = async () => ({ + enabled: false, + level: "OFF", + retentionDays: 0, + totalEvents: 0, + pdfMetadataEnabled: false, + captureFileHash: false, + capturePdfAuthor: false, + captureOperationResults: false, + }); + return ( + + + + ); + }, + ], +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.stories.tsx new file mode 100644 index 0000000000..140445fe0e --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.stories.tsx @@ -0,0 +1,95 @@ +import type React from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import AdminFolderAccessSection from "@app/components/shared/config/configSections/AdminFolderAccessSection"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { UnsavedChangesProvider } from "@app/contexts/UnsavedChangesContext"; + +/** + * AdminFolderAccessSection fetches its settings via apiClient on mount (through + * useAdminSettings, plus its own implied-folder-roots request) rather than + * taking props, so each story mocks both GETs with MSW. It also reads + * useAppConfig()/useSettingsDirty(), which throw without their providers, so + * every story wraps in both. + */ +function withProviders(enableLogin: boolean) { + return function Decorator(Story: () => React.JSX.Element) { + return ( + + + + + + ); + }; +} + +function impliedRootsHandler( + roots: { path: string; reason: string }[] = [ + { path: "/srv/stirling/storage", reason: "serverStorage" }, + { path: "/srv/stirling/watched/invoices", reason: "watchedFolder" }, + ], +) { + return http.get("/api/v1/admin/settings/policies/implied-folder-roots", () => + HttpResponse.json(roots), + ); +} + +function allowedRootsHandler(allowedFolderRoots: string[]) { + return http.get("/api/v1/admin/settings/section/policies", () => + HttpResponse.json({ allowedFolderRoots }), + ); +} + +const meta = { + title: "Config/AdminFolderAccessSection", + component: AdminFolderAccessSection, + parameters: { + layout: "padded", + msw: { + handlers: [ + http.put("/api/v1/admin/settings/section/policies", () => + HttpResponse.json({}), + ), + ], + }, + }, + decorators: [withProviders(true)], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Two allowed folder roots configured, plus the always-allowed implied roots. */ +export const Default: Story = { + parameters: { + msw: { + handlers: [ + allowedRootsHandler(["/data/inbox", "/data/exports"]), + impliedRootsHandler(), + ], + }, + }, +}; + +/** No folder roots configured yet: folder sources and outputs are disabled. */ +export const NoRootsConfigured: Story = { + parameters: { + msw: { + handlers: [allowedRootsHandler([]), impliedRootsHandler([])], + }, + }, +}; + +/** Login mode disabled: the section is read-only and shows the login-required banner. */ +export const LoginDisabled: Story = { + decorators: [withProviders(false)], + parameters: { + msw: { + handlers: [allowedRootsHandler([]), impliedRootsHandler([])], + }, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx new file mode 100644 index 0000000000..3d7e113861 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse, delay } from "msw"; +import ApiKeys from "@app/components/shared/config/configSections/ApiKeys"; + +/** + * Config section showing the user's API key, with copy/refresh actions and + * links to the API docs. Fetches the key from the backend on mount, so the + * different visual states are driven by mocking `get-api-key` via MSW rather + * than props (the component takes none). + */ +const meta: Meta = { + title: "Config/ConfigSections/ApiKeys", + component: ApiKeys, + parameters: { + layout: "padded", + msw: { + handlers: [ + http.post("/api/v1/user/get-api-key", () => + HttpResponse.json("demo-storybook-api-key-000000000000"), + ), + ], + }, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** While the key request is in flight, the card shows a skeleton placeholder. */ +export const Loading: Story = { + parameters: { + msw: { + handlers: [ + http.post("/api/v1/user/get-api-key", async () => { + await delay("infinite"); + return HttpResponse.json(null); + }), + ], + }, + }, +}; + +/** When the key request fails, an error banner with a retry link is shown. */ +export const LoadError: Story = { + parameters: { + msw: { + handlers: [ + http.post("/api/v1/user/get-api-key", () => + HttpResponse.json(null, { status: 500 }), + ), + ], + }, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.stories.tsx new file mode 100644 index 0000000000..d60844a709 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LoginAgreementEditor from "@app/components/shared/config/configSections/LoginAgreementEditor"; + +/** + * Per-language editor for the login agreement markdown shown on the login screen. + */ +const meta: Meta = { + title: "Config/ConfigSections/LoginAgreementEditor", + component: LoginAgreementEditor, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** Inputs are locked while a parent action (e.g. saving elsewhere) is in progress. */ +export const Disabled: Story = { + args: { disabled: true }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx new file mode 100644 index 0000000000..952de00331 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import TeamDetailsSection from "@app/components/shared/config/configSections/TeamDetailsSection"; +import { teamService } from "@app/services/teamService"; +import { userManagementService } from "@app/services/userManagementService"; + +// TeamDetailsSection fetches through these services on mount rather than +// taking data as props, so the story stubs the service methods directly +// instead of passing mock data in. +teamService.getTeamDetails = async () => ({ + team: { id: 1, name: "Engineering" }, + teamUsers: [ + { + id: 1, + username: "alice", + email: "alice@example.com", + roleName: "adminUserSettings.admin", + rolesAsString: "ROLE_ADMIN", + enabled: true, + }, + { + id: 2, + username: "bob", + email: "bob@example.com", + roleName: "adminUserSettings.user", + rolesAsString: "ROLE_USER", + enabled: true, + }, + { + id: 3, + username: "carol", + roleName: "adminUserSettings.user", + rolesAsString: "ROLE_USER", + enabled: false, + }, + ], + availableUsers: [ + { + id: 4, + username: "dave", + roleName: "adminUserSettings.user", + rolesAsString: "ROLE_USER", + enabled: true, + }, + ], + userLastRequest: { alice: Date.now() }, +}); + +teamService.getTeams = async () => [ + { id: 1, name: "Engineering" }, + { id: 2, name: "Default" }, +]; + +userManagementService.getUsers = async () => ({ + users: [], + userSessions: {}, + userLastRequest: {}, + totalUsers: 4, + activeUsers: 3, + disabledUsers: 1, + maxAllowedUsers: 10, + availableSlots: 6, + grandfatheredUserCount: 0, + licenseMaxUsers: 10, + premiumEnabled: true, + mailEnabled: true, + lockedUsers: [], +}); + +const meta = { + title: "Config/TeamDetailsSection", + component: TeamDetailsSection, + parameters: { layout: "padded" }, + args: { + teamId: 1, + onBack: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** A team with a mix of active and disabled members. */ +export const Default: Story = {}; + +/** A team with no members yet shows the empty-state row. */ +export const Empty: Story = { + decorators: [ + (Story) => { + teamService.getTeamDetails = async () => ({ + team: { id: 2, name: "Design" }, + teamUsers: [], + availableUsers: [], + userLastRequest: {}, + }); + return ; + }, + ], + args: { teamId: 2 }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx new file mode 100644 index 0000000000..7f88ffca89 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import TeamsSection from "@app/components/shared/config/configSections/TeamsSection"; +import { teamService } from "@app/services/teamService"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +// Fetches through teamService on mount rather than taking data as props, so +// the story stubs it directly (the module exports a plain object, so this is +// the same seam the component itself calls through). +teamService.getTeams = async () => [ + { id: 1, name: "Internal", userCount: 1 }, + { id: 2, name: "Engineering", userCount: 8 }, + { id: 3, name: "Marketing", userCount: 3 }, + { + id: 4, + name: "Customer Success and Onboarding Specialists", + userCount: 5, + }, +]; + +/** + * Admin panel for creating teams, renaming them, and moving members between + * them. + */ +const meta = { + title: "Config/ConfigSections/TeamsSection", + component: TeamsSection, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** A handful of teams, including the non-deletable "Internal" system team. */ +export const Default: Story = {}; + +/** No teams returned yet — shows the "no teams found" empty state row. */ +export const Empty: Story = { + decorators: [ + (Story) => { + teamService.getTeams = async () => []; + return ; + }, + ], +}; + +/** + * Login disabled — falls back to hardcoded example teams and shows the + * login-required banner, with every row action disabled. + */ +export const LoginDisabled: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.stories.tsx new file mode 100644 index 0000000000..cc11164ccd --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ApiKeySection from "@app/components/shared/config/configSections/apiKeys/ApiKeySection"; + +/** + * Card showing a public API key with copy and refresh actions. + */ +const meta: Meta = { + title: "Config/ApiKeySection", + component: ApiKeySection, + parameters: { layout: "padded" }, + args: { + publicKey: "demo-publishable-key-xxxx", + copied: null, + onCopy: () => {}, + onRefresh: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** After the copy button has been clicked, it shows a "Copied!" state. */ +export const Copied: Story = { + args: { copied: "public" }, +}; + +/** The refresh button is disabled while a refresh is in progress. */ +export const Disabled: Story = { + args: { disabled: true }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.stories.tsx new file mode 100644 index 0000000000..f23872a6ee --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RefreshModal from "@app/components/shared/config/configSections/apiKeys/RefreshModal"; + +/** + * Confirmation modal shown before regenerating API keys, warning that + * existing keys will be invalidated. + */ +const meta: Meta = { + title: "Config/ApiKeys/RefreshModal", + component: RefreshModal, + args: { + opened: true, + onClose: () => {}, + onConfirm: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** When `opened` is false the modal renders nothing. */ +export const Closed: Story = { + args: { opened: false }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditChartsSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditChartsSection.stories.tsx new file mode 100644 index 0000000000..299fb3ee2c --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditChartsSection.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AuditChartsSection from "@app/components/shared/config/configSections/audit/AuditChartsSection"; + +/** + * Dashboard of audit-log charts (events over time, by type, by user). + * When `loginEnabled` is false it renders deterministic demo data instead + * of calling the audit API, which is what these stories rely on. + */ +const meta: Meta = { + title: "Config/AuditChartsSection", + component: AuditChartsSection, + parameters: { layout: "padded" }, + args: { + loginEnabled: false, + timePeriod: "week", + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** Day view of the same demo dataset. */ +export const DayPeriod: Story = { + args: { timePeriod: "day" }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx new file mode 100644 index 0000000000..3bc860885d --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AuditClearDataSection from "@app/components/shared/config/configSections/audit/AuditClearDataSection"; + +/** + * Destructive action card for permanently clearing all audit logs, gated + * behind a randomly generated confirmation code the user must retype. + */ +const meta: Meta = { + title: "Config/Audit/AuditClearDataSection", + component: AuditClearDataSection, + parameters: { layout: "padded" }, + args: { + loginEnabled: true, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** When login is disabled, the delete action is disabled too. */ +export const LoginDisabled: Story = { + args: { loginEnabled: false }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.stories.tsx new file mode 100644 index 0000000000..702610e7b9 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AuditEventsTable from "@app/components/shared/config/configSections/audit/AuditEventsTable"; + +/** + * Table of audit events with sorting, an event-details modal, and pagination. + * When `loginEnabled` is false (auth disabled), it shows built-in sample + * events instead of calling the backend — used here to keep stories + * deterministic without a live API. + */ +const meta: Meta = { + title: "Config/Audit/AuditEventsTable", + component: AuditEventsTable, + parameters: { layout: "padded" }, + args: { + loginEnabled: false, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** Adds the "Author" and "File Hash" columns when PDF metadata capture is enabled. */ +export const WithFileMetadataColumns: Story = { + args: { + captureFileHash: true, + capturePdfAuthor: true, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx new file mode 100644 index 0000000000..246e974a34 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AuditExportSection from "@app/components/shared/config/configSections/audit/AuditExportSection"; + +const meta = { + title: "Config/AuditExportSection", + component: AuditExportSection, + parameters: { layout: "padded" }, + args: { + loginEnabled: true, + captureFileHash: false, + capturePdfAuthor: false, + captureOperationResults: false, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** All optional PDF/file metadata fields enabled and selectable. */ +export const AllFieldsEnabled: Story = { + args: { + captureFileHash: true, + capturePdfAuthor: true, + captureOperationResults: true, + }, +}; + +/** Without an active login, the format, fields, filters, and export button are all disabled. */ +export const LoginDisabled: Story = { + args: { loginEnabled: false }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx new file mode 100644 index 0000000000..4ddee00974 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AuditFiltersForm from "@app/components/shared/config/configSections/audit/AuditFiltersForm"; +import { AuditFilters } from "@app/services/auditService"; + +/** + * Shared filter form for audit components: quick date presets, event type / user + * multi-selects, and start/end date pickers. + */ +const meta: Meta = { + title: "Config/Audit/AuditFiltersForm", + component: AuditFiltersForm, + parameters: { layout: "padded" }, + args: { + eventTypes: ["LOGIN", "LOGOUT", "FILE_UPLOAD", "FILE_DOWNLOAD"], + users: ["alice", "bob", "carol"], + onFilterChange: () => {}, + onClearFilters: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + filters: {}, + }, +}; + +/** With some filters already applied. */ +export const WithFiltersApplied: Story = { + args: { + filters: { + eventType: ["LOGIN"], + username: ["alice"], + startDate: "2026-07-01", + endDate: "2026-07-15", + }, + }, +}; + +/** All inputs disabled, e.g. while a request is in flight. */ +export const Disabled: Story = { + args: { + filters: {}, + disabled: true, + }, +}; + +/** Interactive: filter state updates live as the form is used. */ +export const Interactive: Story = { + render: (args) => { + function InteractiveForm() { + const [filters, setFilters] = useState({}); + return ( + + setFilters((prev) => ({ ...prev, [key]: value })) + } + onClearFilters={() => setFilters({})} + /> + ); + } + return ; + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx new file mode 100644 index 0000000000..01e8a66ea2 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AuditStatsCards from "@app/components/shared/config/configSections/audit/AuditStatsCards"; + +/** + * Stat cards summarising audit log activity (events, success rate, active + * users, latency) for a given time period. With `loginEnabled={false}` it + * renders built-in demo data instead of calling the audit API. + */ +const meta: Meta = { + title: "Config/Audit/AuditStatsCards", + component: AuditStatsCards, + parameters: { layout: "padded" }, + args: { + loginEnabled: false, + timePeriod: "week", + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** Stats for a single day period. */ +export const Day: Story = { + args: { timePeriod: "day" }, +}; + +/** Stats for a full month period. */ +export const Month: Story = { + args: { timePeriod: "month" }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx new file mode 100644 index 0000000000..d7a93740d8 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AuditSystemStatus from "@app/components/shared/config/configSections/audit/AuditSystemStatus"; +import type { AuditSystemStatus as AuditStatus } from "@app/services/auditService"; + +const meta = { + title: "Config/Audit/AuditSystemStatus", + component: AuditSystemStatus, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const baseStatus: AuditStatus = { + enabled: true, + level: "STANDARD", + retentionDays: 90, + totalEvents: 12483, + pdfMetadataEnabled: true, + captureFileHash: true, + capturePdfAuthor: true, + captureOperationResults: true, +}; + +export const Default: Story = { + args: { + status: baseStatus, + }, +}; + +/** Audit logging disabled, and the opt-in fields not yet enabled in settings. */ +export const Disabled: Story = { + args: { + status: { + ...baseStatus, + enabled: false, + totalEvents: 0, + capturePdfAuthor: false, + captureFileHash: false, + }, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx new file mode 100644 index 0000000000..4926f2c805 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AvailablePlansSection from "@app/components/shared/config/configSections/plan/AvailablePlansSection"; +import type { PlanTier } from "@app/services/licenseService"; + +const FEATURES = [ + { name: "PDF conversion", included: true }, + { name: "Digital signatures", included: true }, + { name: "SSO", included: false }, +]; + +const PLANS: PlanTier[] = [ + { + id: "free", + name: "Free", + price: 0, + currency: "$", + period: "month", + features: FEATURES, + highlights: ["Core PDF tools", "Single user"], + lookupKey: "free", + }, + { + id: "selfhosted:server:monthly", + name: "Server", + price: 29, + currency: "$", + period: "month", + popular: true, + features: FEATURES, + highlights: ["Everything in Free", "Team workspaces", "Priority support"], + lookupKey: "selfhosted:server:monthly", + }, + { + id: "selfhosted:enterprise:monthly", + name: "Enterprise", + price: 99, + currency: "$", + period: "month", + seatPrice: 12, + requiresSeats: true, + features: FEATURES, + highlights: ["Everything in Server", "SSO", "Dedicated support"], + lookupKey: "selfhosted:enterprise:monthly", + }, +]; + +const meta = { + title: "Config/Plan/AvailablePlansSection", + component: AvailablePlansSection, + parameters: { layout: "padded" }, + args: { + plans: PLANS, + onUpgradeClick: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** Adds the currency selector shown when currency props are supplied. */ +export const WithCurrencySelector: Story = { + args: { + currency: "usd", + onCurrencyChange: () => {}, + currencyOptions: [ + { value: "usd", label: "USD ($)" }, + { value: "eur", label: "EUR (€)" }, + { value: "gbp", label: "GBP (£)" }, + ], + }, +}; + +/** Logged-out visitors see disabled upgrade/manage controls. */ +export const LoginDisabled: Story = { + args: { + loginEnabled: false, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.stories.tsx new file mode 100644 index 0000000000..bb346bdd6e --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FeatureComparisonTable from "@app/components/shared/config/configSections/plan/FeatureComparisonTable"; +import type { PlanFeature } from "@app/types/license"; + +const features: PlanFeature[] = [ + { name: "PDF merge & split", included: true }, + { name: "OCR", included: true }, + { name: "Custom watermarking", included: true }, + { name: "Priority support", included: false }, +]; + +const plans = [ + { + name: "Free", + tier: "free", + features: features.map((f, i) => ({ ...f, included: i < 2 })), + }, + { + name: "Server", + tier: "server", + popular: true, + features: features.map((f, i) => ({ ...f, included: i < 3 })), + }, + { + name: "Enterprise", + tier: "enterprise", + features: features.map((f) => ({ ...f, included: true })), + }, +]; + +/** + * Table comparing feature availability across plan tiers. + */ +const meta: Meta = { + title: "Config/Plan/FeatureComparisonTable", + component: FeatureComparisonTable, + parameters: { layout: "padded" }, + args: { + plans, + currentTier: "free", + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** When the current tier is enterprise, the "Popular" badge is suppressed on the Server plan. */ +export const CurrentTierEnterprise: Story = { + args: { currentTier: "enterprise" }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx new file mode 100644 index 0000000000..742ad93910 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx @@ -0,0 +1,130 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PlanCard from "@app/components/shared/config/configSections/plan/PlanCard"; +import type { PlanTierGroup } from "@app/services/licenseService"; + +const freePlanGroup: PlanTierGroup = { + tier: "free", + name: "Free", + monthly: { + id: "free", + name: "Free", + price: 0, + currency: "$", + period: "month", + features: [], + highlights: ["Basic PDF tools", "Community support"], + lookupKey: "selfhosted:free", + }, + yearly: null, + features: [], + highlights: ["Basic PDF tools", "Community support"], + popular: false, +}; + +const serverPlanGroup: PlanTierGroup = { + tier: "server", + name: "Server", + monthly: { + id: "server-monthly", + name: "Server", + price: 29, + currency: "$", + period: "month", + features: [], + highlights: ["Unlimited users", "Priority support", "SSO"], + lookupKey: "selfhosted:server:monthly", + }, + yearly: { + id: "server-yearly", + name: "Server", + price: 290, + currency: "$", + period: "year", + features: [], + highlights: ["Unlimited users", "Priority support", "SSO"], + lookupKey: "selfhosted:server:yearly", + }, + features: [], + highlights: ["Unlimited users", "Priority support", "SSO"], + popular: true, +}; + +const enterprisePlanGroup: PlanTierGroup = { + tier: "enterprise", + name: "Enterprise", + monthly: { + id: "enterprise-monthly", + name: "Enterprise", + price: 0, + currency: "$", + period: "month", + seatPrice: 12, + requiresSeats: true, + features: [], + highlights: ["Dedicated support", "Custom SLAs", "Advanced security"], + lookupKey: "selfhosted:enterprise:monthly", + }, + yearly: { + id: "enterprise-yearly", + name: "Enterprise", + price: 0, + currency: "$", + period: "year", + seatPrice: 120, + requiresSeats: true, + features: [], + highlights: ["Dedicated support", "Custom SLAs", "Advanced security"], + lookupKey: "selfhosted:enterprise:yearly", + }, + features: [], + highlights: ["Dedicated support", "Custom SLAs", "Advanced security"], + popular: false, +}; + +const meta = { + title: "Config/Plan/PlanCard", + component: PlanCard, + parameters: { layout: "padded" }, + args: { + planGroup: serverPlanGroup, + isCurrentTier: false, + isDowngrade: false, + onUpgradeClick: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** Free tier plan, shown as a permanently-included option. */ +export const FreePlan: Story = { + args: { + planGroup: freePlanGroup, + }, +}; + +/** Enterprise tier, priced per-seat and blocked until the Server plan is active. */ +export const EnterprisePlan: Story = { + args: { + planGroup: enterprisePlanGroup, + currentTier: "free", + }, +}; + +/** The plan the user already owns, showing the "Manage" action and seat count. */ +export const CurrentTier: Story = { + args: { + planGroup: serverPlanGroup, + isCurrentTier: true, + currentTier: "server", + currentLicenseInfo: { + licenseType: "SERVER", + enabled: true, + maxUsers: 25, + hasKey: true, + }, + onManageClick: () => {}, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsChart.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsChart.stories.tsx new file mode 100644 index 0000000000..e7fed1a275 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsChart.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import UsageAnalyticsChart from "@app/components/shared/config/configSections/usage/UsageAnalyticsChart"; + +/** + * Bar chart summarising endpoint usage counts. + */ +const meta: Meta = { + title: "Config/Usage/UsageAnalyticsChart", + component: UsageAnalyticsChart, + parameters: { layout: "padded" }, + args: { + data: [ + { label: "/api/v1/pdf/merge", value: 482 }, + { label: "/api/v1/pdf/split", value: 317 }, + { label: "/api/v1/pdf/compress", value: 210 }, + { label: "/api/v1/pdf/convert-to-pdf", value: 96 }, + ], + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** No usage data yet available. */ +export const Empty: Story = { + args: { data: [] }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.stories.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.stories.tsx new file mode 100644 index 0000000000..3d7d6e2e4f --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import UsageAnalyticsTable from "@app/components/shared/config/configSections/usage/UsageAnalyticsTable"; +import type { EndpointStatistic } from "@app/services/usageAnalyticsService"; + +const ENDPOINTS: EndpointStatistic[] = [ + { endpoint: "/api/v1/general/merge-pdfs", visits: 1284, percentage: 34.12 }, + { + endpoint: "/api/v1/security/remove-password", + visits: 842, + percentage: 22.36, + }, + { endpoint: "/api/v1/convert/pdf-to-word", visits: 601, percentage: 15.97 }, + { endpoint: "/api/v1/misc/compress-pdf", visits: 388, percentage: 10.31 }, +]; + +const meta = { + title: "Config/Usage/UsageAnalyticsTable", + component: UsageAnalyticsTable, + parameters: { layout: "padded" }, + args: { + data: ENDPOINTS, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +/** No usage recorded yet renders an empty-state row instead of the table body. */ +export const Empty: Story = { + args: { data: [] }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx new file mode 100644 index 0000000000..8d25b6b33e --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import StripeCheckout from "@app/components/shared/stripeCheckout/StripeCheckout"; +import type { PlanTierGroup } from "@app/services/licenseService"; + +const PLAN_GROUP: PlanTierGroup = { + tier: "server", + name: "Server", + monthly: { + id: "server-monthly", + name: "Server Monthly", + price: 29, + currency: "usd", + period: "monthly", + features: [], + highlights: ["Unlimited documents", "Priority support"], + lookupKey: "selfhosted:server:monthly", + }, + yearly: { + id: "server-yearly", + name: "Server Yearly", + price: 290, + currency: "usd", + period: "yearly", + features: [], + highlights: ["Unlimited documents", "Priority support", "2 months free"], + lookupKey: "selfhosted:server:yearly", + }, + features: [], + highlights: ["Unlimited documents", "Priority support"], +}; + +/** + * The multi-stage Stripe checkout modal (email -> plan selection -> payment -> success/error). + */ +const meta: Meta = { + title: "StripeCheckout/StripeCheckout", + component: StripeCheckout, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + planGroup: PLAN_GROUP, + minimumSeats: 1, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const HostedCheckoutSuccess: Story = { + args: { + hostedCheckoutSuccess: { + isUpgrade: false, + licenseKey: "STIRLING-XXXX-XXXX-XXXX", + }, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx new file mode 100644 index 0000000000..b69dc3d9bd --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PriceDisplay } from "@app/components/shared/stripeCheckout/components/PriceDisplay"; + +/** + * Renders a plan's price, either a simple single price or an enterprise + * base/seat/total breakdown. + */ +const meta = { + title: "StripeCheckout/PriceDisplay", + component: PriceDisplay, + parameters: { layout: "centered" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Simple: Story = { + args: { + mode: "simple", + price: 999, + currency: "usd", + period: "per month", + }, +}; + +export const Enterprise: Story = { + args: { + mode: "enterprise", + basePrice: 4999, + seatPrice: 1500, + currency: "usd", + period: "month", + }, +}; + +export const EnterpriseWithTotal: Story = { + args: { + mode: "enterprise", + basePrice: 4999, + seatPrice: 1500, + totalPrice: 19999, + currency: "usd", + period: "year", + seatCount: 10, + size: "lg", + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx new file mode 100644 index 0000000000..2b85605458 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PricingBadge } from "@app/components/shared/stripeCheckout/components/PricingBadge"; + +/** + * The small badge overlaid on pricing plan cards (current plan, popular, savings). + */ +const meta = { + title: "StripeCheckout/PricingBadge", + component: PricingBadge, + parameters: { layout: "centered" }, + args: { + type: "current", + label: "Current plan", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Current: Story = {}; + +export const Popular: Story = { + args: { + type: "popular", + label: "Most popular", + }, +}; + +export const Savings: Story = { + args: { + type: "savings", + label: "Save 20%", + savingsPercent: 20, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx new file mode 100644 index 0000000000..a428aca099 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage"; + +/** + * The email-collection step of the Stripe checkout flow. + */ +const meta = { + title: "StripeCheckout/EmailStage", + component: EmailStage, + parameters: { layout: "centered" }, + args: { + emailInput: "", + setEmailInput: () => {}, + emailError: "", + onSubmit: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Filled: Story = { + args: { + emailInput: "jane@example.com", + }, +}; + +export const WithError: Story = { + args: { + emailInput: "not-an-email", + emailError: "Please enter a valid email address", + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx new file mode 100644 index 0000000000..e0e7e082f8 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ErrorStage } from "@app/components/shared/stripeCheckout/stages/ErrorStage"; + +/** + * The error state shown when a Stripe checkout attempt fails. + */ +const meta = { + title: "StripeCheckout/ErrorStage", + component: ErrorStage, + parameters: { layout: "centered" }, + args: { + error: "Your card was declined. Please try a different payment method.", + onClose: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const NetworkError: Story = { + args: { + error: + "We couldn't reach the payment provider. Please check your connection and try again.", + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.stories.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.stories.tsx new file mode 100644 index 0000000000..8e928cfe3a --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PaymentStage } from "@app/components/shared/stripeCheckout/stages/PaymentStage"; +import type { PlanTier } from "@app/services/licenseService"; + +const serverPlan: PlanTier = { + id: "server-monthly", + name: "Server", + price: 29, + currency: "£", + period: "month", + features: [], + highlights: [], + lookupKey: "selfhosted:server:monthly", +}; + +/** + * The payment step of the Stripe checkout flow. Renders a loading state while + * the checkout session is being prepared, then hands off to Stripe's + * embedded checkout once a client secret is available. + */ +const meta = { + title: "StripeCheckout/PaymentStage", + component: PaymentStage, + parameters: { layout: "centered" }, + args: { + clientSecret: null, + selectedPlan: null, + onPaymentComplete: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Redirecting: Story = { + args: { + clientSecret: "demo-client-secret-xxxx", + selectedPlan: serverPlan, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx new file mode 100644 index 0000000000..c37e51ed53 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx @@ -0,0 +1,103 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PlanSelectionStage } from "@app/components/shared/stripeCheckout/stages/PlanSelectionStage"; +import { PlanTierGroup } from "@app/services/licenseService"; + +/** + * The plan-selection step of the Stripe checkout flow, letting the user + * choose between monthly and yearly billing for the selected plan tier. + */ +const serverPlanGroup: PlanTierGroup = { + tier: "server", + name: "Server", + monthly: { + id: "server-monthly", + name: "Server", + price: 29, + currency: "£", + period: "month", + features: [], + highlights: [], + lookupKey: "selfhosted:server:monthly", + }, + yearly: { + id: "server-yearly", + name: "Server", + price: 290, + currency: "£", + period: "year", + features: [], + highlights: [], + lookupKey: "selfhosted:server:yearly", + }, + features: [], + highlights: [], +}; + +const enterprisePlanGroup: PlanTierGroup = { + tier: "enterprise", + name: "Enterprise", + monthly: { + id: "enterprise-monthly", + name: "Enterprise", + price: 499, + currency: "£", + period: "month", + features: [], + highlights: [], + seatPrice: 15, + requiresSeats: true, + lookupKey: "selfhosted:enterprise:monthly", + }, + yearly: { + id: "enterprise-yearly", + name: "Enterprise", + price: 4999, + currency: "£", + period: "year", + features: [], + highlights: [], + seatPrice: 150, + requiresSeats: true, + lookupKey: "selfhosted:enterprise:yearly", + }, + features: [], + highlights: [], +}; + +const meta = { + title: "StripeCheckout/PlanSelectionStage", + component: PlanSelectionStage, + args: { + planGroup: serverPlanGroup, + minimumSeats: 1, + savings: null, + onSelectPlan: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithSavings: Story = { + args: { + savings: { + amount: 58, + percent: 20, + currency: "£", + }, + }, +}; + +export const Enterprise: Story = { + args: { + planGroup: enterprisePlanGroup, + minimumSeats: 5, + savings: { + amount: 900, + percent: 15, + currency: "£", + }, + }, +}; diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx new file mode 100644 index 0000000000..a02c9caa5c --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SuccessStage } from "@app/components/shared/stripeCheckout/stages/SuccessStage"; + +/** + * The success state shown after a Stripe checkout completes, including + * license key polling and reveal. + */ +const meta = { + title: "StripeCheckout/SuccessStage", + component: SuccessStage, + parameters: { layout: "centered" }, + args: { + pollingStatus: "ready", + currentLicenseKey: null, + licenseKey: "STIRLING-XXXX-XXXX-XXXX-XXXX", + onClose: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Polling: Story = { + args: { + pollingStatus: "polling", + currentLicenseKey: null, + licenseKey: null, + }, +}; + +export const UpgradeComplete: Story = { + args: { + pollingStatus: "ready", + currentLicenseKey: "STIRLING-EXISTING-KEY", + licenseKey: null, + }, +}; + +export const Timeout: Story = { + args: { + pollingStatus: "timeout", + currentLicenseKey: null, + licenseKey: null, + }, +}; diff --git a/frontend/editor/src/proprietary/components/workflow/ParticipantView.stories.tsx b/frontend/editor/src/proprietary/components/workflow/ParticipantView.stories.tsx new file mode 100644 index 0000000000..0fdd4ab393 --- /dev/null +++ b/frontend/editor/src/proprietary/components/workflow/ParticipantView.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import ParticipantView from "@app/components/workflow/ParticipantView"; +import type { + ParticipantResponse, + WorkflowSessionResponse, +} from "@app/services/workflowService"; + +/** + * The signing hook (`useParticipantSession`) fires two GETs on mount — + * session + participant details — so every story mocks both via MSW rather + * than passing data through props (the component only accepts a `token`). + */ +function sessionHandlers( + session: WorkflowSessionResponse, + participant: ParticipantResponse, +) { + return [ + http.get("/api/v1/workflow/participant/session", () => + HttpResponse.json(session), + ), + http.get("/api/v1/workflow/participant/details", () => + HttpResponse.json(participant), + ), + ]; +} + +const baseSession: WorkflowSessionResponse = { + sessionId: "session-1", + ownerId: 1, + ownerUsername: "alex", + workflowType: "SIGNING", + documentName: "vendor-agreement.pdf", + message: "Please sign by end of week, thanks!", + dueDate: "2026-07-20T00:00:00Z", + status: "IN_PROGRESS", + finalized: false, + createdAt: "2026-07-10T09:00:00Z", + updatedAt: "2026-07-10T09:00:00Z", + participants: [], + hasProcessedFile: false, +}; + +const baseParticipant: ParticipantResponse = { + id: 1, + email: "jordan@example.com", + name: "Jordan", + status: "NOTIFIED", + shareToken: null, + accessRole: "EDITOR", + lastUpdated: "2026-07-10T09:00:00Z", + hasCompleted: false, + isExpired: false, +}; + +const meta = { + title: "Workflow/ParticipantView", + component: ParticipantView, + parameters: { layout: "padded" }, + args: { + token: "story-participant-token", + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Awaiting signature: the certificate + signing form is shown. */ +export const Default: Story = { + parameters: { + msw: { handlers: sessionHandlers(baseSession, baseParticipant) }, + }, +}; + +/** Participant already signed: the form is replaced with a completion banner. */ +export const Completed: Story = { + parameters: { + msw: { + handlers: sessionHandlers( + { ...baseSession, status: "COMPLETED", finalized: true }, + { ...baseParticipant, status: "SIGNED", hasCompleted: true }, + ), + }, + }, +}; + +/** The participant's access window has passed: signing is blocked. */ +export const Expired: Story = { + parameters: { + msw: { + handlers: sessionHandlers(baseSession, { + ...baseParticipant, + status: "PENDING", + isExpired: true, + expiresAt: "2026-07-01T00:00:00Z", + }), + }, + }, +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f467cd4e47..86c998e997 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -94,6 +94,7 @@ "@storybook/addon-a11y": "^9.1.20", "@storybook/addon-docs": "^9.1.20", "@storybook/addon-themes": "^9.1.20", + "@storybook/addon-vitest": "^9.1.20", "@storybook/react-vite": "^9.1.20", "@tauri-apps/cli": "^2.9.6", "@testing-library/dom": "^10.4.1", @@ -112,6 +113,7 @@ "@typescript-eslint/parser": "^8.61.1", "@typescript/native": "npm:typescript@^7.0.2", "@vitejs/plugin-react-swc": "^4.1.0", + "@vitest/browser": "^3.2.6", "@vitest/coverage-v8": "^3.2.4", "dotenv": "^16.4.7", "dpdm": "^3.14.0", @@ -2962,6 +2964,13 @@ "node": ">=18" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -3609,6 +3618,44 @@ "storybook": "^9.1.20" } }, + "node_modules/@storybook/addon-vitest": { + "version": "9.1.20", + "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-9.1.20.tgz", + "integrity": "sha512-6zN/qe9Z/7pklbUQJrnjwdTTqRG5PWy4Tyx1J90nwQq6yekj436S8/5NrvXaSns0zYNoZLsynh8nWZPweeu05w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/icons": "^1.4.0", + "prompts": "^2.4.0", + "ts-dedent": "^2.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@vitest/browser": "^3.0.0 || ^4.0.0", + "@vitest/browser-playwright": "^4.0.0", + "@vitest/runner": "^3.0.0 || ^4.0.0", + "storybook": "^9.1.20", + "vitest": "^3.0.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/runner": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, "node_modules/@storybook/builder-vite": { "version": "9.1.20", "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-9.1.20.tgz", @@ -6200,6 +6247,110 @@ "vite": "^4 || ^5 || ^6 || ^7 || ^8" } }, + "node_modules/@vitest/browser": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.6.tgz", + "integrity": "sha512-CNjSynGBtAVOMTfQITv6Bc8da4/XTU1izorocbDStjUsynXcgx2FHVssh+10a8bKd/BxoqDdQtuSbYHfk302Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/user-event": "^14.6.1", + "@vitest/mocker": "3.2.6", + "@vitest/utils": "3.2.6", + "magic-string": "^0.30.17", + "sirv": "^3.0.1", + "tinyrainbow": "^2.0.0", + "ws": "^8.18.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "3.2.6", + "webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/browser/node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/coverage-v8": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.6.tgz", @@ -10724,6 +10875,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -12297,6 +12458,16 @@ "ufo": "^1.6.3" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -13463,6 +13634,20 @@ "node": ">=0.4.0" } }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -14822,6 +15007,28 @@ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "license": "MIT" }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -16194,6 +16401,16 @@ "node": ">=8.0" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 06059a3962..404366383a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -116,6 +116,7 @@ "@storybook/addon-a11y": "^9.1.20", "@storybook/addon-docs": "^9.1.20", "@storybook/addon-themes": "^9.1.20", + "@storybook/addon-vitest": "^9.1.20", "@storybook/react-vite": "^9.1.20", "@tauri-apps/cli": "^2.9.6", "@testing-library/dom": "^10.4.1", @@ -134,6 +135,7 @@ "@typescript-eslint/parser": "^8.61.1", "@typescript/native": "npm:typescript@^7.0.2", "@vitejs/plugin-react-swc": "^4.1.0", + "@vitest/browser": "^3.2.6", "@vitest/coverage-v8": "^3.2.4", "dotenv": "^16.4.7", "dpdm": "^3.14.0", diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000000..f9b138bc97 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,7 @@ +// Root Vitest config so the Storybook addon-vitest Testing UI (and a bare +// `vitest` run from frontend/) discovers the Storybook browser-test project. +// The project definition lives in .storybook/vitest.config.ts; this re-exports +// it from the conventional root location. The editor unit tests are separate +// (editor/vitest.config.ts, run with `vitest --root editor`). +// eslint-disable-next-line no-restricted-imports -- config re-export; no @-alias covers .storybook/ +export { default } from "./.storybook/vitest.config";