From 85c899b399e2521480724be145538993721b9378 Mon Sep 17 00:00:00 2001 From: Joe Boccanfuso <109477394+jbocce@users.noreply.github.com> Date: Fri, 22 Sep 2023 15:36:44 -0400 Subject: [PATCH] feat(SidePanel): new side panel tab look-and-feel (#3657) --- .../.webpack/webpack.prod.js | 8 +- .../src/Components/SidePanelWithServices.tsx | 60 +++ extensions/default/src/ViewerLayout/index.tsx | 5 +- extensions/default/src/getPanelModule.tsx | 2 +- .../src/getPanelModule.tsx | 2 +- .../cypress/integration/MultiStudy.spec.js | 8 +- .../OHIFMeasurementPanel.spec.js | 18 +- platform/app/cypress/support/commands.js | 17 +- .../assets/icons/side-panel-close-left.svg | 14 + .../assets/icons/side-panel-close-right.svg | 14 + platform/ui/src/assets/icons/tab-studies.svg | 13 + platform/ui/src/components/Icon/getIcon.js | 6 + .../LegacySidePanel/LegacySidePanel.tsx | 376 ++++++++++++++++ .../__stories__/legacySidePanel.stories.mdx | 74 +++ .../src/components/LegacySidePanel/index.js | 2 + .../{SidePanel => LegacySidePanel}/style.css | 0 .../ui/src/components/SidePanel/SidePanel.tsx | 421 +++++++++--------- .../__stories__/sidePanel.stories.mdx | 1 + platform/ui/src/components/index.js | 2 + platform/ui/src/index.js | 1 + platform/ui/src/views/Viewer/Viewer.js | 10 +- yarn.lock | 19 + 22 files changed, 827 insertions(+), 246 deletions(-) create mode 100644 extensions/default/src/Components/SidePanelWithServices.tsx create mode 100644 platform/ui/src/assets/icons/side-panel-close-left.svg create mode 100644 platform/ui/src/assets/icons/side-panel-close-right.svg create mode 100644 platform/ui/src/assets/icons/tab-studies.svg create mode 100644 platform/ui/src/components/LegacySidePanel/LegacySidePanel.tsx create mode 100644 platform/ui/src/components/LegacySidePanel/__stories__/legacySidePanel.stories.mdx create mode 100644 platform/ui/src/components/LegacySidePanel/index.js rename platform/ui/src/components/{SidePanel => LegacySidePanel}/style.css (100%) diff --git a/extensions/cornerstone-dicom-seg/.webpack/webpack.prod.js b/extensions/cornerstone-dicom-seg/.webpack/webpack.prod.js index 6b6a4f71d..3f6eb4b69 100644 --- a/extensions/cornerstone-dicom-seg/.webpack/webpack.prod.js +++ b/extensions/cornerstone-dicom-seg/.webpack/webpack.prod.js @@ -45,10 +45,10 @@ module.exports = (env, argv) => { new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1, }), - // new MiniCssExtractPlugin({ - // filename: `./dist/${outputName}.css`, - // chunkFilename: `./dist/${outputName}.css`, - // }), + new MiniCssExtractPlugin({ + filename: `./dist/${outputName}.css`, + chunkFilename: `./dist/${outputName}.css`, + }), ], }); }; diff --git a/extensions/default/src/Components/SidePanelWithServices.tsx b/extensions/default/src/Components/SidePanelWithServices.tsx new file mode 100644 index 000000000..23e9841b0 --- /dev/null +++ b/extensions/default/src/Components/SidePanelWithServices.tsx @@ -0,0 +1,60 @@ +import React, { useEffect, useState } from 'react'; +import { SidePanel } from '@ohif/ui'; +import { PanelService, ServicesManager } from '@ohif/core'; + +export type SidePanelWithServicesProps = { + servicesManager: ServicesManager; + side: 'left' | 'right'; + className: string; + activeTabIndex: number; + tabs: any; +}; + +const SidePanelWithServices = ({ + servicesManager, + side, + className, + activeTabIndex: activeTabIndexProp, + tabs, +}) => { + const panelService: PanelService = servicesManager?.services?.panelService; + + // Tracks whether this SidePanel has been opened at least once since this SidePanel was inserted into the DOM. + // Thus going to the Study List page and back to the viewer resets this flag for a SidePanel. + const [hasBeenOpened, setHasBeenOpened] = useState(false); + const [activeTabIndex, setActiveTabIndex] = useState(activeTabIndexProp); + + useEffect(() => { + if (panelService) { + const activatePanelSubscription = panelService.subscribe( + panelService.EVENTS.ACTIVATE_PANEL, + (activatePanelEvent: Types.ActivatePanelEvent) => { + if (!hasBeenOpened || activatePanelEvent.forceActive) { + const tabIndex = tabs.findIndex(tab => tab.id === activatePanelEvent.panelId); + if (tabIndex !== -1) { + setActiveTabIndex(tabIndex); + } + } + } + ); + + return () => { + activatePanelSubscription.unsubscribe(); + }; + } + }, [tabs, hasBeenOpened, panelService]); + + return ( + { + setHasBeenOpened(true); + }} + > + ); +}; + +export default SidePanelWithServices; diff --git a/extensions/default/src/ViewerLayout/index.tsx b/extensions/default/src/ViewerLayout/index.tsx index a4556c368..e045bca33 100644 --- a/extensions/default/src/ViewerLayout/index.tsx +++ b/extensions/default/src/ViewerLayout/index.tsx @@ -5,6 +5,7 @@ import { SidePanel, ErrorBoundary, LoadingIndicatorProgress } from '@ohif/ui'; import { ServicesManager, HangingProtocolService, CommandsManager } from '@ohif/core'; import { useAppConfig } from '@state'; import ViewerHeader from './ViewerHeader'; +import SidePanelWithServices from '../Components/SidePanelWithServices'; function ViewerLayout({ // From Extension Module Params @@ -119,7 +120,7 @@ function ViewerLayout({ {/* LEFT SIDEPANELS */} {leftPanelComponents.length ? ( - {rightPanelComponents.length ? ( - { it('Should display 2 comparison up', () => { beforeSetup(); - cy.get('[data-cy="viewport-pane"]').its('length').should('be.eq', 4); - cy.get('[data-cy="studyDate"]').should(studyDate => { + cy.get('[data-cy="viewport-pane"]').as('viewportPane'); + cy.get('@viewportPane').its('length').should('be.eq', 4); + + cy.get('[data-cy="studyDate"]').as('studyDate'); + + cy.get('@studyDate').should(studyDate => { expect(studyDate.length).to.be.eq(4); expect(studyDate.text()).to.contain('2014').contain('2001'); expect(studyDate.text().indexOf('2014')).to.be.lessThan(studyDate.text().indexOf('2001')); diff --git a/platform/app/cypress/integration/measurement-tracking/OHIFMeasurementPanel.spec.js b/platform/app/cypress/integration/measurement-tracking/OHIFMeasurementPanel.spec.js index 2d69ae06b..74b172f32 100644 --- a/platform/app/cypress/integration/measurement-tracking/OHIFMeasurementPanel.spec.js +++ b/platform/app/cypress/integration/measurement-tracking/OHIFMeasurementPanel.spec.js @@ -23,26 +23,28 @@ describe('OHIF Measurement Panel', function () { it('checks if measurement item can be Relabeled under Measurements panel', function () { // Add length measurement cy.addLengthMeasurement(); - cy.get('[data-cy="viewport-notification"]').should('exist'); - cy.get('[data-cy="viewport-notification"]').should('be.visible'); - cy.get('[data-cy="prompt-begin-tracking-yes-btn"]').click(); - cy.get('[data-cy="measurement-item"]').click(); + cy.get('[data-cy="viewport-notification"]').as('viewportNotification').should('exist'); + cy.get('[data-cy="viewport-notification"]').as('viewportNotification').should('be.visible'); - cy.get('[data-cy="measurement-item"]').find('svg').click(); + cy.get('[data-cy="prompt-begin-tracking-yes-btn"]').as('promptBeginTrackingYesBtn').click(); + + cy.get('[data-cy="measurement-item"]').as('measurementItem').click(); + + cy.get('[data-cy="measurement-item"]').find('svg').as('measurementItemSvg').click(); // enter Bone label cy.get('[data-cy="input-annotation"]').should('exist'); cy.get('[data-cy="input-annotation"]').should('be.visible'); cy.get('[data-cy="input-annotation"]').type('Bone{enter}'); - // Verify if 'Bone' label was added - cy.get('[data-cy="measurement-item"]').should('contain.text', 'Bone'); + cy.get('[data-cy="measurement-item"]').as('measurementItem').should('contain.text', 'Bone'); }); it('checks if image would jump when clicked on a measurement item', function () { // Add length measurement cy.addLengthMeasurement(); - cy.get('[data-cy="prompt-begin-tracking-yes-btn"]').click(); + cy.get('[data-cy="prompt-begin-tracking-yes-btn"]').as('promptBeginTrackingYesBtn'); + cy.get('@promptBeginTrackingYesBtn').click(); cy.scrollToIndex(13); diff --git a/platform/app/cypress/support/commands.js b/platform/app/cypress/support/commands.js index d6bbbc414..948df1a27 100644 --- a/platform/app/cypress/support/commands.js +++ b/platform/app/cypress/support/commands.js @@ -144,19 +144,22 @@ Cypress.Commands.add('drag', { prevSubject: 'element' }, (...args) => * @param {number[]} secondClick - Click position [x, y] */ Cypress.Commands.add('addLine', (viewport, firstClick, secondClick) => { + const performClick = (alias, x, y) => { + cy.get(alias).click(x, y, { force: true, multiple: true }).wait(250); + }; + cy.get(viewport).as('viewportAlias'); const [x1, y1] = firstClick; const [x2, y2] = secondClick; // First click - cy.get('@viewportAlias').click(x1, y1, { force: true, multiple: true }).wait(250); + performClick('@viewportAlias', x1, y1); - // Move the mouse and then click again - cy.get('@viewportAlias') - .trigger('mousemove', { clientX: x2, clientY: y2 }) - .get('@viewportAlias') - .click(x2, y2, { force: true, multiple: true }) - .wait(250); + // Move the mouse + cy.get('@viewportAlias').trigger('mousemove', { clientX: x2, clientY: y2 }).wait(250); + + // Second click + performClick('@viewportAlias', x2, y2); }); /** diff --git a/platform/ui/src/assets/icons/side-panel-close-left.svg b/platform/ui/src/assets/icons/side-panel-close-left.svg new file mode 100644 index 000000000..ef901e0a8 --- /dev/null +++ b/platform/ui/src/assets/icons/side-panel-close-left.svg @@ -0,0 +1,14 @@ + + + icon-panel-close-left + + + + + + + + + + + diff --git a/platform/ui/src/assets/icons/side-panel-close-right.svg b/platform/ui/src/assets/icons/side-panel-close-right.svg new file mode 100644 index 000000000..d455e2abc --- /dev/null +++ b/platform/ui/src/assets/icons/side-panel-close-right.svg @@ -0,0 +1,14 @@ + + + icon-panel-close-right + + + + + + + + + + + diff --git a/platform/ui/src/assets/icons/tab-studies.svg b/platform/ui/src/assets/icons/tab-studies.svg new file mode 100644 index 000000000..7e3e1ce3e --- /dev/null +++ b/platform/ui/src/assets/icons/tab-studies.svg @@ -0,0 +1,13 @@ + + + tab-studies + + + + + + + + + + diff --git a/platform/ui/src/components/Icon/getIcon.js b/platform/ui/src/components/Icon/getIcon.js index f8272a9e1..9604964a4 100644 --- a/platform/ui/src/components/Icon/getIcon.js +++ b/platform/ui/src/components/Icon/getIcon.js @@ -39,6 +39,8 @@ import profile from './../../assets/icons/profile.svg'; import pushLeft from './../../assets/icons/push-left.svg'; import pushRight from './../../assets/icons/push-right.svg'; import settings from './../../assets/icons/settings.svg'; +import sidePanelCloseLeft from './../../assets/icons/side-panel-close-left.svg'; +import sidePanelCloseRight from './../../assets/icons/side-panel-close-right.svg'; import sorting from './../../assets/icons/sorting.svg'; import sortingActiveDown from './../../assets/icons/sorting-active-down.svg'; import sortingActiveUp from './../../assets/icons/sorting-active-up.svg'; @@ -71,6 +73,7 @@ import tabLinear from './../../assets/icons/tab-linear.svg'; import tabPatientInfo from './../../assets/icons/tab-patient-info.svg'; import tabROIThreshold from './../../assets/icons/tab-roi-threshold.svg'; import tabSegmentation from './../../assets/icons/tab-segmentation.svg'; +import tabStudies from './../../assets/icons/tab-studies.svg'; import uiArrowDown from './../../assets/icons/ui-arrow-down.svg'; import uiArrowUp from './../../assets/icons/ui-arrow-up.svg'; import uiArrowLeft from './../../assets/icons/ui-arrow-left.svg'; @@ -189,6 +192,8 @@ const ICONS = { 'push-left': pushLeft, 'push-right': pushRight, settings: settings, + 'side-panel-close-left': sidePanelCloseLeft, + 'side-panel-close-right': sidePanelCloseRight, 'sorting-active-down': sortingActiveDown, 'sorting-active-up': sortingActiveUp, 'status-alert': statusAlert, @@ -259,6 +264,7 @@ const ICONS = { 'tab-patient-info': tabPatientInfo, 'tab-roi-threshold': tabROIThreshold, 'tab-segmentation': tabSegmentation, + 'tab-studies': tabStudies, 'ui-arrow-down': uiArrowDown, 'ui-arrow-up': uiArrowUp, 'ui-arrow-left': uiArrowLeft, diff --git a/platform/ui/src/components/LegacySidePanel/LegacySidePanel.tsx b/platform/ui/src/components/LegacySidePanel/LegacySidePanel.tsx new file mode 100644 index 000000000..ceb9c491f --- /dev/null +++ b/platform/ui/src/components/LegacySidePanel/LegacySidePanel.tsx @@ -0,0 +1,376 @@ +import classnames from 'classnames'; +import PropTypes from 'prop-types'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import SwiperCore, { A11y, Controller, Navigation, Pagination, Scrollbar } from 'swiper'; +import { Swiper, SwiperSlide } from 'swiper/react'; + +import { PanelService, ServicesManager, Types } from '@ohif/core'; + +import LegacyButton from '../LegacyButton'; +import Icon from '../Icon'; +import IconButton from '../IconButton'; +import Tooltip from '../Tooltip'; + +import 'swiper/css'; +import 'swiper/css/navigation'; +import './style.css'; + +const borderSize = 4; +const expandedWidth = 248; +const collapsedWidth = 25; + +const baseStyle = { + maxWidth: `${expandedWidth}px`, + width: `${expandedWidth}px`, +}; + +const collapsedHideWidth = expandedWidth - collapsedWidth - borderSize; +const styleMap = { + open: { + left: { marginLeft: '0px' }, + right: { marginRight: '0px' }, + }, + closed: { + left: { marginLeft: `-${collapsedHideWidth}px` }, + right: { marginRight: `-${collapsedHideWidth}px` }, + }, +}; + +const baseClasses = + 'transition-all duration-300 ease-in-out h-100 bg-black border-black justify-start box-content flex flex-col'; + +const classesMap = { + open: { + left: `mr-1`, + right: `ml-1`, + }, + closed: { + left: `mr-2 items-end`, + right: `ml-2 items-start`, + }, +}; + +const openStateIconName = { + left: 'push-left', + right: 'push-right', +}; + +const position = { + left: { + right: 5, + }, + right: { + left: 5, + }, +}; + +const LegacySidePanel = ({ + servicesManager, + side, + className, + activeTabIndex: activeTabIndexProp, + tabs, +}) => { + const panelService: PanelService = servicesManager?.services?.panelService; + + const { t } = useTranslation('LegacySidePanel'); + + // Tracks whether this LegacySidePanel has been opened at least once since this LegacySidePanel was inserted into the DOM. + // Thus going to the Study List page and back to the viewer resets this flag for a LegacySidePanel. + const [hasBeenOpened, setHasBeenOpened] = useState(activeTabIndexProp !== null); + const [panelOpen, setPanelOpen] = useState(activeTabIndexProp !== null); + const [activeTabIndex, setActiveTabIndex] = useState(activeTabIndexProp ?? 0); + const swiperRef = useRef() as any; + const [swiper, setSwiper] = useState(); + + const prevRef = React.useRef(); + const nextRef = React.useRef(); + + const openStatus = panelOpen ? 'open' : 'closed'; + const style = Object.assign({}, styleMap[openStatus][side], baseStyle); + + const ActiveComponent = tabs[activeTabIndex].content; + + useEffect(() => { + if (panelOpen && swiper) { + swiper.slideTo(activeTabIndex, 500); + } + }, [panelOpen, swiper]); + + useEffect(() => { + if (swiper) { + swiper.params.navigation.prevEl = prevRef.current; + swiper.params.navigation.nextEl = nextRef.current; + swiper.navigation.init(); + swiper.navigation.update(); + } + }, [swiper]); + + const updatePanelOpen = useCallback((panelOpen: boolean) => { + setPanelOpen(panelOpen); + if (panelOpen) { + setHasBeenOpened(true); + } + }, []); + + const updateActiveTabIndex = useCallback( + (activeTabIndex: number) => { + setActiveTabIndex(activeTabIndex); + updatePanelOpen(true); + }, + [updatePanelOpen] + ); + + useEffect(() => { + if (panelService) { + const activatePanelSubscription = panelService.subscribe( + panelService.EVENTS.ACTIVATE_PANEL, + (activatePanelEvent: Types.ActivatePanelEvent) => { + if (!hasBeenOpened || activatePanelEvent.forceActive) { + const tabIndex = tabs.findIndex(tab => tab.id === activatePanelEvent.panelId); + if (tabIndex !== -1) { + updateActiveTabIndex(tabIndex); + } + } + } + ); + + return () => { + activatePanelSubscription.unsubscribe(); + }; + } + }, [tabs, hasBeenOpened, panelService, updateActiveTabIndex]); + + const getCloseStateComponent = () => { + const _childComponents = Array.isArray(tabs) ? tabs : [tabs]; + return ( + <> +
{ + updatePanelOpen(prev => !prev); + }} + data-cy={`side-panel-header-${side}`} + > + +
+
+ {_childComponents.map((childComponent, index) => ( + + { + updateActiveTabIndex(index); + }} + > + + + + ))} +
+ + ); + }; + + return ( +
+ {panelOpen ? ( + + {/** Panel Header with Arrow and Close Actions */} +
{ + updatePanelOpen(prev => !prev); + // slideToActivePanel(); + }} + data-cy={`side-panel-header-${side}`} + > + {/* TODO This should be redesigned to not be a button. */} + + + {/* Todo: ass secondary label here */} + + {tabs.length === 1 && (t(tabs[activeTabIndex].label) as string)} + + +
+ {tabs.length > 1 && + _getMoreThanOneTabLayout( + swiperRef, + setSwiper, + prevRef, + nextRef, + tabs, + activeTabIndex, + updateActiveTabIndex + )} + {/** carousel navigation with the arrows */} + {/** only show carousel nav if tabs are more than 3 tabs */} + {tabs.length > 3 && ( +
+ + +
+ )} + +
+ ) : ( + {getCloseStateComponent()} + )} +
+ ); +}; + +LegacySidePanel.defaultProps = { + defaultComponentOpen: null, +}; + +LegacySidePanel.propTypes = { + servicesManager: PropTypes.instanceOf(ServicesManager), + side: PropTypes.oneOf(['left', 'right']).isRequired, + className: PropTypes.string, + activeTabIndex: PropTypes.number, + tabs: PropTypes.oneOfType([ + PropTypes.arrayOf( + PropTypes.shape({ + iconName: PropTypes.string.isRequired, + iconLabel: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + content: PropTypes.func, // TODO: Should be node, but it keeps complaining? + }) + ), + ]), +}; + +function _getMoreThanOneTabLayout( + swiperRef: any, + setSwiper: React.Dispatch, + prevRef: React.MutableRefObject, + nextRef: React.MutableRefObject, + tabs: any, + activeTabIndex: any, + updateActiveTabIndex +) { + return ( +
+
+ { + swiperRef.current = core.el; + }} + simulateTouch={false} + modules={[Navigation, Pagination, Scrollbar, A11y, Controller]} + slidesPerView={3} + spaceBetween={5} + onSwiper={swiper => setSwiper(swiper)} + navigation={{ + prevEl: prevRef?.current, + nextEl: nextRef?.current, + }} + > + {tabs.map((obj, index) => ( + +
{ + updateActiveTabIndex(index); + }} + data-cy={`${obj.name}-btn`} + > + + + + + {obj.label} + +
+
+ ))} +
+
+
+ ); +} + +export default LegacySidePanel; diff --git a/platform/ui/src/components/LegacySidePanel/__stories__/legacySidePanel.stories.mdx b/platform/ui/src/components/LegacySidePanel/__stories__/legacySidePanel.stories.mdx new file mode 100644 index 000000000..712649c8d --- /dev/null +++ b/platform/ui/src/components/LegacySidePanel/__stories__/legacySidePanel.stories.mdx @@ -0,0 +1,74 @@ +import LegacySidePanel from '../LegacySidePanel'; + +import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs'; +import { createComponentTemplate } from '../../../storybook/functions/create-component-story'; + +export const argTypes = { + component: LegacySidePanel, + title: 'Components/LegacySidePanel', +}; + + + +export const LegacySidePanelTemplate = args => ( +
+ +
+); + + + +- [Overview](#overview) +- [Props](#props) +- [Contribute](#contribute) + +## Overview + +LegacySidePanel is a component that renders the LegacySidePanels. + + +
Hello
, + }, + ], + childComponents: [ + { + iconName: 'home', + iconLabel: 'launch-info', + name: 'Example', + label: 'home', + content: () =>
Hello world
, + }, + ], + }} + > + {LegacySidePanelTemplate.bind({})} +
+
+ +## Props + + + +## Usage + +## Contribute + +