diff --git a/extensions/default/src/ViewerLayout/index.tsx b/extensions/default/src/ViewerLayout/index.tsx index d39d6e181..3125dd136 100644 --- a/extensions/default/src/ViewerLayout/index.tsx +++ b/extensions/default/src/ViewerLayout/index.tsx @@ -6,6 +6,7 @@ import { HangingProtocolService, CommandsManager } from '@ohif/core'; import { useAppConfig } from '@state'; import ViewerHeader from './ViewerHeader'; import SidePanelWithServices from '../Components/SidePanelWithServices'; +import { Onboarding } from '@ohif/ui-next'; function ViewerLayout({ // From Extension Module Params @@ -155,7 +156,7 @@ function ViewerLayout({ ) : null} - + ); diff --git a/platform/app/package.json b/platform/app/package.json index d4d1f05cf..97906a088 100644 --- a/platform/app/package.json +++ b/platform/app/package.json @@ -98,6 +98,8 @@ "react-resize-detector": "^10.0.1", "react-router": "^6.23.1", "react-router-dom": "^6.8.1", + "react-shepherd": "^6.1.1", + "shepherd.js": "^13.0.3", "url-loader": "^4.1.1" }, "devDependencies": { diff --git a/platform/app/public/config/default.js b/platform/app/public/config/default.js index 6be608980..238c9d53b 100644 --- a/platform/app/public/config/default.js +++ b/platform/app/public/config/default.js @@ -291,4 +291,212 @@ window.config = { keys: ['9'], }, ], + tours: [ + { + id: 'basicViewerTour', + route: '/viewer', + steps: [ + { + id: 'scroll', + title: 'Scrolling Through Images', + text: 'You can scroll through the images using the mouse wheel or scrollbar.', + attachTo: { + element: '.viewport-element', + on: 'top', + }, + advanceOn: { + selector: '.cornerstone-viewport-element', + event: 'CORNERSTONE_TOOLS_MOUSE_WHEEL', + }, + beforeShowPromise: () => waitForElement('.viewport-element'), + }, + { + id: 'zoom', + title: 'Zooming In and Out', + text: 'You can zoom the images using the right click.', + attachTo: { + element: '.viewport-element', + on: 'left', + }, + advanceOn: { + selector: '.cornerstone-viewport-element', + event: 'CORNERSTONE_TOOLS_MOUSE_UP', + }, + beforeShowPromise: () => waitForElement('.viewport-element'), + }, + { + id: 'pan', + title: 'Panning the Image', + text: 'You can pan the images using the middle click.', + attachTo: { + element: '.viewport-element', + on: 'top', + }, + advanceOn: { + selector: '.cornerstone-viewport-element', + event: 'CORNERSTONE_TOOLS_MOUSE_UP', + }, + beforeShowPromise: () => waitForElement('.viewport-element'), + }, + { + id: 'windowing', + title: 'Adjusting Window Level', + text: 'You can modify the window level using the left click.', + attachTo: { + element: '.viewport-element', + on: 'left', + }, + advanceOn: { + selector: '.cornerstone-viewport-element', + event: 'CORNERSTONE_TOOLS_MOUSE_UP', + }, + beforeShowPromise: () => waitForElement('.viewport-element'), + }, + { + id: 'length', + title: 'Using the Measurement Tools', + text: 'You can measure the length of a region using the Length tool.', + attachTo: { + element: '[data-cy="MeasurementTools-split-button-primary"]', + on: 'bottom', + }, + advanceOn: { + selector: '[data-cy="MeasurementTools-split-button-primary"]', + event: 'click', + }, + beforeShowPromise: () => + waitForElement('[data-cy="MeasurementTools-split-button-primary]'), + }, + { + id: 'drawAnnotation', + title: 'Drawing Length Annotations', + text: 'Use the length tool on the viewport to measure the length of a region.', + attachTo: { + element: '.viewport-element', + on: 'right', + }, + advanceOn: { + selector: 'body', + event: 'event::measurement_added', + }, + beforeShowPromise: () => waitForElement('.viewport-element'), + }, + { + id: 'trackMeasurement', + title: 'Tracking Measurements in the Panel', + text: 'Click yes to track the measurements in the measurement panel.', + attachTo: { + element: '[data-cy="prompt-begin-tracking-yes-btn"]', + on: 'bottom', + }, + advanceOn: { + selector: '[data-cy="prompt-begin-tracking-yes-btn"]', + event: 'click', + }, + beforeShowPromise: () => waitForElement('[data-cy="prompt-begin-tracking-yes-btn"]'), + }, + { + id: 'openMeasurementPanel', + title: 'Opening the Measurements Panel', + text: 'Click the measurements button to open the measurements panel.', + attachTo: { + element: '#trackedMeasurements-btn', + on: 'left-start', + }, + advanceOn: { + selector: '#trackedMeasurements-btn', + event: 'click', + }, + beforeShowPromise: () => waitForElement('#trackedMeasurements-btn'), + }, + { + id: 'scrollAwayFromMeasurement', + title: 'Scrolling Away from a Measurement', + text: 'Scroll the images using the mouse wheel away from the measurement.', + attachTo: { + element: '.viewport-element', + on: 'left', + }, + advanceOn: { + selector: '.cornerstone-viewport-element', + event: 'CORNERSTONE_TOOLS_MOUSE_WHEEL', + }, + beforeShowPromise: () => waitForElement('.viewport-element'), + }, + { + id: 'jumpToMeasurement', + title: 'Jumping to Measurements in the Panel', + text: 'Click the measurement in the measurement panel to jump to it.', + attachTo: { + element: '[data-cy="measurement-item"]', + on: 'left-start', + }, + advanceOn: { + selector: '[data-cy="measurement-item"]', + event: 'click', + }, + beforeShowPromise: () => waitForElement('[data-cy="measurement-item"]'), + }, + { + id: 'changeLayout', + title: 'Changing Layout', + text: 'You can change the layout of the viewer using the layout button.', + attachTo: { + element: '[data-cy="Layout"]', + on: 'bottom', + }, + advanceOn: { + selector: '[data-cy="Layout"]', + event: 'click', + }, + beforeShowPromise: () => waitForElement('[data-cy="Layout"]'), + }, + { + id: 'selectLayout', + title: 'Selecting the MPR Layout', + text: 'Select the MPR layout to view the images in MPR mode.', + attachTo: { + element: '[data-cy="MPR"]', + on: 'left-start', + }, + advanceOn: { + selector: '[data-cy="MPR"]', + event: 'click', + }, + beforeShowPromise: () => waitForElement('[data-cy="MPR"]'), + }, + ], + tourOptions: { + useModalOverlay: true, + defaultStepOptions: { + buttons: [ + { + text: 'Skip all', + action() { + this.complete(); + }, + secondary: true, + }, + ], + }, + }, + }, + ], }; + +function waitForElement(selector, maxAttempts = 20, interval = 25) { + return new Promise(resolve => { + let attempts = 0; + + const checkForElement = setInterval(() => { + const element = document.querySelector(selector); + + if (element || attempts >= maxAttempts) { + clearInterval(checkForElement); + resolve(); + } + + attempts++; + }, interval); + }); +} diff --git a/platform/app/public/config/netlify.js b/platform/app/public/config/netlify.js index ab4756e6a..729bba3f6 100644 --- a/platform/app/public/config/netlify.js +++ b/platform/app/public/config/netlify.js @@ -247,4 +247,212 @@ window.config = { keys: ['9'], }, ], + tours: [ + { + id: 'basicViewerTour', + route: '/viewer', + steps: [ + { + id: 'scroll', + title: 'Scrolling Through Images', + text: 'You can scroll through the images using the mouse wheel or scrollbar.', + attachTo: { + element: '.viewport-element', + on: 'top', + }, + advanceOn: { + selector: '.cornerstone-viewport-element', + event: 'CORNERSTONE_TOOLS_MOUSE_WHEEL', + }, + beforeShowPromise: () => waitForElement('.viewport-element'), + }, + { + id: 'zoom', + title: 'Zooming In and Out', + text: 'You can zoom the images using the right click.', + attachTo: { + element: '.viewport-element', + on: 'left', + }, + advanceOn: { + selector: '.cornerstone-viewport-element', + event: 'CORNERSTONE_TOOLS_MOUSE_UP', + }, + beforeShowPromise: () => waitForElement('.viewport-element'), + }, + { + id: 'pan', + title: 'Panning the Image', + text: 'You can pan the images using the middle click.', + attachTo: { + element: '.viewport-element', + on: 'top', + }, + advanceOn: { + selector: '.cornerstone-viewport-element', + event: 'CORNERSTONE_TOOLS_MOUSE_UP', + }, + beforeShowPromise: () => waitForElement('.viewport-element'), + }, + { + id: 'windowing', + title: 'Adjusting Window Level', + text: 'You can modify the window level using the left click.', + attachTo: { + element: '.viewport-element', + on: 'left', + }, + advanceOn: { + selector: '.cornerstone-viewport-element', + event: 'CORNERSTONE_TOOLS_MOUSE_UP', + }, + beforeShowPromise: () => waitForElement('.viewport-element'), + }, + { + id: 'length', + title: 'Using the Measurement Tools', + text: 'You can measure the length of a region using the Length tool.', + attachTo: { + element: '[data-cy="MeasurementTools-split-button-primary"]', + on: 'bottom', + }, + advanceOn: { + selector: '[data-cy="MeasurementTools-split-button-primary"]', + event: 'click', + }, + beforeShowPromise: () => + waitForElement('[data-cy="MeasurementTools-split-button-primary]'), + }, + { + id: 'drawAnnotation', + title: 'Drawing Length Annotations', + text: 'Use the length tool on the viewport to measure the length of a region.', + attachTo: { + element: '.viewport-element', + on: 'right', + }, + advanceOn: { + selector: 'body', + event: 'event::measurement_added', + }, + beforeShowPromise: () => waitForElement('.viewport-element'), + }, + { + id: 'trackMeasurement', + title: 'Tracking Measurements in the Panel', + text: 'Click yes to track the measurements in the measurement panel.', + attachTo: { + element: '[data-cy="prompt-begin-tracking-yes-btn"]', + on: 'bottom', + }, + advanceOn: { + selector: '[data-cy="prompt-begin-tracking-yes-btn"]', + event: 'click', + }, + beforeShowPromise: () => waitForElement('[data-cy="prompt-begin-tracking-yes-btn"]'), + }, + { + id: 'openMeasurementPanel', + title: 'Opening the Measurements Panel', + text: 'Click the measurements button to open the measurements panel.', + attachTo: { + element: '#trackedMeasurements-btn', + on: 'left-start', + }, + advanceOn: { + selector: '#trackedMeasurements-btn', + event: 'click', + }, + beforeShowPromise: () => waitForElement('#trackedMeasurements-btn'), + }, + { + id: 'scrollAwayFromMeasurement', + title: 'Scrolling Away from a Measurement', + text: 'Scroll the images using the mouse wheel away from the measurement.', + attachTo: { + element: '.viewport-element', + on: 'left', + }, + advanceOn: { + selector: '.cornerstone-viewport-element', + event: 'CORNERSTONE_TOOLS_MOUSE_WHEEL', + }, + beforeShowPromise: () => waitForElement('.viewport-element'), + }, + { + id: 'jumpToMeasurement', + title: 'Jumping to Measurements in the Panel', + text: 'Click the measurement in the measurement panel to jump to it.', + attachTo: { + element: '[data-cy="measurement-item"]', + on: 'left-start', + }, + advanceOn: { + selector: '[data-cy="measurement-item"]', + event: 'click', + }, + beforeShowPromise: () => waitForElement('[data-cy="measurement-item"]'), + }, + { + id: 'changeLayout', + title: 'Changing Layout', + text: 'You can change the layout of the viewer using the layout button.', + attachTo: { + element: '[data-cy="Layout"]', + on: 'bottom', + }, + advanceOn: { + selector: '[data-cy="Layout"]', + event: 'click', + }, + beforeShowPromise: () => waitForElement('[data-cy="Layout"]'), + }, + { + id: 'selectLayout', + title: 'Selecting the MPR Layout', + text: 'Select the MPR layout to view the images in MPR mode.', + attachTo: { + element: '[data-cy="MPR"]', + on: 'left-start', + }, + advanceOn: { + selector: '[data-cy="MPR"]', + event: 'click', + }, + beforeShowPromise: () => waitForElement('[data-cy="MPR"]'), + }, + ], + tourOptions: { + useModalOverlay: true, + defaultStepOptions: { + buttons: [ + { + text: 'Skip all', + action() { + this.complete(); + }, + secondary: true, + }, + ], + }, + }, + }, + ], }; + +function waitForElement(selector, maxAttempts = 20, interval = 25) { + return new Promise(resolve => { + let attempts = 0; + + const checkForElement = setInterval(() => { + const element = document.querySelector(selector); + + if (element || attempts >= maxAttempts) { + clearInterval(checkForElement); + resolve(); + } + + attempts++; + }, interval); + }); +} diff --git a/platform/app/src/App.tsx b/platform/app/src/App.tsx index 13b6fecc4..23690d612 100644 --- a/platform/app/src/App.tsx +++ b/platform/app/src/App.tsx @@ -31,6 +31,7 @@ import { AppConfigProvider } from '@state'; import createRoutes from './routes'; import appInit from './appInit.js'; import OpenIdConnectRoutes from './utils/OpenIdConnectRoutes'; +import { ShepherdJourneyProvider } from 'react-shepherd'; let commandsManager: CommandsManager, extensionManager: ExtensionManager, @@ -115,6 +116,7 @@ function App({ [SnackbarProvider, { service: uiNotificationService }], [DialogProvider, { service: uiDialogService }], [ModalProvider, { service: uiModalService, modal: Modal }], + [ShepherdJourneyProvider], ]; // Loop through and register each of the service providers registered with the ServiceProvidersManager. diff --git a/platform/app/src/routes/WorkList/WorkList.tsx b/platform/app/src/routes/WorkList/WorkList.tsx index 01c39509e..785810ca3 100644 --- a/platform/app/src/routes/WorkList/WorkList.tsx +++ b/platform/app/src/routes/WorkList/WorkList.tsx @@ -34,6 +34,7 @@ import { import { Types } from '@ohif/ui'; import i18n from '@ohif/i18n'; +import { Onboarding } from '@ohif/ui-next'; const PatientInfoVisibility = Types.PatientInfoVisibility; @@ -411,7 +412,7 @@ function WorkList({ disabled={!isValidMode} startIconTooltip={ !isValidMode ? ( -
+
{invalidModeDescription}
) : null @@ -534,6 +535,7 @@ function WorkList({ WhiteLabeling={appConfig.whiteLabeling} showPatientInfo={PatientInfoVisibility.DISABLED} /> +
0; const hasCallbacks = Array.isArray(this.listeners[eventName]); + const event = new CustomEvent(eventName, { detail: callbackProps }); + document.body.dispatchEvent(event); + if (hasListeners && hasCallbacks) { this.listeners[eventName].forEach(listener => { listener.callback(callbackProps); diff --git a/platform/core/src/types/AppTypes.ts b/platform/core/src/types/AppTypes.ts index 7d5d35f5e..579dfa313 100644 --- a/platform/core/src/types/AppTypes.ts +++ b/platform/core/src/types/AppTypes.ts @@ -22,6 +22,8 @@ import ExtensionManagerType from '../extensions/ExtensionManager'; import Hotkey from '../classes/Hotkey'; +import { StepOptions, TourOptions } from 'shepherd.js'; + declare global { namespace AppTypes { export type ServicesManager = ServicesManagerType; @@ -129,6 +131,12 @@ declare global { maxNumPrefetchRequests: number; order: 'closest' | 'downward' | 'upward'; }; + tours?: Array<{ + id: string; + steps: StepOptions[]; + tourOptions: TourOptions; + route: string; + }>; } export interface Test { diff --git a/platform/docs/docs/configuration/tour-demo.gif b/platform/docs/docs/configuration/tour-demo.gif new file mode 100644 index 000000000..f351f759b Binary files /dev/null and b/platform/docs/docs/configuration/tour-demo.gif differ diff --git a/platform/docs/docs/configuration/tours.md b/platform/docs/docs/configuration/tours.md new file mode 100644 index 000000000..3c8828a8d --- /dev/null +++ b/platform/docs/docs/configuration/tours.md @@ -0,0 +1,151 @@ +--- +sidebar_position: 3 +sidebar_label: Tours +--- + +# Configuring Tours in OHIF with Shepherd.js + +In OHIF, you can configure guided tours for users by leveraging [Shepherd.js](https://shepherdjs.dev/), a JavaScript library for building feature tours. This page explains how you can define and customize these tours within your app configuration file. + +## Overview + +Tours allow you to provide step-by-step guidance to users, explaining different features of your mode/extension or the viewer. Each tour is associated with a route and consists of several steps, each guiding the user through specific interactions in the viewer. + +### Adding a Tour to your Configuration + +Here’s an example of adding a tour to your configuration file: + +```javascript +window.config = { + tours: [ + { + id: 'basicViewerTour', + route: '/viewer', + steps: [ + { + id: 'scroll', + title: 'Scrolling Through Images', + text: 'You can scroll through the images using the mouse wheel or scrollbar.', + attachTo: { + element: '.viewport-element', + on: 'top', + }, + advanceOn: { + selector: '.cornerstone-viewport-element', + event: 'CORNERSTONE_TOOLS_MOUSE_WHEEL', + }, + }, + { + id: 'zoom', + title: 'Zooming In and Out', + text: 'You can zoom the images using the right click.', + attachTo: { + element: '.viewport-element', + on: 'left', + }, + advanceOn: { + selector: '.cornerstone-viewport-element', + event: 'CORNERSTONE_TOOLS_MOUSE_UP', + }, + }, + // Add more steps as needed + ], + tourOptions: { + useModalOverlay: true, + defaultStepOptions: { + buttons: [ + { + text: 'Skip all', + action() { + this.complete(); + }, + secondary: true, + }, + ], + }, + }, + }, + ], +}; +``` + +## Explanation of Parameters + +### `tours` Array + +Each item in the `tours` array defines a specific tour for a particular route. The object contains the following properties: + +- **`id`**: A unique identifier for the tour. This helps in tracking whether the tour has been shown. +- **`route`**: The route in the application where the tour is applicable. When the user navigates to this route, the tour can automatically trigger if it hasn't been shown before. +- **`steps`**: An array of steps that define the individual guide elements in the tour. Each step corresponds to a UI element and guides the user through interactions. +- **`tourOptions`**: An object that allows you to configure the overall behavior of the tour, such as using a modal overlay or defining default step options. + +### `steps` Array + +Each step defines a part of the tour. Here's a breakdown of the properties you can define: + +- **`id`**: A unique identifier for the step within the tour. +- **`title`**: The title of the step, which appears at the top of the tooltip for the step. +- **`text`**: The content or description of the step, explaining what the user needs to do or understand. +- **`attachTo`**: Specifies where the step should be attached in the DOM. It includes: + - `element`: A string selector or a DOM element that the step should attach to. + - `on`: Specifies the position of the tooltip relative to the element (e.g., 'top', 'left', 'bottom', 'right'). +- **`advanceOn`**: Defines an event that will automatically advance the tour to the next step. This is useful for actions like clicking a button or scrolling. + - `selector`: The CSS selector for the element that triggers the advance. + - `event`: The event name that advances the step, this can be a OHIF service event, or a cornerstone event, or any native JS event (e.g., 'click', 'CORNERSTONE_TOOLS_MOUSE_WHEEL'). +- **`beforeShowPromise`**: A function that returns a promise. When the promise resolves, the rest of the show logic for the step will execute. You can use this to ensure that the target element is ready before the step shows. + +### `tourOptions` + +The `tourOptions` object allows you to configure the overall behavior of the tour. Here's a breakdown of the available properties: + +- **`useModalOverlay`**: A boolean that, if set to `true`, places the tour steps above a darkened modal overlay. The overlay creates an opening around the target element so it can remain interactive. +- **`defaultStepOptions`**: Default options that apply to all steps in the tour. You can override these in individual steps. The following are some options available: + - `buttons`: An array of button objects that appear in the footer of each step. Each button can trigger actions like advancing the tour or skipping it. For example: + - **`text`**: The label text on the button. + - **`action`**: A function to execute when the button is clicked. You can advance the tour using `this.next()`, or complete it using `this.complete()`. + - **`secondary`**: A boolean that, when set to `true`, styles the button as secondary (often for actions like skipping). + +### `floatingUIOptions` + +You can define positioning options for the steps using **Floating UI** middleware. This helps control how the steps are positioned, especially near the browser edges. + +For example, you can ensure that the steps maintain a margin of 24px from the viewport edges by configuring `preventOverflow` middleware: + +```javascript +floatingUIOptions: { + middleware: [ + preventOverflow({ padding: 24 }), + flip(), // Allows the step to flip if it is overflowing + ] +} +``` + +### Shepherd.js Lifecycle Events + +Each step and tour can have lifecycle events like `show`, `hide`, `complete`, or `cancel`. These events allow you to hook into the tour’s lifecycle to perform actions when certain events are triggered. + +For example: + +```javascript +when: { + show() { + console.log('Step shown!'); + }, + hide() { + console.log('Step hidden.'); + } +} +``` + +## Customizing Your Tour + +Once you have a basic tour in place, you can extend it with more advanced features like custom scrolling behavior, dynamic elements, and event-based step advancement. For more details, check out the [Shepherd.js documentation](https://shepherdjs.dev/). + +## Demo + +![Tour Demo]() + +## Conclusion + +By leveraging **Shepherd.js**, you can provide users with interactive and informative guided tours of the viewer. This can greatly improve the user experience and help users understand how to use key features. diff --git a/platform/ui-next/package.json b/platform/ui-next/package.json index ca62fb19f..a0f19c1c2 100644 --- a/platform/ui-next/package.json +++ b/platform/ui-next/package.json @@ -52,11 +52,16 @@ "next-themes": "^0.3.0", "react": "^18.3.1", "react-day-picker": "^8.10.1", + "react-shepherd": "^6.1.1", + "shepherd.js": "^13.0.3", "sonner": "^1.4.41", "tailwind-merge": "^2.3.0", "tailwindcss": "3.2.4", "tailwindcss-animate": "^1.0.7" }, + "devDependencies": { + "@babel/plugin-proposal-private-property-in-object": "^7.16.7" + }, "keywords": [], "author": "OHIF", "license": "MIT" diff --git a/platform/ui-next/src/components/Onboarding/Onboarding.css b/platform/ui-next/src/components/Onboarding/Onboarding.css new file mode 100644 index 000000000..2df4130f3 --- /dev/null +++ b/platform/ui-next/src/components/Onboarding/Onboarding.css @@ -0,0 +1,48 @@ +.shepherd-header { + @apply !bg-popover !w-[100%] !p-0; +} + +.shepherd-title { + @apply !text-highlight !w-[100%] !break-words !text-lg !leading-[1.5]; +} + +.shepherd-content { + @apply flex flex-col gap-[8px] p-[12px]; +} + +.shepherd-element { + @apply !bg-popover !max-w-[260px]; +} + +.shepherd-text { + @apply text-foreground !w-[100%] p-0 text-base leading-normal; +} + +.shepherd-footer { + @apply !w-[100%] p-0; +} + +.shepherd-button { + @apply !inline-flex !h-[36px] !min-w-[62px] !flex-row !items-center !justify-center !gap-[5px] !whitespace-nowrap !rounded !bg-[#348cfd] !px-[10px] !text-center !font-sans !text-[14px] !leading-[1.2] !text-white !outline-none !transition !duration-300 !ease-in-out focus:!outline-none; +} + +.shepherd-button.shepherd-button-secondary { + @apply !bg-transparent !text-[#348cfd]; +} + +.shepherd-arrow::before { + @apply !bg-popover !h-[30px] !w-[30px]; +} + +.shepherd-element[data-popper-placement^='left'] > .shepherd-arrow { + right: 3px !important; + top: 6px !important; +} + +.shepherd-element[data-popper-placement^='top'] > .shepherd-arrow { + bottom: 2px !important; +} + +.shepherd-modal-overlay-container.shepherd-modal-is-visible { + @apply !opacity-70; +} diff --git a/platform/ui-next/src/components/Onboarding/Onboarding.tsx b/platform/ui-next/src/components/Onboarding/Onboarding.tsx new file mode 100644 index 000000000..7b9fc0852 --- /dev/null +++ b/platform/ui-next/src/components/Onboarding/Onboarding.tsx @@ -0,0 +1,57 @@ +import { useEffect } from 'react'; +import { useShepherd } from 'react-shepherd'; +import { StepOptions, TourOptions } from 'shepherd.js'; +import { useLocation } from 'react-router'; +import 'shepherd.js/dist/css/shepherd.css'; +import './Onboarding.css'; + +import { hasTourBeenShown, markTourAsShown, defaultShowHandler, middleware } from './utilities'; + +const Onboarding = () => { + const Shepherd = useShepherd(); + const location = useLocation(); + const tours = window.config.tours as Array<{ + id: string; + route: string; + tourOptions: TourOptions; + steps: StepOptions[]; + }>; + + /** + * Show the tour if it hasn't been shown yet based on the current route. + * Constructs a tour instance and adds steps to it based on the matching tour. + */ + useEffect(() => { + if (!tours) { + return; + } + + const matchingTour = tours.find(tour => tour.route === location.pathname); + if (!matchingTour || hasTourBeenShown(matchingTour.id)) { + return; + } + + const tourInstance = new Shepherd.Tour({ + ...matchingTour.tourOptions, + defaultStepOptions: { + ...matchingTour.tourOptions?.defaultStepOptions, + floatingUIOptions: matchingTour.tourOptions?.defaultStepOptions?.floatingUIOptions || { + middleware, + }, + when: { + ...matchingTour.tourOptions?.defaultStepOptions?.when, + show: + matchingTour.tourOptions?.defaultStepOptions?.when?.show || + (() => defaultShowHandler(Shepherd)), + }, + }, + }); + matchingTour.steps.forEach(step => tourInstance.addStep(step)); + tourInstance.start(); + markTourAsShown(matchingTour.id); + }, [Shepherd, tours, location.pathname]); + + return null; +}; + +export { Onboarding }; diff --git a/platform/ui-next/src/components/Onboarding/index.ts b/platform/ui-next/src/components/Onboarding/index.ts new file mode 100644 index 000000000..459553f0b --- /dev/null +++ b/platform/ui-next/src/components/Onboarding/index.ts @@ -0,0 +1,3 @@ +import { Onboarding } from './Onboarding'; + +export { Onboarding }; diff --git a/platform/ui-next/src/components/Onboarding/utilities.ts b/platform/ui-next/src/components/Onboarding/utilities.ts new file mode 100644 index 000000000..bcd8c646a --- /dev/null +++ b/platform/ui-next/src/components/Onboarding/utilities.ts @@ -0,0 +1,91 @@ +import { ShepherdBase } from 'shepherd.js'; +import { offset, flip, shift, detectOverflow } from '@floating-ui/dom'; + +/** + * Retrieves the list of tours that have been shown from localStorage. + * @returns {string[]} An array of tour IDs that have been shown. + */ + +const getShownTours = () => JSON.parse(localStorage.getItem('shownTours')) || []; + +/** + * Checks if a specific tour has been shown. + * @param {string} tourId - The ID of the tour to check. + * @returns {boolean} True if the tour has been shown, false otherwise. + */ +const hasTourBeenShown = (tourId: string) => getShownTours().includes(tourId); + +/** + * Marks a specific tour as shown by adding it to localStorage. + * @param {string} tourId - The ID of the tour to mark as shown. + * @returns {void} + */ +const markTourAsShown = (tourId: string) => { + const shownTours = getShownTours(); + if (!shownTours.includes(tourId)) { + shownTours.push(tourId); + localStorage.setItem('shownTours', JSON.stringify(shownTours)); + } +}; + +/** + * Default handler for the 'show' event in Shepherd steps. + * Adds a progress indicator to the footer of the current step. + * + * @param {ShepherdBase} Shepherd - The Shepherd.js instance. + * @returns {void} + */ +const defaultShowHandler = (Shepherd: ShepherdBase) => { + const currentStep = Shepherd.activeTour?.getCurrentStep(); + if (currentStep) { + const progress = document.createElement('span'); + progress.className = 'shepherd-progress text-base text-muted-foreground'; + progress.innerText = `${Shepherd.activeTour?.steps.indexOf(currentStep) + 1}/${Shepherd.activeTour?.steps.length}`; + progress.style.position = 'absolute'; + progress.style.left = '13px'; + progress.style.bottom = '20px'; + progress.style.zIndex = '1'; + + const footer = currentStep?.getElement()?.querySelector('.shepherd-footer'); + footer?.appendChild(progress); + } +}; + +/** + * Custom middleware for adjusting Shepherd step positioning when overflowing. + * + * @type {object} + * @property {string} name - The name of the middleware. + * @property {function} fn - The function that adjusts the position of the step when overflowing. + */ + +const customMiddleware = { + name: 'customOverflowMiddleware', + async fn(state) { + const overflow = await detectOverflow(state, { + boundary: document.querySelector('body'), + padding: 24, + }); + + const xAdjustment = + overflow.left > 0 ? overflow.left : overflow.right > 0 ? -overflow.right : 0; + const yAdjustment = + overflow.top > 0 ? overflow.top : overflow.bottom > 0 ? -overflow.bottom : 0; + + return { + x: state.x + xAdjustment, + y: state.y + yAdjustment, + }; + }, +}; + +/** + * Default Floating UI middleware for positioning steps in Shepherd.js. + * Includes offset, shift, flip, and custom overflow middleware. + * + * @type {Array} + */ + +const middleware = [offset(15), shift(), flip(), customMiddleware]; + +export { hasTourBeenShown, markTourAsShown, middleware, defaultShowHandler }; diff --git a/platform/ui-next/src/components/index.ts b/platform/ui-next/src/components/index.ts index 2f48a9d06..983484367 100644 --- a/platform/ui-next/src/components/index.ts +++ b/platform/ui-next/src/components/index.ts @@ -67,6 +67,7 @@ import { DropdownMenuSubTrigger, DropdownMenuRadioGroup, } from './DropdownMenu'; +import { Onboarding } from './Onboarding'; export { Button, @@ -145,4 +146,5 @@ export { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuRadioGroup, + Onboarding, }; diff --git a/platform/ui-next/src/index.ts b/platform/ui-next/src/index.ts index 3a3dfff9f..880b3c11f 100644 --- a/platform/ui-next/src/index.ts +++ b/platform/ui-next/src/index.ts @@ -33,6 +33,7 @@ import { DisplaySetMessageListTooltip, Toolbox, ToolboxUI, + Onboarding, } from './components'; import { useNotification, NotificationProvider } from './contextProviders'; @@ -76,4 +77,5 @@ export { DisplaySetMessageListTooltip, Toolbox, ToolboxUI, + Onboarding, }; diff --git a/platform/ui/src/components/LayoutPreset/LayoutPreset.tsx b/platform/ui/src/components/LayoutPreset/LayoutPreset.tsx index d8d0a7272..024c7a879 100644 --- a/platform/ui/src/components/LayoutPreset/LayoutPreset.tsx +++ b/platform/ui/src/components/LayoutPreset/LayoutPreset.tsx @@ -17,6 +17,7 @@ function LayoutPreset({ onClick={() => { onSelection(commandOptions); }} + data-cy={title} >