diff --git a/platform/core/src/classes/HotkeysManager.js b/platform/core/src/classes/HotkeysManager.js index 280c045ce..781f79251 100644 --- a/platform/core/src/classes/HotkeysManager.js +++ b/platform/core/src/classes/HotkeysManager.js @@ -1,4 +1,3 @@ -import cloneDeep from 'lodash.clonedeep'; import hotkeys from './hotkeys'; import log from './../log.js'; @@ -44,20 +43,68 @@ export class HotkeysManager { } /** - * Registers a list of hotkeydefinitions. Optionally, sets the - * default hotkey bindings for all provided definitions. These + * Registers a list of hotkeydefinitions. + * + * @param {HotkeyDefinition[] | Object} hotkeyDefinitions Contains hotkeys definitions + */ + setHotkeys(hotkeyDefinitions) { + const definitions = Array.isArray(hotkeyDefinitions) + ? [...hotkeyDefinitions] + : this._parseToArrayLike(hotkeyDefinitions); + + definitions.forEach(definition => this.registerHotkeys(definition)); + } + + /** + * Set default hotkey bindings. These * values are used in `this.restoreDefaultBindings`. * - * @param {HotkeyDefinition[]} hotkeyDefinitions - * @param {Boolean} [isDefaultDefinitions] + * @param {HotkeyDefinition[] | Object} hotkeyDefinitions Contains hotkeys definitions */ - setHotkeys(hotkeyDefinitions, isDefaultDefinitions = false) { - const definitions = cloneDeep(hotkeyDefinitions); - definitions.forEach(definition => this.registerHotkeys(definition)); + setDefaultHotKeys(hotkeyDefinitions) { + const definitions = Array.isArray(hotkeyDefinitions) + ? [...hotkeyDefinitions] + : this._parseToArrayLike(hotkeyDefinitions); - if (isDefaultDefinitions) { - this.hotkeyDefaults = definitions; - } + this.hotkeyDefaults = definitions; + } + + /** + * It parses given object containing hotkeyDefinition to array like. + * Each property of given object will be mapped to an object of an array. And its property name will be the value of a property named as commandName + * + * @param {HotkeyDefinition[] | Object} hotkeyDefinitions Contains hotkeys definitions + * @returns {HotkeyDefinition[]} + */ + _parseToArrayLike(hotkeyDefinitionsObj) { + const copy = { ...hotkeyDefinitionsObj }; + return Object.entries(copy).map(entryValue => + this._parseToHotKeyObj(entryValue[0], entryValue[1]) + ); + } + + /** + * Return HotkeyDefinition object like based on given property name and property value + * @param {string} propertyName property name of hotkey definition object + * @param {object} propertyValue property value of hotkey definition object + * + * @example + * + * const hotKeyObj = {hotKeyDefA: {keys:[],....}} + * + * const parsed = _parseToHotKeyObj(Object.keys(hotKeyDefA)[0], hotKeyObj[hotKeyDefA]); + * { + * commandName: hotKeyDefA, + * keys: [], + * .... + * } + * + */ + _parseToHotKeyObj(propertyName, propertyValue) { + return { + commandName: propertyName, + ...propertyValue, + }; } /** diff --git a/platform/core/src/classes/HotkeysManager.test.js b/platform/core/src/classes/HotkeysManager.test.js index e02c1f9a0..c980c5b1a 100644 --- a/platform/core/src/classes/HotkeysManager.test.js +++ b/platform/core/src/classes/HotkeysManager.test.js @@ -94,18 +94,20 @@ describe('HotkeysManager', () => { expect(firstCallArgs).toEqual(hotkeyDefinitions[0]); expect(secondCallArgs).toEqual(hotkeyDefinitions[1]); }); - it('does not set this.hotkeyDefaults by default', () => { + it('does not set this.hotkeyDefaults when calling setHotKeys', () => { const hotkeyDefinitions = [{ commandName: 'dance', keys: '+' }]; hotkeysManager.setHotkeys(hotkeyDefinitions); expect(hotkeysManager.hotkeyDefaults).toEqual([]); }); - it('sets this.hotkeyDefaults when isDefaultDefinitions is true', () => { - const hotkeyDefinitions = [{ commandName: 'dance', keys: '+' }]; - const isDefaultDefinitions = true; + }); - hotkeysManager.setHotkeys(hotkeyDefinitions, isDefaultDefinitions); + describe('setDefaultHotKeys()', () => { + it('it sets default hotkeys', () => { + const hotkeyDefinitions = [{ commandName: 'dance', keys: '+' }]; + + hotkeysManager.setDefaultHotKeys(hotkeyDefinitions); expect(hotkeysManager.hotkeyDefaults).toEqual(hotkeyDefinitions); }); diff --git a/platform/core/src/redux/reducers/preferences.js b/platform/core/src/redux/reducers/preferences.js index 443996adc..c04ea2603 100644 --- a/platform/core/src/redux/reducers/preferences.js +++ b/platform/core/src/redux/reducers/preferences.js @@ -11,12 +11,15 @@ const defaultState = { // order, description, window (int), level (int) // 0: { description: 'Soft tissue', window: '', level: '' }, }, + generalPreferences: { + // language: 'en-US' + }, }; const preferences = (state, action) => { switch (action.type) { case 'SET_USER_PREFERENCES': { - const newState = action.state ? action.state : cloneDeep(defaultState); + const newState = action.state || cloneDeep(defaultState); return Object.assign({}, state, newState); } diff --git a/platform/i18n/src/index.js b/platform/i18n/src/index.js index 1862714c6..f8b34fc9f 100644 --- a/platform/i18n/src/index.js +++ b/platform/i18n/src/index.js @@ -42,6 +42,7 @@ const locizeOptions = { const envUseLocize = !!config.USE_LOCIZE; const envApiKeyAvailable = !!config.LOCIZE_API_KEY; +const DEFAULT_LANGUAGE = 'en-US'; function initI18n( detection = detectionOptions, @@ -74,7 +75,7 @@ function initI18n( // init i18next // for all options read: https://www.i18next.com/overview/configuration-options .init({ - fallbackLng: 'en-US', + fallbackLng: DEFAULT_LANGUAGE, saveMissing: apiKeyAvailable, debug: debugMode, keySeparator: false, @@ -111,7 +112,7 @@ function initI18n( // init i18next // for all options read: https://www.i18next.com/overview/configuration-options .init({ - fallbackLng: 'en-US', + fallbackLng: DEFAULT_LANGUAGE, resources: locales, debug: debugMode, keySeparator: false, @@ -136,5 +137,6 @@ customDebug(`version ${pkg.version} loaded.`, 'info'); i18n.initializing = initI18n(); i18n.initI18n = initI18n; i18n.addLocales = addLocales; +i18n.defaultLanguage = DEFAULT_LANGUAGE; export default i18n; diff --git a/platform/i18n/src/locales/ar/UserPreferencesModal.json b/platform/i18n/src/locales/ar/UserPreferencesModal.json new file mode 100644 index 000000000..06992c508 --- /dev/null +++ b/platform/i18n/src/locales/ar/UserPreferencesModal.json @@ -0,0 +1,3 @@ +{ + "No hotkeys found": "Nenhuma tecla de atalho está configurada para este aplicativo. As teclas de atalho podem ser configuradas no arquivo app-config.js do aplicativo." +} \ No newline at end of file diff --git a/platform/i18n/src/locales/ar/index.js b/platform/i18n/src/locales/ar/index.js new file mode 100644 index 000000000..37525f490 --- /dev/null +++ b/platform/i18n/src/locales/ar/index.js @@ -0,0 +1,7 @@ +import UserPreferencesModal from "./UserPreferencesModal.json"; + +export default { + 'ar': { + UserPreferencesModal, + } +}; \ No newline at end of file diff --git a/platform/i18n/src/locales/en-US/UserPreferencesModal.json b/platform/i18n/src/locales/en-US/UserPreferencesModal.json index 1b1b5f1d8..52b0dc167 100644 --- a/platform/i18n/src/locales/en-US/UserPreferencesModal.json +++ b/platform/i18n/src/locales/en-US/UserPreferencesModal.json @@ -1,6 +1,9 @@ { "Cancel": "$t(Buttons:Cancel)", + "No hotkeys found": "No hotkeys are configured for this application. Hotkeys can be configured in the application's app-config.js file.", "Reset to Defaults": "$t(Buttons:Reset to Defaults)", + "ResetDefaultMessage": "Preferences successfully reset to default.
You must Save to perform this action.", "Save": "$t(Buttons:Save)", + "SaveMessage": "Preferences saved", "User Preferences": "User Preferences" } \ No newline at end of file diff --git a/platform/i18n/src/locales/index.js b/platform/i18n/src/locales/index.js index 2f7a0f0c6..02ef9d331 100644 --- a/platform/i18n/src/locales/index.js +++ b/platform/i18n/src/locales/index.js @@ -1,3 +1,4 @@ +import ar from './ar/'; import en_US from './en-US/'; import es from './es/'; import ja_JP from './ja-JP/'; @@ -7,6 +8,7 @@ import vi from './vi/'; import zh from './zh/'; export default { + ...ar, ...en_US, ...es, ...ja_JP, diff --git a/platform/i18n/src/locales/pt-BR/UserPreferencesModal.json b/platform/i18n/src/locales/pt-BR/UserPreferencesModal.json index 156a13dfd..b500f9f82 100644 --- a/platform/i18n/src/locales/pt-BR/UserPreferencesModal.json +++ b/platform/i18n/src/locales/pt-BR/UserPreferencesModal.json @@ -1,6 +1,8 @@ { "Cancel": "Cancelar", "Reset to Defaults": "Restaurar Default", + "ResetDefaultMessage": "Preferências resetadas com sucesso.
Você deve Salvar para que essa ação seja realizada.", "Save": "Salvar", + "SaveMessage": "Preferências salvas", "User Preferences": "Preferências do Usuário" } \ No newline at end of file diff --git a/platform/ui/src/components/languageSwitcher/LanguageSwitcher.js b/platform/ui/src/components/languageSwitcher/LanguageSwitcher.js index f531b6e62..da22eb80b 100644 --- a/platform/ui/src/components/languageSwitcher/LanguageSwitcher.js +++ b/platform/ui/src/components/languageSwitcher/LanguageSwitcher.js @@ -1,14 +1,12 @@ -import React, { useState, useEffect } from 'react'; -import i18n from '@ohif/i18n'; +import React from 'react'; +import PropTypes from 'prop-types'; import './LanguageSwitcher.styl'; import { withTranslation } from '../../contextProviders'; -const LanguageSwitcher = () => { - const getCurrentLanguage = (language = i18n.language) => - language.split('-')[0]; +const LanguageSwitcher = ({ language, onLanguageChange }) => { + const parseLanguage = lang => lang.split('-')[0]; - const [currentLanguage, setCurrentLanguage] = useState(getCurrentLanguage()); const languages = [ // TODO: list of available languages should come from i18n.options.resources { @@ -21,46 +19,31 @@ const LanguageSwitcher = () => { }, ]; - const onChange = () => { + const onChange = event => { const { value } = event.target; - const language = getCurrentLanguage(value); - setCurrentLanguage(language); - - i18n.init({ - fallbackLng: language, - lng: language, - }); + onLanguageChange(parseLanguage(value)); }; - useEffect(() => { - let mounted = true; - - i18n.on('languageChanged', () => { - if (mounted) { - setCurrentLanguage(getCurrentLanguage()); - } - }); - - return () => { - mounted = false; - }; - }, []); - return ( ); }; +LanguageSwitcher.propTypes = { + language: PropTypes.string.isRequired, + onLanguageChange: PropTypes.func.isRequired, +}; + export default withTranslation('UserPreferencesModal')(LanguageSwitcher); diff --git a/platform/ui/src/components/studyList/CustomDateRangePicker.js b/platform/ui/src/components/studyList/CustomDateRangePicker.js index 17b5d47df..dc82d5f32 100644 --- a/platform/ui/src/components/studyList/CustomDateRangePicker.js +++ b/platform/ui/src/components/studyList/CustomDateRangePicker.js @@ -131,7 +131,6 @@ CustomDateRangePicker.propTypes = { end: PropTypes.required, }) ), - autoFocus: PropTypes.bool.isRequired, onDatesChange: PropTypes.func.isRequired, startDate: PropTypes.instanceOf(Date), endDate: PropTypes.instanceOf(Date), diff --git a/platform/ui/src/components/studyList/StudyList.js b/platform/ui/src/components/studyList/StudyList.js index 4565c9823..9e6c56fad 100644 --- a/platform/ui/src/components/studyList/StudyList.js +++ b/platform/ui/src/components/studyList/StudyList.js @@ -81,7 +81,7 @@ function StudyList(props) { const mediumTableMeta = [ { - displayText: `${t('Patient')} / ${t('MRN')}`, + displayText: `${t('PatientName')} / ${t('MRN')}`, fieldName: 'patientNameOrId', inputType: 'text', size: 250, @@ -187,7 +187,6 @@ function StudyList(props) { studyDate={study.studyDate} studyDescription={study.studyDescription || ''} studyInstanceUid={study.studyInstanceUid} - t={t} displaySize={displaySize} /> ))} @@ -239,10 +238,11 @@ function TableRow(props) { studyDescription, studyInstanceUid, onClick: handleClick, - t, displaySize, } = props; + const { t } = useTranslation('StudyList'); + const largeRowTemplate = ( handleClick(studyInstanceUid)} diff --git a/platform/ui/src/components/studyList/TableSearchFilter.js b/platform/ui/src/components/studyList/TableSearchFilter.js index 396738db9..ca9874e5a 100644 --- a/platform/ui/src/components/studyList/TableSearchFilter.js +++ b/platform/ui/src/components/studyList/TableSearchFilter.js @@ -103,15 +103,14 @@ function TableSearchFilter(props) { // https://github.com/airbnb/react-dates { - onValueChange('studyDateTo', startDate); - onValueChange('studyDateFrom', endDate); + onValueChange('studyDateFrom', startDate); + onValueChange('studyDateTo', endDate); }} focusedInput={focusedInput} onFocusChange={updatedVal => setFocusedInput(updatedVal)} diff --git a/platform/ui/src/components/userPreferencesForm/GeneralPreferences.js b/platform/ui/src/components/userPreferencesForm/GeneralPreferences.js index 51d68344e..42ad1e7ec 100644 --- a/platform/ui/src/components/userPreferencesForm/GeneralPreferences.js +++ b/platform/ui/src/components/userPreferencesForm/GeneralPreferences.js @@ -1,17 +1,57 @@ -import React, { Component } from 'react'; +import React from 'react'; +import PropTypes from 'prop-types'; import LanguageSwitcher from '../languageSwitcher'; +import i18n from '@ohif/i18n'; -export class GeneralPreferences extends Component { - render() { - return ( -
-
- - -
+/** + * General Preferences tab + */ + +/** + * General Preferences tab + * It renders the General Preferences content + * + * It stores current state and whenever it changes, component messages parent of new value (through function callback) + * @param {object} props component props + * @param {string} props.name Tab`s name + * @param {object} props.generalPreferences Data for initial state + * @param {function} props.onTabStateChanged Callback function to communicate parent in case its states changes + * @param {function} props.onTabErrorChanged Callback Function in case any error on tab + */ +function GeneralPreferences({ + generalPreferences, + name, + onTabStateChanged, + onTabErrorChanged, +}) { + const { language = i18n.language } = generalPreferences; + + const onLanguageChange = language => { + onTabStateChanged(name, { + generalPreferences: { ...generalPreferences, language }, + }); + }; + + return ( +
+
+ +
- ); - } +
+ ); } + +GeneralPreferences.propTypes = { + generalPreferences: PropTypes.any, + name: PropTypes.string, + onTabStateChanged: PropTypes.func, + onTabErrorChanged: PropTypes.func, +}; + +export { GeneralPreferences }; diff --git a/platform/ui/src/components/userPreferencesForm/HotKeysPreferences.js b/platform/ui/src/components/userPreferencesForm/HotKeysPreferences.js index b14cf945e..8458102b5 100644 --- a/platform/ui/src/components/userPreferencesForm/HotKeysPreferences.js +++ b/platform/ui/src/components/userPreferencesForm/HotKeysPreferences.js @@ -1,80 +1,238 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; + import './HotKeysPreferences.styl'; -import React, { Component } from 'react'; + import { allowedKeys, disallowedCombinations, specialKeys, } from './hotKeysConfig.js'; -import PropTypes from 'prop-types'; +import isEqual from 'lodash.isequal'; -export class HotKeysPreferences extends Component { - static propTypes = { - hotkeyDefinitions: PropTypes.arrayOf( - PropTypes.shape({ - commandName: PropTypes.string, - keys: PropTypes.arrayOf(PropTypes.string), - label: PropTypes.string, - }) - ).isRequired, +const getKeysPressedArray = keyDownEvent => { + const keysPressedArray = []; + const { ctrlKey, altKey, shiftKey } = keyDownEvent; + + if (ctrlKey && !altKey) { + keysPressedArray.push('ctrl'); + } + + if (shiftKey && !altKey) { + keysPressedArray.push('shift'); + } + + if (altKey && !ctrlKey) { + keysPressedArray.push('alt'); + } + + return keysPressedArray; +}; + +const findConflictingCommand = ( + originalHotKeys, + currentCommandName, + currentHotKeys +) => { + let firstConflictingCommand = undefined; + + for (const commandName in originalHotKeys) { + const toolHotKeys = originalHotKeys[commandName].keys; + + if ( + isEqual(toolHotKeys, currentHotKeys) && + commandName !== currentCommandName + ) { + firstConflictingCommand = originalHotKeys[commandName]; + break; + } + } + + return firstConflictingCommand; +}; + +/** + * Splits given keysObj into arrays. Each array item will be a representation of column + * @param {obj} keysObj objects to be splitted into columns + * @param {number} columnSize How many rows per column + */ +const getHotKeysArrayColumns = (keysObj = {}, columnSize) => { + if (isNaN(columnSize)) { + return keysObj; + } + + const keys = Object.keys(keysObj); + const keysValues = Object.values(keysObj); + const keysLength = keys.length; + + // Columns from left should be bigger; + let currentColumn = 0; + const dividedKeys = []; + + for ( + let it = 0; + it < keysLength; + it++, it % columnSize === 0 ? currentColumn++ : currentColumn + ) { + if (!dividedKeys[currentColumn]) { + dividedKeys[currentColumn] = []; + } + + dividedKeys[currentColumn][keys[it]] = keysValues[it]; + } + + return dividedKeys; +}; + +const NO_FIELD_ERROR_MESSAGE = undefined; +const formatPressedKeys = pressedKeysArray => pressedKeysArray.join('+'); +const unFormatPressedKeys = (pressedKeysStr = '') => pressedKeysStr.split('+'); +const inputValidators = ( + commandName, + inputValue, + pressedKeys, + lastPressedKey, + originalHotKeys +) => { + let hasError = false; + let errorMessage = NO_FIELD_ERROR_MESSAGE; + + const modifierValidator = ({ lastPressedKey }) => { + // Check if it has a valid modifier + const isModifier = ['ctrl', 'alt', 'shift'].includes(lastPressedKey); + if (isModifier) { + hasError = true; + errorMessage = + "It's not possible to define only modifier keys (ctrl, alt and shift) as a shortcut"; + return { + hasError, + errorMessage, + }; + } }; - constructor(props) { - super(props); - - this.state = { - hotKeys: this.props.hotkeyDefinitions, - errorMessages: {}, - }; - - this.onInputKeyDown = this.onInputKeyDown.bind(this); - } - - /** - * Normalizes the keys used in a KeyPress event and returns an array of the - * keys pressed - * - * @param {KeyDownEvent} keyDownEvent - * @returns {string[]} - */ - getKeysPressedArray(keyDownEvent) { - const keysPressedArray = []; - const { ctrlKey, altKey, shiftKey } = keyDownEvent; - - if (ctrlKey && !altKey) { - keysPressedArray.push('ctrl'); + const emptyValidator = ({ inputValue }) => { + if (!inputValue) { + hasError = true; + errorMessage = "Field can't be empty."; + return { + hasError, + errorMessage, + }; } - - if (shiftKey && !altKey) { - keysPressedArray.push('shift'); + }; + const conflictingValidator = ({ + commandName, + pressedKeys, + originalHotKeys, + }) => { + const conflictingCommand = findConflictingCommand( + originalHotKeys, + commandName, + pressedKeys + ); + if (conflictingCommand) { + hasError = true; + errorMessage = `"${conflictingCommand.label}" is already using the "${pressedKeys}" shortcut.`; + return { + hasError, + errorMessage, + }; } + }; - if (altKey && !ctrlKey) { - keysPressedArray.push('alt'); + const disallowedValidator = ({ inputValue, pressedKeys, lastPressedKey }) => { + const modifierCommand = formatPressedKeys( + pressedKeys.slice(0, pressedKeys.length - 1) + ); + + const disallowedCombination = disallowedCombinations[modifierCommand]; + const hasDisallowedCombinations = disallowedCombination + ? disallowedCombination.includes(lastPressedKey) + : false; + + if (hasDisallowedCombinations) { + hasError = true; + errorMessage = `"${inputValue}" shortcut combination is not allowed`; + return { + hasError, + errorMessage, + }; } + }; - return keysPressedArray; - } + const validators = [ + emptyValidator, + modifierValidator, + conflictingValidator, + disallowedValidator, + ]; - getConflictingCommand(currentCommandName, currentHotKeys) { - return this.state.hotKeys.find((tool, index) => { - const toolHotKeys = tool.keys[0]; - return ( - toolHotKeys && - toolHotKeys === currentHotKeys && - tool.commandName !== currentCommandName - ); + for (const validator of validators) { + const validation = validator({ + commandName, + inputValue, + pressedKeys, + lastPressedKey, + originalHotKeys, }); + if (validation && validation.hasError) { + return validation; + } } - /** - * - * @param {String} commandName - * @param {KeyDownEvent} keyDownEvent - * @param {Boolean} [displayPressedKey=false] - */ - updateInputText(commandName, keyDownEvent, displayPressedKey = false) { - const pressedKeys = this.getKeysPressedArray(keyDownEvent); + // validation has passed successfully + return { + hasError, + errorMessage, + }; +}; + +/** + * HotKeysPreferencesRow + * Renders row for hotkey preference + * It stores current state and whenever it changes, component messages parent of new value (through function callback) + * @param {object} props component props + * @param {string} props.commandName command name associated to given row + * @param {string[]} props.hotkeys keys associated to given command + * @param {object} props.originalHotKeys original hotkeys values + * @param {function} props.onSuccessChanged Callback function to communicate parent in case its states changes + * @param {function} props.onFailureChanged Callback Function in case any error on row + */ +function HotKeyPreferencesRow({ + commandName, + hotkeys, + label, + originalHotKeys, + tabError, + onSuccessChanged, + onFailureChanged, +}) { + const [inputValue, setInputValue] = useState(formatPressedKeys(hotkeys)); + const [error, setError] = useState(false); + + const [fieldErrorMessage, setFieldErrorMessage] = useState( + NO_FIELD_ERROR_MESSAGE + ); + + // reset error count if tab has no errors + useEffect(() => { + if (!tabError) { + setError(false); + setFieldErrorMessage(NO_FIELD_ERROR_MESSAGE); + setInputValue(formatPressedKeys(hotkeys)); + } + }, [tabError]); + + // update state values if props changes + useEffect(() => { + setInputValue(formatPressedKeys(hotkeys)); + }, [hotkeys]); + + const updateInputText = (keyDownEvent, displayPressedKey = false) => { + const pressedKeys = getKeysPressedArray(keyDownEvent); if (displayPressedKey) { const specialKeyName = specialKeys[keyDownEvent.which]; @@ -82,183 +240,213 @@ export class HotKeysPreferences extends Component { specialKeyName || keyDownEvent.key || String.fromCharCode(keyDownEvent.keyCode); + + // ensure lowerCase pressedKeys.push(keyName.toLowerCase()); } - this.updateHotKeysState(commandName, pressedKeys.join('+')); - } + setInputValue(formatPressedKeys(pressedKeys)); + }; - updateHotKeysState(commandName, keys) { - const hotKeys = this.state.hotKeys; - const hotKeyIndex = this.state.hotKeys.findIndex( - x => x.commandName === commandName + // validate input value + const validateInput = () => { + const pressedKeys = unFormatPressedKeys(inputValue); + const lastPressedKey = pressedKeys[pressedKeys.length - 1]; + + const { + hasError = false, + errorMessage = NO_FIELD_ERROR_MESSAGE, + } = inputValidators( + commandName, + inputValue, + pressedKeys, + lastPressedKey, + originalHotKeys ); - hotKeys[hotKeyIndex].keys[0] = keys; - this.setState({ hotKeys }); - } - updateErrorsState(toolKey, errorMessage) { - const errorMessages = this.state.errorMessages; - errorMessages[toolKey] = errorMessage; - this.setState({ errorMessages }); - } + if (hasError) { + setInputValue(''); + } else { + onSuccessChanged([inputValue]); + } - onInputKeyDown(event, commandName) { + if (hasError !== error) { + setError(hasError); + } + + setFieldErrorMessage(errorMessage); + }; + + useEffect(() => { + onFailureChanged(error); + }, [error]); + + const onInputKeyDown = event => { // Prevent ESC key from propagating and closing the modal if (event.key === 'Escape') { event.stopPropagation(); } - if (allowedKeys.includes(event.keyCode)) { - this.updateInputText(commandName, event, true); - } else { - this.updateInputText(commandName, event, false); - } - + updateInputText(event, allowedKeys.includes(event.keyCode)); event.preventDefault(); - } + }; - onChange(event, commandName) { - if (event.ctrlKey || event.altKey || event.shiftKey) { - return; + return ( + + {label} + + + + + ); +} + +HotKeyPreferencesRow.propTypes = { + commandName: PropTypes.string.isRequired, + hotkeys: PropTypes.array.isRequired, + label: PropTypes.string.isRequired, + originalHotKeys: PropTypes.object.isRequired, + tabError: PropTypes.bool.isRequired, + onSuccessChanged: PropTypes.func.isRequired, + onFailureChanged: PropTypes.func.isRequired, +}; + +/** + * HotKeysPreferences tab + * It renders all hotkeys displayed into columns/rows + * + * It stores current state and whenever it changes, component messages parent of new value (through function callback) + * @param {object} props component props + * @param {string} props.name Tab`s name + * @param {object} props.hotkeyDefinitions Data for initial state + * @param {function} props.onTabStateChanged Callback function to communicate parent in case its states changes + * @param {function} props.onTabErrorChanged Callback Function in case any error on tab + */ +function HotKeysPreferences({ + hotkeyDefinitions, + name, + tabError, + onTabStateChanged, + onTabErrorChanged, +}) { + const [tabState, setTabState] = useState(hotkeyDefinitions); + const [tabErrorCounter, setTabErrorCounter] = useState(0); + + const [numColumns] = useState(2); + const [columnSize] = useState(() => + Math.ceil(Object.keys(tabState || {}).length / numColumns) + ); + + const splittedHotKeys = getHotKeysArrayColumns(tabState, columnSize); + + const onHotKeyChanged = (commandName, hotkeyDefinition, keys) => { + const newState = { + ...tabState, + [commandName]: { ...hotkeyDefinition, keys }, + }; + setTabState(newState); + onTabStateChanged(name, { hotkeyDefinitions: newState }); + }; + + const onErrorChanged = (toInc = true) => { + const increment = toInc ? 1 : -1; + const newValue = tabErrorCounter + increment; + if (newValue >= 0) { + setTabErrorCounter(newValue); + } + }; + + // reset error count if tab has no errors + useEffect(() => { + if (!tabError) { + setTabErrorCounter(0); + // update tab state + setTabState({ ...hotkeyDefinitions }); + } + }, [tabError]); + + // tell parent to update its state + useEffect(() => { + if (tabErrorCounter === 0) { + onTabErrorChanged(name, false); } - const hotKeyIndex = this.state.hotKeys.findIndex( - x => x.commandName === commandName - ); - const hotKey = this.state.hotKeys[hotKeyIndex]; - const keys = hotKey.keys[0]; - const pressedKeys = keys.split('+'); - const lastPressedKey = pressedKeys[pressedKeys.length - 1].toLowerCase(); + if (tabErrorCounter === 1) { + onTabErrorChanged(name, true); + } + }, [tabErrorCounter]); - // clear the prior errors - this.setState({ errorMessages: {} }, () => { - // Check if it has a valid modifier - const isModifier = ['ctrl', 'alt', 'shift'].includes(lastPressedKey); - if (isModifier) { - this.updateHotKeysState(commandName, ''); - this.updateErrorsState( - commandName, - "It's not possible to define only modifier keys (ctrl, alt and shift) as a shortcut" - ); - return; - } + // update local state if parent updates + useEffect(() => { + setTabState({ ...hotkeyDefinitions }); + }, [hotkeyDefinitions]); - /* - * Check if it has some conflict - */ - const conflictedCommand = this.getConflictingCommand(commandName, keys); - if (conflictedCommand) { - this.updateHotKeysState(commandName, ''); - this.updateErrorsState( - commandName, - `"${conflictedCommand.label}" is already using the "${keys}" shortcut.` - ); - return; - } - - /* - * Check if is a valid combination - */ - const modifierCommand = pressedKeys - .slice(0, pressedKeys.length - 1) - .join('+') - .toLowerCase(); - - const disallowedCombination = disallowedCombinations[modifierCommand]; - const hasDisallowedCombinations = disallowedCombination - ? disallowedCombination.includes(lastPressedKey) - : false; - - if (hasDisallowedCombinations) { - this.updateHotKeysState(commandName, ''); - this.updateErrorsState( - commandName, - `"${pressedKeys.join('+')}" shortcut combination is not allowed` - ); - return; - } - }); - } - - renderRow({ commandName, label, keys }) { - return ( - - {label} - - - - - ); - } - - render() { - const halfWayThough = Math.floor(this.state.hotKeys.length / 2); - const firstHalfHotkeys = this.state.hotKeys.slice(0, halfWayThough); - const secondHalfHotkeys = this.state.hotKeys.slice( - halfWayThough, - this.state.hotKeys.length - ); - - return this.state.hotKeys.length > 0 ? ( -
- {/* */} -
- - - - - - - - - {firstHalfHotkeys.map(hotkeyDefinition => - this.renderRow(hotkeyDefinition) - )} - -
FunctionShortcut
-
- {/* */} -
- - - - - - - - - {secondHalfHotkeys.map(hotkeyDefinition => - this.renderRow(hotkeyDefinition) - )} - -
FunctionShortcut
-
-
- ) : ( -

{`No hotkeys are configured for this application. Hotkeys can be configured in the application's app-config.js file.`}

- ); - } + return ( +
+ {splittedHotKeys.length > 0 + ? splittedHotKeys.map((columnHotKeys, index) => { + return ( +
+ + + + + + + + + {Object.entries(columnHotKeys).map( + hotkeyDefinitionTuple => ( + + onHotKeyChanged( + hotkeyDefinitionTuple[0], + hotkeyDefinitionTuple[1], + keys + ) + } + onFailureChanged={onErrorChanged} + > + ) + )} + +
FunctionShortcut
+
+ ); + }) + : null} +
+ ); } + +HotKeysPreferences.propTypes = { + hotkeyDefinitions: PropTypes.any, + name: PropTypes.string, + tabError: PropTypes.bool, + onTabStateChanged: PropTypes.func, + onTabErrorChanged: PropTypes.func, +}; + +export { HotKeysPreferences }; diff --git a/platform/ui/src/components/userPreferencesForm/UserPreferences.js b/platform/ui/src/components/userPreferencesForm/UserPreferences.js index 7c6f6ad94..9ee6b07b2 100644 --- a/platform/ui/src/components/userPreferencesForm/UserPreferences.js +++ b/platform/ui/src/components/userPreferencesForm/UserPreferences.js @@ -10,7 +10,7 @@ export class UserPreferences extends Component { static defaultProps = { hotkeyDefinitions: [], windowLevelData: {}, - generalData: {}, + generalPreferences: {}, }; // TODO: Make this more generic. Tabs should not be restricted to these entries @@ -23,7 +23,8 @@ export class UserPreferences extends Component { }) ).isRequired, windowLevelData: PropTypes.object.isRequired, - generalData: PropTypes.object.isRequired, + generalPreferences: PropTypes.object.isRequired, + updatePropValue: PropTypes.func.isRequired, }; state = { @@ -64,7 +65,10 @@ export class UserPreferences extends Component { return (
- +
); diff --git a/platform/ui/src/components/userPreferencesForm/UserPreferencesForm.js b/platform/ui/src/components/userPreferencesForm/UserPreferencesForm.js index 22bb9adb1..893ecd318 100644 --- a/platform/ui/src/components/userPreferencesForm/UserPreferencesForm.js +++ b/platform/ui/src/components/userPreferencesForm/UserPreferencesForm.js @@ -1,101 +1,219 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useSnackbarContext } from '@ohif/ui'; + import './UserPreferencesForm.styl'; -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { withTranslation } from '../../contextProviders'; +import { useTranslation } from 'react-i18next'; -import cloneDeep from 'lodash.clonedeep'; -import isEqual from 'lodash.isequal'; -import { UserPreferences } from './UserPreferences'; +// Tabs Component wrapper +import { UserPreferencesTabs } from './UserPreferencesTabs'; -class UserPreferencesForm extends Component { - // TODO: Make this component more generic to allow things other than W/L and hotkeys... - static propTypes = { - onClose: PropTypes.func, - onSave: PropTypes.func, - onResetToDefaults: PropTypes.func, - windowLevelData: PropTypes.object, - hotkeyDefinitions: PropTypes.arrayOf( - PropTypes.shape({ - commandName: PropTypes.string, - keys: PropTypes.arrayOf(PropTypes.string), - label: PropTypes.string, - }) - ).isRequired, - t: PropTypes.func, +// Tabs +import { HotKeysPreferences } from './HotKeysPreferences'; +import { WindowLevelPreferences } from './WindowLevelPreferences'; +import { GeneralPreferences } from './GeneralPreferences'; + +/** + @typedef TabObject + @type {Object} + @property {string} name Name for given tab + @property {ReactComponent} Component React component for given tab. + @property {object} props Props State for given tab component + @property {boolean} [hidden] To hidden tab or not + */ + +/** + * Create tabs obj. + * @returns {TabObject[]} Array of TabObjs. + */ +const createTabs = () => { + return [ + { + name: 'Hotkeys', + Component: HotKeysPreferences, + props: {}, + }, + { + name: 'General', + Component: GeneralPreferences, + props: {}, + }, + { + name: 'Window Level', + Component: WindowLevelPreferences, + props: {}, + hidden: true, + }, + ]; +}; + +/** + * Main form component to render preferences tabs and buttons + * @param {object} props component props + * @param {string} props.name Tab`s name + * @param {object} props.hotkeyDefinitions Hotkeys Data + * @param {object} props.windowLevelData Window level data + * @param {function} props.onSave Callback function when saving + * @param {function} props.onClose Callback function when closing + * @param {function} props.onResetToDefaults Callback function when resetting + */ +function UserPreferencesForm({ + onClose, + onSave, + onResetToDefaults, + windowLevelData, + hotkeyDefinitions, + generalPreferences, + hotkeysManager, + defaultLanguage, + hotkeyDefaults, +}) { + const [tabs, setTabs] = useState(createTabs()); + + const createTabsState = ( + windowLevelData, + hotkeyDefinitions, + generalPreferences + ) => { + return { + Hotkeys: { hotkeyDefinitions }, + 'Window Level': { windowLevelData }, + General: { generalPreferences }, + }; }; - constructor(props) { - super(props); + const [tabsState, setTabsState] = useState( + createTabsState(windowLevelData, hotkeyDefinitions, generalPreferences) + ); - this.state = { - windowLevelData: cloneDeep(props.windowLevelData), - hotkeyDefinitions: cloneDeep(props.hotkeyDefinitions), - }; - } + const [tabsError, setTabsError] = useState( + tabs.reduce((acc, tab) => { + acc[tab.name] = false; + return acc; + }, {}) + ); - save = () => { - this.props.onSave({ - windowLevelData: this.state.windowLevelData, - hotkeyDefinitions: this.state.hotkeyDefinitions, + const snackbar = useSnackbarContext(); + + const { t, ready: translationsAreReady } = useTranslation( + 'UserPreferencesModal' + ); + + const onTabStateChanged = (tabName, newState) => { + setTabsState({ ...tabsState, [tabName]: newState }); + }; + + const onTabErrorChanged = (tabName, hasError) => { + setTabsError({ ...tabsError, [tabName]: hasError }); + }; + + const hasAnyError = () => { + return Object.values(tabsError).reduce((acc, value) => acc || value); + }; + + const onResetPreferences = () => { + const defaultHotKeyDefitions = {}; + + hotkeyDefaults.map(item => { + const { commandName, ...values } = item; + defaultHotKeyDefitions[commandName] = { ...values }; + }); + + // update local state + setTabsState({ + ...tabsState, + Hotkeys: { hotkeyDefinitions: defaultHotKeyDefitions }, + General: { generalPreferences: { language: defaultLanguage } }, + }); + + // update tabs state + setTabs(createTabs(windowLevelData, hotkeyDefinitions, generalPreferences)); + + // reset errors + setTabsError( + tabs.reduce((acc, tab) => { + acc[tab.name] = false; + return acc; + }, {}) + ); + + snackbar.show({ + message: ( +
+ ), + type: 'info', }); }; - componentDidUpdate(prev, next) { - const newStateData = {}; + const onSavePreferences = event => { + const toSave = Object.values(tabsState).reduce((acc, tabState) => { + return { ...acc, ...tabState }; + }, {}); - if (!isEqual(prev.windowLevelData, next.windowLevelData)) { - newStateData.windowLevelData = prev.windowLevelData; - } + onSave(toSave); + snackbar.show({ + message: t('SaveMessage'), + type: 'success', + }); + }; - if (!isEqual(prev.hotkeyDefinitions, next.hotkeyDefinitions)) { - newStateData.hotkeyDefinitions = prev.hotkeyDefinitions; - } + // update local state if prop values changes + useEffect(() => { + setTabsState( + createTabsState(windowLevelData, hotkeyDefinitions, generalPreferences) + ); + }, [windowLevelData, hotkeyDefinitions, generalPreferences]); - if (newStateData.hotkeyDefinitions || newStateData.windowLevelData) { - this.setState(newStateData); - } - } - - render() { - return ( -
- -
- +
+
- {this.props.t('Reset to Defaults')} - -
-
- {this.props.t('Cancel')} -
- + {t('Cancel')}
+
- ); - } +
+ ) : null; } -const connectedComponent = withTranslation('UserPreferencesForm')( - UserPreferencesForm -); -export { connectedComponent as UserPreferencesForm }; -export default connectedComponent; +UserPreferencesForm.propTypes = { + onClose: PropTypes.func, + onSave: PropTypes.func, + onResetToDefaults: PropTypes.func, + windowLevelData: PropTypes.object, + hotkeyDefinitions: PropTypes.object, + generalPreferences: PropTypes.object, + hotkeysManager: PropTypes.object, + defaultLanguage: PropTypes.string, + hotkeyDefaults: PropTypes.array, +}; + +export { UserPreferencesForm }; diff --git a/platform/ui/src/components/userPreferencesForm/UserPreferencesTabs.js b/platform/ui/src/components/userPreferencesForm/UserPreferencesTabs.js new file mode 100644 index 000000000..e5d343fd8 --- /dev/null +++ b/platform/ui/src/components/userPreferencesForm/UserPreferencesTabs.js @@ -0,0 +1,105 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import './UserPreferencesTabs.styl'; + +/** + * Render tab component + * @param {object} tab TabObject containing tab data + * @param {function} onTabStateChanged Callback Function in case tab changes its state + * @param {function} onTabErrorChanged Callback Function in case any error on tab + */ +const renderTab = ( + tab = {}, + tabsState, + tabsError, + onTabStateChanged, + onTabErrorChanged +) => { + const { props, Component, name, hidden = false } = tab; + + const tabState = tabsState[name]; + const tabError = tabsError[name]; + + return !hidden ? ( +
+
+ +
+
+ ) : null; +}; + +const renderTabsHeader = (tabs, activeTabIndex, onHeaderChanged) => { + return tabs.length > 0 + ? tabs.map((tab, index) => { + const { name, hidden = false } = tab; + + const tabClass = + index === activeTabIndex ? 'nav-link active' : 'nav-link'; + return !hidden ? ( +
  • { + onHeaderChanged(index); + }} + className={tabClass} + > + +
  • + ) : null; + }) + : null; +}; +/** + * Component to render tabs based on currentActiveTabIndex + * + * In case any tab changes its state this current component tells parent through function callback + * @param {object} props Component props + */ +function UserPreferencesTabs({ + tabs, + tabsState, + tabsError, + onTabStateChanged, + onTabErrorChanged, +}) { + const [activeTabIndex, setActiveTabIndex] = useState(0); + + return ( +
    +
    +
    +
      + {renderTabsHeader(tabs, activeTabIndex, setActiveTabIndex)} +
    +
    +
    + {renderTab( + tabs[activeTabIndex], + tabsState, + tabsError, + onTabStateChanged, + onTabErrorChanged + )} +
    + ); +} + +UserPreferencesTabs.propTypes = { + tabs: PropTypes.array.isRequired, + tabsState: PropTypes.object.isRequired, + tabsError: PropTypes.object.isRequired, + onTabStateChanged: PropTypes.func.isRequired, + onTabErrorChanged: PropTypes.func.isRequired, +}; + +export { UserPreferencesTabs }; diff --git a/platform/ui/src/components/userPreferencesForm/UserPreferences.styl b/platform/ui/src/components/userPreferencesForm/UserPreferencesTabs.styl similarity index 96% rename from platform/ui/src/components/userPreferencesForm/UserPreferences.styl rename to platform/ui/src/components/userPreferencesForm/UserPreferencesTabs.styl index d3e3049c0..e7197167a 100644 --- a/platform/ui/src/components/userPreferencesForm/UserPreferences.styl +++ b/platform/ui/src/components/userPreferencesForm/UserPreferencesTabs.styl @@ -3,7 +3,7 @@ @import './../../design/styles/common/state.styl' @import './../../design/styles/common/global.styl' -.UserPreferences +.UserPreferencesTabs display: flex flex-direction: column diff --git a/platform/ui/src/components/userPreferencesForm/WindowLevelPreferences.js b/platform/ui/src/components/userPreferencesForm/WindowLevelPreferences.js index 45e931b3e..381050caa 100644 --- a/platform/ui/src/components/userPreferencesForm/WindowLevelPreferences.js +++ b/platform/ui/src/components/userPreferencesForm/WindowLevelPreferences.js @@ -1,94 +1,132 @@ -import React, { Component } from 'react'; +import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; + import './WindowLevelPreferences.styl'; +/** + * WindowLevelPreferencesRow + * Renders row for window level preference + * It stores current state and whenever it changes, component messages parent of new value (through function callback) + * @param {object} props component props + * @param {string} props.description description for given preset + * @param {number} props.window window value + * @param {number} props.level level value + * @param {string} props.rowName name of given row to identify it + * @param {function} props.onSuccessChanged Callback function to communicate parent in case its states changes + */ +function WindowLevelPreferencesRow({ + description, + window, + level, + rowName, + onSuccessChanged, + // onFailureChanged +}) { + const [rowState, setRowState] = useState({ description, window, level }); -export class WindowLevelPreferences extends Component { - constructor(props) { - super(props); - - this.state = { - data: this.props.windowLevelData, - }; - } - - static propTypes = { - windowLevelData: PropTypes.object.isRequired, - onChange: PropTypes.func, + const onInputChanged = (event, name) => { + const newValue = event.target.value; + setRowState({ ...rowState, [name]: newValue }); }; - onChange(event, key, field) { - const data = this.state.data; - const entry = data[key]; - entry[field] = event.target.value; - this.setState({ data }); + useEffect(() => { + onSuccessChanged(rowName, rowState); + }, [rowState]); - if (this.props.onChange) { - this.props.onChange(data); - } - } - - getWLPreferencesRows(key) { - const entry = this.state.data[key]; + const renderTd = (value, name, type) => { return ( - - {key} - - - - - - - - - - + + + ); - } + }; - render() { - return ( - - - - - - - - - - - {Object.keys(this.state.data).map(key => { - return this.getWLPreferencesRows(key); - })} - -
    PresetDescriptionWindowLevel
    - ); - } + return ( + + {rowName} + {renderTd(rowState.description, 'description', 'text')} + {renderTd(rowState.window, 'window', 'number')} + {renderTd(rowState.level, 'level', 'number')} + + ); } + +WindowLevelPreferencesRow.propTypes = { + description: PropTypes.string.isRequired, + window: PropTypes.number.isRequired, + level: PropTypes.number.isRequired, + rowName: PropTypes.string.isRequired, + onSuccessChanged: PropTypes.func.isRequired, + //onFailureChanged: PropTypes.func.isRequired, +}; + +/** + * WindowLevelPreferences tab + * It renders all window level presets + * + * It stores current state and whenever it changes, component messages parent of new value (through function callback) + * @param {object} props component props + * @param {string} props.name Tab`s name + * @param {object} props.windowLevelData Data for initial state + * @param {function} props.onTabStateChanged Callback function to communicate parent in case its states changes + */ +function WindowLevelPreferences({ + windowLevelData, + name, + onTabStateChanged /*onTabErrorChanged*/, +}) { + const [tabState, setTabState] = useState(windowLevelData); + // TODO to be used once error handling is implemented + //const [tabError, setTabError] = useState(false); + + const onWindowLevelChanged = (key, state) => { + setTabState({ ...tabState, [key]: state }); + }; + + // tell parent to update its state + useEffect(() => { + onTabStateChanged(name, { windowLevelData: tabState }); + }, [tabState]); + + return ( + + + + + + + + + + + {Object.keys(tabState).map(objKey => ( + + ))} + +
    PresetDescriptionWindowLevel
    + ); +} + +WindowLevelPreferences.propTypes = { + windowLevelData: PropTypes.object.isRequired, + name: PropTypes.string.isRequired, + onTabStateChanged: PropTypes.func.isRequired, + //onTabErrorChanged: PropTypes.func.isRequired, +}; + +export { WindowLevelPreferences }; diff --git a/platform/ui/src/components/userPreferencesForm/WindowLevelPreferences.styl b/platform/ui/src/components/userPreferencesForm/WindowLevelPreferences.styl index 78864357b..fd49a7936 100644 --- a/platform/ui/src/components/userPreferencesForm/WindowLevelPreferences.styl +++ b/platform/ui/src/components/userPreferencesForm/WindowLevelPreferences.styl @@ -1,4 +1,4 @@ -@import './UserPreferences.styl' +@import './UserPreferencesTabs.styl' .presetIndex - padding: 0px 10px 0px 10px \ No newline at end of file + padding: 0px 10px 0px 10px diff --git a/platform/ui/src/components/userPreferencesForm/index.js b/platform/ui/src/components/userPreferencesForm/index.js index 06e18ab08..612cc490c 100644 --- a/platform/ui/src/components/userPreferencesForm/index.js +++ b/platform/ui/src/components/userPreferencesForm/index.js @@ -1,4 +1,4 @@ -export { UserPreferences } from './UserPreferences.js'; +export { UserPreferencesTabs } from './UserPreferencesTabs.js'; export { AboutContent } from '../content/aboutContent/AboutContent.js'; export { UserPreferencesForm } from './UserPreferencesForm.js'; export { GeneralPreferences } from './GeneralPreferences.js'; diff --git a/platform/ui/src/elements/form/DropdownMenu.js b/platform/ui/src/elements/form/DropdownMenu.js index b0fcb2abe..e0562370a 100644 --- a/platform/ui/src/elements/form/DropdownMenu.js +++ b/platform/ui/src/elements/form/DropdownMenu.js @@ -46,7 +46,7 @@ class DropdownMenu extends Component {