diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 2b95e9d515..ef7989e4cd 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -1460,14 +1460,14 @@ inactive = "Inactive" [adminOnboarding] adminTools = "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform." -configButton = "Click the Config button to access all system settings and administrative controls." +configButton = "Open Settings to access all system configuration and administrative controls." connectionsSection = "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications." databaseSection = "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure." settingsOverview = "This is the Settings Panel. Admin settings are organised by category for easy navigation." systemCustomization = "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users." teamsAndUsers = "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself." welcome = "Welcome to the Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators." -wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the Help menu." +wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime — just open Settings and find it here in the Tours section under Help." [adminUserSettings] actions = "Actions" @@ -4892,7 +4892,7 @@ toolInterface = "This is the Crop tool interface. As you can se viewer = "The Viewer lets you read and annotate your PDFs." viewSwitcher = "Use these controls to select how you want to view your PDFs." workbench = "This is the Workbench - the main area where you view and edit your PDFs." -wrapUp = "You're all set! You've learnt about the main areas of the app and how to use them. Click the Help button whenever you like to see this tour again." +wrapUp = "You're all set! You can replay this tour anytime — just open Settings and find it here in the Tours section under Help." [onboarding.buttons] back = "Back" diff --git a/frontend/src/core/components/fileEditor/FileEditorStatusDot.tsx b/frontend/src/core/components/fileEditor/FileEditorStatusDot.tsx new file mode 100644 index 0000000000..d28ee13681 --- /dev/null +++ b/frontend/src/core/components/fileEditor/FileEditorStatusDot.tsx @@ -0,0 +1,9 @@ +import { StirlingFileStub } from "@app/types/fileContext"; + +interface FileEditorStatusDotProps { + file: StirlingFileStub; +} + +export function FileEditorStatusDot(_props: FileEditorStatusDotProps) { + return null; +} diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css b/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css index d09f142524..fcd0335238 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css @@ -196,6 +196,26 @@ pointer-events: none; } +.thumbBadgesRight { + position: absolute; + top: 6px; + right: 6px; + display: flex; + gap: 4px; + align-items: center; + z-index: 3; + pointer-events: auto; +} + +.statusDot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.15); +} + .ownershipBadge { background: var(--mantine-color-blue-6); color: white; diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index 50cf8ddc59..0b78d4e261 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -27,7 +27,6 @@ import { } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { StirlingFileStub } from "@app/types/fileContext"; import { zipFileService } from "@app/services/zipFileService"; -import { truncateCenter } from "@app/utils/textUtils"; import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css"; import { useFileContext } from "@app/contexts/FileContext"; @@ -44,6 +43,8 @@ import ShareFileModal from "@app/components/shared/ShareFileModal"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useFileThumbnail } from "@app/hooks/useFileThumbnail"; import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail"; +import { truncateCenter } from "@app/utils/textUtils"; +import { FileEditorStatusDot } from "@app/components/fileEditor/FileEditorStatusDot"; interface FileEditorThumbnailProps { file: StirlingFileStub; @@ -303,6 +304,7 @@ const FileEditorThumbnail = ({ label: isPinned ? t("unpin", "Unpin File (replace after tool run)") : t("pin", "Pin File (keep active after tool run)"), + dataTour: "file-card-pin", onClick: (e) => { e.stopPropagation(); if (actualFile) { @@ -504,7 +506,7 @@ const FileEditorThumbnail = ({ }} /> - {/* Badges — visible on hover via CSS */} + {/* Badges — top-left: version, pin, ownership, encrypted */}
v{file.versionNumber} @@ -541,6 +543,8 @@ const FileEditorThumbnail = ({ )}
+ + {/* end thumbUnit */} diff --git a/frontend/src/core/components/onboarding/Onboarding.tsx b/frontend/src/core/components/onboarding/Onboarding.tsx index d215c0e734..c84d21f976 100644 --- a/frontend/src/core/components/onboarding/Onboarding.tsx +++ b/frontend/src/core/components/onboarding/Onboarding.tsx @@ -232,12 +232,15 @@ export default function Onboarding() { loadSampleFile: tourOrch.loadSampleFile, switchToActiveFiles: tourOrch.switchToActiveFiles, pinFile: tourOrch.pinFile, + revealFileCardHoverMenu: tourOrch.revealFileCardHoverMenu, modifyCropSettings: tourOrch.modifyCropSettings, executeTool: tourOrch.executeTool, openFilesModal, + openSettingsHelpSection: () => + adminTourOrch.navigateToSection("help"), }, }), - [t, tourOrch, closeFilesModal, openFilesModal], + [t, tourOrch, adminTourOrch, closeFilesModal, openFilesModal], ); const whatsNewStepsConfig = useMemo( diff --git a/frontend/src/core/components/onboarding/adminStepsConfig.ts b/frontend/src/core/components/onboarding/adminStepsConfig.ts index 4d33abb57c..1d8083201c 100644 --- a/frontend/src/core/components/onboarding/adminStepsConfig.ts +++ b/frontend/src/core/components/onboarding/adminStepsConfig.ts @@ -4,6 +4,10 @@ import { addGlowToElements, removeAllGlows, } from "@app/components/onboarding/tourGlow"; +import { + waitForElement, + waitForHighlightable, +} from "@app/components/onboarding/tourUtils"; export enum AdminTourStep { WELCOME, @@ -57,7 +61,7 @@ export function createAdminStepsConfig({ selector: '[data-tour="config-button"]', content: t( "adminOnboarding.configButton", - "Click the Config button to access all system settings and administrative controls.", + "Open Settings to access all system configuration and administrative controls.", ), position: "right", padding: 10, @@ -204,15 +208,18 @@ export function createAdminStepsConfig({ }, }, [AdminTourStep.WRAP_UP]: { - selector: '[data-tour="help-button"]', + selector: '[data-tour="admin-help-nav"]', content: t( "adminOnboarding.wrapUp", - "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the Help menu.", + "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime — just open Settings and find it here in the Tours section under Help.", ), position: "right", padding: 10, - action: () => { + action: async () => { removeAllGlows(); + navigateToSection("help"); + await waitForElement('[data-tour="admin-help-nav"]', 5000); + await waitForHighlightable('[data-tour="admin-help-nav"]', 5000); }, }, }; diff --git a/frontend/src/core/components/onboarding/tourUtils.ts b/frontend/src/core/components/onboarding/tourUtils.ts new file mode 100644 index 0000000000..e6d8828d39 --- /dev/null +++ b/frontend/src/core/components/onboarding/tourUtils.ts @@ -0,0 +1,110 @@ +/** + * Waits for a CSS selector to appear in the DOM using MutationObserver. + * Resolves immediately if already present; resolves after timeoutMs if it + * never appears (no throw — tour steps are best-effort). + */ +export function waitForElement( + selector: string, + timeoutMs = 7000, +): Promise { + return new Promise((resolve) => { + if (typeof document === "undefined") { + resolve(); + return; + } + + if (document.querySelector(selector)) { + resolve(); + return; + } + + const observer = new MutationObserver(() => { + if (document.querySelector(selector)) { + clearTimeout(timer); + observer.disconnect(); + resolve(); + } + }); + + const timer = setTimeout(() => { + observer.disconnect(); + resolve(); + }, timeoutMs); + + observer.observe(document.body, { childList: true, subtree: true }); + }); +} + +const nudgeReactour = () => { + window.dispatchEvent(new Event("resize")); + requestAnimationFrame(() => window.dispatchEvent(new Event("resize"))); +}; + +/** + * Waits for a CSS selector to be present AND have a non-zero bounding box, + * then nudges Reactour to recalculate its spotlight position. + * + * Uses MutationObserver to detect element insertion, then ResizeObserver to + * detect when the element receives layout dimensions. + */ +export function waitForHighlightable( + selector: string, + timeoutMs = 7000, +): Promise { + return new Promise((resolve) => { + if (typeof document === "undefined") { + resolve(); + return; + } + + let mutationObserver: MutationObserver | null = null; + let resizeObserver: ResizeObserver | null = null; + + const cleanup = () => { + mutationObserver?.disconnect(); + resizeObserver?.disconnect(); + }; + + const done = () => { + clearTimeout(timer); + cleanup(); + nudgeReactour(); + resolve(); + }; + + const timer = setTimeout(done, timeoutMs); + + const watchLayout = (el: HTMLElement) => { + if (el.getClientRects().length > 0) { + done(); + return; + } + resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + if (entry.contentRect.width > 0 || entry.contentRect.height > 0) { + done(); + return; + } + } + }); + resizeObserver.observe(el); + }; + + const el = document.querySelector(selector); + if (el) { + watchLayout(el); + return; + } + + mutationObserver = new MutationObserver(() => { + const found = document.querySelector(selector); + if (found) { + mutationObserver!.disconnect(); + mutationObserver = null; + watchLayout(found); + } + }); + + mutationObserver.observe(document.body, { childList: true, subtree: true }); + }); +} diff --git a/frontend/src/core/components/onboarding/userStepsConfig.ts b/frontend/src/core/components/onboarding/userStepsConfig.ts index f5805cbf68..e8a52803da 100644 --- a/frontend/src/core/components/onboarding/userStepsConfig.ts +++ b/frontend/src/core/components/onboarding/userStepsConfig.ts @@ -1,5 +1,9 @@ import type { StepType } from "@reactour/tour"; import type { TFunction } from "i18next"; +import { + waitForElement, + waitForHighlightable, +} from "@app/components/onboarding/tourUtils"; export enum TourStep { ALL_TOOLS, @@ -26,9 +30,11 @@ interface UserStepActions { loadSampleFile: () => void; switchToActiveFiles: () => void; pinFile: () => void; + revealFileCardHoverMenu: () => void; modifyCropSettings: () => void; executeTool: () => void; openFilesModal: () => void; + openSettingsHelpSection: () => void; } interface CreateUserStepsConfigArgs { @@ -48,9 +54,11 @@ export function createUserStepsConfig({ loadSampleFile, switchToActiveFiles, pinFile, + revealFileCardHoverMenu, modifyCropSettings, executeTool, openFilesModal, + openSettingsHelpSection, } = actions; return { @@ -95,7 +103,7 @@ export function createUserStepsConfig({ ), position: "right", padding: 10, - action: () => openFilesModal(), + actionAfter: () => openFilesModal(), }, [TourStep.FILE_SOURCES]: { selector: '[data-tour="file-sources"]', @@ -105,6 +113,10 @@ export function createUserStepsConfig({ ), position: "right", padding: 0, + action: async () => { + await waitForElement('[data-tour="file-sources"]', 5000); + await waitForHighlightable('[data-tour="file-sources"]', 5000); + }, actionAfter: () => { loadSampleFile(); closeFilesModal(); @@ -184,16 +196,22 @@ export function createUserStepsConfig({ ), position: "left", padding: 10, - action: () => pinFile(), + action: () => revealFileCardHoverMenu(), + actionAfter: () => pinFile(), }, [TourStep.WRAP_UP]: { - selector: '[data-tour="help-button"]', + selector: '[data-tour="admin-help-nav"]', content: t( "onboarding.wrapUp", - "You're all set! You've learnt about the main areas of the app and how to use them. Click the Help button whenever you like to see this tour again.", + "You're all set! You can replay this tour anytime — just open Settings and find it here in the Tours section under Help.", ), position: "right", padding: 10, + action: async () => { + openSettingsHelpSection(); + await waitForElement('[data-tour="admin-help-nav"]', 5000); + await waitForHighlightable('[data-tour="admin-help-nav"]', 5000); + }, }, }; } diff --git a/frontend/src/core/components/onboarding/whatsNewStepsConfig.ts b/frontend/src/core/components/onboarding/whatsNewStepsConfig.ts index 4501d89739..5e52ac7549 100644 --- a/frontend/src/core/components/onboarding/whatsNewStepsConfig.ts +++ b/frontend/src/core/components/onboarding/whatsNewStepsConfig.ts @@ -1,52 +1,9 @@ import type { StepType } from "@reactour/tour"; import type { TFunction } from "i18next"; - -async function waitForElement( - selector: string, - timeoutMs = 7000, - intervalMs = 100, -): Promise { - if (typeof document === "undefined") return; - const start = Date.now(); - // Immediate hit - if (document.querySelector(selector)) return; - - return new Promise((resolve) => { - const check = () => { - if (document.querySelector(selector) || Date.now() - start >= timeoutMs) { - resolve(); - return; - } - setTimeout(check, intervalMs); - }; - check(); - }); -} - -async function waitForHighlightable( - selector: string, - timeoutMs = 7000, - intervalMs = 500, -): Promise { - if (typeof document === "undefined") return; - const start = Date.now(); - - return new Promise((resolve) => { - const check = () => { - const el = document.querySelector(selector); - const isVisible = !!el && el.getClientRects().length > 0; - if (isVisible || Date.now() - start >= timeoutMs) { - // Nudge Reactour to recalc positions in case layout shifted - window.dispatchEvent(new Event("resize")); - requestAnimationFrame(() => window.dispatchEvent(new Event("resize"))); - resolve(); - return; - } - setTimeout(check, intervalMs); - }; - check(); - }); -} +import { + waitForElement, + waitForHighlightable, +} from "@app/components/onboarding/tourUtils"; export enum WhatsNewTourStep { QUICK_ACCESS, @@ -124,17 +81,15 @@ export function createWhatsNewStepsConfig({ ), position: "right", padding: 10, - action: async () => { - openFilesModal(); - await waitForElement('[data-tour="file-sources"]', 5000, 100); - }, actionAfter: async () => { + openFilesModal(); + await waitForElement('[data-tour="file-sources"]', 5000); await Promise.resolve(loadSampleFile()); closeFilesModal(); switchToViewer(); // wait for file render and top controls to mount - await waitForElement('[data-tour="view-switcher"]', 7000, 100); - await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500); + await waitForElement('[data-tour="view-switcher"]', 7000); + await waitForHighlightable('[data-tour="view-switcher"]', 7000); }, }, [WhatsNewTourStep.RIGHT_RAIL]: { @@ -150,7 +105,7 @@ export function createWhatsNewStepsConfig({ position: "left", padding: 10, action: async () => { - await waitForElement('[data-tour="right-rail-controls"]', 7000, 100); + await waitForElement('[data-tour="right-rail-controls"]', 7000); selectFirstFile(); }, }, @@ -165,8 +120,8 @@ export function createWhatsNewStepsConfig({ // Ensure the switcher has mounted before this step renders action: async () => { switchToViewer(); - await waitForElement('[data-tour="view-switcher"]', 7000, 100); - await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500); + await waitForElement('[data-tour="view-switcher"]', 7000); + await waitForHighlightable('[data-tour="view-switcher"]', 7000); }, }, [WhatsNewTourStep.PAGE_EDITOR_VIEW]: { @@ -179,8 +134,8 @@ export function createWhatsNewStepsConfig({ padding: 8, action: async () => { switchToPageEditor(); - await waitForElement('[data-tour="view-switcher"]', 7000, 100); - await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500); + await waitForElement('[data-tour="view-switcher"]', 7000); + await waitForHighlightable('[data-tour="view-switcher"]', 7000); }, }, [WhatsNewTourStep.ACTIVE_FILES_VIEW]: { @@ -193,8 +148,8 @@ export function createWhatsNewStepsConfig({ padding: 8, action: async () => { switchToActiveFiles(); - await waitForElement('[data-tour="view-switcher"]', 7000, 100); - await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500); + await waitForElement('[data-tour="view-switcher"]', 7000); + await waitForHighlightable('[data-tour="view-switcher"]', 7000); }, }, [WhatsNewTourStep.WRAP_UP]: { diff --git a/frontend/src/core/components/shared/AppConfigModal.tsx b/frontend/src/core/components/shared/AppConfigModal.tsx index 765d099416..142c29314e 100644 --- a/frontend/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/src/core/components/shared/AppConfigModal.tsx @@ -108,11 +108,26 @@ const AppConfigModalInner: React.FC = ({ const runningEE = config?.runningEE ?? false; const loginEnabled = config?.enableLogin ?? false; + const handleClose = useCallback(async () => { + const canProceed = await confirmIfDirty(); + if (!canProceed) return; + + // Navigate back to home when closing modal + navigate("/", { replace: true }); + onClose(); + }, [confirmIfDirty, navigate, onClose]); + + // Synchronous wrapper for contexts (e.g. tour buttons) that need () => void + const handleCloseSync = useCallback(() => { + void handleClose(); + }, [handleClose]); + // Left navigation structure and icons const configNavSections = useConfigNavSections( isAdmin, runningEE, loginEnabled, + handleCloseSync, ); const activeLabel = useMemo(() => { @@ -131,15 +146,6 @@ const AppConfigModalInner: React.FC = ({ return null; }, [configNavSections, active]); - const handleClose = useCallback(async () => { - const canProceed = await confirmIfDirty(); - if (!canProceed) return; - - // Navigate back to home when closing modal - navigate("/", { replace: true }); - onClose(); - }, [confirmIfDirty, navigate, onClose]); - const handleNavigation = useCallback( async (key: NavKey) => { const canProceed = await confirmIfDirty(); diff --git a/frontend/src/core/components/shared/FileSidebar.tsx b/frontend/src/core/components/shared/FileSidebar.tsx index 54f8d4b7ac..bcf4f9666e 100644 --- a/frontend/src/core/components/shared/FileSidebar.tsx +++ b/frontend/src/core/components/shared/FileSidebar.tsx @@ -316,6 +316,7 @@ const FileSidebar = forwardRef( style={{ width, minWidth: width, maxWidth: width }} data-collapsed={collapsed} data-sidebar="file-sidebar" + data-tour="quick-access-bar" >
{/* Header: hamburger + branding */} @@ -392,6 +393,7 @@ const FileSidebar = forwardRef(
{ if (collapsed && onToggleCollapse) onToggleCollapse(); openFilesModal(); @@ -555,6 +557,7 @@ const FileSidebar = forwardRef( : undefined } data-testid={onOpenSettings ? "config-button" : undefined} + data-tour={onOpenSettings ? "config-button" : undefined} aria-label={ onOpenSettings ? t("fileSidebar.openSettings", "Open settings") diff --git a/frontend/src/core/components/shared/HoverActionMenu.tsx b/frontend/src/core/components/shared/HoverActionMenu.tsx index e617d3c792..9bec8bc600 100644 --- a/frontend/src/core/components/shared/HoverActionMenu.tsx +++ b/frontend/src/core/components/shared/HoverActionMenu.tsx @@ -11,6 +11,7 @@ export interface HoverAction { disabled?: boolean; color?: string; hidden?: boolean; + dataTour?: string; } interface HoverActionMenuProps { @@ -64,6 +65,7 @@ const HoverActionMenu: React.FC = ({ onClick={action.onClick} c={action.color} style={{ color: action.color || "var(--right-rail-icon)" }} + data-tour={action.dataTour} > {action.icon} diff --git a/frontend/src/core/components/shared/InfoBanner.tsx b/frontend/src/core/components/shared/InfoBanner.tsx index 0e3b521daa..50a1cba225 100644 --- a/frontend/src/core/components/shared/InfoBanner.tsx +++ b/frontend/src/core/components/shared/InfoBanner.tsx @@ -97,7 +97,11 @@ export const InfoBanner: React.FC = ({ radius={0} style={{ background: background ?? toneStyle.background, - borderBottom: `1px solid ${borderColor ?? toneStyle.border}`, + border: "none", + borderBottom: + borderColor === "transparent" + ? "none" + : `1px solid ${borderColor ?? toneStyle.border}`, minHeight, display: "flex", alignItems: "center", diff --git a/frontend/src/core/components/shared/config/configNavSections.tsx b/frontend/src/core/components/shared/config/configNavSections.tsx index d9b9f56d83..68ed7285dd 100644 --- a/frontend/src/core/components/shared/config/configNavSections.tsx +++ b/frontend/src/core/components/shared/config/configNavSections.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { NavKey } from "@app/components/shared/config/types"; import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection"; import GeneralSection from "@app/components/shared/config/configSections/GeneralSection"; +import HelpSection from "@app/components/shared/config/configSections/HelpSection"; export interface ConfigNavItem { key: NavKey; @@ -34,6 +35,7 @@ export const useConfigNavSections = ( _isAdmin: boolean = false, _runningEE: boolean = false, _loginEnabled: boolean = false, + onRequestClose: () => void = () => {}, ): ConfigNavSection[] => { const { t } = useTranslation(); @@ -55,6 +57,19 @@ export const useConfigNavSections = ( }, ], }, + { + title: t("settings.help.title", "Help"), + items: [ + { + key: "help", + label: t("settings.help.label", "Tours"), + icon: "help-rounded", + component: ( + + ), + }, + ], + }, ]; return sections; diff --git a/frontend/src/core/components/shared/config/configSections/HelpSection.tsx b/frontend/src/core/components/shared/config/configSections/HelpSection.tsx new file mode 100644 index 0000000000..89299fe3d0 --- /dev/null +++ b/frontend/src/core/components/shared/config/configSections/HelpSection.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { Button, Group, Paper, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { requestStartTour } from "@app/constants/events"; + +interface HelpSectionProps { + isAdmin: boolean; + onRequestClose: () => void; +} + +const HelpSection: React.FC = ({ + isAdmin, + onRequestClose, +}) => { + const { t } = useTranslation(); + + const startTour = (tourType: "tools" | "admin") => { + onRequestClose(); + setTimeout(() => requestStartTour(tourType), 300); + }; + + return ( + + + + +
+ + {t("settings.help.toolsTour.title", "Tools Tour")} + + + {t( + "settings.help.toolsTour.description", + "Walk through uploading files, picking a tool, and reviewing results.", + )} + +
+ +
+ + {isAdmin && ( + +
+ + {t("settings.help.adminTour.title", "Admin Tour")} + + + {t( + "settings.help.adminTour.description", + "Explore team management, system settings, and enterprise features.", + )} + +
+ +
+ )} +
+
+
+ ); +}; + +export default HelpSection; diff --git a/frontend/src/core/components/shared/config/types.ts b/frontend/src/core/components/shared/config/types.ts index 6a22ed3112..110c7801ee 100644 --- a/frontend/src/core/components/shared/config/types.ts +++ b/frontend/src/core/components/shared/config/types.ts @@ -30,6 +30,7 @@ export const VALID_NAV_KEYS = [ "adminUsage", "adminEndpoints", "adminStorageSharing", + "help", ] as const; // Derive the type from the array diff --git a/frontend/src/core/contexts/TourOrchestrationContext.tsx b/frontend/src/core/contexts/TourOrchestrationContext.tsx index 1a338b6b59..eca4229755 100644 --- a/frontend/src/core/contexts/TourOrchestrationContext.tsx +++ b/frontend/src/core/contexts/TourOrchestrationContext.tsx @@ -30,6 +30,7 @@ interface TourOrchestrationContextType { // File operations selectFirstFile: () => void; pinFile: () => void; + revealFileCardHoverMenu: () => void; // Crop settings (placeholder for now) modifyCropSettings: () => void; @@ -192,6 +193,20 @@ export const TourOrchestrationProvider: React.FC<{ window.dispatchEvent(event); }, []); + const revealFileCardHoverMenu = useCallback(() => { + // The hover menu is hidden via CSS until the card is hovered. + // Override data-force-visible so the CSS rule makes it opacity:1 / pointer-events:auto, + // then nudge Reactour to re-measure the now-visible pin button. + const menu = document.querySelector( + '[data-hover-action-menu-mode="cssHover"]', + ) as HTMLElement | null; + if (menu) { + menu.setAttribute("data-force-visible", "true"); + window.dispatchEvent(new Event("resize")); + requestAnimationFrame(() => window.dispatchEvent(new Event("resize"))); + } + }, []); + const executeTool = useCallback(() => { // Trigger the run button click const runButton = document.querySelector( @@ -213,6 +228,7 @@ export const TourOrchestrationProvider: React.FC<{ switchToActiveFiles, selectFirstFile, pinFile, + revealFileCardHoverMenu, modifyCropSettings, executeTool, }; diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx index 11b9659687..dd2d88bd14 100644 --- a/frontend/src/core/pages/HomePage.tsx +++ b/frontend/src/core/pages/HomePage.tsx @@ -15,6 +15,7 @@ import { useNavigationActions, } from "@app/contexts/NavigationContext"; import { useViewer } from "@app/contexts/ViewerContext"; +import { useLocation } from "react-router-dom"; import AppsIcon from "@mui/icons-material/AppsRounded"; import ToolPanel from "@app/components/tools/ToolPanel"; @@ -57,6 +58,14 @@ export default function HomePage() { const isProgrammaticScroll = useRef(false); const [configModalOpen, setConfigModalOpen] = useState(false); const [fileSidebarCollapsed, setFileSidebarCollapsed] = useState(false); + const location = useLocation(); + + // Open the config modal whenever the URL is /settings/* (e.g. from the admin + // tour's openConfigModal action which navigates to /settings/overview). + useEffect(() => { + const isSettings = location.pathname.startsWith("/settings"); + setConfigModalOpen(isSettings); + }, [location.pathname]); const { activeFiles } = useFileContext(); const navigationState = useNavigationState(); diff --git a/frontend/src/core/tests/helpers/api-stubs.ts b/frontend/src/core/tests/helpers/api-stubs.ts index 4dd0286b1d..b18653d88d 100644 --- a/frontend/src/core/tests/helpers/api-stubs.ts +++ b/frontend/src/core/tests/helpers/api-stubs.ts @@ -118,6 +118,8 @@ const DEFAULT_ENDPOINTS_AVAILABILITY = Object.fromEntries( export interface MockAppApiOptions { /** Override `enableLogin`. Default `false` — app loads in anonymous mode. */ enableLogin?: boolean; + /** Override `isAdmin` in app-config. Default `false`. */ + isAdmin?: boolean; /** Override the logged-in user returned by `/auth/me`. */ user?: { id?: number; @@ -145,6 +147,7 @@ export async function mockAppApis( ): Promise { const { enableLogin = false, + isAdmin = false, user = { id: 1, username: "testuser", @@ -167,6 +170,7 @@ export async function mockAppApis( route.fulfill({ json: { enableLogin, + isAdmin, languages, defaultLocale, }, diff --git a/frontend/src/core/tests/stubbed/tour-onboarding.spec.ts b/frontend/src/core/tests/stubbed/tour-onboarding.spec.ts index 9e5f5ad856..fb0c438285 100644 --- a/frontend/src/core/tests/stubbed/tour-onboarding.spec.ts +++ b/frontend/src/core/tests/stubbed/tour-onboarding.spec.ts @@ -1,73 +1,232 @@ +import path from "path"; import { test, expect } from "@app/tests/helpers/stub-test-base"; +import { uploadFiles, openSettings } from "@app/tests/helpers/ui-helpers"; -test.describe("15. Tour/Onboarding", () => { - test.describe("15.1 Tour Button", () => { - test("should start and dismiss tour guide", async ({ page }) => { - // The help/tours button was in the old QuickAccessBar which is not rendered - // in the redesigned UI (replaced by FileSidebar). Skip until a tour entry - // point is added to the new layout. - test.skip( - true, - "Help/tour button not yet present in redesigned FileSidebar UI", - ); +/** + * Tour selector integrity tests. + * + * Each test asserts that a `data-tour="…"` element referenced by one of the + * three guided tours (user, admin, whats-new) is actually present in the DOM + * at the point in the UI where the tour step would fire. If an element is + * renamed or removed the test fails immediately, surfacing the breakage before + * it silently breaks the tour for real users. + * + * Selectors under test come from: + * - userStepsConfig.ts + * - adminStepsConfig.ts + * - whatsNewStepsConfig.ts + */ - // Step 1: Click the Tours button (identified by data-tour="help-button") - const tourButton = page.locator('[data-tour="help-button"]').first(); - await expect(tourButton).toBeVisible({ timeout: 5000 }); - await tourButton.click(); +const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf"); - // Step 2: Verify the tours menu opens with tour options - const toursMenu = page.locator(".mantine-Menu-dropdown").first(); - await expect(toursMenu).toBeVisible({ timeout: 5000 }); - - // Step 3: Click on the "See what's new in V2" tour option to start a tour - const whatsNewOption = page.getByText(/what.?s new|See what/i).first(); - await expect(whatsNewOption).toBeVisible({ timeout: 5000 }); - await whatsNewOption.click(); - - // Step 4: Verify the reactour popover appears (tour has started) - const tourPopover = page.locator(".reactour__popover").first(); - await expect(tourPopover).toBeVisible({ timeout: 10000 }); - - // Step 5: Dismiss the tour by clicking the close button inside the popover - const closeButton = tourPopover.locator("button").first(); - if (await closeButton.isVisible({ timeout: 3000 }).catch(() => false)) { - await closeButton.click(); - } else { - // Fallback: press Escape to close the tour - await page.keyboard.press("Escape"); - } - - // Step 6: Verify the tour popover disappears - await expect(tourPopover).not.toBeVisible({ timeout: 5000 }); +// --------------------------------------------------------------------------- +// 15.1 Static layout — always visible on the main page +// --------------------------------------------------------------------------- +test.describe("15.1 Tour selectors — static layout", () => { + test("tool-panel is present", async ({ page }) => { + await expect(page.locator('[data-tour="tool-panel"]').first()).toBeVisible({ + timeout: 10_000, }); }); - test.describe("15.2 Tour Tooltip", () => { - test("should show and dismiss tour tooltip", async ({ page }) => { - // Step 1: Check if a tours tooltip with a close button appears near the help button - // The tooltip uses the custom Tooltip component with showCloseButton enabled - const tooltipContent = page - .locator('[data-radix-popper-content-wrapper], [role="tooltip"]') - .first(); + test("quick-access-bar (FileSidebar) is present", async ({ page }) => { + await expect( + page.locator('[data-tour="quick-access-bar"]').first(), + ).toBeVisible({ timeout: 10_000 }); + }); - // Step 2: If a tooltip with a close button is visible, dismiss it - const closeButton = tooltipContent.locator("button").first(); - if (await closeButton.isVisible({ timeout: 5000 }).catch(() => false)) { - await closeButton.click(); + test("files-button is present", async ({ page }) => { + await expect( + page.locator('[data-tour="files-button"]').first(), + ).toBeVisible({ timeout: 10_000 }); + }); - // Step 3: Verify the tooltip content disappears - await expect(tooltipContent).not.toBeVisible({ timeout: 5000 }); - } + test("config-button is present", async ({ page }) => { + await expect( + page.locator('[data-tour="config-button"]').first(), + ).toBeVisible({ timeout: 10_000 }); + }); - // Step 4: Refresh the page and verify it loads normally - await page.reload(); - await page.waitForLoadState("domcontentloaded"); + test("tool-button-crop is present in tool panel", async ({ page }) => { + await expect( + page.locator('[data-tour="tool-button-crop"]').first(), + ).toBeVisible({ timeout: 10_000 }); + }); - // Step 5: Verify the page loaded (search box or navigation visible) - await expect(page.getByPlaceholder(/search|cari/i).first()).toBeVisible({ - timeout: 10000, - }); + // help-button: not yet implemented in the redesigned FileSidebar layout. + // Re-enable once a tours/help entry point is added to the new UI. + test.skip("help-button is present — TODO: add to new sidebar layout", async ({ + page, + }) => { + await expect(page.locator('[data-tour="help-button"]').first()).toBeVisible( + { timeout: 10_000 }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// 15.2 Tour selectors — files modal +// --------------------------------------------------------------------------- +test.describe("15.2 Tour selectors — files modal", () => { + test("file-sources is present when files modal is open", async ({ page }) => { + await page.getByTestId("files-button").click(); + await expect( + page.locator('[data-tour="file-sources"]').first(), + ).toBeVisible({ timeout: 10_000 }); + await page.keyboard.press("Escape"); + }); +}); + +// --------------------------------------------------------------------------- +// 15.3 Tour selectors — workbench elements (require a loaded file) +// --------------------------------------------------------------------------- +test.describe("15.3 Tour selectors — workbench with file", () => { + test.beforeEach(async ({ page }) => { + await uploadFiles(page, SAMPLE_PDF); + }); + + test("workbench is present", async ({ page }) => { + await expect(page.locator('[data-tour="workbench"]').first()).toBeVisible({ + timeout: 10_000, + }); + }); + + test("view-switcher is present in viewer mode", async ({ page }) => { + await expect( + page.locator('[data-tour="view-switcher"]').first(), + ).toBeVisible({ timeout: 10_000 }); + }); + + test("right-rail-controls is present", async ({ page }) => { + await expect( + page.locator('[data-tour="right-rail-controls"]').first(), + ).toBeVisible({ timeout: 10_000 }); + }); + + test("right-rail-settings is present", async ({ page }) => { + await expect( + page.locator('[data-tour="right-rail-settings"]').first(), + ).toBeVisible({ timeout: 10_000 }); + }); +}); + +// --------------------------------------------------------------------------- +// 15.4 Tour selectors — active files view (file-card-checkbox, file-card-pin) +// Two files → app auto-navigates to fileEditor (active files) mode. +// --------------------------------------------------------------------------- +test.describe("15.4 Tour selectors — active files view", () => { + test.beforeEach(async ({ page }) => { + // Two files → getStartupNavigationAction returns workbench:"fileEditor" + await uploadFiles(page, [SAMPLE_PDF, SAMPLE_PDF]); + }); + + test("file-card-checkbox is present in active files view", async ({ + page, + }) => { + await expect( + page.locator('[data-tour="file-card-checkbox"]').first(), + ).toBeVisible({ timeout: 15_000 }); + }); + + test("file-card-pin is in DOM when file cards are rendered", async ({ + page, + }) => { + // The pin button lives inside HoverActionMenu (CSS-hover driven, always + // attached to DOM). Hover over the first file card to ensure the element + // is rendered, then assert it is attached. + const fileCard = page.locator('[data-tour="file-card-checkbox"]').first(); + await expect(fileCard).toBeVisible({ timeout: 15_000 }); + await fileCard.hover(); + await expect( + page.locator('[data-tour="file-card-pin"]').first(), + ).toBeAttached({ timeout: 5_000 }); + }); +}); + +// --------------------------------------------------------------------------- +// 15.5 Tour selectors — crop tool (crop-settings, run-button) +// --------------------------------------------------------------------------- +test.describe("15.5 Tour selectors — crop tool", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/crop", { waitUntil: "domcontentloaded" }); + await uploadFiles(page, SAMPLE_PDF); + }); + + test("crop-settings is present", async ({ page }) => { + await expect( + page.locator('[data-tour="crop-settings"]').first(), + ).toBeVisible({ timeout: 10_000 }); + }); + + test("run-button is present", async ({ page }) => { + await expect(page.locator('[data-tour="run-button"]').first()).toBeVisible({ + timeout: 10_000, }); }); }); + +// --------------------------------------------------------------------------- +// 15.6 Tour selectors — config modal (non-admin) +// --------------------------------------------------------------------------- +test.describe("15.6 Tour selectors — config modal", () => { + test.beforeEach(async ({ page }) => { + await openSettings(page); + }); + + test("modal-nav is present", async ({ page }) => { + await expect(page.locator(".modal-nav").first()).toBeVisible({ + timeout: 10_000, + }); + }); + + test("settings-content-area is present", async ({ page }) => { + await expect( + page.locator('[data-tour="settings-content-area"]').first(), + ).toBeVisible({ timeout: 10_000 }); + }); +}); + +// --------------------------------------------------------------------------- +// 15.7 Tour selectors — admin config modal nav items +// Requires isAdmin:true in app-config so the proprietary admin sections render. +// These nav items are EE-only; the test is skipped in core-only builds where +// the sections are not registered. +// --------------------------------------------------------------------------- +test.describe("15.7 Tour selectors — admin modal nav items", () => { + test.use({ + stubOptions: { enableLogin: true, isAdmin: true }, + }); + + const adminNavSections = [ + "people", + "teams", + "adminGeneral", + "adminFeatures", + "adminEndpoints", + "adminDatabase", + "adminConnections", + "adminAudit", + "adminUsage", + "help", + ] as const; + + for (const section of adminNavSections) { + test(`admin-${section}-nav is present`, async ({ page }) => { + await openSettings(page); + const navItem = page + .locator(`[data-tour="admin-${section}-nav"]`) + .first(); + const isPresent = await navItem + .isVisible({ timeout: 5_000 }) + .catch(() => false); + if (!isPresent) { + test.skip( + true, + `admin-${section}-nav not rendered — section may be EE-only or not yet ported to this build`, + ); + return; + } + await expect(navItem).toBeVisible(); + }); + } +}); diff --git a/frontend/src/desktop/components/fileEditor/FileEditorFileName.tsx b/frontend/src/desktop/components/fileEditor/FileEditorFileName.tsx index 46349fd31a..9fa92bfc65 100644 --- a/frontend/src/desktop/components/fileEditor/FileEditorFileName.tsx +++ b/frontend/src/desktop/components/fileEditor/FileEditorFileName.tsx @@ -1,6 +1,4 @@ import React from "react"; -import { Tooltip } from "@mantine/core"; -import { useTranslation } from "react-i18next"; import { StirlingFileStub } from "@app/types/fileContext"; import { PrivateContent } from "@app/components/shared/PrivateContent"; import { truncateCenter } from "@app/utils/textUtils"; @@ -14,57 +12,8 @@ const FileEditorFileName = ({ file, maxLength = 40, }: FileEditorFileNameProps) => { - const { t } = useTranslation(); - return ( - <> - {truncateCenter(file.name, maxLength)} - {!file.localFilePath && ( - - - - )} - {file.localFilePath && file.isDirty && ( - - - - )} - {file.localFilePath && !file.isDirty && ( - - - - )} - + {truncateCenter(file.name, maxLength)} ); }; diff --git a/frontend/src/desktop/components/fileEditor/FileEditorStatusDot.tsx b/frontend/src/desktop/components/fileEditor/FileEditorStatusDot.tsx new file mode 100644 index 0000000000..e27b50dadc --- /dev/null +++ b/frontend/src/desktop/components/fileEditor/FileEditorStatusDot.tsx @@ -0,0 +1,36 @@ +import { Tooltip } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { StirlingFileStub } from "@app/types/fileContext"; +import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css"; + +interface FileEditorStatusDotProps { + file: StirlingFileStub; +} + +export function FileEditorStatusDot({ file }: FileEditorStatusDotProps) { + const { t } = useTranslation(); + + const label = !file.localFilePath + ? t("fileNotSavedToDisk", "Not saved to disk") + : file.isDirty + ? t("unsavedChanges", "Unsaved changes") + : t("fileSavedToDisk", "Saved to disk"); + + const color = !file.localFilePath + ? "var(--mantine-color-red-6)" + : file.isDirty + ? "var(--mantine-color-yellow-6)" + : "var(--mantine-color-green-6)"; + + return ( +
+ + + +
+ ); +} diff --git a/frontend/src/proprietary/components/shared/config/configNavSections.tsx b/frontend/src/proprietary/components/shared/config/configNavSections.tsx index b21ac08c81..1ccfea9e7b 100644 --- a/frontend/src/proprietary/components/shared/config/configNavSections.tsx +++ b/frontend/src/proprietary/components/shared/config/configNavSections.tsx @@ -31,11 +31,17 @@ export const useConfigNavSections = ( isAdmin: boolean = false, runningEE: boolean = false, loginEnabled: boolean = false, + onRequestClose: () => void = () => {}, ): ConfigNavSection[] => { const { t } = useTranslation(); - // Get the core sections (just Preferences) - const sections = useCoreConfigNavSections(isAdmin, runningEE, loginEnabled); + // Get the core sections (Preferences + Help) + const sections = useCoreConfigNavSections( + isAdmin, + runningEE, + loginEnabled, + onRequestClose, + ); // Add account management under Preferences const preferencesSection = sections.find((section) =>