diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5480fd83..2853426d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,7 +27,9 @@ "react-resizable-panels": "^3.0.2", "react-router": "^7.12.0", "react-router-dom": "^7.12.0", + "react-shepherd": "^6.1.9", "react-syntax-highlighter": "^16.1.0", + "shepherd.js": "^14.5.1", "tailwindcss": "^3.4.17", "zarrita": "^0.5.1" }, @@ -1520,6 +1522,13 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@tanstack/eslint-plugin-query": { "version": "5.91.2", "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.91.2.tgz", @@ -3645,6 +3654,15 @@ "node": ">=0.10.0" } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -8755,6 +8773,20 @@ "react-dom": ">=18" } }, + "node_modules/react-shepherd": { + "version": "6.1.9", + "resolved": "https://registry.npmjs.org/react-shepherd/-/react-shepherd-6.1.9.tgz", + "integrity": "sha512-kSFs7ER9+tDAQ9a80CGTaWHpuNf/6RNnnAqtPxFqZSt5NnlKi6T8/E93sYMPOibhvdtpG5pIZpeT3JI1+Ppqiw==", + "license": "MIT", + "dependencies": { + "shepherd.js": "14.5.1" + }, + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "^5.0.0" + } + }, "node_modules/react-syntax-highlighter": { "version": "16.1.0", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz", @@ -9327,6 +9359,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shepherd.js": { + "version": "14.5.1", + "resolved": "https://registry.npmjs.org/shepherd.js/-/shepherd.js-14.5.1.tgz", + "integrity": "sha512-VuvPvLG1QjNOLP7AIm2HGyfmxEIz8QdskvWOHwUcxLDibYWjLRBmCWd8LSL5FlwhBW7D/GU+3gNVC/ASxAWdxg==", + "license": "AGPL-3.0", + "dependencies": { + "@floating-ui/dom": "^1.7.0", + "@scarf/scarf": "^1.4.0", + "deepmerge-ts": "^7.1.1" + }, + "engines": { + "node": "18.* || >= 20" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -10225,7 +10271,6 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/package.json b/frontend/package.json index d8c03168..b823f694 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,7 +48,9 @@ "react-resizable-panels": "^3.0.2", "react-router": "^7.12.0", "react-router-dom": "^7.12.0", + "react-shepherd": "^6.1.9", "react-syntax-highlighter": "^16.1.0", + "shepherd.js": "^14.5.1", "tailwindcss": "^3.4.17", "zarrita": "^0.5.1" }, diff --git a/frontend/src/__tests__/componentTests/Browse.test.tsx b/frontend/src/__tests__/componentTests/Browse.test.tsx new file mode 100644 index 00000000..5e9a2250 --- /dev/null +++ b/frontend/src/__tests__/componentTests/Browse.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { render, screen } from '@/__tests__/test-utils'; +import Browse from '@/components/Browse'; + +// Mock the StartTour component to avoid ShepherdJourneyProvider dependency +vi.mock('@/components/tours/StartTour', () => ({ + default: ({ children }: { children: React.ReactNode }) => ( + + ) +})); + +// Mock useOutletContext since Browse requires it +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); + return { + ...actual, + useOutletContext: () => ({ + setShowPermissionsDialog: vi.fn(), + togglePropertiesDrawer: vi.fn(), + toggleSidebar: vi.fn(), + setShowConvertFileDialog: vi.fn(), + showPermissionsDialog: false, + showPropertiesDrawer: false, + showSidebar: false, + showConvertFileDialog: false + }) + }; +}); + +describe('Browse - WelcomeTutorialCard visibility', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows WelcomeTutorialCard on dashboard when showTutorial is true', async () => { + render(, { initialEntries: ['/browse'] }); + + await waitFor(() => { + expect(screen.getByText('Welcome to Fileglancer!')).toBeInTheDocument(); + }); + }); + + it('does not show WelcomeTutorialCard after the user selects to hide it', async () => { + render(, { initialEntries: ['/browse'] }); + await waitFor(() => { + expect(screen.getByText('Welcome to Fileglancer!')).toBeInTheDocument(); + }); + + const user = userEvent.setup(); + const checkbox = screen.getByRole('checkbox', { name: /hide this card/i }); + expect(checkbox).not.toBeChecked(); + await user.click(checkbox); + + await waitFor(() => { + expect( + screen.queryByText('Welcome to Fileglancer!') + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx b/frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx new file mode 100644 index 00000000..a8d4525b --- /dev/null +++ b/frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import toast from 'react-hot-toast'; +import { render, screen } from '@/__tests__/test-utils'; +import WelcomeTutorialCard from '@/components/ui/BrowsePage/Dashboard/WelcomeTutorialCard'; + +// Mock the StartTour component to avoid ShepherdJourneyProvider dependency +vi.mock('@/components/tours/StartTour', () => ({ + default: ({ children }: { children: React.ReactNode }) => ( + + ) +})); + +describe('WelcomeTutorialCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders welcome message and tour button', async () => { + render(, { initialEntries: ['/browse'] }); + + await waitFor(() => { + expect(screen.getByText('Welcome to Fileglancer!')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /start tour/i }) + ).toBeInTheDocument(); + }); + }); + + it('displays checkbox with correct label and helper text', async () => { + render(, { initialEntries: ['/browse'] }); + + await waitFor(() => { + expect( + screen.getByLabelText(/hide this card.*help page/i) + ).toBeInTheDocument(); + expect( + screen.getByText(/you can always access tutorials from the help page/i) + ).toBeInTheDocument(); + }); + }); + + it('checkbox reflects showTutorial preference state', async () => { + render(, { initialEntries: ['/browse'] }); + + const checkbox = await screen.findByLabelText(/hide this card.*help page/i); + + // showTutorial is true by default, so checkbox should be unchecked (inverse) + expect(checkbox).not.toBeChecked(); + }); + + it('toggles preference when checkbox is clicked and shows success toast', async () => { + render(, { initialEntries: ['/browse'] }); + + const user = userEvent.setup(); + const checkbox = await screen.findByLabelText(/hide this card.*help page/i); + + await user.click(checkbox); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + 'Welcome card hidden. Access Tutorials from the Help page.' + ); + }); + }); + + it('shows error toast when preference update fails', async () => { + const { server } = await import('@/__tests__/mocks/node'); + const { http, HttpResponse } = await import('msw'); + + server.use( + http.put('/api/preference/showTutorial', () => { + return HttpResponse.json({ error: 'Update failed' }, { status: 500 }); + }) + ); + + render(, { initialEntries: ['/browse'] }); + + const user = userEvent.setup(); + const checkbox = await screen.findByLabelText(/hide this card.*help page/i); + + await user.click(checkbox); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith(`500 Internal Server Error: +Update failed`); + }); + }); + + it('contains descriptive text about FileGlancer', async () => { + render(, { initialEntries: ['/browse'] }); + + await waitFor(() => { + expect( + screen.getByText( + /fileglancer helps you browse.*visualize.*share.*scientific imaging data/i + ) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/__tests__/mocks/handlers.ts b/frontend/src/__tests__/mocks/handlers.ts index d8870dc9..e61b2d9b 100644 --- a/frontend/src/__tests__/mocks/handlers.ts +++ b/frontend/src/__tests__/mocks/handlers.ts @@ -41,6 +41,7 @@ export const handlers = [ disableNeuroglancerStateGeneration: { value: false }, useLegacyMultichannelApproach: { value: false }, isFilteredByGroups: { value: true }, + showTutorial: { value: true }, layout: { value: '' }, zone: { value: [] }, fileSharePath: { value: [] }, diff --git a/frontend/src/assets/arrow-up.svg b/frontend/src/assets/arrow-up.svg new file mode 100644 index 00000000..813745d7 --- /dev/null +++ b/frontend/src/assets/arrow-up.svg @@ -0,0 +1,18 @@ + + + + + + diff --git a/frontend/src/components/Browse.tsx b/frontend/src/components/Browse.tsx index be74b47a..cc532eff 100644 --- a/frontend/src/components/Browse.tsx +++ b/frontend/src/components/Browse.tsx @@ -3,6 +3,7 @@ import { useOutletContext } from 'react-router'; import { default as log } from '@/logger'; import type { OutletContextType } from '@/layouts/BrowseLayout'; import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; +import { usePreferencesContext } from '@/contexts/PreferencesContext'; import FileBrowser from './ui/BrowsePage/FileBrowser'; import Toolbar from './ui/BrowsePage/Toolbar'; import RenameDialog from './ui/Dialogs/Rename'; @@ -11,6 +12,7 @@ import ChangePermissions from './ui/Dialogs/ChangePermissions'; import ConvertFileDialog from './ui/Dialogs/ConvertFile'; import RecentDataLinksCard from './ui/BrowsePage/Dashboard/RecentDataLinksCard'; import RecentlyViewedCard from './ui/BrowsePage/Dashboard/RecentlyViewedCard'; +import WelcomeTutorialCard from './ui/BrowsePage/Dashboard/WelcomeTutorialCard'; import NavigationInput from './ui/BrowsePage/NavigateInput'; import FgDialog from './ui/Dialogs/FgDialog'; @@ -27,6 +29,7 @@ export default function Browse() { } = useOutletContext(); const { fspName } = useFileBrowserContext(); + const { showTutorial } = usePreferencesContext(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); @@ -114,10 +117,12 @@ export default function Browse() { {!fspName ? (
+ {showTutorial ? : null}
800 ? '' : 'flex-col'}`} > +
diff --git a/frontend/src/components/Help.tsx b/frontend/src/components/Help.tsx index dc08e21c..7e824363 100644 --- a/frontend/src/components/Help.tsx +++ b/frontend/src/components/Help.tsx @@ -5,9 +5,11 @@ import { SiClickup, SiSlack } from 'react-icons/si'; import { IconType } from 'react-icons/lib'; import { LuBookOpenText } from 'react-icons/lu'; import { HiExternalLink } from 'react-icons/hi'; +import { MdTour } from 'react-icons/md'; import useVersionQuery from '@/queries/versionQuery'; import { buildUrl } from '@/utils'; +import StartTour from '@/components/tours/StartTour'; type HelpLink = { icon: IconType; @@ -86,10 +88,26 @@ export default function Help() {
+ {/* Tour Card */} + +
+ + + Take a Tutorial + +
+ + Guided tours of common Fileglancer workflows + +
+ {helpLinks.map(({ icon: Icon, title, description, url }) => (
- + {description} diff --git a/frontend/src/components/Jobs.tsx b/frontend/src/components/Jobs.tsx index 878fa10b..b1693604 100644 --- a/frontend/src/components/Jobs.tsx +++ b/frontend/src/components/Jobs.tsx @@ -7,7 +7,7 @@ import { jobsColumns } from './ui/Table/jobsColumns'; export default function Jobs() { const { allTicketsQuery } = useTicketContext(); return ( - <> +
Tasks @@ -25,6 +25,6 @@ export default function Jobs() { gridColsClass="grid-cols-[3fr_3fr_1fr_2fr]" loadingState={allTicketsQuery.isPending} /> - +
); } diff --git a/frontend/src/components/Preferences.tsx b/frontend/src/components/Preferences.tsx index e028df65..1ac9c085 100644 --- a/frontend/src/components/Preferences.tsx +++ b/frontend/src/components/Preferences.tsx @@ -17,7 +17,9 @@ export default function Preferences() { toggleDisableNeuroglancerStateGeneration, disableHeuristicalLayerTypeDetection, toggleDisableHeuristicalLayerTypeDetection, - toggleFilterByGroups + toggleFilterByGroups, + showTutorial, + toggleShowTutorial } = usePreferencesContext(); return ( @@ -28,7 +30,7 @@ export default function Preferences() { - + Format to use for file paths: @@ -123,9 +125,13 @@ export default function Preferences() { - Options: + + Options: + + Display +
+
+ { + const result = await toggleShowTutorial(); + if (result.success) { + toast.success( + showTutorial + ? 'Tutorial welcome card will no longer be shown on Browse page' + : 'Tutorial welcome card will be shown on Browse page' + ); + } else { + toast.error(result.error); + } + }} + type="checkbox" + /> + + Show tutorial welcome card on Browse page + +
+ + Data Links +
+ Neuroglancer +
diff --git a/frontend/src/components/tours/StartTour.tsx b/frontend/src/components/tours/StartTour.tsx new file mode 100644 index 00000000..05f520cb --- /dev/null +++ b/frontend/src/components/tours/StartTour.tsx @@ -0,0 +1,348 @@ +import { Button } from '@material-tailwind/react'; +import type { ButtonProps } from '@material-tailwind/react'; +import { useNavigate } from 'react-router'; +import { useShepherd } from 'react-shepherd'; +import type { Tour } from 'shepherd.js'; +import { tourSteps, backButton, exitButton } from './tourSteps'; +import { useZoneAndFspMapContext } from '@/contexts/ZonesAndFspMapContext'; +import type { Zone } from '@/shared.types'; + +// Helper to wait for an element to appear in the DOM +function waitForElement(selector: string, timeoutMs = 3000): Promise { + const start = Date.now(); + return new Promise((resolve, reject) => { + const tick = () => { + if (document.querySelector(selector)) { + return resolve(); + } + if (Date.now() - start > timeoutMs) { + return reject(new Error(`Timeout waiting for ${selector}`)); + } + requestAnimationFrame(tick); + }; + tick(); + }); +} + +interface StartTourProps extends ButtonProps { + readonly children: React.ReactNode; +} + +export default function StartTour({ + children, + ...buttonProps +}: StartTourProps) { + const navigate = useNavigate(); + const shepherd = useShepherd(); + const { zonesAndFspQuery } = useZoneAndFspMapContext(); + + // Check if running on Janelia filesystem + const isJaneliaFilesystem = + zonesAndFspQuery.data && + Object.values(zonesAndFspQuery.data).some( + item => + 'mount_path' in item && + (item.mount_path.toLowerCase().includes('nrs') || + item.mount_path.toLowerCase().includes('prfs') || + item.mount_path.toLowerCase().includes('nearline')) + ); + + const tasksEnabled = import.meta.env.VITE_ENABLE_TASKS === 'true'; + + // Helper to set up navigation input step with dynamic text including sample path + const setupNavigationInputStep = (tour: Tour) => { + const navInputStep = tour.getById('nav-navigation-input'); + if (navInputStep) { + let samplePath = ''; + if (isJaneliaFilesystem) { + samplePath = '/nrs/opendata'; + } else { + const firstZone = Object.values(zonesAndFspQuery.data || {}).find( + item => 'fileSharePaths' in item + ) as Zone | undefined; + if (firstZone && firstZone.fileSharePaths.length > 0) { + const firstFsp = firstZone.fileSharePaths[0]; + samplePath = firstFsp.mount_path; + } + } + + navInputStep.updateStepOptions({ + text: `Use this navigation bar to quickly jump to any path in the file system. You can type or paste a path here. Try copying this path: ${samplePath} and hitting "Go."` + }); + + // Set up navigation detection to auto-advance when user navigates + let navigationCheckInterval: NodeJS.Timeout | null = null; + const startPath = window.location.pathname; + + const checkForNavigation = () => { + const currentPath = window.location.pathname; + // If user navigated away from the starting /browse page + if (currentPath !== startPath && currentPath.startsWith('/browse/')) { + if (navigationCheckInterval) { + clearInterval(navigationCheckInterval); + navigationCheckInterval = null; + } + // Auto-advance to next step + if (tour.getCurrentStep()?.id === 'nav-navigation-input') { + tour.next(); + } + } + }; + + // Add event listener when step is shown + navInputStep.on('show', () => { + // Start checking for navigation every 500ms + navigationCheckInterval = setInterval(checkForNavigation, 500); + }); + + // Clean up interval when step is hidden + navInputStep.on('hide', () => { + if (navigationCheckInterval) { + clearInterval(navigationCheckInterval); + navigationCheckInterval = null; + } + }); + } + }; + + // Helper to set up navigation sidebar step with conditional navigation + const setupNavigationSidebarStep = (tour: Tour) => { + const navSidebarStep = tour.getById('nav-sidebar'); + if (navSidebarStep) { + navSidebarStep.updateStepOptions({ + buttons: [ + backButton, + { + text: 'Next', + action: async function (this: any) { + // Check if user has navigated from /browse + if (window.location.pathname === '/browse') { + // User hasn't navigated, force navigation to a file path + if (isJaneliaFilesystem) { + navigate('/browse/nrs_opendata'); + } else { + const firstZone = Object.values( + zonesAndFspQuery.data || {} + ).find(item => 'fileSharePaths' in item) as Zone | undefined; + if (firstZone && firstZone.fileSharePaths.length > 0) { + const firstFsp = firstZone.fileSharePaths[0]; + navigate(`/browse/${firstFsp.name}`); + } + } + await waitForElement('[data-tour="file-browser"]'); + } + return this.next(); + } + }, + exitButton + ] + }); + } + }; + + // Helper to set up conversion properties step with convert tab opened + const setupConversionStartStep = (tour: Tour) => { + const conversionStartStep = tour.getById('conversion-start'); + if (conversionStartStep) { + conversionStartStep.updateStepOptions({ + buttons: [ + backButton, + { + text: 'Next', + action: async function (this: any) { + // Navigate with openConvertTab state to open properties with Convert tab selected + const currentPath = window.location.pathname; + navigate(currentPath, { + state: { openConvertTab: true }, + replace: true + }); + await waitForElement('[data-tour="open-conversion-request"]'); + return this.next(); + } + }, + exitButton + ] + }); + } + }; + + // Helper to set up conversion properties step with convert tab opened + const setupConversionPropertiesStep = (tour: Tour) => { + const conversionPropertiesStep = tour.getById('conversion-properties'); + if (conversionPropertiesStep) { + conversionPropertiesStep.updateStepOptions({ + buttons: [ + backButton, + { + text: 'Next', + action: async function (this: any) { + navigate('/jobs'); + await waitForElement('[data-tour="tasks-page"]'); + return this.next(); + } + }, + exitButton + ] + }); + } + }; + + // Helper to set up completion buttons for tour ending steps + const setupCompletionButtons = (tour: Tour) => { + const completionStepIds = [ + 'nav-properties', + 'datalinks-janelia-preferences', + 'datalinks-general-preferences', + 'conversion-jobs' + ]; + + completionStepIds.forEach(stepId => { + const step = tour.getById(stepId); + if (step) { + const buttons = [ + backButton, + { + text: 'Take Another Tour', + action: function (this: any) { + const currentTour = shepherd.activeTour as Tour; + // Re-setup workflow buttons to ensure they work when returning + setupWorkflowButtons(currentTour); + // Show the workflow selection step + currentTour.show('choose-workflow'); + } + }, + exitButton + ]; + + step.updateStepOptions({ buttons }); + } + }); + }; + + // Helper to set up workflow selection buttons + const setupWorkflowButtons = (tour: Tour) => { + const firstStep = tour.getById('choose-workflow'); + if (!firstStep) { + return; + } + + const workflowButtons: any[] = [ + { + text: 'Navigation', + action: async function (this: any) { + const currentTour = shepherd.activeTour as Tour; + navigate('/browse'); + await waitForElement('[data-tour="navigation-input"]'); + setupNavigationInputStep(currentTour); + setupNavigationSidebarStep(currentTour); + setupCompletionButtons(currentTour); + currentTour.show('nav-navigation-input'); + } + }, + { + text: 'Data Links', + action: async function (this: any) { + const currentTour = shepherd.activeTour as Tour; + if (isJaneliaFilesystem) { + navigate( + '/browse/nrs_opendata/ome-zarr-examples/fused-timeseries.zarr' + ); + await waitForElement('[data-tour="file-browser"]'); + setupCompletionButtons(currentTour); + currentTour.show('datalinks-janelia-start'); + } else { + // Navigate to first FSP of first zone + const firstZone = Object.values(zonesAndFspQuery.data || {}).find( + item => 'fileSharePaths' in item + ) as Zone | undefined; + if (firstZone && firstZone.fileSharePaths.length > 0) { + const firstFsp = firstZone.fileSharePaths[0]; + navigate(`/browse/${firstFsp.name}`); + await waitForElement('[data-tour="file-browser"]'); + setupCompletionButtons(currentTour); + currentTour.show('datalinks-general-start'); + } + } + } + } + ]; + + // Only add File Conversion option if tasks are enabled + if (tasksEnabled) { + workflowButtons.push({ + text: 'File Conversion', + action: async function (this: any) { + const currentTour = shepherd.activeTour as Tour; + if (isJaneliaFilesystem) { + navigate( + '/browse/nrs_opendata/ome-zarr-examples/fused-timeseries.zarr' + ); + } else { + const firstZone = Object.values(zonesAndFspQuery.data || {}).find( + item => 'fileSharePaths' in item + ) as Zone | undefined; + if (firstZone && firstZone.fileSharePaths.length > 0) { + const firstFsp = firstZone.fileSharePaths[0]; + navigate(`/browse/${firstFsp.name}`); + } + } + await waitForElement('[data-tour="file-browser"]'); + setupConversionStartStep(currentTour); + setupConversionPropertiesStep(currentTour); + setupCompletionButtons(currentTour); + currentTour.show('conversion-start'); + } + }); + } + + workflowButtons.push({ + text: 'Exit', + action: function (this: any) { + const currentTour = shepherd.activeTour as Tour; + currentTour.cancel(); + }, + classes: 'shepherd-button-secondary' + }); + + firstStep.updateStepOptions({ buttons: workflowButtons }); + }; + + const handleStartTour = () => { + // Get or create the tour instance + let tour = shepherd.activeTour as Tour | undefined; + if (!tour) { + tour = new shepherd.Tour({ + useModalOverlay: true, + defaultStepOptions: { + classes: 'shepherd-theme-default', + scrollTo: true, + cancelIcon: { + enabled: true + }, + modalOverlayOpeningPadding: 8, + modalOverlayOpeningRadius: 4 + } + }); + shepherd.activeTour = tour; + } + + // Add steps if not already added + if (!tour.steps || tour.steps.length === 0) { + tour.addSteps(tourSteps); + } + + // Set up workflow selection buttons (do this every time to ensure they work when returning) + setupWorkflowButtons(tour); + + // Set up completion buttons with proper tour context + setupCompletionButtons(tour); + + tour.start(); + }; + + return ( + + ); +} diff --git a/frontend/src/components/tours/shepherd-overrides.css b/frontend/src/components/tours/shepherd-overrides.css new file mode 100644 index 00000000..cb724544 --- /dev/null +++ b/frontend/src/components/tours/shepherd-overrides.css @@ -0,0 +1,158 @@ +/* Custom Shepherd.js styling overrides to match Material Tailwind dialogs */ +/* This file is imported AFTER shepherd.css to ensure our styles take precedence */ +/* Using CSS custom properties from Tailwind theme instead of @apply */ +/* @apply was not included in the final build CSS when used here */ + +/* Override default Shepherd theme */ +.shepherd-element { + max-width: 600px; + width: fit-content; +} + +/* Style the tour content container like FgDialog */ +.shepherd-content { + padding: 1.5rem; + background-color: rgb(var(--color-surface-light)); + box-shadow: + 0 20px 25px -5px rgb(0 0 0 / 0.1), + 0 8px 10px -6px rgb(0 0 0 / 0.1); + border: 1px solid rgb(var(--color-surface)); + min-width: 400px; +} + +/* Style the header */ +.shepherd-has-title .shepherd-content .shepherd-header { + background-color: rgb(var(--color-surface-light)); + padding: 0.75rem 0; + border-bottom: 1px solid rgb(var(--color-surface-dark)); + margin: 0.75rem; +} + +/* Dark mode for content and header */ +.dark .shepherd-content, +.dark .shepherd-has-title .shepherd-content .shepherd-header { + background-color: rgb(var(--color-surface)); + border-color: rgb(var(--color-surface-light)); +} + +/* Style the title */ +.shepherd-title { + font-size: 1.25rem; + font-weight: 600; + color: rgb(var(--color-foreground)); + margin: 0; +} + +.dark .shepherd-title { + color: rgb(var(--color-foreground)); +} + +/* Style the text content */ +.shepherd-text { + color: rgb(var(--color-foreground)); + font-size: 0.875rem; + line-height: 1.625; + margin-bottom: 1.25rem; +} + +.dark .shepherd-text { + color: rgb(var(--color-foreground)); +} + +/* Style the button container to prevent overflow */ +.shepherd-footer { + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; + align-items: center; +} + +/* Style buttons to match Material Tailwind Button component */ +.shepherd-button { + padding: 0.5rem 1rem; + border-radius: calc(var(--radius) - 18px); + font-weight: 500; + font-size: 0.875rem; + transition: all 0.2s ease-in-out; + border: 0; + cursor: pointer; + white-space: nowrap; +} + +/* Primary button (Next, Submit, Complete) */ +.shepherd-button:not(.shepherd-button-secondary) { + background-color: rgb(var(--color-primary)); + color: rgb(var(--color-primary-foreground)); +} + +.shepherd-button:not(.shepherd-button-secondary):hover { + background-color: rgb(var(--color-primary-light)); + color: rgb(var(--color-background)); +} + +/* Secondary button (Back, Exit) */ +.shepherd-button-secondary { + background-color: rgb(var(--color-surface)); + color: rgb(var(--color-foreground)); + border: 1px solid rgb(var(--color-surface-dark)); +} + +.shepherd-button-secondary:hover { + background-color: rgb(var(--color-surface-dark)); + color: rgb(var(--color-surface-foreground)); +} + +.dark .shepherd-button-secondary { + background-color: rgb(var(--color-surface)); + color: rgb(var(--color-foreground)); + border-color: rgb(var(--color-surface-light)); +} + +.dark .shepherd-button-secondary:hover { + background-color: rgb(var(--color-surface-light)); + color: rgb(var(--color-surface-foreground)); +} + +/* Style the cancel icon in header */ +.shepherd-has-title .shepherd-content .shepherd-cancel-icon { + color: rgb(var(--color-secondary)); + font-size: 1.5rem; + font-weight: 500; + cursor: pointer; + border-radius: 9999px; + border: 1px solid rgb(var(--color-secondary)); + transition: all 0.3s cubic-bezier(0.4, 0, 1, 1); + width: 2rem; +} + +.shepherd-has-title .shepherd-content .shepherd-cancel-icon:hover { + background-color: rgb(var(--color-secondary)); + color: rgb(var(--color-surface)); +} + +/* Style the arrow pointing to the highlighted element */ +.shepherd-element .shepherd-arrow { + content: url(../../assets/arrow-up.svg); + display: inline-block; + transform: scale(1.5); + z-index: 8888; +} + +/* Rotate arrow based on tooltip placement */ + +/* Left of the target placement: arrow points right (90deg) */ +.shepherd-element[data-popper-placement^='left'] .shepherd-arrow { + transform: scale(1.5) rotate(90deg); +} + +/* Top of the target placement: arrow points down (180deg) */ +.shepherd-element[data-popper-placement^='top'] .shepherd-arrow { + transform: scale(1.5) rotate(180deg); +} + +/* Right of the target placement: arrow points left (270deg) */ +.shepherd-element[data-popper-placement^='right'] .shepherd-arrow { + transform: scale(1.5) rotate(270deg); +} diff --git a/frontend/src/components/tours/tourSteps.ts b/frontend/src/components/tours/tourSteps.ts new file mode 100644 index 00000000..68b5fd53 --- /dev/null +++ b/frontend/src/components/tours/tourSteps.ts @@ -0,0 +1,156 @@ +import type { StepOptions } from 'shepherd.js'; + +// Common button configs +const nextButton = { + text: 'Next', + action: function (this: any) { + return this.next(); + } +}; + +// Exported for reuse in StartTour.tsx +// Don't need to export next button as it's dynamically replaced in StartTour +export const backButton = { + text: 'Back', + action: function (this: any) { + return this.back(); + }, + classes: 'shepherd-button-secondary' +}; + +export const exitButton = { + text: 'Exit Tour', + action: function (this: any) { + return this.cancel(); + }, + classes: 'shepherd-button-secondary' +}; + +// Note: This will be replaced dynamically in StartTour.tsx to have proper tour context +const takeAnotherTourButton = { + text: 'Take Another Tour', + action: function (this: any) { + // This is a placeholder - will be replaced in setupCompletionButtons + console.warn('takeAnotherTourButton action not properly initialized'); + } +}; + +export const tourSteps: StepOptions[] = [ + { + id: 'choose-workflow', + title: 'Welcome to Fileglancer!', + text: 'Which workflow would you like to explore?', + buttons: [] // Will be dynamically replaced with branching buttons + }, + + // Navigation workflow steps + { + id: 'nav-navigation-input', + title: 'Navigation', + text: 'Use this navigation bar to quickly jump to any path in the file system. You can type or paste a path here.', + attachTo: { element: '[data-tour="navigation-input"]', on: 'bottom' }, + buttons: [backButton, nextButton, exitButton] + }, + { + id: 'nav-sidebar', + title: 'Sidebar', + text: 'The sidebar shows your zones, file share paths, and favorite folders. Click on any item to navigate to it.', + attachTo: { element: '[data-tour="sidebar"]', on: 'right' }, + buttons: [] // Will be dynamically replaced with navigation logic + }, + { + id: 'nav-file-browser', + title: 'File Browser', + text: 'Browse files and folders here. You can sort by column headers and select items to perform actions.', + attachTo: { element: '[data-tour="file-browser"]', on: 'top' }, + buttons: [backButton, nextButton, exitButton] + }, + { + id: 'nav-properties', + title: 'Properties Panel', + text: 'View file metadata and perform actions like changing file permissions, creating shareable data links, or requesting file conversions.', + attachTo: { element: '[data-tour="properties-drawer"]', on: 'left' }, + buttons: [backButton, takeAnotherTourButton, exitButton] + }, + + // Data Links workflow - Janelia filesystem + { + id: 'datalinks-janelia-start', + title: 'Data Links', + text: "Navigate to a Zarr file to create a data link you can use to open files in external viewers like Neuroglancer. Let's go to an example Zarr dataset.", + attachTo: { element: '[data-tour="zarr-metadata"]', on: 'bottom' }, + buttons: [backButton, nextButton, exitButton] + }, + { + id: 'datalinks-janelia-properties', + title: 'Creating Data Links', + text: 'You can create a data link from the properties panel using the data link toggle. Turn it on to create a shareable link.', + attachTo: { element: '[data-tour="properties-drawer"]', on: 'left' }, + buttons: [backButton, nextButton, exitButton] + }, + { + id: 'datalinks-janelia-viewer', + title: 'Viewer Links', + text: 'Click on viewer icons in the metadata section to open the Zarr file in external viewers like Neuroglancer.', + attachTo: { element: '[data-tour="data-tool-links"]', on: 'bottom' }, + buttons: [backButton, nextButton, exitButton] + }, + { + id: 'datalinks-janelia-preferences', + title: 'Automatic Data Links', + text: 'You can enable automatic data link creation on the Preferences page, accessible from the profile menu.', + attachTo: { element: '[data-tour="profile-menu"]', on: 'bottom' }, + buttons: [backButton, takeAnotherTourButton, exitButton] + }, + + // Data Links workflow - Non-Janelia filesystem + { + id: 'datalinks-general-start', + title: 'Data Links', + text: 'You can create data links for any directory. Navigate to a folder you want to share.', + attachTo: { element: '[data-tour="file-browser"]', on: 'top' }, + buttons: [backButton, nextButton, exitButton] + }, + { + id: 'datalinks-general-properties', + title: 'Creating Data Links', + text: 'Open the properties panel and toggle the data link option to create a shareable link for this directory.', + attachTo: { element: '[data-tour="properties-drawer"]', on: 'left' }, + buttons: [backButton, nextButton, exitButton] + }, + { + id: 'datalinks-general-zarr', + title: 'Zarr/N5 Files', + text: "If a file is detected as Zarr or N5, you'll see viewer icons in the metadata displayed at the top of the file browser. You can click on a viewer icon to open the data link in external viewers like Neuroglancer.", + buttons: [backButton, nextButton, exitButton] + }, + { + id: 'datalinks-general-preferences', + title: 'Automatic Data Links', + text: 'You can enable automatic data link creation on the Preferences page, accessible from the profile menu.', + attachTo: { element: '[data-tour="profile-menu"]', on: 'bottom' }, + buttons: [backButton, takeAnotherTourButton, exitButton] + }, + + // File Conversion workflow + { + id: 'conversion-start', + title: 'File Conversion', + text: "Navigate to a file you want to convert. Let's go to an example file.", + buttons: [] // Will be dynamically replaced with navigation logic + }, + { + id: 'conversion-properties', + title: 'Request Conversion', + text: 'Select a file and open the properties panel to the "Convert" tab. Click "Open conversion request" to submit a conversion task.', + attachTo: { element: '[data-tour="properties-drawer"]', on: 'left' }, + buttons: [] // Will be dynamically replaced with navigation logic + }, + { + id: 'conversion-jobs', + title: 'Monitor Tasks', + text: 'View the status of your conversion requests on the Tasks page.', + attachTo: { element: '[data-tour="tasks-page"]', on: 'bottom' }, + buttons: [backButton, takeAnotherTourButton, exitButton] + } +]; diff --git a/frontend/src/components/ui/BrowsePage/Dashboard/FgDashboardCard.tsx b/frontend/src/components/ui/BrowsePage/Dashboard/FgDashboardCard.tsx index 4aacb9fd..dc0767c7 100644 --- a/frontend/src/components/ui/BrowsePage/Dashboard/FgDashboardCard.tsx +++ b/frontend/src/components/ui/BrowsePage/Dashboard/FgDashboardCard.tsx @@ -3,13 +3,17 @@ import { Card, Typography } from '@material-tailwind/react'; export default function DashboardCard({ title, - children + children, + className = '' }: { readonly title: string; readonly children: ReactNode; + readonly className?: string; }) { return ( - +
+ {allProxiedPathsQuery.isPending || zonesAndFspQuery.isPending ? ( Array(5) .fill(0) diff --git a/frontend/src/components/ui/BrowsePage/Dashboard/RecentlyViewedCard.tsx b/frontend/src/components/ui/BrowsePage/Dashboard/RecentlyViewedCard.tsx index 6f236b98..045fb6ea 100644 --- a/frontend/src/components/ui/BrowsePage/Dashboard/RecentlyViewedCard.tsx +++ b/frontend/src/components/ui/BrowsePage/Dashboard/RecentlyViewedCard.tsx @@ -14,7 +14,7 @@ export default function RecentlyViewedCard() { const { recentlyViewedFolders, preferenceQuery } = usePreferencesContext(); return ( - + {preferenceQuery.isPending || zonesAndFspQuery.isPending ? ( Array(5) .fill(0) @@ -28,6 +28,16 @@ export default function RecentlyViewedCard() { {zonesAndFspQuery.error.message}
+ ) : recentlyViewedFolders.length === 0 ? ( +
+ + No recently viewed folders. + + + Start navigating the file system to see your recently viewed folders + appear here. + +
) : (
    {recentlyViewedFolders.map((item, index) => { diff --git a/frontend/src/components/ui/BrowsePage/Dashboard/WelcomeTutorialCard.tsx b/frontend/src/components/ui/BrowsePage/Dashboard/WelcomeTutorialCard.tsx new file mode 100644 index 00000000..7f4ced5f --- /dev/null +++ b/frontend/src/components/ui/BrowsePage/Dashboard/WelcomeTutorialCard.tsx @@ -0,0 +1,53 @@ +import { Typography } from '@material-tailwind/react'; +import toast from 'react-hot-toast'; + +import { usePreferencesContext } from '@/contexts/PreferencesContext'; +import StartTour from '@/components/tours/StartTour'; +import DashboardCard from '@/components/ui/BrowsePage/Dashboard/FgDashboardCard'; + +export default function WelcomeTutorialCard() { + const { showTutorial, toggleShowTutorial } = usePreferencesContext(); + + const handleToggle = async () => { + const result = await toggleShowTutorial(); + if (result.success) { + toast.success( + 'Welcome card hidden. Access Tutorials from the Help page.' + ); + } else { + toast.error(result.error); + } + }; + + return ( + +
    + + Fileglancer helps you browse, visualize, and share scientific imaging + data. Get started with a guided tour to learn the basics! + + + Start Tour +
    + +
    +
    + + + Hide this card - you can always access Tutorials from the Help page + +
    +
    +
    + ); +} diff --git a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx index 18826eea..b4e34d0c 100644 --- a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx +++ b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx @@ -28,7 +28,7 @@ export default function DataToolLinks({ } return ( -
    +
    {title} diff --git a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx index c520a01f..770c9bdd 100644 --- a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx +++ b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx @@ -243,11 +243,13 @@ export default function FileBrowser({
    ) : displayFiles.length > 0 ? ( - +
    +
    + ) : displayFiles.length === 0 && !fileQuery.data.errorMessage ? (
    No files available for display. diff --git a/frontend/src/components/ui/BrowsePage/NavigateInput.tsx b/frontend/src/components/ui/BrowsePage/NavigateInput.tsx index cb3825c8..533ff50d 100644 --- a/frontend/src/components/ui/BrowsePage/NavigateInput.tsx +++ b/frontend/src/components/ui/BrowsePage/NavigateInput.tsx @@ -40,6 +40,7 @@ export default function NavigationInput({ return (
    @@ -47,6 +48,7 @@ export default function ProfileMenu() { diff --git a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx index 570d32db..36dbe8f6 100644 --- a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx +++ b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx @@ -120,7 +120,7 @@ export default function PropertiesDrawer({ const tooltipTriggerClasses = 'max-w-[calc(100%-2rem)] truncate'; return ( - <> +
    Properties @@ -338,6 +338,7 @@ export default function PropertiesDrawer({ like Neuroglancer.
    ); } diff --git a/frontend/src/components/ui/Sidebar/Sidebar.tsx b/frontend/src/components/ui/Sidebar/Sidebar.tsx index 68509b2b..9a4879aa 100644 --- a/frontend/src/components/ui/Sidebar/Sidebar.tsx +++ b/frontend/src/components/ui/Sidebar/Sidebar.tsx @@ -18,7 +18,10 @@ export default function Sidebar() { } = useFilteredZonesAndFavorites(); return ( - +
    Promise>; toggleUseLegacyMultichannelApproach: () => Promise>; toggleFilterByGroups: () => Promise>; + toggleShowTutorial: () => Promise>; handleFavoriteChange: ( item: Zone | FileSharePath | FolderFavorite, type: 'zone' | 'fileSharePath' | 'folder' @@ -233,6 +235,13 @@ export const PreferencesProvider = ({ ); }; + const toggleShowTutorial = async (): Promise> => { + return togglePreference( + 'showTutorial', + preferencesQuery.data?.showTutorial ?? true + ); + }; + function updatePreferenceList( key: string, itemToUpdate: T, @@ -490,6 +499,7 @@ export const PreferencesProvider = ({ useLegacyMultichannelApproach: preferencesQuery.data?.useLegacyMultichannelApproach || false, isFilteredByGroups: preferencesQuery.data?.isFilteredByGroups ?? true, + showTutorial: preferencesQuery.data?.showTutorial ?? true, // Favorites zoneFavorites: preferencesQuery.data?.zoneFavorites || [], @@ -515,6 +525,7 @@ export const PreferencesProvider = ({ toggleDisableHeuristicalLayerTypeDetection, toggleUseLegacyMultichannelApproach, toggleFilterByGroups, + toggleShowTutorial, handleFavoriteChange, handleContextMenuFavorite }; diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index e7096f8c..88d0b33c 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -4,6 +4,9 @@ import { Outlet, useParams } from 'react-router'; import { Toaster } from 'react-hot-toast'; import { ErrorBoundary } from 'react-error-boundary'; +import { ShepherdJourneyProvider } from 'react-shepherd'; +import 'shepherd.js/dist/css/shepherd.css'; +import '@/components/tours/shepherd-overrides.css'; import { ZonesAndFspMapContextProvider } from '@/contexts/ZonesAndFspMapContext'; import { FileBrowserContextProvider } from '@/contexts/FileBrowserContext'; @@ -26,7 +29,7 @@ const MainLayoutContent = () => { useServerHealthContext(); return ( - <> + { onRetry={checkHealth} open={showWarningOverlay} /> - + ); }; diff --git a/frontend/src/queries/preferencesQueries.ts b/frontend/src/queries/preferencesQueries.ts index 30083ca6..a5962069 100644 --- a/frontend/src/queries/preferencesQueries.ts +++ b/frontend/src/queries/preferencesQueries.ts @@ -37,6 +37,7 @@ type PreferencesApiResponse = { disableHeuristicalLayerTypeDetection?: { value: boolean }; useLegacyMultichannelApproach?: { value: boolean }; isFilteredByGroups?: { value: boolean }; + showTutorial?: { value: boolean }; zone?: { value: ZonePreference[] }; fileSharePath?: { value: FileSharePathPreference[] }; folder?: { value: FolderPreference[] }; @@ -66,6 +67,7 @@ export type PreferencesQueryData = { disableHeuristicalLayerTypeDetection: boolean; useLegacyMultichannelApproach: boolean; isFilteredByGroups: boolean; + showTutorial: boolean; }; /** @@ -229,7 +231,8 @@ const createTransformPreferences = ( rawData.disableHeuristicalLayerTypeDetection?.value || false, useLegacyMultichannelApproach: rawData.useLegacyMultichannelApproach?.value || false, - isFilteredByGroups: rawData.isFilteredByGroups?.value ?? true + isFilteredByGroups: rawData.isFilteredByGroups?.value ?? true, + showTutorial: rawData.showTutorial?.value ?? true }; }; };