wire up UI Services

This commit is contained in:
dannyrb 2020-05-23 23:38:20 -04:00
parent c9a4ab13ef
commit db76eac047
14 changed files with 662 additions and 28 deletions

View File

@ -4,16 +4,22 @@ export { utils };
/** CONTEXT/HOOKS */
export {
DialogProvider,
useDialog,
withDialog,
DragAndDropProvider,
ModalProvider,
ModalConsumer,
useModal,
withModal,
ViewportDialogProvider,
useViewportDialog,
ImageViewerContext,
ImageViewerProvider,
useImageViewer,
SnackbarProvider,
useSnackbar,
withSnackbar,
ViewportDialogProvider,
useViewportDialog,
ViewportGridContext,
ViewportGridProvider,
useViewportGrid,
@ -37,6 +43,7 @@ export {
Label,
MeasurementsPanel,
MeasurementTable,
Modal,
NavBar,
Notification,
Select,

View File

@ -43,6 +43,7 @@
"react-dnd-html5-backend": "^10.0.2",
"react-dnd-touch-backend": "^10.0.2",
"react-dom": "16.11.0",
"react-modal": "^3.11.2",
"react-powerplug": "1.0.0",
"react-select": "^3.0.8",
"theme-ui": "^0.2.38"

View File

@ -0,0 +1,72 @@
import React from 'react';
import PropTypes from 'prop-types';
import ReactModal from 'react-modal';
import classNames from 'classnames';
const customStyle = {
overlay: {
zIndex: 1071,
backgroundColor: 'rgb(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
};
ReactModal.setAppElement(document.getElementById('root'));
const Modal = ({
className,
closeButton,
shouldCloseOnEsc,
isOpen,
title,
onClose,
children,
}) => {
const renderHeader = () => {
return (
title && (
<header>
<h4>{title}</h4>
{closeButton && (
<button data-cy="close-button" onClick={onClose}>
×
</button>
)}
</header>
)
);
};
return (
<ReactModal
className={classNames(className)}
shouldCloseOnEsc={shouldCloseOnEsc}
isOpen={isOpen}
title={title}
style={customStyle}
>
<>
{renderHeader()}
<section>{children}</section>
</>
</ReactModal>
);
};
Modal.propTypes = {
className: PropTypes.string,
closeButton: PropTypes.bool,
shouldCloseOnEsc: PropTypes.bool,
isOpen: PropTypes.bool,
title: PropTypes.string,
onClose: PropTypes.func,
/** The modal's content */
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
};
export default Modal;

View File

@ -0,0 +1,2 @@
import Modal from './Modal';
export default Modal;

View File

@ -0,0 +1,52 @@
import React from 'react';
import SnackbarItem from './SnackbarItem';
import { useSnackbar } from '../../contextProviders';
const SnackbarContainer = () => {
const { snackbarItems, hide } = useSnackbar();
const renderItem = item => {
return <SnackbarItem key={item.itemId} options={item} onClose={hide} />;
};
if (!snackbarItems) {
return null;
}
const renderItems = () => {
const items = {
topLeft: [],
topCenter: [],
topRight: [],
bottomLeft: [],
bottomCenter: [],
bottomRight: [],
};
snackbarItems.map(item => {
items[item.position].push(item);
});
return (
<div>
{Object.keys(items).map(pos => {
if (!items[pos].length) {
return null;
}
return (
<div key={pos} className={`sb-container sb-${pos}`}>
{items[pos].map((item, index) => (
<div key={item.id + index}>{renderItem(item)}</div>
))}
</div>
);
})}
</div>
);
};
return <>{renderItems()}</>;
};
export default SnackbarContainer;

View File

@ -0,0 +1,27 @@
import React, { useEffect } from 'react';
const SnackbarItem = ({ options, onClose }) => {
const handleClose = () => {
onClose(options.id);
};
useEffect(() => {
if (options.autoClose) {
setTimeout(() => {
handleClose();
}, options.duration);
}
}, []);
return (
<div>
<span onClick={handleClose}>
<span>x</span>
</span>
{options.title && <div>{options.title}</div>}
{options.message && <div>{options.message}</div>}
</div>
);
};
export default SnackbarItem;

View File

@ -0,0 +1,6 @@
export default {
INFO: 'info',
WARNING: 'warning',
SUCCESS: 'success',
ERROR: 'error',
};

View File

@ -14,6 +14,7 @@ import InputText from './InputText';
import Label from './Label';
import MeasurementsPanel from './MeasurementsPanel';
import MeasurementTable from './MeasurementTable';
import Modal from './Modal';
import NavBar from './NavBar';
import Notification from './Notification';
import Select from './Select';
@ -62,6 +63,7 @@ export {
Label,
MeasurementsPanel,
MeasurementTable,
Modal,
NavBar,
Notification,
Select,

View File

@ -0,0 +1,280 @@
import React, {
useState,
createContext,
useContext,
useCallback,
useEffect,
} from 'react';
import PropTypes from 'prop-types';
import Draggable from 'react-draggable';
import classNames from 'classnames';
import { utils } from '@ohif/core';
const DialogContext = createContext(null);
export const useDialog = () => useContext(DialogContext);
const DialogProvider = ({ children, service }) => {
const [isDragging, setIsDragging] = useState(false);
const [dialogs, setDialogs] = useState([]);
const [lastDialogId, setLastDialogId] = useState(null);
const [lastDialogPosition, setLastDialogPosition] = useState(null);
const [centerPositions, setCenterPositions] = useState([]);
useEffect(() => {
setCenterPositions(
dialogs.map(dialog => ({
id: dialog.id,
...getCenterPosition(dialog.id),
}))
);
}, [dialogs]);
const getCenterPosition = id => {
const root = document.querySelector('#root');
const centerX = root.offsetLeft + root.offsetWidth / 2;
const centerY = root.offsetTop + root.offsetHeight / 2;
const item = document.querySelector(`#draggableItem-${id}`);
const itemBounds = item.getBoundingClientRect();
return {
x: centerX - itemBounds.width / 2,
y: centerY - itemBounds.height / 2,
};
};
/**
* Sets the implementation of a dialog service that can be used by extensions.
*
* @returns void
*/
useEffect(() => {
if (service) {
service.setServiceImplementation({ create, dismiss, dismissAll });
}
}, [create, dismiss, service]);
/**
* UI Dialog
*
* @typedef {Object} DialogProps
* @property {string} id The dialog id.
* @property {DialogContent} content The dialog content.
* @property {Object} contentProps The dialog content props.
* @property {boolean} isDraggable Controls if dialog content is draggable or not.
* @property {boolean} showOverlay Controls dialog overlay.
* @property {boolean} centralize Center the dialog on the screen.
* @property {boolean} preservePosition Use last position instead of default.
* @property {ElementPosition} defaultPosition Specifies the `x` and `y` that the dragged item should start at.
* @property {Function} onStart Called when dragging starts. If `false` is returned any handler, the action will cancel.
* @property {Function} onStop Called when dragging stops.
* @property {Function} onDrag Called while dragging.
*/
useEffect(() => _bringToFront(lastDialogId), [_bringToFront, lastDialogId]);
/**
* Creates a new dialog and return its id.
*
* @param {DialogProps} props The dialog props.
* @returns The new dialog id.
*/
const create = useCallback(props => {
const { id } = props;
let dialogId = id;
if (!dialogId) {
dialogId = utils.guid();
}
setDialogs(dialogs => [...dialogs, { ...props, id: dialogId }]);
setLastDialogId(dialogId);
return dialogId;
}, []);
/**
* Dismisses the dialog with a given id.
*
* @param {Object} props -
* @property {string} props.id The dialog id.
* @returns void
*/
const dismiss = useCallback(
({ id }) =>
setDialogs(dialogs => dialogs.filter(dialog => dialog.id !== id)),
[]
);
/**
* Dismisses all dialogs.
*
* @returns void
*/
const dismissAll = () => {
setDialogs([]);
};
/**
* Indicate if there are no dialogs present.
*
* @returns True if no dialogs are present.
*/
const isEmpty = () => dialogs && dialogs.length < 1;
/**
* Moves the dialog to the foreground if clicked.
*
* @param {string} id The dialog id.
* @returns void
*/
const _bringToFront = useCallback(id => {
setDialogs(dialogs => {
const topDialog = dialogs.find(dialog => dialog.id === id);
return topDialog
? [...dialogs.filter(dialog => dialog.id !== id), topDialog]
: dialogs;
});
}, []);
const renderDialogs = () =>
dialogs.map(dialog => {
const {
id,
content: DialogContent,
contentProps,
defaultPosition,
centralize = false,
preservePosition = true,
isDraggable = true,
onStart,
onStop,
onDrag,
} = dialog;
let position =
(preservePosition && lastDialogPosition) || defaultPosition;
if (centralize) {
position = centerPositions.find(position => position.id === id);
}
return (
<Draggable
key={id}
disabled={!isDraggable}
position={position}
defaultPosition={position}
bounds="parent"
onStart={event => {
const e = event || window.event;
const target = e.target || e.srcElement;
const BLACKLIST = [
'SVG',
'BUTTON',
'PATH',
'INPUT',
'SPAN',
'LABEL',
];
if (BLACKLIST.includes(target.tagName.toUpperCase())) {
return false;
}
if (validCallback(onStart)) {
return onStart(event);
}
}}
onStop={event => {
setIsDragging(false);
if (validCallback(onStop)) {
return onStop(event);
}
}}
onDrag={event => {
setIsDragging(true);
_bringToFront(id);
_updateLastDialogPosition(id);
if (validCallback(onDrag)) {
return onDrag(event);
}
}}
>
<div
id={`draggableItem-${id}`}
className={classNames(
'DraggableItem',
isDragging && 'dragging',
isDraggable && 'draggable'
)}
style={{ zIndex: '999', position: 'absolute' }}
onClick={() => _bringToFront(id)}
>
<DialogContent {...dialog} {...contentProps} />
</div>
</Draggable>
);
});
/**
* Update the last dialog position to be used as the new default position.
*
* @returns void
*/
const _updateLastDialogPosition = dialogId => {
const draggableItemBounds = document
.querySelector(`#draggableItem-${dialogId}`)
.getBoundingClientRect();
setLastDialogPosition({
x: draggableItemBounds.x,
y: draggableItemBounds.y,
});
};
const validCallback = callback => callback && typeof callback === 'function';
return (
<DialogContext.Provider value={{ create, dismiss, dismissAll, isEmpty }}>
<div className="DraggableArea">
{dialogs.some(dialog => dialog.showOverlay) ? (
<div className="Overlay active">{renderDialogs()}</div>
) : (
renderDialogs()
)}
</div>
{children}
</DialogContext.Provider>
);
};
/**
*
* High Order Component to use the dialog methods through a Class Component
*
*/
export const withDialog = Component => {
return function WrappedComponent(props) {
const { create, dismiss, dismissAll, isEmpty } = useDialog();
return (
<Component {...props} dialog={{ create, dismiss, dismissAll, isEmpty }} />
);
};
};
DialogProvider.defaultProps = {
service: null,
};
DialogProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
PropTypes.func,
]).isRequired,
service: PropTypes.shape({
setServiceImplementation: PropTypes.func,
}),
};
export default DialogProvider;

View File

@ -39,6 +39,17 @@ const ModalProvider = ({ children, modal: Modal, service }) => {
const [options, setOptions] = useState(DEFAULT_OPTIONS);
/**
* Sets the implementation of a modal service that can be used by extensions.
*
* @returns void
*/
useEffect(() => {
if (service) {
service.setServiceImplementation({ hide, show });
}
}, [hide, service, show]);
/**
* Show the modal and override its configuration props.
*
@ -58,17 +69,6 @@ const ModalProvider = ({ children, modal: Modal, service }) => {
DEFAULT_OPTIONS,
]);
/**
* Sets the implementation of a modal service that can be used by extensions.
*
* @returns void
*/
useEffect(() => {
if (service) {
service.setServiceImplementation({ hide, show });
}
}, [hide, service, show]);
const {
content: ModalContent,
contentProps,
@ -115,18 +115,15 @@ ModalProvider.defaultProps = {
};
ModalProvider.propTypes = {
/** Children that will be wrapped with Modal Context */
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
/** Modal component */
modal: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
PropTypes.func,
]).isRequired,
/** service to be update once modal provider is instanciated */
service: PropTypes.shape({
setServiceImplementation: PropTypes.func,
}),

View File

@ -0,0 +1,142 @@
import React, {
useState,
createContext,
useContext,
useCallback,
useEffect,
} from 'react';
import PropTypes from 'prop-types';
import SnackbarContainer from '../components/Snackbar/SnackbarContainer';
import SnackbarTypes from '../components/Snackbar/SnackbarTypes';
const SnackbarContext = createContext(null);
export const useSnackbar = () => useContext(SnackbarContext);
const SnackbarProvider = ({ children, service }) => {
const DEFAULT_OPTIONS = {
title: '',
message: '',
duration: 5000,
autoClose: true,
position: 'bottomRight',
type: SnackbarTypes.INFO,
};
const [count, setCount] = useState(1);
const [snackbarItems, setSnackbarItems] = useState([]);
/**
* Sets the implementation of a notification service that can be used by extensions.
*
* @returns void
*/
useEffect(() => {
if (service) {
service.setServiceImplementation({ hide, show });
}
}, [service, hide, show]);
const show = useCallback(
options => {
if (!options || (!options.title && !options.message)) {
console.warn(
'Snackbar cannot be rendered without required parameters: title | message'
);
return null;
}
const newItem = {
...DEFAULT_OPTIONS,
...options,
id: count,
visible: true,
};
setSnackbarItems(state => [...state, newItem]);
setCount(count + 1);
},
[count, DEFAULT_OPTIONS]
);
const hide = useCallback(
id => {
const hideItem = items => {
const newItems = items.map(item => {
if (item.id === id) {
item.visible = false;
}
return item;
});
return newItems;
};
setSnackbarItems(state => hideItem(state));
setTimeout(() => {
setSnackbarItems(state => [...state.filter(item => item.id !== id)]);
}, 1000);
},
[setSnackbarItems]
);
const hideAll = () => {
// reset count
setCount(1);
// remove all items from array
setSnackbarItems(() => []);
};
/**
* expose snackbar methods to window for debug purposes
* TODO: Check if it's really necessary
*/
window.snackbar = {
show,
hide,
hideAll,
};
return (
<SnackbarContext.Provider value={{ show, hide, hideAll, snackbarItems }}>
{!!snackbarItems && <SnackbarContainer />}
{children}
</SnackbarContext.Provider>
);
};
SnackbarProvider.defaultProps = {
service: null,
};
SnackbarProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
PropTypes.func,
]).isRequired,
service: PropTypes.shape({
setServiceImplementation: PropTypes.func,
}),
};
/**
*
* High Order Component to use the snackbar methods through a Class Component
*
*/
export const withSnackbar = Component => {
return function WrappedComponent(props) {
const snackbarContext = {
...useSnackbarContext(),
};
return <Component {...props} snackbarContext={snackbarContext} />;
};
};
export default SnackbarProvider;

View File

@ -1,3 +1,11 @@
export {
default as DialogProvider,
useDialog,
withDialog,
} from './DialogProvider'
export { default as DragAndDropProvider } from './DragAndDropProvider';
export {
default as ModalProvider,
useModal,
@ -5,21 +13,25 @@ export {
ModalConsumer,
} from './ModalProvider';
export {
default as ViewportDialogProvider,
useViewportDialog,
} from './ViewportDialogProvider';
export {
ImageViewerContext,
ImageViewerProvider,
useImageViewer,
} from './ImageViewerProvider';
export {
default as SnackbarProvider,
useSnackbar,
withSnackbar,
} from './SnackbarProvider'
export {
default as ViewportDialogProvider,
useViewportDialog,
} from './ViewportDialogProvider';
export {
ViewportGridContext,
ViewportGridProvider,
useViewportGrid,
} from './ViewportGridProvider';
export { default as DragAndDropProvider } from './DragAndDropProvider';

View File

@ -2,7 +2,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import { BrowserRouter, HashRouter } from 'react-router-dom';
import { ThemeWrapper } from '@ohif/ui';
import {
DialogProvider,
Modal,
ModalProvider,
SnackbarProvider,
ThemeWrapper,
} from '@ohif/ui';
// Viewer Project
// TODO: Should this influence study list?
import { appConfigContext } from '@state/appConfig.context';
@ -10,7 +16,7 @@ import { useAppConfig } from '@hooks/useAppConfig';
import createRoutes from './routes';
import appInit from './appInit.js';
// Temporarily for testing
// TODO: Temporarily for testing
import '@ohif/mode-example';
/**
@ -40,11 +46,20 @@ function App({ config, defaultExtensions }) {
extensionManager,
servicesManager
);
const { UIDialogService, UIModalService, UINotificationService } = servicesManager.services;
return (
<appConfigContext.Provider value={appConfigContextApi}>
<Router basename={routerBasename}>
<ThemeWrapper>{appRoutes}</ThemeWrapper>
<ThemeWrapper>
<SnackbarProvider service={UINotificationService}>
<DialogProvider service={UIDialogService}>
<ModalProvider modal={Modal} service={UIModalService}>
{appRoutes}
</ModalProvider>
</DialogProvider>
</SnackbarProvider>
</ThemeWrapper>
</Router>
</appConfigContext.Provider>
);

View File

@ -868,13 +868,27 @@
pirates "^4.0.0"
source-map-support "^0.5.16"
"@babel/runtime@7.1.2", "@babel/runtime@7.7.6", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.2.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6", "@babel/runtime@^7.7.7", "@babel/runtime@^7.8.4":
"@babel/runtime@7.1.2":
version "7.1.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.1.2.tgz#81c89935f4647706fc54541145e6b4ecfef4b8e3"
integrity sha512-Y3SCjmhSupzFB6wcv1KmmFucH6gDVnI30WjOcicV10ju0cZjak3Jcs67YLIXBrmZYw1xCrVeJPbycFwrqNyxpg==
dependencies:
regenerator-runtime "^0.12.0"
"@babel/runtime@7.7.6", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.2.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6":
version "7.7.6"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.7.6.tgz#d18c511121aff1b4f2cd1d452f1bac9601dd830f"
integrity sha512-BWAJxpNVa0QlE5gZdWjSxXtemZyZ9RmrmVozxt3NUXeZhVIJ5ANyqmMc0JDrivBZyxUuQvFxlvH4OWWOogGfUw==
dependencies:
regenerator-runtime "^0.13.2"
"@babel/runtime@^7.7.7", "@babel/runtime@^7.8.4":
version "7.9.6"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.9.6.tgz#a9102eb5cadedf3f31d08a9ecf294af7827ea29f"
integrity sha512-64AF1xY3OAkFHqOb9s4jpgk1Mm5vDZ4L3acHvAml+53nO1XbXLuDodsVpO4OIUsmemlUHMxNdYMNJmsvOwLrvQ==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/standalone@^7.4.5":
version "7.8.6"
resolved "https://registry.yarnpkg.com/@babel/standalone/-/standalone-7.8.6.tgz#1364534775c83bf7b7988e4ca98823bef56a0a53"
@ -16720,7 +16734,7 @@ react-live@^2.2.1:
react-simple-code-editor "^0.10.0"
unescape "^1.0.1"
react-modal@^3.11.1:
react-modal@^3.11.1, react-modal@^3.11.2:
version "3.11.2"
resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.11.2.tgz#bad911976d4add31aa30dba8a41d11e21c4ac8a4"
integrity sha512-o8gvvCOFaG1T7W6JUvsYjRjMVToLZgLIsi5kdhFIQCtHxDkA47LznX62j+l6YQkpXDbvQegsDyxe/+JJsFQN7w==
@ -17282,6 +17296,11 @@ regenerator-runtime@^0.11.0:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
regenerator-runtime@^0.12.0:
version "0.12.1"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de"
integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==
regenerator-runtime@^0.13.1, regenerator-runtime@^0.13.2:
version "0.13.3"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5"