Fixes/tour and desktop improvements (#6320)

Added back the desktop file isDirty indicator
<img width="414" height="385" alt="Screenshot 2026-05-07 at 1 36 19 PM"
src="https://github.com/user-attachments/assets/d2d79c46-469d-4bb2-9540-e86183dcd372"
/>

Also added back the tours and added tests to ensure we don't delete
parts of them in future by mistake
<img width="2056" height="1069" alt="Screenshot 2026-05-07 at 1 36
31 PM"
src="https://github.com/user-attachments/assets/987741a0-01aa-4a0e-9e06-584a52cc1e43"
/>
<img width="2056" height="1066" alt="Screenshot 2026-05-07 at 1 36
43 PM"
src="https://github.com/user-attachments/assets/48ea369c-816c-485d-889a-6ecce71b1855"
/>
This commit is contained in:
EthanHealy01
2026-05-07 16:19:55 +01:00
committed by GitHub
parent b5f11c4efe
commit 23dc03832f
23 changed files with 619 additions and 197 deletions
@@ -1460,14 +1460,14 @@ inactive = "Inactive"
[adminOnboarding]
adminTools = "Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform."
configButton = "Click the <strong>Config</strong> button to access all system settings and administrative controls."
configButton = "Open <strong>Settings</strong> to access all system configuration and administrative controls."
connectionsSection = "The <strong>Connections</strong> 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 <strong>external database hookups</strong> so you can integrate with your existing infrastructure."
settingsOverview = "This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation."
systemCustomization = "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."
teamsAndUsers = "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."
welcome = "Welcome to the <strong>Admin Tour</strong>! 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 <strong>Help</strong> 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 <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
[adminUserSettings]
actions = "Actions"
@@ -4892,7 +4892,7 @@ toolInterface = "This is the <strong>Crop</strong> tool interface. As you can se
viewer = "The <strong>Viewer</strong> lets you read and annotate your PDFs."
viewSwitcher = "Use these controls to select how you want to view your PDFs."
workbench = "This is the <strong>Workbench</strong> - 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 <strong>Help</strong> button whenever you like to see this tour again."
wrapUp = "You're all set! You can replay this tour anytime — just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help."
[onboarding.buttons]
back = "Back"
@@ -0,0 +1,9 @@
import { StirlingFileStub } from "@app/types/fileContext";
interface FileEditorStatusDotProps {
file: StirlingFileStub;
}
export function FileEditorStatusDot(_props: FileEditorStatusDotProps) {
return null;
}
@@ -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;
@@ -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 */}
<div className={styles.thumbBadges}>
<span className={styles.versionBadgeThumb}>
v{file.versionNumber}
@@ -541,6 +543,8 @@ const FileEditorThumbnail = ({
</Tooltip>
)}
</div>
<FileEditorStatusDot file={file} />
</div>
</div>
{/* end thumbUnit */}
@@ -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(
@@ -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 <strong>Config</strong> button to access all system settings and administrative controls.",
"Open <strong>Settings</strong> 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 <strong>Help</strong> 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 <strong>Settings</strong> and find it here in the <strong>Tours</strong> 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);
},
},
};
@@ -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<void> {
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<void> {
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<HTMLElement>(selector);
if (el) {
watchLayout(el);
return;
}
mutationObserver = new MutationObserver(() => {
const found = document.querySelector<HTMLElement>(selector);
if (found) {
mutationObserver!.disconnect();
mutationObserver = null;
watchLayout(found);
}
});
mutationObserver.observe(document.body, { childList: true, subtree: true });
});
}
@@ -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 <strong>Help</strong> button whenever you like to see this tour again.",
"You're all set! You can replay this tour anytime — just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> 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);
},
},
};
}
@@ -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<void> {
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<void> {
if (typeof document === "undefined") return;
const start = Date.now();
return new Promise((resolve) => {
const check = () => {
const el = document.querySelector<HTMLElement>(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]: {
@@ -108,11 +108,26 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
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<AppConfigModalProps> = ({
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();
@@ -316,6 +316,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
style={{ width, minWidth: width, maxWidth: width }}
data-collapsed={collapsed}
data-sidebar="file-sidebar"
data-tour="quick-access-bar"
>
<div className="file-sidebar-inner">
{/* Header: hamburger + branding */}
@@ -392,6 +393,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
<div
className="file-sidebar-action-row"
data-testid="files-button"
data-tour="files-button"
onClick={() => {
if (collapsed && onToggleCollapse) onToggleCollapse();
openFilesModal();
@@ -555,6 +557,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
: undefined
}
data-testid={onOpenSettings ? "config-button" : undefined}
data-tour={onOpenSettings ? "config-button" : undefined}
aria-label={
onOpenSettings
? t("fileSidebar.openSettings", "Open settings")
@@ -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<HoverActionMenuProps> = ({
onClick={action.onClick}
c={action.color}
style={{ color: action.color || "var(--right-rail-icon)" }}
data-tour={action.dataTour}
>
{action.icon}
</ActionIcon>
@@ -97,7 +97,11 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
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",
@@ -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: (
<HelpSection isAdmin={_isAdmin} onRequestClose={onRequestClose} />
),
},
],
},
];
return sections;
@@ -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<HelpSectionProps> = ({
isAdmin,
onRequestClose,
}) => {
const { t } = useTranslation();
const startTour = (tourType: "tools" | "admin") => {
onRequestClose();
setTimeout(() => requestStartTour(tourType), 300);
};
return (
<Stack gap="lg">
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<div>
<Text fw={600} size="sm">
{t("settings.help.toolsTour.title", "Tools Tour")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"settings.help.toolsTour.description",
"Walk through uploading files, picking a tool, and reviewing results.",
)}
</Text>
</div>
<Button
variant="default"
size="sm"
leftSection={
<LocalIcon
icon="build-outline-rounded"
width="1rem"
height="1rem"
/>
}
onClick={() => startTour("tools")}
>
{t("settings.help.toolsTour.start", "Start")}
</Button>
</Group>
{isAdmin && (
<Group justify="space-between" align="center">
<div>
<Text fw={600} size="sm">
{t("settings.help.adminTour.title", "Admin Tour")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"settings.help.adminTour.description",
"Explore team management, system settings, and enterprise features.",
)}
</Text>
</div>
<Button
variant="default"
size="sm"
leftSection={
<LocalIcon icon="person-rounded" width="1rem" height="1rem" />
}
onClick={() => startTour("admin")}
>
{t("settings.help.adminTour.start", "Start")}
</Button>
</Group>
)}
</Stack>
</Paper>
</Stack>
);
};
export default HelpSection;
@@ -30,6 +30,7 @@ export const VALID_NAV_KEYS = [
"adminUsage",
"adminEndpoints",
"adminStorageSharing",
"help",
] as const;
// Derive the type from the array
@@ -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,
};
+9
View File
@@ -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();
@@ -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<void> {
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,
},
@@ -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();
});
}
});
@@ -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 (
<>
<PrivateContent>{truncateCenter(file.name, maxLength)}</PrivateContent>
{!file.localFilePath && (
<Tooltip label={t("fileNotSavedToDisk", "Not saved to disk")}>
<span
style={{
display: "inline-block",
width: "6px",
height: "6px",
borderRadius: "50%",
backgroundColor: "var(--mantine-color-red-6)",
flexShrink: 0,
}}
aria-label={t("fileNotSavedToDisk", "Not saved to disk")}
/>
</Tooltip>
)}
{file.localFilePath && file.isDirty && (
<Tooltip label={t("unsavedChanges", "Unsaved changes")}>
<span
style={{
display: "inline-block",
width: "6px",
height: "6px",
borderRadius: "50%",
backgroundColor: "var(--mantine-color-yellow-6)",
flexShrink: 0,
}}
aria-label={t("unsavedChanges", "Unsaved changes")}
/>
</Tooltip>
)}
{file.localFilePath && !file.isDirty && (
<Tooltip label={t("fileSavedToDisk", "File saved to disk")}>
<span
style={{
display: "inline-block",
width: "6px",
height: "6px",
borderRadius: "50%",
backgroundColor: "var(--mantine-color-green-6)",
flexShrink: 0,
}}
aria-label={t("fileSavedToDisk", "File saved to disk")}
/>
</Tooltip>
)}
</>
<PrivateContent>{truncateCenter(file.name, maxLength)}</PrivateContent>
);
};
@@ -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 (
<div className={styles.thumbBadgesRight}>
<Tooltip label={label}>
<span
className={styles.statusDot}
style={{ backgroundColor: color }}
aria-label={label}
/>
</Tooltip>
</div>
);
}
@@ -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) =>