From 9d5f62128cad61675bb7d50c225f674936eed8a3 Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Fri, 26 Nov 2021 19:05:57 +0100 Subject: [PATCH 01/10] Migrate VM List screen to PF4 Includes: 1. toolbars 2. tooltips 3. spinner 4. masthead icons Fixes: 1. use translation independent filters 2. allow filter multiselection --- src/components/CreateVmWizard/AddVmButton.js | 3 +- src/components/LoadingData/index.js | 4 +- src/components/Settings/SettingsToolbar.js | 68 +-- src/components/Settings/style.css | 5 +- src/components/Toolbar/VmFilters.js | 405 ++++++++++-------- src/components/Toolbar/VmSort.js | 98 ++--- src/components/Toolbar/VmsListToolbar.js | 136 ++---- src/components/Toolbar/index.js | 43 +- src/components/Toolbar/style.css | 5 + src/components/VmActions/Action.js | 211 +-------- src/components/VmActions/ConfirmationModal.js | 30 +- src/components/VmActions/VmDetailsActions.js | 161 +++++++ src/components/VmActions/VmDropdownActions.js | 92 ++++ src/components/VmActions/index.js | 209 +++------ src/components/VmActions/style.css | 17 - src/components/VmConsole/VmConsoleSelector.js | 50 ++- src/components/VmConsole/index.js | 4 +- src/components/VmIcon/index.js | 4 +- src/components/VmStatusIcon/index.js | 55 ++- src/components/VmUserMessages/Bellicon.js | 3 +- src/components/VmsList/BaseCard.js | 46 +- src/components/VmsList/Pool.js | 5 +- src/components/VmsList/Vm.js | 3 +- src/components/VmsList/VmCardList.js | 21 +- src/components/VmsList/index.js | 16 +- src/components/VmsList/style.css | 53 ++- src/components/VmsPageHeader/UserMenu.js | 3 +- src/components/VmsPageHeader/index.js | 9 +- src/components/sharedStyle.css | 2 +- src/components/tooltips/Tooltip.js | 16 +- src/index-nomodules.css | 2 + src/intl/messages.js | 7 +- src/reducers/vms.js | 6 +- src/sagas/login.js | 9 +- src/utils/vms-filters.js | 22 +- src/vm-status.js | 2 +- 36 files changed, 926 insertions(+), 899 deletions(-) create mode 100644 src/components/VmActions/VmDetailsActions.js create mode 100644 src/components/VmActions/VmDropdownActions.js diff --git a/src/components/CreateVmWizard/AddVmButton.js b/src/components/CreateVmWizard/AddVmButton.js index c9a454b97..e0323e564 100644 --- a/src/components/CreateVmWizard/AddVmButton.js +++ b/src/components/CreateVmWizard/AddVmButton.js @@ -1,7 +1,7 @@ import React from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' -import { Button } from 'patternfly-react' +import { Button } from '@patternfly/react-core' import * as Actions from '_/actions' import { CREATE_PAGE_TYPE } from '_/constants' @@ -43,7 +43,6 @@ class AddVmButton extends React.Component { <> - - - - - , + + + + + + + + + + + + + + + + + + + + , container ) } diff --git a/src/components/Settings/style.css b/src/components/Settings/style.css index 6fc927d7e..c7da0bbed 100644 --- a/src/components/Settings/style.css +++ b/src/components/Settings/style.css @@ -58,8 +58,9 @@ margin-right: 10px; } -:global(#settings-toolbar) { - margin-left: -20px; +.settings-toolbar { + padding-top: 5px; + padding-bottom: 5px; } .section-list{ diff --git a/src/components/Toolbar/VmFilters.js b/src/components/Toolbar/VmFilters.js index b1d392056..383dd7d48 100644 --- a/src/components/Toolbar/VmFilters.js +++ b/src/components/Toolbar/VmFilters.js @@ -1,215 +1,262 @@ -import React from 'react' +import React, { useState, useMemo } from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' -import { Filter, FormControl } from 'patternfly-react' import { enumMsg, withMsg } from '_/intl' import { saveVmsFilters } from '_/actions' -import { localeCompare } from '_/helpers' +import { localeCompare, toJS } from '_/helpers' +import { + Button, + ButtonVariant, + Dropdown, + DropdownItem, + DropdownPosition, + DropdownToggle, + InputGroup, + Select, + SelectOption, + SelectVariant, + TextInput, + ToolbarGroup, + ToolbarFilter, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core' -import style from './style.css' +import { FilterIcon, SearchIcon } from '@patternfly/react-icons/dist/esm/icons' -class VmFilters extends React.Component { - constructor (props) { - super(props) +const STATUS = 'status' +const OS = 'os' +const NAME = 'name' - this.composeFilterTypes = this.composeFilterTypes.bind(this) - this.filterAdded = this.filterAdded.bind(this) - this.selectFilterType = this.selectFilterType.bind(this) - this.filterValueSelected = this.filterValueSelected.bind(this) - this.updateCurrentValue = this.updateCurrentValue.bind(this) - this.onValueKeyPress = this.onValueKeyPress.bind(this) - this.filterExists = this.filterExists.bind(this) - this.getFilterValue = this.getFilterValue.bind(this) - this.renderInput = this.renderInput.bind(this) - - const filterTypes = this.composeFilterTypes() - this.state = { - currentFilterType: filterTypes[0], - activeFilters: {}, - currentValue: '', - } - } - - composeFilterTypes () { - const { msg, locale } = this.props - const statuses = [ - 'up', - 'powering_up', - 'down', - 'paused', - 'suspended', - 'powering_down', - 'not_responding', - 'unknown', - 'unassigned', - 'migrating', - 'wait_for_launch', - 'reboot_in_progress', - 'saving_state', - 'restoring_state', - 'image_locked', - ] - const filterTypes = [ - { - id: 'name', - title: msg.name(), - placeholder: msg.vmFilterTypePlaceholderName(), - filterType: 'text', - }, - { - id: 'status', - title: msg.status(), - placeholder: msg.vmFilterTypePlaceholderStatus(), - filterType: 'select', - filterValues: statuses - .map((status) => ({ title: enumMsg('VmStatus', status, msg), id: status })) - .sort((a, b) => localeCompare(a.title, b.title, locale)), - }, - { - id: 'os', - title: msg.operatingSystem(), - placeholder: msg.vmFilterTypePlaceholderOS(), - filterType: 'select', - filterValues: Array.from(this.props.operatingSystems - .toList() - .reduce((acc, item) => ( - acc.add(item.get('description')) - ), new Set())) - .map(item => ({ title: item, id: item })) - .sort((a, b) => localeCompare(a.title, b.title, locale)), - }, - ] - return filterTypes +const composeStatus = (msg, locale) => { + const statuses = [ + 'up', + 'powering_up', + 'down', + 'paused', + 'suspended', + 'powering_down', + 'not_responding', + 'unknown', + 'unassigned', + 'migrating', + 'wait_for_launch', + 'reboot_in_progress', + 'saving_state', + 'restoring_state', + 'image_locked', + ] + return { + id: STATUS, + title: msg.status(), + placeholder: msg.vmFilterTypePlaceholderStatus(), + filterValues: Object.entries(statuses + .map((status) => ({ title: enumMsg('VmStatus', status, msg), id: status })) + .reduce((acc, { title, id }) => { + acc[title] = { ...acc[title], [id]: id } + return acc + }, {})) + .map(([title, ids]) => ({ title, ids })) + .sort((a, b) => localeCompare(a.title, b.title, locale)), } +} - filterAdded (field, value) { - const activeFilters = { ...this.props.vms.get('filters').toJS() } - if ((field.filterType === 'select')) { - activeFilters[field.id] = value.title - } else { - if (!activeFilters[field.id]) { - activeFilters[field.id] = [] - } - activeFilters[field.id].push(value) - } - this.props.onFilterUpdate(activeFilters) - }; +const composeOs = (msg, locale, operatingSystems) => { + return ({ + id: OS, + title: msg.operatingSystem(), + placeholder: msg.vmFilterTypePlaceholderOS(), + filterValues: Object.entries(operatingSystems + .toList().toJS() + // { name: 'other_linux_ppc64', description: 'Linux'}, {description: 'Linux', name: 'other_linux'} + // {title: 'Linux', ids: {'other_linux_ppc64', 'other_linux'} + .reduce((acc, { name, description }) => { + acc[description] = { ...acc[description], [name]: name } + return acc + }, {})) + .map(([description, ids]) => ({ title: description, ids })) + .sort((a, b) => localeCompare(a.title, b.title, locale)), + }) +} - selectFilterType (filterType) { - const { currentFilterType } = this.state - if (currentFilterType !== filterType) { - let newCurrentValue = '' - if (filterType.filterType === 'select') { - if (this.filterExists(filterType.id)) { - const filterValue = this.getFilterValue(filterType.id) - newCurrentValue = filterValue - } - } - this.setState({ - currentFilterType: filterType, - currentValue: newCurrentValue, - }) - } - } +const Filter = ({ filterIds = [], setFilters, allSupportedFilters = [], title, filterColumnId, showToolbarItem }) => { + const [isExpanded, setExpanded] = useState(false) - filterValueSelected (filterValue) { - const { currentFilterType, currentValue } = this.state + // one label can map to many IDs so it's easier work with labels + // and reverse map label-to-IDs on save + const toChip = ({ title }) => title + const toOption = ({ title }) => title + const toOptionNode = ({ title }) => + - if (filterValue !== currentValue) { - this.setState({ currentValue: filterValue }) - if (filterValue) { - this.filterAdded(currentFilterType, filterValue) - } - } + // titles are guaranteed to be unique + // return first filter with matching title + const labelToIds = (title) => { + const [{ ids = {} } = {}] = allSupportedFilters.filter(filter => filter.title === title) || [] + return ids } - - updateCurrentValue (event) { - this.setState({ currentValue: event.target.value }) + const selectedFilters = allSupportedFilters.filter(({ ids }) => filterIds.find(id => ids[id])) + const deleteFilter = (title) => { + const ids = labelToIds(title) + // delete all filter IDs linked to provided title + setFilters(filterIds.filter(id => !ids[id])) } - onValueKeyPress (keyEvent) { - const { currentValue, currentFilterType } = this.state - - if (keyEvent.key === 'Enter' && currentValue && currentValue.length > 0) { - this.setState({ currentValue: '' }) - this.filterAdded(currentFilterType, currentValue) - keyEvent.stopPropagation() - keyEvent.preventDefault() - } + const addFilter = (title) => { + const ids = labelToIds(title) + // add all filter IDs linked + setFilters([...filterIds, ...Object.keys(ids)]) } + return ( + deleteFilter(option)} + deleteChipGroup={() => setFilters([])} + categoryName={filterColumnId} + showToolbarItem={showToolbarItem} + > + + + ) +} - filterExists (fieldId) { - return !!this.props.vms.getIn(['filters', fieldId]) - }; +Filter.propTypes = { + filterIds: PropTypes.array.isRequired, + allSupportedFilters: PropTypes.array.isRequired, + setFilters: PropTypes.func.isRequired, + title: PropTypes.string.isRequired, + filterColumnId: PropTypes.string.isRequired, + showToolbarItem: PropTypes.bool.isRequired, +} - getFilterValue (fieldId) { - return this.props.vms.getIn(['filters', fieldId]) - }; +const VmFilters = ({ msg, locale, operatingSystems, selectedFilters, onFilterUpdate }) => { + const filterTypes = useMemo(() => [ + { + id: NAME, + title: msg.name(), + placeholder: msg.vmFilterTypePlaceholderName(), + }, + composeStatus(msg, locale), + composeOs(msg, locale, operatingSystems), + ], [msg, locale, operatingSystems]) + const [currentFilterType, setCurrentFilterType] = useState(filterTypes[0]) + const [expanded, setExpanded] = useState(false) + const [inputValue, setInputValue] = useState('') - renderInput () { - const { currentFilterType, currentValue, filterCategory } = this.state - if (!currentFilterType) { - return null - } + const nameFilter = filterTypes.find(({ id }) => id === NAME) + const labelToFilter = (label) => filterTypes.find(({ title }) => title === label) ?? currentFilterType - if (currentFilterType.filterType === 'select') { - if (currentValue !== '' && !this.filterExists(currentFilterType.id)) { - this.setState({ - currentValue: '', - filterCategory, - }) - } - return ( - - ) + const onFilterTypeSelect = (event) => { + setCurrentFilterType(labelToFilter(event?.target?.innerText)) + setExpanded(!expanded) + } + const onFilterTypeToggle = () => setExpanded(!expanded) + const onNameInput = (event) => { + if ((event.key && event.key !== 'Enter') || + !inputValue || + selectedFilters?.[NAME]?.includes(inputValue)) { + return } - return ( - this.updateCurrentValue(e)} - onKeyPress={e => this.onValueKeyPress(e)} - /> - ) + onFilterUpdate({ ...selectedFilters, [NAME]: [...(selectedFilters?.[NAME] ?? []), inputValue] }) + setInputValue('') } - render () { - const { currentFilterType } = this.state - - const filterTypes = this.composeFilterTypes() - - return ( - - - {this.renderInput()} - - ) - } + return ( + } breakpoint="xl"> + + + + {currentFilterType.title} + + )} + isOpen={expanded} + style={{ width: '100%' }} + dropdownItems={ + filterTypes.map(({ id, title }) => + {title}) + } + /> + + onFilterUpdate({ + ...selectedFilters, + [NAME]: selectedFilters?.[NAME]?.filter?.(value => value !== option) ?? [], + })} + deleteChipGroup={() => onFilterUpdate({ ...selectedFilters, [NAME]: [] })} + categoryName={NAME} + showToolbarItem={currentFilterType.id === NAME} + > + + + + + + {filterTypes.filter(({ id }) => id !== NAME)?.map(({ id, filterValues, placeholder }) => ( + onFilterUpdate({ ...selectedFilters, [id]: filtersToSave })} + title={placeholder} + /> + ) + )} + + + ) } VmFilters.propTypes = { operatingSystems: PropTypes.object.isRequired, - vms: PropTypes.object.isRequired, + selectedFilters: PropTypes.object.isRequired, onFilterUpdate: PropTypes.func.isRequired, msg: PropTypes.object.isRequired, locale: PropTypes.string.isRequired, } export default connect( - (state) => ({ - operatingSystems: state.operatingSystems, - vms: state.vms, + ({ operatingSystems, vms }) => ({ + operatingSystems, + selectedFilters: toJS(vms.get('filters')), }), (dispatch) => ({ onFilterUpdate: (filters) => dispatch(saveVmsFilters({ filters })), diff --git a/src/components/Toolbar/VmSort.js b/src/components/Toolbar/VmSort.js index 7c7de7478..9fe14c2ac 100644 --- a/src/components/Toolbar/VmSort.js +++ b/src/components/Toolbar/VmSort.js @@ -1,63 +1,63 @@ -import React from 'react' +import React, { useState } from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' -import { Sort } from 'patternfly-react' import { setVmSort } from '_/actions' import { SortFields } from '_/utils' -import { Tooltip } from '_/components/tooltips' import { withMsg } from '_/intl' import { translate } from '_/helpers' +import { + OptionsMenu, + OptionsMenuItemGroup, + OptionsMenuSeparator, + OptionsMenuItem, + OptionsMenuToggle, +} from '@patternfly/react-core' +import { SortAmountDownIcon, SortAmountDownAltIcon } from '@patternfly/react-icons/dist/esm/icons' -class VmSort extends React.Component { - constructor (props) { - super(props) - this.updateCurrentSortType = this.updateCurrentSortType.bind(this) - this.toggleCurrentSortDirection = this.toggleCurrentSortDirection.bind(this) - this.getSortTooltipMessage = this.getSortTooltipMessage.bind(this) - } +const VmSort = ({ sort, msg, onSortChange }) => { + const { id: enabledSortId, isAsc } = sort + const [expanded, setExpanded] = useState(false) - updateCurrentSortType (sortType) { - this.props.onSortChange({ ...sortType, isAsc: this.props.sort.isAsc }) - } + const menuItems = [ + + {Object.values(SortFields) + .map(type => ({ ...type, title: translate({ ...type.messageDescriptor, msg }) })) + .map(({ title, id, messageDescriptor }) => ( + onSortChange({ ...sort, id, messageDescriptor })} + > + {title} + + )) + } + , + , + + onSortChange({ ...sort, isAsc: true })} isSelected={isAsc} id="ascending" key="ascending">{msg.ascending()} + onSortChange({ ...sort, isAsc: false })} isSelected={!isAsc} id="descending" key="descending">{msg.descending()} + , + ] - toggleCurrentSortDirection () { - const sort = this.props.sort - this.props.onSortChange({ ...sort, isAsc: !sort.isAsc }) - } - - getSortTooltipMessage () { - const { msg } = this.props - const { sort: { id, isAsc } } = this.props - return id === 'os' || id === 'name' - ? (isAsc ? msg.sortAToZ() : msg.sortZToA()) - : (isAsc ? msg.sortOffFirst() : msg.sortRunningFirst()) - } - - render () { - const { sort, msg } = this.props - - return ( - - ({ ...type, title: translate({ ...type.messageDescriptor, msg }) }))} - currentSortType={sort && { ...sort, title: translate({ ...sort.messageDescriptor, msg }) }} - onSortTypeSelected={this.updateCurrentSortType} + return [ + setExpanded(!expanded)} + toggleTemplate={msg.sortBy()} /> - - - - - ) - } + )} + isGrouped + />, + isAsc ? : , + ] } VmSort.propTypes = { diff --git a/src/components/Toolbar/VmsListToolbar.js b/src/components/Toolbar/VmsListToolbar.js index 8491eedff..2fab6fc84 100644 --- a/src/components/Toolbar/VmsListToolbar.js +++ b/src/components/Toolbar/VmsListToolbar.js @@ -1,123 +1,75 @@ -import React, { useContext } from 'react' +import React from 'react' import PropTypes from 'prop-types' -import { List } from 'immutable' import { connect } from 'react-redux' import { saveVmsFilters } from '_/actions' -import { MsgContext } from '_/intl' +import { withMsg } from '_/intl' import { RouterPropTypeShapes } from '_/propTypeShapes' -import { filterVms, mapFilterValues } from '_/utils' +import { filterVms } from '_/utils' -import { Toolbar, Filter } from 'patternfly-react' +import { + Toolbar, + ToolbarItem, + ToolbarContent, +} from '@patternfly/react-core' import { AddVmButton } from '_/components/CreateVmWizard' -import VmFilter from './VmFilters' +import VmFilters from './VmFilters' import VmSort from './VmSort' -import style from './style.css' -const VmsListToolbar = ({ match, vms, onRemoveFilter, onClearFilters }) => { - const { msg } = useContext(MsgContext) - const filters = vms.get('filters').toJS() +import { toJS } from '_/helpers' - const removeFilter = (filter) => { - let filters = vms.get('filters') - const filterValue = filters.get(filter.id) - if (filterValue) { - if (List.isList(filterValue)) { - filters = filters.update(filter.id, (v) => v.delete(v.findIndex(v2 => filter.value === v2))) - if (filters.get(filter.id).size === 0) { - filters = filters.delete(filter.id) - } - } else { - filters = filters.delete(filter.id) - } - onRemoveFilter(filters.toJS()) - } - } +const VmsListToolbar = ({ match, vms, pools, filters = {}, onClearFilters, msg }) => { + const { name, status, os } = filters + const hasFilters = name?.length || status?.length || os?.length - const mapLabels = (item, index) => { - const labels = [] - if (List.isList(item)) { - item.forEach((t, i) => { - labels.push( - - {msg[index]()}: {mapFilterValues[index](t)} - - ) - }) - } else { - labels.push( - - {msg[index]()}: {mapFilterValues[index](item)} - - ) - } - return labels - } - - const total = vms.get('vms').size + vms.get('pools').size - const available = vms.get('filters').size && - vms.get('vms').filter(vm => filterVms(vm, filters, msg)).size + - vms.get('pools').filter(vm => filterVms(vm, filters, msg)).size + const total = vms.size + pools.size + const available = vms.filter(vm => filterVms(vm, filters)).size + + pools.filter(vm => filterVms(vm, filters)).size return ( - - - - - - - -
- { - vms.get('filters').size + <> + + + + + + + +
+ { + hasFilters ? msg.resultsOf({ total, available }) : msg.results({ total }) } -
- { vms.get('filters').size > 0 && ( - <> - {msg.activeFilters()} - - {[].concat(...vms.get('filters').map(mapLabels).toList().toJS())} - - { - e.preventDefault() - onClearFilters() - }} - > - {msg.clearAllFilters()} - - - )} - -
+
+ + + + + +
+ ) } VmsListToolbar.propTypes = { vms: PropTypes.object.isRequired, + pools: PropTypes.object.isRequired, + filters: PropTypes.object.isRequired, match: RouterPropTypeShapes.match.isRequired, - onRemoveFilter: PropTypes.func.isRequired, onClearFilters: PropTypes.func.isRequired, + msg: PropTypes.object.isRequired, } export default connect( - (state) => ({ - vms: state.vms, + ({ vms }) => ({ + vms: vms.get('vms'), + pools: vms.get('pools'), + filters: toJS(vms.get('filters')), }), + (dispatch) => ({ - onRemoveFilter: (filters) => dispatch(saveVmsFilters({ filters })), onClearFilters: () => dispatch(saveVmsFilters({ filters: {} })), }) -)(VmsListToolbar) +)(withMsg(VmsListToolbar)) diff --git a/src/components/Toolbar/index.js b/src/components/Toolbar/index.js index 57c4b0edc..376eb08ff 100644 --- a/src/components/Toolbar/index.js +++ b/src/components/Toolbar/index.js @@ -2,14 +2,16 @@ import React from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' -import { Toolbar } from 'patternfly-react' -import style from './style.css' +import { + Toolbar, + ToolbarContent, + ToolbarGroup, +} from '@patternfly/react-core' import { RouterPropTypeShapes } from '_/propTypeShapes' import VmActions from '../VmActions' import VmConsoleSelector from '../VmConsole/VmConsoleSelector' import VmConsoleInstructionsModal from '../VmConsole/VmConsoleInstructionsModal' import VmsListToolbar from './VmsListToolbar' - import { NATIVE_VNC, SPICE } from '_/constants' const VmDetailToolbar = ({ match, vms }) => { @@ -17,10 +19,12 @@ const VmDetailToolbar = ({ match, vms }) => { const poolId = vms.getIn(['vms', match.params.id, 'pool', 'id']) const pool = vms.getIn(['pools', poolId]) return ( - - - - + + + + + + ) } @@ -40,10 +44,14 @@ const VmDetailToolbarConnected = connect( )(VmDetailToolbar) const VmConsoleToolbar = ({ match: { params: { id, consoleType } } = {}, vms }) => { - if (vms.getIn(['vms', id])) { - return ( -
-
+ if (!vms.getIn(['vms', id])) { + return + } + + return ( + + + -
-
+ +
-
-
- ) - } - return
+ + + + ) } VmConsoleToolbar.propTypes = { diff --git a/src/components/Toolbar/style.css b/src/components/Toolbar/style.css index 9c47286f6..7cf1ddb5d 100644 --- a/src/components/Toolbar/style.css +++ b/src/components/Toolbar/style.css @@ -29,3 +29,8 @@ overflow: auto; max-height: 600px; } + +:global(.pf-c-toolbar.vm-list-toolbar) { + padding-top: 5px; + padding-bottom: 5px; +} diff --git a/src/components/VmActions/Action.js b/src/components/VmActions/Action.js index 5de706455..78b543af9 100644 --- a/src/components/VmActions/Action.js +++ b/src/components/VmActions/Action.js @@ -1,207 +1,28 @@ import React from 'react' import PropTypes from 'prop-types' -import { DropdownButton, MenuItem } from 'patternfly-react' - -import { hrefWithoutHistory } from '_/helpers' - -import style from './style.css' - -class Action extends React.Component { - constructor (props) { - super(props) - this.state = { showModal: false } - this.handleOpen = this.handleOpen.bind(this) - this.handleClose = this.handleClose.bind(this) - } - - handleOpen (e) { - if (e && e.preventDefault) e.preventDefault() - this.setState({ showModal: true }) - this.props.children.props.onClick && this.props.children.props.onClick(e) - } - - handleClose () { - this.setState({ showModal: false }) - } - - render () { - const { children, confirmation } = this.props - - const trigger = confirmation - ? React.cloneElement(children, { onClick: this.handleOpen }) - : children - - const confirmationDialog = confirmation - ? React.cloneElement(confirmation, { show: this.state.showModal, onClose: this.handleClose }) - : null - - return ( - <> - {trigger} - {confirmationDialog} - - ) - } -} -Action.propTypes = { - children: PropTypes.node.isRequired, - confirmation: PropTypes.node, -} - -const Button = ({ - className, - tooltip = '', - shortTitle, - onClick = () => {}, - actionDisabled = false, - id, -}) => { - const handleClick = hrefWithoutHistory(onClick) - - if (actionDisabled) { - return ( - - ) - } +import { + Button, + ButtonVariant, +} from '@patternfly/react-core' +const ActionButtonWraper = ({ id, actionDisabled, shortTitle, onClick, variant = ButtonVariant.control }) => { return ( - - - - {shortTitle} - - - + ) } -Button.propTypes = { - className: PropTypes.string.isRequired, - tooltip: PropTypes.string, +ActionButtonWraper.propTypes = { shortTitle: PropTypes.string.isRequired, onClick: PropTypes.func, actionDisabled: PropTypes.bool, id: PropTypes.string.isRequired, + variant: PropTypes.string, } -const MenuItemAction = ({ - confirmation, - onClick, - shortTitle, - icon, - actionDisabled = false, - id, - className, -}) => { - return ( - - { - onClick && onClick(...args) - document.dispatchEvent(new MouseEvent('click')) - }} - id={id} - className={className} - > - {shortTitle} {icon} - - - ) -} -MenuItemAction.propTypes = { - id: PropTypes.string.isRequired, - confirmation: PropTypes.node, - onClick: PropTypes.func, - shortTitle: PropTypes.string.isRequired, - icon: PropTypes.node, - className: PropTypes.string, - actionDisabled: PropTypes.bool, -} - -const ActionButtonWraper = ({ items, confirmation, actionDisabled, shortTitle, bsStyle, ...rest }) => { - if (items && items.filter(i => i !== null).length > 0) { - return ( - - { - items.filter(i => i !== null && !i.actionDisabled).map( - item => - ) - } - - ) - } - - return ( - - + + + ) } VmSort.propTypes = { diff --git a/src/components/Toolbar/VmsListToolbar.js b/src/components/Toolbar/VmsListToolbar.js index 2fab6fc84..fa703ab21 100644 --- a/src/components/Toolbar/VmsListToolbar.js +++ b/src/components/Toolbar/VmsListToolbar.js @@ -31,17 +31,15 @@ const VmsListToolbar = ({ match, vms, pools, filters = {}, onClearFilters, msg } + - - - -
+

{ hasFilters ? msg.resultsOf({ total, available }) : msg.results({ total }) } -

+
diff --git a/src/utils/vms-filters.js b/src/utils/vms-filters.js index 8a343a98d..33dfa848c 100644 --- a/src/utils/vms-filters.js +++ b/src/utils/vms-filters.js @@ -9,7 +9,7 @@ export function filterVms (item, filters) { let res = true for (const name in filters) { if (compareMap[name]) { - res &= compareMap[name](item, filters[name]) + res &&= compareMap[name](item, filters[name]) } } return res From b206d6f2905fcb1ffc98ae695095c89cde1631eb Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Mon, 6 Jun 2022 16:00:11 +0200 Subject: [PATCH 04/10] Keep the xpath selector used by OST to check VM count Integration tests (OST) expect VM count under specific xpath: '//div[@class='col-sm-12']/h5' Preserve this path: 1. keep the class 'col-sm-12' as marker class 2. keep the structure:
followed by
--- src/components/Toolbar/VmsListToolbar.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/components/Toolbar/VmsListToolbar.js b/src/components/Toolbar/VmsListToolbar.js index fa703ab21..ac2989cc2 100644 --- a/src/components/Toolbar/VmsListToolbar.js +++ b/src/components/Toolbar/VmsListToolbar.js @@ -33,13 +33,21 @@ const VmsListToolbar = ({ match, vms, pools, filters = {}, onClearFilters, msg } -

- { + { + /* integration tests (OST) expect VM count under xpath //div[@class='col-sm-12']/h5 + preserve this path: + keep the class 'col-sm-12' as marker class + keep the structure
followed by
*/ + } +
+
+ { hasFilters ? msg.resultsOf({ total, available }) : msg.results({ total }) } -
+
+
From f7d50978c38db167e964d227c22ec342d98ed732 Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Wed, 8 Dec 2021 17:25:34 +0100 Subject: [PATCH 05/10] Migrate Create VM Wizard to PF4 Key changes: 1. reimplement Network and Storage steps using TableComposable 2. add 'final' step after the Summary for tracking progress --- package.json | 6 +- .../CreateVmWizard/CreateVmWizard.js | 224 ++-- .../CreateVmWizard/StorageDomainSelect.js | 56 + .../CreateVmWizard/steps/BasicSettings.js | 130 +- .../steps/DiskNameWithLabels.js | 40 + .../CreateVmWizard/steps/FinishedStep.js | 62 + .../CreateVmWizard/steps/Networking.js | 721 ++++-------- .../CreateVmWizard/steps/NicNameWithLabels.js | 35 + .../CreateVmWizard/steps/Storage.js | 1048 ++++++----------- .../CreateVmWizard/steps/SummaryReview.js | 79 +- .../steps/_TableInlineEditRow.js | 57 - src/components/SelectBox.js | 13 +- .../utils/build-select-box-lists.js | 4 +- src/components/utils/disks.js | 15 +- src/components/utils/disks.test.js | 22 +- yarn.lock | 64 +- 16 files changed, 1042 insertions(+), 1534 deletions(-) create mode 100644 src/components/CreateVmWizard/StorageDomainSelect.js create mode 100644 src/components/CreateVmWizard/steps/DiskNameWithLabels.js create mode 100644 src/components/CreateVmWizard/steps/FinishedStep.js create mode 100644 src/components/CreateVmWizard/steps/NicNameWithLabels.js delete mode 100644 src/components/CreateVmWizard/steps/_TableInlineEditRow.js diff --git a/package.json b/package.json index c91e56381..9592cdf44 100644 --- a/package.json +++ b/package.json @@ -66,9 +66,11 @@ }, "dependencies": { "@closeio/use-infinite-scroll": "1.0.0", - "@patternfly/react-charts": "6.21.8", + "@patternfly/react-charts": "6.34.1", "@patternfly/react-console": "^2.0.18", - "@patternfly/react-core": "4.168.8", + "@patternfly/react-core": "4.181.1", + "@patternfly/react-icons": "4.32.1", + "@patternfly/react-table": "4.50.1", "bootstrap": "3.4.1", "bootstrap-select": "1.13.18", "bootstrap-switch": "3.3.4", diff --git a/src/components/CreateVmWizard/CreateVmWizard.js b/src/components/CreateVmWizard/CreateVmWizard.js index 9e3b6ccb1..abf6ef8a8 100644 --- a/src/components/CreateVmWizard/CreateVmWizard.js +++ b/src/components/CreateVmWizard/CreateVmWizard.js @@ -1,7 +1,7 @@ import React from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' -import { Wizard, Button, Icon } from 'patternfly-react' +import { Wizard } from '@patternfly/react-core' import produce from 'immer' import { List } from 'immutable' @@ -17,12 +17,17 @@ import BasicSettings from './steps/BasicSettings' import Networking from './steps/Networking' import Storage from './steps/Storage' import SummaryReview from './steps/SummaryReview' +import FinishedStep from './steps/FinishedStep' const DEFAULT_STATE = { activeStepIndex: 0, + // each time the wizard is opened it should have new key prop + // this forces component re-creation which clears internal state + wizardKey: 1, steps: { basic: { + name: '', operatingSystemId: '0', // "Other OS" memory: 1024, // MiB cpus: 1, @@ -181,22 +186,18 @@ class CreateVmWizard extends React.Component { this.handleBasicOnExit = this.handleBasicOnExit.bind(this) this.handleListOnUpdate = this.handleListOnUpdate.bind(this) this.handleCreateVm = this.handleCreateVm.bind(this) - this.wizardAllowGoToStepFromActiveStep = this.wizardAllowGoToStepFromActiveStep.bind(this) - this.wizardAllowClickBack = this.wizardAllowClickBack.bind(this) - this.wizardAllowClickNext = this.wizardAllowClickNext.bind(this) - this.wizardGoToStep = this.wizardGoToStep.bind(this) - this.wizardClickBack = this.wizardClickBack.bind(this) - this.wizardClickNext = this.wizardClickNext.bind(this) this.hideCloseWizardDialog = this.hideCloseWizardDialog.bind(this) this.showCloseWizardDialog = this.showCloseWizardDialog.bind(this) - const { msg } = this.props + this.createSteps = this.createSteps.bind(this) + } - this.wizardSteps = [ + createSteps (msg, vmCreateStarted) { + return [ { id: 'basic', - title: msg.createVmWizardStepTitleBasic(), + name: msg.createVmWizardStepTitleBasic(), - render: (activeStepIndex, title) => ( + component: ( ), + hideBackButton: true, + enableNext: this.state.stepNavigation.basic.valid, onExit: this.handleBasicOnExit, + canJumpTo: true, }, { id: 'network', - title: msg.createVmWizardStepTitleNetwork(), + name: msg.createVmWizardStepTitleNetwork(), - render: () => ( + component: ( ), + enableNext: this.state.stepNavigation.network.valid, + canJumpTo: this.state.stepNavigation.basic.valid, }, { id: 'storage', - title: msg.createVmWizardStepTitleStorage(), + name: msg.createVmWizardStepTitleStorage(), - render: () => ( + component: ( ), + enableNext: this.state.stepNavigation.storage.valid, + canJumpTo: this.state.stepNavigation.basic.valid && this.state.stepNavigation.network.valid, }, - { id: 'review', - title: msg.createVmWizardStepTitleReview(), + name: msg.createVmWizardStepTitleReview(), + + component: ( + + ), + onExit: () => this.handleCreateVm(), + enableNext: true, + canJumpTo: this.state.stepNavigation.basic.valid && this.state.stepNavigation.network.valid && this.state.stepNavigation.storage.valid, + nextButtonText: msg.createVmWizardButtonCreate(), - render: () => { + }, + { + id: 'finish', + name: 'Finished', + component: (() => { const { correlationId } = this.state const inProgress = correlationId !== null && !this.props.actionResults.has(correlationId) @@ -281,11 +305,9 @@ class CreateVmWizard extends React.Component { .toJS() return ( - ) - }, - onExit: () => { - this.setState(produce(draft => { draft.correlationId = null })) - }, + })(), + isFinishedStep: true, }, ] } @@ -315,7 +335,10 @@ class CreateVmWizard extends React.Component { } hideAndResetState () { - this.setState(getInitialState(this.props)) + this.setState({ + ...getInitialState(this.props), + wizardKey: this.state.wizardKey + 1, + }) this.props.onHide() } @@ -424,8 +447,22 @@ class CreateVmWizard extends React.Component { isFromTemplate: true, } }) + .map(disk => + // all template disks with invalid storage domain + // need to moved to edit mode + disk.canUserUseStorageDomain + ? disk + : { + ...disk, + underConstruction: { + ...disk, + storageDomainId: '_', + }, + }) .toJS(), } + + draft.stepNavigation.storage.valid = !draft.steps.storage.disks.find(({ underConstruction }) => underConstruction) } })) } @@ -469,126 +506,33 @@ class CreateVmWizard extends React.Component { this.props.onCreate(basic, nics, disks, correlationId) } - wizardAllowGoToStepFromActiveStep (newStepIndex) { - if (newStepIndex < 0 || newStepIndex >= this.wizardSteps.length) { - return false - } - - const { activeStepIndex, stepNavigation } = this.state - const newStep = this.wizardSteps[newStepIndex] - - // Direction >0 is forward, <0 is backward - // Forward ok if ... can enter the new step and each step between active and new is valid - // Backward ok if ... can enter the new step - const direction = newStepIndex - activeStepIndex - if (direction > 0) { - return !stepNavigation[newStep.id].preventEnter && - this.wizardSteps.slice(activeStepIndex, newStepIndex).every(step => stepNavigation[step.id].valid) - } else if (direction < 0) { - return !stepNavigation[newStep.id].preventEnter - } - - return false - } - - wizardAllowClickBack () { - return this.wizardAllowGoToStepFromActiveStep(Math.max(this.state.activeStepIndex - 1, 0)) - } - - wizardAllowClickNext () { - return this.wizardAllowGoToStepFromActiveStep(Math.min(this.state.activeStepIndex + 1, this.wizardSteps.length - 1)) - } - - wizardGoToStep (newStepIndex) { - const { activeStepIndex } = this.state - const activeStep = this.wizardSteps[activeStepIndex] - - // make sure we can leave the current step and enter the new step - if (!this.wizardAllowGoToStepFromActiveStep(newStepIndex)) { - return - } - - // run and the current step's `onExit()` - if (activeStep.onExit) { - activeStep.onExit() - } - - this.setState(produce(draft => { draft.activeStepIndex = newStepIndex })) - } - - wizardClickBack () { - this.wizardGoToStep(Math.max(this.state.activeStepIndex - 1, 0)) - } - - wizardClickNext () { - this.wizardGoToStep(Math.min(this.state.activeStepIndex + 1, this.wizardSteps.length - 1)) - } - render () { - const { msg } = this.props - const { activeStepIndex, correlationId, showCloseWizardDialog } = this.state - const vmCreateWorking = correlationId !== null && !this.props.actionResults.has(correlationId) - const vmCreateStarted = correlationId !== null && !!this.props.actionResults.get(correlationId) + const { msg, show, actionResults } = this.props + const { correlationId, showCloseWizardDialog, wizardKey } = this.state + const vmCreateStarted = correlationId !== null && !!actionResults?.get(correlationId) - const isReviewStep = this.wizardSteps[activeStepIndex].id === 'review' - const isPrimaryNext = !isReviewStep - const isPrimaryCreate = isReviewStep && !vmCreateStarted - const isPrimaryClose = isReviewStep && vmCreateStarted + const wizardSteps = this.createSteps(msg, vmCreateStarted) - const enableGoBack = activeStepIndex > 0 && !isPrimaryClose && this.wizardAllowClickBack() - const enableGoForward = (isReviewStep && !vmCreateWorking) || this.wizardAllowClickNext() + const onMove = ({ id: newStepId }, { prevId: oldStepId }) => { + const oldStep = wizardSteps.find(({ id }) => id === oldStepId) + oldStep?.onExit?.() + } return ( <> {!showCloseWizardDialog && ( - - - - - - - - { isPrimaryClose && ( - - )} - - - + key={wizardKey} + isOpen={show} + title={msg.addNewVm()} + onClose={this.showCloseWizardDialog} + steps={wizardSteps} + cancelButtonText={msg.createVmWizardButtonCancel()} + backButtonText={msg.createVmWizardButtonBack()} + nextButtonText={ msg.createVmWizardButtonNext()} + onNext={onMove} + onGoToStep={onMove} + /> ) } { + const [open, setOpen] = useState(false) + const options = items.map(({ id, usage, value }) => ({ id, usage, toString: () => value })) + const selectedOption = options.find(option => option.id === selectedId) + + const onSelect = (event, { id = '_' } = {}, isPlaceholder) => { + if (!isPlaceholder) { + setOpen(false) + onChange(id) + } + } + return ( + <> + + + ) +} + +StorageDomainSelect.propTypes = { + selectedId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), // id of a selected item, false-ish for the first item + items: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + value: PropTypes.string, + usage: PropTypes.string, + })).isRequired, // Array<{ id: string, value: string }>, order matters if sort is false-ish + onChange: PropTypes.func.isRequired, // (selectedId: string) => any + id: PropTypes.string, + validated: PropTypes.oneOf(['default', 'error']), + isDisabled: PropTypes.bool, + placeholderText: PropTypes.string, +} + +export default StorageDomainSelect diff --git a/src/components/CreateVmWizard/steps/BasicSettings.js b/src/components/CreateVmWizard/steps/BasicSettings.js index eb9d7de68..f56b6577c 100644 --- a/src/components/CreateVmWizard/steps/BasicSettings.js +++ b/src/components/CreateVmWizard/steps/BasicSettings.js @@ -27,12 +27,16 @@ import { } from '_/components/utils' import { - ExpandCollapse, - Form, - FormControl, - HelpBlock, Checkbox, -} from 'patternfly-react' + ExpandableSection, + Form, + HelperText, + HelperTextItem, + NumberInput, + TextInput, + TextArea, +} from '@patternfly/react-core' + import { Grid, Row, Col } from '_/components/Grid' import SelectBox from '_/components/SelectBox' @@ -72,9 +76,13 @@ const FieldRow = ({ {label}

{children}
- {(validationState && errorMessage) && - {errorMessage} - } + {(validationState && errorMessage) && ( + + + {errorMessage} + + + )} )} @@ -91,9 +99,13 @@ const FieldRow = ({ {children} - {(validationState && errorMessage) && - {errorMessage} - } + {(validationState && errorMessage) && ( + + + {errorMessage} + + + )} )} @@ -499,26 +511,25 @@ class BasicSettings extends React.Component { // ----- RENDER ----- return ( -
+ {/* -- VM name and where it will live -- */} - this.handleChange('name', e.target.value)} + value={data.name} + onChange={value => this.handleChange('name', value)} /> - this.handleChange('description', e.target.value)} + value={data.description} + onChange={value => this.handleChange('description', value)} /> @@ -579,12 +590,15 @@ class BasicSettings extends React.Component { - this.handleChange('memory', e.target.value)} + onChange={value => this.handleChange('memory', value)} + min={0} + onMinus={() => this.handleChange('memory', data.memory - 1) } + onPlus={() => this.handleChange('memory', data.memory + 1) } + widthChars={10} /> @@ -595,12 +609,15 @@ class BasicSettings extends React.Component { validationState={indicators.cpu || indicators.topology} errorMessage={!vCpuCountIsFactored ? msg.cpusBadTopology() : ''} > - this.handleChange('cpus', e.target.value)} + onChange={value => this.handleChange('cpus', value)} + min={0} + onMinus={() => this.handleChange('cpus', data.cpus - 1)} + onPlus={() => this.handleChange('cpus', data.cpus + 1)} + widthChars={10} /> @@ -616,22 +633,20 @@ class BasicSettings extends React.Component { this.handleChange('startOnCreation', e.target.checked)} - > - {msg.startVmOnCreation()} - + isChecked={!!data.startOnCreation} + onChange={checked => this.handleChange('startOnCreation', checked)} + label={msg.startVmOnCreation()} + /> {/* -- Cloud-Init -- */} this.handleChange('cloudInitEnabled', e.target.checked)} - > - {msg.cloudInitEnable()} - + isChecked={!!data.cloudInitEnabled} + onChange={checked => this.handleChange('cloudInitEnabled', checked)} + label={msg.cloudInitEnable()} + /> { enableCloudInit && ( @@ -645,21 +660,20 @@ class BasicSettings extends React.Component { validationState={data.initHostname && indicators.hostName ? 'error' : undefined} errorMessage={msg.pleaseEnterValidHostName()} > - this.handleChange('initHostname', e.target.value)} + value={data.initHostname} + onChange={value => this.handleChange('initHostname', value)} /> - this.handleChange('initSshKeys', e.target.value)} + value={data.initSshKeys} + onChange={value => this.handleChange('initSshKeys', value)} /> @@ -676,11 +690,11 @@ class BasicSettings extends React.Component { validationState={data.initHostname && indicators.hostName ? 'error' : undefined} errorMessage={msg.pleaseEnterValidHostName()} > - this.handleChange('initHostname', e.target.value)} + value={data.initHostname} + onChange={value => this.handleChange('initHostname', value)} /> @@ -688,11 +702,10 @@ class BasicSettings extends React.Component { this.handleChange('enableInitTimezone', e.target.checked)} - > - {msg.sysPrepTimezoneConfigure()} - + isChecked={data.enableInitTimezone} + onChange={checked => this.handleChange('enableInitTimezone', checked)} + label={msg.sysPrepTimezoneConfigure()} + /> @@ -706,21 +719,20 @@ class BasicSettings extends React.Component { - this.handleChange('initAdminPassword', e.target.value)} + value={data.initAdminPassword} + onChange={value => this.handleChange('initAdminPassword', value)} /> - this.handleChange('initCustomScript', e.target.value)} + value={data.initCustomScript} + onChange={value => this.handleChange('initCustomScript', value)} /> @@ -728,7 +740,7 @@ class BasicSettings extends React.Component { {/* Advanced CPU Topology Options */} - + - +
) } diff --git a/src/components/CreateVmWizard/steps/DiskNameWithLabels.js b/src/components/CreateVmWizard/steps/DiskNameWithLabels.js new file mode 100644 index 000000000..fab9fe43f --- /dev/null +++ b/src/components/CreateVmWizard/steps/DiskNameWithLabels.js @@ -0,0 +1,40 @@ +import React, { useContext } from 'react' +import PropTypes from 'prop-types' +import { MsgContext } from '_/intl' + +import { + Label, +} from '@patternfly/react-core' + +import style from './style.css' +import { Tooltip } from '_/components/tooltips' + +const DiskNameWithLabels = ({ id, name, isFromTemplate, bootable }) => { + const { msg } = useContext(MsgContext) + const idPrefix = `${id}-disk` + return ( + <> + { name } + { isFromTemplate && ( + + + + )} + { bootable && ( + + )} + + ) +} +DiskNameWithLabels.propTypes = { + id: PropTypes.string, + name: PropTypes.string, + isFromTemplate: PropTypes.bool, + bootable: PropTypes.bool, +} + +export default DiskNameWithLabels diff --git a/src/components/CreateVmWizard/steps/FinishedStep.js b/src/components/CreateVmWizard/steps/FinishedStep.js new file mode 100644 index 000000000..d478842fd --- /dev/null +++ b/src/components/CreateVmWizard/steps/FinishedStep.js @@ -0,0 +1,62 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { + Alert, + Button, + EmptyState, + EmptyStateIcon, + EmptyStateBody, + EmptyStateSecondaryActions, + Spinner, + Title, +} from '@patternfly/react-core' + +import { withMsg } from '_/intl' + +import { OkIcon, ExclamationCircleIcon } from '@patternfly/react-icons/dist/esm/icons' + +const FinishedStep = ({ + id: propsId, + progress: { inProgress, result, messages } = { inProgress: true }, + hideAndNavigate, + hideAndResetState, + msg, +}) => { + const id = propsId ? `${propsId}-finished` : 'create-vm-wizard-finished' + const success = result === 'success' + return ( + + + + + {inProgress ? msg.createVmWizardReviewInProgress() : success ? msg.createVmWizardReviewSuccess() : msg.createVmWizardReviewError()} + + + + { messages?.length > 0 && messages.map((message, index) => ( + + )) } + + + + + + + ) +} + +FinishedStep.propTypes = { + id: PropTypes.string, + + progress: PropTypes.shape({ + inProgress: PropTypes.bool.isRequired, + result: PropTypes.oneOf(['success', 'error']), + messages: PropTypes.arrayOf(PropTypes.string), + }), + hideAndNavigate: PropTypes.func.isRequired, + hideAndResetState: PropTypes.func.isRequired, + + msg: PropTypes.object.isRequired, +} + +export default withMsg(FinishedStep) diff --git a/src/components/CreateVmWizard/steps/Networking.js b/src/components/CreateVmWizard/steps/Networking.js index 11b8c2072..574162803 100644 --- a/src/components/CreateVmWizard/steps/Networking.js +++ b/src/components/CreateVmWizard/steps/Networking.js @@ -1,7 +1,7 @@ -import React, { useContext } from 'react' +import React from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' -import { MsgContext, enumMsg, withMsg } from '_/intl' +import { enumMsg, withMsg } from '_/intl' import { generateUnique } from '_/helpers' import { NIC_SHAPE } from '../dataPropTypes' @@ -14,486 +14,277 @@ import { isNicNameValid, } from '_/components/utils' +import { + TableComposable, + Tbody, + Th, + Thead, + Td, + Tr, +} from '@patternfly/react-table' + import { Button, - DropdownKebab, + ButtonVariant, EmptyState, - FormControl, - FormGroup, - HelpBlock, - Label, - MenuItem, - Table, -} from 'patternfly-react' -import _TableInlineEditRow from './_TableInlineEditRow' -import SelectBox from '_/components/SelectBox' + EmptyStateIcon, + EmptyStateBody, + EmptyStateSecondaryActions, + HelperText, + HelperTextItem, + Title, + TextInput, + Tooltip, +} from '@patternfly/react-core' + +import { + AddCircleOIcon, + CheckIcon, + PencilAltIcon, + TimesIcon, + TrashIcon, +} from '@patternfly/react-icons/dist/esm/icons' import style from './style.css' -import { Tooltip, InfoTooltip } from '_/components/tooltips' import { EMPTY_VNIC_PROFILE_ID } from '_/constants' -const NIC_INTERFACE_DEFAULT = 'virtio' - -export const NicNameWithLabels = ({ id, nic }) => { - const { msg } = useContext(MsgContext) - const idPrefix = `${id}-nic-${nic.id}` - return ( - <> - { nic.name } - { nic.isFromTemplate && ( - - - - )} - - ) -} -NicNameWithLabels.propTypes = { - id: PropTypes.string, - nic: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - isFromTemplate: PropTypes.bool, - }), -} - -/** - * The nic table cannot be completely a controlled component, some state about rows being - * edited needs to be held within the component. Right now only the fact that a row is - * being edited is being tracked in component state. - * - * Text fields can't notify onChange since any change will cause the field to loose focus - * as the component state is updated (not exactly sure if it because of code here, in - * patternfly-react or in the underlying reactabular itself). Text fields update onBlur, - * and that does cause some issues with keyboard navigation. - */ -class Networking extends React.Component { - constructor (props) { - super(props) - this.handleCellChange = this.handleCellChange.bind(this) - this.handleRowCancelChange = this.handleRowCancelChange.bind(this) - this.handleRowConfirmChange = this.handleRowConfirmChange.bind(this) - this.onCreateNic = this.onCreateNic.bind(this) - this.onDeleteRow = this.onDeleteRow.bind(this) - this.onEditNic = this.onEditNic.bind(this) - this.rowRenderProps = this.rowRenderProps.bind(this) - this.isVnicNameUniqueAndValid = this.isVnicNameUniqueAndValid.bind(this) - - const { msg, locale } = this.props - const NIC_INTERFACES = createNicInterfacesList(msg) - props.onUpdate({ valid: true }) - - this.state = { - editingErrors: { - nicInvalidName: false, - nicDuplicateName: false, - }, - editing: {}, - creating: false, - } - - const idPrefix = this.props.id || 'create-vm-wizard-nics' - - // ---- Table Row Editing Controller - this.inlineEditController = { - isEditing: ({ rowData, column, property }) => this.state.editing[rowData.id] !== undefined, - onActivate: ({ rowData }) => this.onEditNic(rowData), - onConfirm: ({ rowData }) => this.handleRowConfirmChange(rowData), - onCancel: ({ rowData }) => this.handleRowCancelChange(rowData), - } - - // ----- Table Cell Formatters - const headerFormatText = (label, { column }) => {label} - - const inlineEditFormatter = Table.inlineEditFormatterFactory({ - isEditing: additionalData => this.inlineEditController.isEditing(additionalData), - - renderValue: (value, additionalData) => { - const { column } = additionalData - return ( - - { column.valueView ? column.valueView(value, additionalData) : value } - - ) - }, - - renderEdit: (value, additionalData) => { - const { column } = additionalData - return ( - - { column.editView ? column.editView(value, additionalData) : value } - - ) - }, - }) - - // ---- Table Column Definitions - this.columns = [ - // name - { - header: { - label: msg.createVmNetTableHeaderNicName(), - formatters: [headerFormatText], - props: { - style: { - width: '32%', - }, - }, - }, - property: 'name', - cell: { - formatters: [inlineEditFormatter], - }, - valueView: (value, { rowData }) => { - return - }, - editView: (value, { rowData }) => { - const row = this.state.editing[rowData.id] - const { nicInvalidName, nicDuplicateName } = this.state.editingErrors - const validAndUniqueName = !nicInvalidName && !nicDuplicateName - - return ( - - this.handleCellChange(rowData, 'name', e.target.value)} - /> - {!validAndUniqueName && - {msg.createVmWizardNetVNICNameRules()} - } - - ) - }, - }, - - // vnic profile - { - header: { - label: msg.createVmNetTableHeaderVnicProfile(), - formatters: [headerFormatText], - props: { - style: { - width: '32%', - }, - }, - }, - property: 'vnic', - cell: { - formatters: [inlineEditFormatter], - }, - editView: (value, { rowData }) => { - const { - dataCenterId, - cluster, - vnicProfiles, - } = props - const vnicList = createVNicProfileList(vnicProfiles, { locale, msg }, { dataCenterId, cluster }) - const row = this.state.editing[rowData.id] - - return ( - this.handleCellChange(rowData, 'vnicProfileId', value)} - /> - ) - }, - }, - - // device type - { - header: { - label: msg.createVmNetTableHeaderType(), - formatters: [headerFormatText], - props: { - style: { - width: '32%', - }, - }, - }, - property: 'device', - cell: { - formatters: [inlineEditFormatter], - }, - editView: (value, { rowData }) => { - const row = this.state.editing[rowData.id] - - return ( - this.handleCellChange(rowData, 'deviceType', value)} - /> - ) - }, - }, - - // actions - { - header: { - label: '', - formatters: [headerFormatText], - props: { - style: { - width: '40px', - }, - }, - }, - type: 'actions', - cell: { - formatters: [ - (value, { rowData, rowIndex }) => { - const hideKebab = this.state.creating === rowData.id - const actionsDisabled = !!this.state.creating || Object.keys(this.state.editing).length > 0 || rowData.isFromTemplate - const templateDefined = rowData.isFromTemplate - const kebabId = `${idPrefix}-kebab-${rowData.name}` - - return ( - <> - { hideKebab && } - - { templateDefined && ( - - - - )} +import NicNameWithLabels from './NicNameWithLabels' +import SelectBox from '_/components/SelectBox' +import { InfoTooltip } from '_/components/tooltips' - { !hideKebab && !templateDefined && ( - - -
- - { this.inlineEditController.onActivate({ rowIndex, rowData }) }} - disabled={actionsDisabled} - > - {msg.edit()} - - { this.onDeleteRow(rowData) }} - disabled={actionsDisabled} - > - {msg.delete()} - - -
-
-
- )} - - ) - }, - ], - }, - }, - ] - } +const NIC_INTERFACE_DEFAULT = 'virtio' - isVnicNameUniqueAndValid (editingRow) { - const { nics } = this.props - return { unique: isNicNameUnique(nics, editingRow), valid: isNicNameValid(editingRow.name) } +const Networking = ({ + msg, + locale, + dataCenterId, + cluster, + id: idPrefix = 'create-vm-wizard-nics', + vnicProfiles, + nics, + onUpdate, +}) => { + const columnNames = { + nicName: msg.createVmNetTableHeaderNicName(), + vnicProfile: msg.createVmNetTableHeaderVnicProfile(), + deviceType: msg.createVmNetTableHeaderType(), } - - onCreateNic () { - const newId = generateUnique('NEW_') - const nextNicName = suggestNicName(this.props.nics) - - // Setup a new nic in the editing object - this.setState(state => ({ - creating: newId, - editing: { - ...state.editing, - [newId]: { - id: newId, - name: nextNicName, + const nicIterfaces = createNicInterfacesList(msg) + const vnicList = createVNicProfileList(vnicProfiles, { locale, msg }, { dataCenterId, cluster }) + const translateVnic = (vnicProfileId) => vnicList.find(({ id }) => id === vnicProfileId)?.value ?? msg.createVmNetUnknownVnicProfile() + const translateDevice = (deviceType) => deviceType ? enumMsg('NicInterface', deviceType, msg) : '' + + const nicList = sortNicsDisks([...nics], locale) + + const editInProgress = nics.find(({ underConstruction }) => underConstruction) + const onCreateNic = () => { + onUpdate({ + valid: false, + create: { + id: generateUnique('NEW_'), + name: '', + underConstruction: { + name: suggestNicName(nics), deviceType: NIC_INTERFACE_DEFAULT, vnicProfileId: EMPTY_VNIC_PROFILE_ID, }, }, - })) - this.props.onUpdate({ valid: false }) - } - - onEditNic (rowData) { - this.setState(state => ({ - editing: { - ...state.editing, - [rowData.id]: rowData, - }, - })) - this.props.onUpdate({ valid: false }) - } - - onDeleteRow (rowData) { - this.props.onUpdate({ remove: rowData.id }) - } - - handleCellChange (rowData, field, value) { - const editingRow = this.state.editing[rowData.id] - - if (editingRow) { - editingRow[field] = value - const editingErrors = {} - if (field === 'name') { - const { unique, valid } = this.isVnicNameUniqueAndValid(editingRow) - editingErrors.nicInvalidName = !valid - editingErrors.nicDuplicateName = !unique - } - this.setState(state => ({ - editingErrors: { - ...state.editingErrors, - ...editingErrors, - }, - editing: { - ...state.editing, - [rowData.id]: editingRow, - }, - })) - } - } - - // Push the new or editing row up via __onUpdate__ - handleRowConfirmChange (rowData) { - const actionCreate = !!this.state.creating && this.state.creating === rowData.id - const editedRow = this.state.editing[rowData.id] - - let editingErrors = false - for (const errorKey in this.state.editingErrors) { - editingErrors = editingErrors || this.state.editingErrors[errorKey] - } - - if (!editingErrors) { - this.props.onUpdate({ [actionCreate ? 'create' : 'update']: editedRow }) - this.handleRowCancelChange(rowData) - } - } - - // Cancel the creation or editing of a row by throwing out edit state - handleRowCancelChange (rowData) { - this.components = undefined // remove the current reference to make the table re-render - this.setState(state => { - const editing = state.editing - delete editing[rowData.id] - return { - editingErrors: { - nicInvalidName: false, - nicDuplicateName: false, - }, - creating: false, - editing, - } }) - this.props.onUpdate({ valid: true }) } - // Create props for each row that will be passed to the row component (TableInlineEditRow) - rowRenderProps (nicList, rowData, { rowIndex }) { - const actionButtonsTop = - rowIndex > 5 && - rowIndex === nicList.length - 1 - - return { - role: 'row', - - isEditing: () => this.inlineEditController.isEditing({ rowData }), - onConfirm: () => this.inlineEditController.onConfirm({ rowData, rowIndex }), - onCancel: () => this.inlineEditController.onCancel({ rowData, rowIndex }), - last: actionButtonsTop, // last === if the confirm/cancel buttons should go above the row - } - } - - render () { - const { - id: idPrefix = 'create-vm-wizard-nics', - dataCenterId, - cluster, - nics, - vnicProfiles, - msg, - locale, - } = this.props - - const vnicList = createVNicProfileList(vnicProfiles, { locale, msg }, { dataCenterId, cluster }) - const enableCreate = vnicList.length > 0 && Object.keys(this.state.editing).length === 0 - - const nicList = sortNicsDisks([...nics], locale) - .concat(this.state.creating ? [this.state.editing[this.state.creating]] : []) - .map(nic => ({ - ...(this.state.editing[nic.id] ? this.state.editing[nic.id] : nic), - vnic: vnicList.find(vnic => vnic.id === nic.vnicProfileId) - ? vnicList.find(vnic => vnic.id === nic.vnicProfileId).value - : msg.createVmNetUnknownVnicProfile(), - device: enumMsg('NicInterface', nic.deviceType, msg), - })) - const components = { - body: { - row: _TableInlineEditRow, - }, - } - this.components = this.components || components // if the table should (re)render the value of this.components should be undefined - - return ( -
- { nicList.length === 0 && ( - <> - - - {msg.createVmNetEmptyTitle()} - {msg.createVmNetEmptyInfo()} - - - - - - ) } - - { nicList.length > 0 && ( - <> -
- -
-
- - - this.rowRenderProps(nicList, ...rest)} - /> - -
- - ) } -
- ) - } + return ( +
+ { nicList.length === 0 && ( + + + + {msg.createVmNetEmptyTitle()} + + + + {msg.createVmNetEmptyInfo()} + + + + + + ) } + + { nicList.length > 0 && ( + <> +
+ +
+
+ + + + {columnNames.nicName} + {columnNames.vnicProfile} + {columnNames.deviceType} + + + + {nicList.map(({ id, underConstruction, ...rest }) => { + const { name, vnicProfileId, deviceType, isFromTemplate } = underConstruction ?? rest + const isValid = isNicNameUnique(nicList, { name, id }) && isNicNameValid(name) + return ( + + + {!underConstruction && } + {underConstruction && ( + <> + onUpdate({ + valid: isValid, + update: { + id, + underConstruction: { + name: value, + deviceType, + vnicProfileId, + }, + }, + })} + /> + {!isValid && ( + + + {msg.createVmWizardNetVNICNameRules()} + + + )} + + )} + + + {!underConstruction && translateVnic(vnicProfileId)} + {underConstruction && ( + ({ ...item, isDefault: item.id === EMPTY_VNIC_PROFILE_ID }))} + selected={ vnicProfileId} + onChange={value => onUpdate({ + valid: isValid, + update: { + id, + underConstruction: { + name, + deviceType, + vnicProfileId: value, + }, + }, + })} + /> + )} + + + {!underConstruction && translateDevice(deviceType)} + {underConstruction && ( + ({ ...item, isDefault: item.id === NIC_INTERFACE_DEFAULT }))} + selected={ deviceType} + onChange={value => onUpdate({ + valid: isValid, + update: { + id, + underConstruction: { + name, + deviceType: value, + vnicProfileId, + }, + }, + })} + /> + )} + + + {[ + !underConstruction && !isFromTemplate && { + ariaLabel: msg.edit(), + id: `${id}-edit`, + icon: (), + isDisabled: editInProgress, + onClick: () => onUpdate({ + valid: true, + update: { + id, + underConstruction: { + name, + deviceType, + vnicProfileId, + }, + }, + }), + }, + underConstruction && { + ariaLabel: msg.save(), + id: `${id}-save`, + icon: (), + isDisabled: !isValid, + onClick: () => onUpdate({ + valid: true, + update: { + id, + name, + deviceType, + vnicProfileId, + underConstruction: undefined, + }, + }), + }, + underConstruction && { + ariaLabel: msg.cancel(), + id: `${id}-cancel`, + icon: (), + onClick: () => onUpdate({ + valid: true, + remove: !rest.name && id, + update: { + id, + underConstruction: undefined, + }, + }), + }, + !underConstruction && !isFromTemplate && { + ariaLabel: msg.delete(), + id: `${id}-delete`, + icon: (), + isDisabled: editInProgress, + onClick: () => onUpdate({ remove: id }), + }, + ].filter(Boolean) + .map(({ ariaLabel, ...rest }) => ( + +
+ + ) } +
+ ) } Networking.propTypes = { diff --git a/src/components/CreateVmWizard/steps/NicNameWithLabels.js b/src/components/CreateVmWizard/steps/NicNameWithLabels.js new file mode 100644 index 000000000..1d80aabd0 --- /dev/null +++ b/src/components/CreateVmWizard/steps/NicNameWithLabels.js @@ -0,0 +1,35 @@ +import React, { useContext } from 'react' +import PropTypes from 'prop-types' +import { MsgContext } from '_/intl' +import { + Label, +} from '@patternfly/react-core' + +import { + Tooltip, +} from '_/components/tooltips' + +import style from './style.css' + +const NicNameWithLabels = ({ id, name, isFromTemplate }) => { + const { msg } = useContext(MsgContext) + return ( + <> + { name } + { isFromTemplate && ( + + + + )} + + ) +} +NicNameWithLabels.propTypes = { + id: PropTypes.string, + name: PropTypes.string, + isFromTemplate: PropTypes.bool, +} + +export default NicNameWithLabels diff --git a/src/components/CreateVmWizard/steps/Storage.js b/src/components/CreateVmWizard/steps/Storage.js index 0b9f464b3..09b929fb3 100644 --- a/src/components/CreateVmWizard/steps/Storage.js +++ b/src/components/CreateVmWizard/steps/Storage.js @@ -1,9 +1,9 @@ -import React, { useContext } from 'react' +import React from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' -import { MsgContext, withMsg } from '_/intl' -import { generateUnique } from '_/helpers' +import { withMsg } from '_/intl' import { isNumber, convertValue } from '_/utils' +import { generateUnique } from '_/helpers' import { BASIC_DATA_SHAPE, STORAGE_SHAPE } from '../dataPropTypes' import { @@ -14,464 +14,85 @@ import { isDiskNameValid, } from '_/components/utils' +import { + TableComposable, + Tbody, + Th, + Thead, + Td, + Tr, +} from '@patternfly/react-table' + import { Button, + ButtonVariant, Checkbox, - DropdownKebab, EmptyState, - FormGroup, - FormControl, - HelpBlock, - MenuItem, - Table, - Label, -} from 'patternfly-react' -import _TableInlineEditRow from './_TableInlineEditRow' -import SelectBox from '_/components/SelectBox' - -import style from './style.css' -import { Tooltip, InfoTooltip } from '_/components/tooltips' - -export const DiskNameWithLabels = ({ id, disk }) => { - const { msg } = useContext(MsgContext) - const idPrefix = `${id}-disk-${disk.id}` - return ( - <> - { disk.name } - { disk.isFromTemplate && ( - - - - )} - { disk.bootable && ( - - )} - - ) -} -DiskNameWithLabels.propTypes = { - id: PropTypes.string, - disk: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - isFromTemplate: PropTypes.bool, - bootable: PropTypes.bool, - }), -} + EmptyStateIcon, + EmptyStateBody, + EmptyStateSecondaryActions, + HelperText, + HelperTextItem, + Title, + TextInput, + Tooltip, +} from '@patternfly/react-core' -/** - * The disks table cannot be completely a controlled component, some state about rows being - * edited needs to be held within the component. Right now only the fact that a row is - * being edited is being tracked in component state. - * - * Input field editing in the table have the same restrictions as the nics table. - */ -class Storage extends React.Component { - constructor (props) { - super(props) - this.handleCellChange = this.handleCellChange.bind(this) - this.handleTemplateDiskStorageDomainChange = this.handleTemplateDiskStorageDomainChange.bind(this) - this.handleRowCancelChange = this.handleRowCancelChange.bind(this) - this.handleRowConfirmChange = this.handleRowConfirmChange.bind(this) - this.removeEditState = this.removeEditState.bind(this) - this.onCreateDisk = this.onCreateDisk.bind(this) - this.onDeleteDisk = this.onDeleteDisk.bind(this) - this.onEditDisk = this.onEditDisk.bind(this) - this.rowRenderProps = this.rowRenderProps.bind(this) - this.bootableInfo = this.bootableInfo.bind(this) - this.isBootableDiskTemplate = this.isBootableDiskTemplate.bind(this) - this.isEditingMode = this.isEditingMode.bind(this) - this.isValidDiskSize = this.isValidDiskSize.bind(this) - - props.onUpdate({ valid: this.validateTemplateDiskStorageDomains() }) - - const { msg } = this.props - - this.state = { - editingErrors: { - diskInvalidName: false, - }, - editing: {}, - creating: false, - } - - const { - id: idPrefix = 'create-vm-wizard-storage', - } = this.props - - // ---- Table Row Editing Controller - this.inlineEditController = { - isEditing: ({ rowData, column, property }) => this.state.editing[rowData.id] !== undefined, - onActivate: ({ rowData }) => this.onEditDisk(rowData), - onConfirm: ({ rowData }) => this.handleRowConfirmChange(rowData), - onCancel: ({ rowData }) => this.handleRowCancelChange(rowData), - } - - // ----- Table Cell Renderers - const headerFormatText = (label, { column }) => {label} - - const inlineEditFormatter = Table.inlineEditFormatterFactory({ - isEditing: additionalData => this.inlineEditController.isEditing(additionalData), - - renderValue: (value, additionalData) => { - const { column } = additionalData - - return ( - - { column.valueView ? column.valueView(value, additionalData) : value } - - ) - }, - - renderEdit: (value, additionalData) => { - const { column } = additionalData - return ( - - { column.editView ? column.editView(value, additionalData) : value } - - ) - }, - }) - - // ---- Table Column Definitions - this.columns = [ - // name - { - header: { - label: msg.createVmStorageTableHeaderName(), - formatters: [headerFormatText], - props: { - style: { - width: '30%', - }, - }, - }, - cell: { - formatters: [inlineEditFormatter], - }, - valueView: (value, { rowData }) => { - return - }, - editView: (value, { rowData }) => { - const row = this.state.editing[rowData.id] - const { diskInvalidName } = this.state.editingErrors - - return row.isFromTemplate - ? this.columns[0].valueView(value, { rowData }) - : ( - - this.handleCellChange(rowData, 'name', e.target.value)} - /> - {diskInvalidName && - {msg.diskNameValidationRules()} - } - - ) - }, - }, - - // Bootable column - displayed only when editing disk - // Note: only one disk can be bootable at a time - { - header: { - label: msg.createVmStorageTableHeaderBootable(), - formatters: [(...formatArgs) => this.isEditingMode() && headerFormatText(...formatArgs)], - props: { - style: { - width: '5%', - }, - }, - }, - cell: { - formatters: [(...formatArgs) => this.isEditingMode() && inlineEditFormatter(...formatArgs)], - }, - valueView: null, - editView: (value, { rowData }) => { - return ( -
- { !this.isBootableDiskTemplate() && ( - this.handleCellChange(rowData, 'bootable', e.target.checked)} - title='Bootable flag' - /> - )} -
- -
-
- ) - }, - }, - - // size - { - header: { - label: msg.createVmStorageTableHeaderSize(), - formatters: [headerFormatText], - props: { - style: { - width: '15%', - }, - }, - }, - cell: { - formatters: [inlineEditFormatter], - }, - valueView: (value, { rowData }) => { - return ( - <> - { rowData.sized.value } { rowData.sized.unit } - - ) - }, - editView: (value, { rowData }) => { - const row = this.state.editing[rowData.id] - const sizeGiB = row.size / (1024 ** 3) - - return ( -
-
- this.handleCellChange(rowData, 'size', e.target.value)} - /> -
- GiB -
- -
-
- ) - }, - }, - - // storage domain - { - header: { - label: msg.createVmStorageTableHeaderStorageDomain(), - formatters: [headerFormatText], - props: { - style: { - width: '25%', - }, - }, - }, - cell: { - formatters: [inlineEditFormatter], - }, - valueView: (value, { rowData }) => { - const { - storageDomainId: id, - storageDomain: sd, - canUserUseStorageDomain, - isFromTemplate, - } = rowData - - if (isFromTemplate && !canUserUseStorageDomain) { - const { storageDomains, dataCenterId, locale } = props - const storageDomainList = createStorageDomainList({ storageDomains, dataCenterId, includeUsage: true, locale, msg }) - - if (storageDomainList.length === 0) { - return ( - <> - {msg.createVmStorageNoStorageDomainAvailable()} - - - ) - } else { - if (!sd.isOk) { - storageDomainList.unshift({ id: '_', value: `-- ${msg.createVmStorageSelectStorageDomain()} --` }) - } - - return ( - this.handleTemplateDiskStorageDomainChange(rowData, value)} - /> - ) - } - } - - return ( - <> - { id === '_' - ? `-- ${msg.createVmStorageSelectStorageDomain()} --` - : sd.isOk - ? sd.name - : msg.createVmStorageUnknownStorageDomain() - } - - ) - }, - editView: (value, { rowData }) => { - const { storageDomains, dataCenterId, locale } = props - const storageDomainList = createStorageDomainList({ storageDomains, dataCenterId, includeUsage: true, locale, msg }) - const row = this.state.editing[rowData.id] - - if (storageDomainList.length > 1 || row.storageDomainId === '_') { - storageDomainList.unshift({ id: '_', value: `-- ${msg.createVmStorageSelectStorageDomain()} --` }) - } - - return ( - this.handleCellChange(rowData, 'storageDomainId', value)} - validationState={row.storageDomainId === '_' && 'error'} - /> - ) - }, - }, +import { + AddCircleOIcon, + CheckIcon, + InfoCircleIcon, + PencilAltIcon, + TimesIcon, + TrashIcon, +} from '@patternfly/react-icons/dist/esm/icons' - // disk type (thin/sparse/cow vs preallocated/raw) - { - header: { - label: msg.createVmStorageTableHeaderType(), - formatters: [headerFormatText], - props: { - style: { - width: '20%', - }, - }, - }, - property: 'diskTypeLabel', - cell: { - formatters: [inlineEditFormatter], - }, - editView: (value, { rowData }) => { - const row = this.state.editing[rowData.id] - - const typeList = createDiskTypeList(msg) - if (!row.diskType || row.diskType === '_') { - typeList.unshift({ id: '_', value: `-- ${msg.createVmStorageSelectDiskType()} --` }) - } - - return ( - this.handleCellChange(rowData, 'diskType', value)} - /> - ) - }, - }, +import style from './style.css' - // actions - { - header: { - label: '', - formatters: [headerFormatText], - - props: { - style: { - width: '20px', - }, - }, - }, - type: 'actions', - cell: { - formatters: [ - (value, { rowData, rowIndex }) => { - const hideKebab = this.state.creating === rowData.id - const actionsDisabled = !!this.state.creating || this.isEditingMode() || rowData.isFromTemplate - const templateDefined = rowData.isFromTemplate - const kebabId = `${idPrefix}-kebab-${rowData.name}` - - return ( - <> - { hideKebab && } - - { templateDefined && ( - - - - )} - - { !hideKebab && !templateDefined && ( - - -
- - { this.inlineEditController.onActivate({ rowIndex, rowData }) }} - disabled={actionsDisabled} - > - {msg.edit()} - - { this.onDeleteDisk(rowData) }} - disabled={actionsDisabled} - > - {msg.delete()} - - -
-
-
- )} - - ) - }, - ], - }, - }, - ] +import DiskNameWithLabels from './DiskNameWithLabels' +import SelectBox from '_/components/SelectBox' +import { InfoTooltip } from '_/components/tooltips' + +const Storage = ({ + msg, + locale, + dataCenterId, + cluster, + id: idPrefix = 'create-vm-wizard-nics', + onUpdate, + + vmName, + optimizedFor, + disks, + + clusterId, + storageDomains, + maxDiskSizeInGiB, + minDiskSizeInGiB, +}) => { + const columnNames = { + name: msg.createVmStorageTableHeaderName(), + size: msg.createVmStorageTableHeaderSize(), + storageDomain: msg.createVmStorageTableHeaderStorageDomain(), + // disk type (thin/sparse/cow vs preallocated/raw) + diskType: msg.createVmStorageTableHeaderType(), } - // return boolean value to answer if we are editing a Disk or not - isEditingMode () { - return Object.keys(this.state.editing).length > 0 - } + const allStorageDomains = createStorageDomainList({ storageDomains, locale, msg }) + const dataCenterStorageDomainsList = createStorageDomainList({ storageDomains, dataCenterId, locale, msg }) + const editInProgress = disks.find(({ underConstruction }) => underConstruction) + const enableCreate = dataCenterStorageDomainsList.length > 0 && !editInProgress // return true if the VM has any template disks that are set bootable - isBootableDiskTemplate () { - const bootableTemplateDisks = this.props.disks - .filter(disk => disk.isFromTemplate && disk.bootable) + const isBootableDiskTemplate = () => disks.filter(disk => disk.isFromTemplate && disk.bootable).length > 0 - return bootableTemplateDisks.length > 0 - } + const isValidDiskSize = (size) => isNumber(size) && size >= minDiskSizeInGiB && size <= maxDiskSizeInGiB // set appropriate tooltip message regarding setting bootable flag - bootableInfo (isActualDiskBootable) { - const { msg } = this.props - const bootableDisk = this.props.disks.find(disk => disk.bootable) + const bootableInfo = (isActualDiskBootable) => { + const bootableDisk = disks.find(disk => disk.bootable) - if (this.isBootableDiskTemplate()) { + if (isBootableDiskTemplate()) { // template based disk cannot be edited so bootable flag cannot be removed from it return msg.createVmStorageNoEditBootableMessage({ diskName: bootableDisk.name }) } else if (bootableDisk && !isActualDiskBootable) { @@ -483,286 +104,311 @@ class Storage extends React.Component { return msg.createVmStorageBootableMessage() } - validateTemplateDiskStorageDomains ({ update, ignoreId } = {}) { - const { disks, storageDomains } = this.props - let disksAreValid = true - - const templateDisks = disks.filter(disk => disk.isFromTemplate) - for (let i = 0; disksAreValid && i < templateDisks.length; i++) { - let disk = templateDisks[i] - if (disk.id === ignoreId) { - continue - } - if (update && update.id === disk.id) { - disk = { ...disk, ...update } - } - disksAreValid = disksAreValid && storageDomains.getIn([disk.storageDomainId, 'canUserUseDomain'], false) - } + const isSdOk = ({ storageDomainId, canUserUseStorageDomain }) => allStorageDomains.find(sd => sd.id === storageDomainId) && ( + canUserUseStorageDomain || dataCenterStorageDomainsList.find(sd => sd.id === storageDomainId)) + + const validateTemplateDiskStorageDomains = (ignoreId) => disks + .filter(disk => disk.isFromTemplate) + .filter(disk => disk.id !== ignoreId) + .map(({ storageDomainId, canUserUseStorageDomain }) => !!isSdOk({ storageDomainId, canUserUseStorageDomain })) + .filter(valid => !valid) + .length === 0 + + const toGiB = size => size / (1024 ** 3) - return disksAreValid + const convertSize = size => { + const { value: number, unit: storageUnits } = convertValue('B', size) + return { number, storageUnits } } - onCreateDisk () { - const newId = generateUnique('NEW_') - const { - minDiskSizeInGiB: diskInitialSizeInGib, - storageDomains, - dataCenterId, - vmName, - disks, - locale, - msg, - } = this.props - - // If only 1 storage domain is available, select it automatically - const storageDomainList = createStorageDomainList({ storageDomains, dataCenterId, locale, msg }) - const storageDomainId = storageDomainList.length === 1 ? storageDomainList[0].id : '_' - - // Setup a new disk in the editing hash - this.setState(state => ({ - creating: newId, - editing: { - ...state.editing, - [newId]: { - id: newId, + const diskList = sortNicsDisks([...disks], locale) + + const translateDiskType = diskType => diskType === 'thin' + ? msg.diskEditorDiskTypeOptionThin() + : diskType === 'pre' + ? msg.diskEditorDiskTypeOptionPre() + : diskType + const translateStorageDomain = storageDomainId => allStorageDomains.find(sd => sd.id === storageDomainId)?.value ?? msg.createVmStorageUnknownStorageDomain() + + const onCreateDisk = () => { + onUpdate({ + valid: false, + create: { + id: generateUnique('NEW_'), + name: '', + underConstruction: { name: suggestDiskName(vmName, disks), diskId: '_', - storageDomainId, + storageDomainId: dataCenterStorageDomainsList.length === 1 ? dataCenterStorageDomainsList[0].id : '_', bootable: disks.length === 0, diskType: 'thin', - size: (diskInitialSizeInGib * 1024 ** 3), + size: (minDiskSizeInGiB * 1024 ** 3), }, }, - })) - this.props.onUpdate({ valid: false }) // the step isn't valid until Create is done - } - - onEditDisk (rowData) { - this.setState(state => ({ - editing: { - ...state.editing, - [rowData.id]: rowData, - }, - })) - this.props.onUpdate({ valid: false }) // the step isn't valid until Edit is done - } - - onDeleteDisk (rowData) { - this.props.onUpdate({ - valid: this.validateTemplateDiskStorageDomains({ ignoreId: rowData.id }), - remove: rowData.id, }) } - isValidDiskSize (size) { - const { minDiskSizeInGiB, maxDiskSizeInGiB } = this.props - return isNumber(size) && size >= minDiskSizeInGiB && size <= maxDiskSizeInGiB - } - - handleCellChange (rowData, field, value) { - const editingRow = this.state.editing[rowData.id] - const editingErrors = {} - switch (field) { - case 'size': - if (!this.isValidDiskSize(value)) return - value = +value * (1024 ** 3) // GiB to B - break - case 'name': - editingErrors.diskInvalidName = !isDiskNameValid(value) - break - } - - if (editingRow) { - editingRow[field] = value - this.setState(state => ({ - editingErrors: { - ...state.editingErrors, - ...editingErrors, - }, - editing: { - ...state.editing, - [rowData.id]: editingRow, + const onEditDisk = (valid, { id, ...rest }) => { + onUpdate({ + valid: valid && validateTemplateDiskStorageDomains(id), + update: { + id, + underConstruction: { + ...rest, }, - })) - } - } - - handleTemplateDiskStorageDomainChange (rowData, storageDomainId) { - const update = { id: rowData.id, storageDomainId } - this.props.onUpdate({ - valid: this.validateTemplateDiskStorageDomains({ update }), - update, + }, }) } - // Verify changes, and if valid, push the new or editing row up via __onUpdate__ - handleRowConfirmChange (rowData) { - const { creating, editing, editingErrors } = this.state - const actionCreate = !!creating && creating === rowData.id - const editedRow = editing[rowData.id] - - if (Object.values(editingErrors).find(val => val) || editedRow.storageDomainId === '_') return - + const onSave = ({ id, underConstruction: { bootable, ...rest } }) => { + const { id: prevBootableDiskId } = disks.find(disk => disk.bootable) || {} // if the edited disk is set bootable, make sure to remove bootable from the other disks - if (editedRow.bootable) { - const previousBootableDisk = this.props.disks.find(disk => disk.bootable) - if (previousBootableDisk) { - this.props.onUpdate({ - update: { id: previousBootableDisk.id, bootable: false }, - }) - } + if (bootable && prevBootableDiskId && prevBootableDiskId !== id) { + onUpdate({ + update: { id: prevBootableDiskId, bootable: false }, + }) } - - this.props.onUpdate({ - [actionCreate ? 'create' : 'update']: editedRow, - valid: this.validateTemplateDiskStorageDomains(), // don't need to update changes on non-template disks - }) - this.removeEditState(rowData.id) - } - - // Cancel the creation or editing of a row by throwing out edit state - handleRowCancelChange (rowData) { - this.props.onUpdate({ valid: this.validateTemplateDiskStorageDomains() }) - this.removeEditState(rowData.id) - } - - // Drop table edit state - removeEditState (rowId) { - this.components = undefined // forces the table to reload - this.setState(state => { - const editing = state.editing - delete editing[rowId] - return { - editingErrors: { - diskInvalidName: false, - }, - creating: false, - editing, - } + onUpdate({ + valid: validateTemplateDiskStorageDomains(id), + update: { + id, + bootable, + ...rest, + underConstruction: undefined, + }, }) } - // Create props for each row that will be passed to the row component (TableInlineEditRow) - rowRenderProps (nicList, rowData, { rowIndex }) { - const actionButtonsTop = - rowIndex > 5 && - rowIndex === nicList.length - 1 - - return { - role: 'row', - - isEditing: () => this.inlineEditController.isEditing({ rowData }), - onConfirm: () => this.inlineEditController.onConfirm({ rowData, rowIndex }), - onCancel: () => this.inlineEditController.onCancel({ rowData, rowIndex }), - last: actionButtonsTop, // last === if the confirm/cancel buttons should go above the row - } - } - - render () { - const { - id: idPrefix = 'create-vm-wizard-disks', - storageDomains, - disks, - dataCenterId, - msg, - locale, - } = this.props - - const storageDomainList = createStorageDomainList({ storageDomains, locale, msg }) - const dataCenterStorageDomainsList = createStorageDomainList({ storageDomains, dataCenterId, locale, msg }) - const enableCreate = storageDomainList.length > 0 && !this.isEditingMode() - - const diskList = sortNicsDisks([...disks], locale) - .concat(this.state.creating ? [this.state.editing[this.state.creating]] : []) - .map(disk => { - disk = this.state.editing[disk.id] || disk - const sd = storageDomainList.find(sd => sd.id === disk.storageDomainId) - const isSdOk = !!sd && ( - disk.canUserUseStorageDomain || - dataCenterStorageDomainsList.find(sd => sd.id === disk.storageDomainId) - ) - - return { - ...disk, - - // compose raw disk info to be used for table render data - sized: convertValue('B', disk.size), - storageDomain: { - isOk: isSdOk, - name: sd && sd.value, - }, - diskTypeLabel: disk.diskType === 'thin' - ? msg.diskEditorDiskTypeOptionThin() - : disk.diskType === 'pre' - ? msg.diskEditorDiskTypeOptionPre() - : disk.diskType, - } - }) - - // reuse _TableInlineEditRow to allow for normal form behavior (keyboard navigation - // and using onChange field handlers) - this.components = this.components || { - body: { - row: _TableInlineEditRow, - }, - } - - return ( -
- { diskList.length === 0 && ( - <> - - - {msg.createVmStorageEmptyTitle()} - {msg.createVmStorageEmptyInfo()} - { enableCreate && ( - - - - )} - { !enableCreate && ( - - {msg.diskNoCreate()} - - )} - - - ) } - - { diskList.length > 0 && ( - <> -
- -
-
- - - this.rowRenderProps(diskList, ...rest)} - /> - -
- - ) } -
- ) - } + + )} + { !enableCreate && ( + + {msg.diskNoCreate()} + + )} + + ) } + + { diskList.length > 0 && ( + <> +
+ +
+
+ + + + {columnNames.name} + {columnNames.size} + {columnNames.storageDomain} + {columnNames.diskType} + + + + {diskList.map(({ id, underConstruction, ...rest }) => { + const { name, storageDomainId, canUserUseStorageDomain, isFromTemplate, bootable, size, diskType } = underConstruction ?? rest + const isSdValid = !!isSdOk({ storageDomainId, canUserUseStorageDomain }) + // for template based disk we need to accept even invalid values + // as user is not able to change them (except storage domain) + const isNameValid = isDiskNameValid(name) || isFromTemplate + const isDiskTypeValid = (diskType && diskType !== '_') || isFromTemplate + const isDiskSizeValid = isValidDiskSize(toGiB(size)) || isFromTemplate + const isValid = isSdValid && isNameValid && isDiskSizeValid && isDiskTypeValid + return ( + + + {(!underConstruction || isFromTemplate) && } + {underConstruction && !isFromTemplate && ( + <> + onEditDisk(isValid, { id, ...underConstruction, name: value }) } + /> + {!isNameValid && ( + + + {msg.diskNameValidationRules()} + + + )} + + {msg.diskLabelBootable()} + + + )} + isDisabled={isBootableDiskTemplate()} + id={`${id}-bootable`} + onChange={value => onEditDisk(isValid, { id, ...underConstruction, bootable: value }) } + /> + + )} + + + {(!underConstruction || isFromTemplate) && msg.utilizationCardUnitNumber(convertSize(size))} + {underConstruction && !isFromTemplate && ( + <> + onEditDisk( + isValid, + { + id, + ...underConstruction, + size: +value * (1024 ** 3), // GiB to B + })} + /> + + + GiB + + + + )} + + + {!underConstruction && translateStorageDomain(storageDomainId)} + {underConstruction && dataCenterStorageDomainsList.length === 0 && ( + <> + {msg.createVmStorageNoStorageDomainAvailable()} + { isFromTemplate && ( + + )} + + )} + {underConstruction && dataCenterStorageDomainsList.length > 0 && ( + ({ description: usage, ...rest }))} + validationState={isSdValid ? 'default' : 'error'} + onChange={storageDomainId => onEditDisk(isValid, { id, ...underConstruction, storageDomainId }) } + /> + )} + + + {(!underConstruction || isFromTemplate) && translateDiskType(diskType)} + {underConstruction && !isFromTemplate && ( + onEditDisk(isValid, { id, ...underConstruction, diskType }) } + /> + )} + + + {[ + !underConstruction && !isFromTemplate && { + ariaLabel: msg.edit(), + id: `${id}-edit`, + icon: (), + isDisabled: editInProgress, + onClick: () => onUpdate({ + valid: true, + update: { + id, + underConstruction: { + ...rest, + }, + }, + }), + }, + underConstruction && { + ariaLabel: msg.save(), + id: `${id}-save`, + icon: (), + isDisabled: !isValid, + onClick: () => onSave({ id, underConstruction }), + }, + underConstruction && { + ariaLabel: msg.cancel(), + id: `${id}-cancel`, + icon: (), + // TODO template cannot be edited again + isDisabled: isFromTemplate && !isValid, + onClick: () => onUpdate({ + valid: true, + remove: !rest.name && id, + update: { + id, + underConstruction: undefined, + }, + }), + }, + !underConstruction && !isFromTemplate && { + ariaLabel: msg.delete(), + id: `${id}-delete`, + icon: (), + isDisabled: editInProgress, + onClick: () => onUpdate({ remove: id }), + }, + ].filter(Boolean) + .map(({ ariaLabel, ...rest }, index) => ( + +
+ + ) } +
+ ) } Storage.propTypes = { diff --git a/src/components/CreateVmWizard/steps/SummaryReview.js b/src/components/CreateVmWizard/steps/SummaryReview.js index b87819fdb..40535f273 100644 --- a/src/components/CreateVmWizard/steps/SummaryReview.js +++ b/src/components/CreateVmWizard/steps/SummaryReview.js @@ -1,8 +1,7 @@ import React, { useContext } from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' -import { Icon, Spinner, Label } from 'patternfly-react' -import { Alert } from '@patternfly/react-core' +import { Label } from '@patternfly/react-core' import { InfoCircleIcon } from '@patternfly/react-icons' import { MsgContext, enumMsg } from '_/intl' @@ -13,8 +12,8 @@ import { sortNicsDisks } from '_/components/utils' import { BASIC_DATA_SHAPE, NIC_SHAPE, STORAGE_SHAPE } from '../dataPropTypes' import { optimizedForMap } from './BasicSettings' -import { NicNameWithLabels } from './Networking' -import { DiskNameWithLabels } from './Storage' +import NicNameWithLabels from './NicNameWithLabels' +import DiskNameWithLabels from './DiskNameWithLabels' import style from './style.css' import { EMPTY_VNIC_PROFILE_ID } from '_/constants' @@ -56,7 +55,7 @@ const ReviewBasic = ({ id, dataCenters, clusters, isos, templates, operatingSyst { templateNameRenderer(templates.get(basic.templateId)) } { basic.templateClone && - + } @@ -244,7 +243,6 @@ const SummaryReview = ({ id: propsId, network, storage, - progress = { inProgress: false }, dataCenters, clusters, templates, @@ -254,7 +252,7 @@ const SummaryReview = ({ vnicProfiles, storageDomains, }) => { - const { msg, locale } = useContext(MsgContext) + const { locale } = useContext(MsgContext) const id = propsId ? `${propsId}-review` : 'create-vm-wizard-review' const disksList = sortNicsDisks([...storage], locale) // Sort the template based ones first, then by name @@ -262,67 +260,6 @@ const SummaryReview = ({ return (
- { (!progress.inProgress && !progress.result) && ( -
-
- -
-
- {msg.createVmWizardReviewConfirm()} -
-
- )} - { progress.inProgress && ( -
-
- -
-
- {msg.createVmWizardReviewInProgress()} -
-
- )} - { progress.result === 'success' && ( -
-
- -
-
- {msg.createVmWizardReviewSuccess()} -
- { progress.messages && progress.messages.length > 0 && ( - { - progress.messages.map((message, index) => ( -
- { message } -
- )) - } -
- )} -
- )} - { progress.result === 'error' && ( -
-
- -
-
- {msg.createVmWizardReviewError()} -
- { progress.messages && progress.messages.length > 0 && ( - { - progress.messages.map((message, index) => ( -
- { message } -
- )) - } -
- )} -
- )} - @@ -365,12 +302,6 @@ SummaryReview.propTypes = { network: PropTypes.arrayOf(PropTypes.shape(NIC_SHAPE)).isRequired, storage: PropTypes.arrayOf(PropTypes.shape(STORAGE_SHAPE)).isRequired, - progress: PropTypes.shape({ - inProgress: PropTypes.bool.isRequired, - result: PropTypes.oneOf(['success', 'error']), - messages: PropTypes.arrayOf(PropTypes.string), - }), - clusters: PropTypes.object, dataCenters: PropTypes.object, isoFiles: PropTypes.object, diff --git a/src/components/CreateVmWizard/steps/_TableInlineEditRow.js b/src/components/CreateVmWizard/steps/_TableInlineEditRow.js deleted file mode 100644 index a1fcf6cb8..000000000 --- a/src/components/CreateVmWizard/steps/_TableInlineEditRow.js +++ /dev/null @@ -1,57 +0,0 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import TableConfirmButtonsRow from 'patternfly-react/dist/js/components/Table/TableConfirmButtonsRow' -class TableInlineEditRow extends Component { - constructor (props) { - super(props) - this.buttonsPosition = this.buttonsPosition.bind(this) - this.rowHeight = undefined - } - - buttonsPosition (window, rowDimensions) { - const position = {} - const modalDomElement = document.querySelectorAll('div.fade.in.modal') - const scrolledInPx = modalDomElement.length === 1 ? modalDomElement[0].scrollTop : 0 - - if (!this.rowHeight || Math.abs(this.rowHeight - rowDimensions.height) < 10) { - this.rowHeight = rowDimensions.height - } - - position.top = rowDimensions.y + this.rowHeight + scrolledInPx - position.right = 75 // window.width - rowDimensions.right + 10 - - console.info('button position', position) - return position - } - - render () { - return - } -} - -TableInlineEditRow.shouldComponentUpdate = true - -TableInlineEditRow.defaultProps = { - ...TableConfirmButtonsRow.defaultProps, - last: false, -} - -TableInlineEditRow.propTypes = { - /** Function that determines whether values or edit components should be rendered */ - isEditing: PropTypes.func, - /** Confirm edit callback */ - onConfirm: PropTypes.func, - /** Cancel edit callback */ - onCancel: PropTypes.func, - /** Flag to indicate last row */ - last: PropTypes.bool, - /** Row cells */ - children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]), - /** Message text inputs for i18n */ - messages: PropTypes.shape({ - confirmButtonLabel: PropTypes.string, - cancelButtonLabel: PropTypes.string, - }), -} - -export default TableInlineEditRow diff --git a/src/components/SelectBox.js b/src/components/SelectBox.js index a4092123b..76747e2fa 100644 --- a/src/components/SelectBox.js +++ b/src/components/SelectBox.js @@ -5,13 +5,12 @@ import { sortedBy } from '_/helpers' import { withMsg } from '_/intl' import { Select, SelectOption, SelectVariant } from '@patternfly/react-core' -const SelectBox = ({ msg, sort, items = [], locale, selected, onChange, validationState, id, disabled }) => { +const SelectBox = ({ msg, sort, items = [], locale, selected: selectedId, onChange, validationState, id, disabled, placeholderText, width }) => { const [open, setOpen] = useState(false) if (sort) { sortedBy(items, 'value', locale) } - const options = items.map(({ id, value: name, isDefault }) => ({ id, name, isDefault, toString: () => name })) - const selectedId = selected ?? items?.[0]?.id ?? null + const options = items.map(({ value, ...rest }) => ({ value, ...rest, toString: () => value })) const selectedOption = options.find(option => option.id === selectedId) const onSelect = (event, { id = '' } = {}, isPlaceholder) => { @@ -23,6 +22,7 @@ const SelectBox = ({ msg, sort, items = [], locale, selected, onChange, validati return ( @@ -48,12 +50,15 @@ SelectBox.propTypes = { id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), value: PropTypes.string, isDefault: PropTypes.bool, + description: PropTypes.string, })).isRequired, // Array<{ id: string, value: string }>, order matters if sort is false-ish sort: PropTypes.bool, // sorted alphabetically by current locale with { numeric: true } if true onChange: PropTypes.func.isRequired, // (selectedId: string) => any id: PropTypes.string, validationState: PropTypes.oneOf([false, 'default', 'error']), disabled: PropTypes.bool, + placeholderText: PropTypes.string, + width: PropTypes.string, locale: PropTypes.string.isRequired, msg: PropTypes.object.isRequired, } diff --git a/src/components/utils/build-select-box-lists.js b/src/components/utils/build-select-box-lists.js index 00b4d1548..59110c126 100644 --- a/src/components/utils/build-select-box-lists.js +++ b/src/components/utils/build-select-box-lists.js @@ -110,11 +110,13 @@ function createStorageDomainList ({ storageDomains, dataCenterId = null, include ) .map(sd => { const avail = convertValue('B', sd.get('availableSpace', 0)) + const usage = msg.storageDomainFreeSpace({ size: avail.value, unit: avail.unit }) return { id: sd.get('id'), value: sd.get('name') + - (includeUsage ? ' ' + msg.storageDomainFreeSpace({ size: avail.value, unit: avail.unit }) : ''), + (includeUsage ? ' ' + usage : ''), + usage, } }) .sort((a, b) => localeCompare(a.value, b.value, locale)) diff --git a/src/components/utils/disks.js b/src/components/utils/disks.js index 0459b784f..250974e87 100644 --- a/src/components/utils/disks.js +++ b/src/components/utils/disks.js @@ -10,14 +10,21 @@ export function sortDisksForDisplay (disks, locale) { } // Sort the Disks and NICs for display in the CreateVmWizard -// Sort the template based ones first, then by name +// Sort priority (from highest): +// 1. falsy name (should go last) then +// 2. template based (should go first) then +// 3. by name (natural order localized string compare) export function sortNicsDisks (objs, locale) { return objs .sort((a, b) => - a.isFromTemplate && !b.isFromTemplate + a.name && !b.name ? -1 - : !a.isFromTemplate && b.isFromTemplate + : !a.name && b.name ? 1 - : localeCompare(a.name, b.name, locale) + : a.isFromTemplate && !b.isFromTemplate + ? -1 + : !a.isFromTemplate && b.isFromTemplate + ? 1 + : localeCompare(a.name ?? '', b.name ?? '', locale) ) } diff --git a/src/components/utils/disks.test.js b/src/components/utils/disks.test.js index ab616c341..1d27befca 100644 --- a/src/components/utils/disks.test.js +++ b/src/components/utils/disks.test.js @@ -1,6 +1,6 @@ /* eslint-env jest */ -import { sortDisksForDisplay } from './disks' +import { sortDisksForDisplay, sortNicsDisks } from './disks' import { fromJS } from 'immutable' const samples = [ @@ -117,4 +117,24 @@ describe('disk sorting', () => { test('invalid locale should fail', () => { expect(() => sortDisksForDisplay(samples[0].test, 'BadLocale')).toThrow() }) + + test('undefined(falsy) names go last', () => { + expect(sortNicsDisks([{ name: undefined }, { name: 'b' }, { name: undefined }, { name: 'a' }], 'en')) + .toEqual([{ name: 'a' }, { name: 'b' }, { name: undefined }, { name: undefined }]) + }) + + test('templates go before string comparison on names', () => { + expect(sortNicsDisks([ + { name: undefined, isFromTemplate: true }, + { name: 'b', isFromTemplate: true }, + { name: undefined }, + { name: 'a' }, + ], 'en')) + .toEqual([ + { name: 'b', isFromTemplate: true }, + { name: 'a' }, + { name: undefined, isFromTemplate: true }, + { name: undefined }, + ]) + }) }) diff --git a/yarn.lock b/yarn.lock index a6c9850fb..d6116f513 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1518,13 +1518,13 @@ dependencies: "@octokit/openapi-types" "^11.2.0" -"@patternfly/react-charts@6.21.8": - version "6.21.8" - resolved "https://registry.yarnpkg.com/@patternfly/react-charts/-/react-charts-6.21.8.tgz#5dfd484605e0b5ab3db66adf1ab9ca04e73785d7" - integrity sha512-DmS0Ue4zXPodeRptEcPS+sXuiEBypZ2/hbUkxZtrt8tLvezbh9FCNLRZ7NsDXpsLwx6vtD89w3+zFzTHnqngQA== +"@patternfly/react-charts@6.34.1": + version "6.34.1" + resolved "https://registry.yarnpkg.com/@patternfly/react-charts/-/react-charts-6.34.1.tgz#5154f979474cdde4616a275ff776e332383ddd8c" + integrity sha512-KcPf4zZGABzeqXMOzIIl/CWCf/St70EM8QKUnWdxAz8z3ZbSNvPQtUl0XuGqCoR7KBI74FJ6RnWEYtsExh9gvA== dependencies: - "@patternfly/react-styles" "^4.18.8" - "@patternfly/react-tokens" "^4.20.8" + "@patternfly/react-styles" "^4.31.1" + "@patternfly/react-tokens" "^4.33.1" hoist-non-react-statics "^3.3.0" lodash "^4.17.19" tslib "^2.0.0" @@ -1557,33 +1557,45 @@ xterm "^4.0.0" xterm-addon-fit "^0.2.1" -"@patternfly/react-core@4.168.8": - version "4.168.8" - resolved "https://registry.yarnpkg.com/@patternfly/react-core/-/react-core-4.168.8.tgz#6f200dba7bb83423144db34bbd2ffc833b097566" - integrity sha512-wfG52saibeAXRoBYytzMgVGANoInbPuhUK0y6AynVp3/QKAPCKHpaI4nCh9ytNZ+UjiT2KGOcunhavsOw5+iMA== +"@patternfly/react-core@4.181.1", "@patternfly/react-core@^4.181.1": + version "4.181.1" + resolved "https://registry.yarnpkg.com/@patternfly/react-core/-/react-core-4.181.1.tgz#ed530532aa16fdad5dda07556cb47b63fdd2157a" + integrity sha512-5pt4R8Cg8CkA6xCY4v6wurpwn8WIS8N8NqQEtp56WABMVpZC+TE7k1TSsrDDR1WhJlxsl48VbsANkwMYLlsSJg== dependencies: - "@patternfly/react-icons" "^4.19.8" - "@patternfly/react-styles" "^4.18.8" - "@patternfly/react-tokens" "^4.20.8" + "@patternfly/react-icons" "^4.32.1" + "@patternfly/react-styles" "^4.31.1" + "@patternfly/react-tokens" "^4.33.1" focus-trap "6.2.2" react-dropzone "9.0.0" tippy.js "5.1.2" tslib "^2.0.0" -"@patternfly/react-icons@^4.19.8": - version "4.19.8" - resolved "https://registry.yarnpkg.com/@patternfly/react-icons/-/react-icons-4.19.8.tgz#376cfe136b3acef86fa28081c99e4e88bd13457c" - integrity sha512-JBBcVntRLspe857JnGo8lFaZfy+WOR/IYe2E9W3Rknqm1T8WO6oXpeEnrr5r9bpYDFUa4ttcb/acuQXc1xypxg== - -"@patternfly/react-styles@^4.18.8": - version "4.18.8" - resolved "https://registry.yarnpkg.com/@patternfly/react-styles/-/react-styles-4.18.8.tgz#92b314c41a8df27cf4c531ab60fc9daf11da8150" - integrity sha512-136N/Whs0qXDtPpsh5JeNJld5BeFPsbmmcgI5LlMQ+P9dioddh24rTqaaOIea67tHiXUB7orxaqKpBBILXqcPQ== +"@patternfly/react-icons@4.32.1", "@patternfly/react-icons@^4.32.1": + version "4.32.1" + resolved "https://registry.yarnpkg.com/@patternfly/react-icons/-/react-icons-4.32.1.tgz#5989fa8c1563b16824f1239f56b50afa2655b6de" + integrity sha512-E7Fpnvax37e2ow8Xc2GngQBM7IuOpRyphZXCIUAk/NGsqpvFX27YhIVZiOUIAuBXAI5VXQBwueW/tmhmlXP7+w== + +"@patternfly/react-styles@^4.31.1": + version "4.31.1" + resolved "https://registry.yarnpkg.com/@patternfly/react-styles/-/react-styles-4.31.1.tgz#878c44282edfc731948d952d845ff477931875aa" + integrity sha512-Yw2hgg3T2qEqPYej5xprYD0iTTkzFMNjaF8u/YFyZOBNwfhjK8QQDZx8Du7Z6em8zGtajXFG5rZqxDiiz8bDfQ== + +"@patternfly/react-table@4.50.1": + version "4.50.1" + resolved "https://registry.yarnpkg.com/@patternfly/react-table/-/react-table-4.50.1.tgz#c1784a66d80e28a314d1bc7f4de86b0ae1023a83" + integrity sha512-8/vYbp1ryARu/ZOuMgZQzV0lRiq1whUq4s6R7lW1O/UZf73geGoIODag7zn2CD9tddHTDWfck1xo6bMn6fjn5w== + dependencies: + "@patternfly/react-core" "^4.181.1" + "@patternfly/react-icons" "^4.32.1" + "@patternfly/react-styles" "^4.31.1" + "@patternfly/react-tokens" "^4.33.1" + lodash "^4.17.19" + tslib "^2.0.0" -"@patternfly/react-tokens@^4.20.8": - version "4.20.8" - resolved "https://registry.yarnpkg.com/@patternfly/react-tokens/-/react-tokens-4.20.8.tgz#8413af8516fdcdaea1da81912434eef4c9c7865a" - integrity sha512-OFlgTknFBNAETWVYYoLdISJBjPMQ8e3tcLpWb3h/KgKk/Tn1NR+P7Pl1gQGvTL/9liBZfS/HKDD5+P8c70w+2Q== +"@patternfly/react-tokens@^4.33.1": + version "4.33.1" + resolved "https://registry.yarnpkg.com/@patternfly/react-tokens/-/react-tokens-4.33.1.tgz#c7c932aaaabdbf6963343a7bb2b38690b8d1c4ad" + integrity sha512-n06+BYDviOEuoo+qE9f0Jm1DkMDpwtXWyaJFHsvi8w8uddQEsBGGoP7lpkwZj15u6jc+F9IDgk/j+EkTScrvUA== "@redux-saga/core@^1.1.3": version "1.1.3" From dea1f5b4b7ff6f63f3b1b9ceeb2469cdb9c34b26 Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Mon, 6 Jun 2022 14:54:34 +0200 Subject: [PATCH 06/10] Add title to SummaryReview step Bring back the title used previously in the PF3 version. Includes styling changes in BasicSettings step: 1. re-enable validation for VM name 2. add more space above sysprep/cloud init section --- .../CreateVmWizard/steps/BasicSettings.js | 1 + src/components/CreateVmWizard/steps/Storage.js | 2 +- .../CreateVmWizard/steps/SummaryReview.js | 17 +++++++++++++++-- src/components/CreateVmWizard/steps/style.css | 1 + src/components/utils/disks.test.js | 14 ++++++++++++-- 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/components/CreateVmWizard/steps/BasicSettings.js b/src/components/CreateVmWizard/steps/BasicSettings.js index f56b6577c..f29531711 100644 --- a/src/components/CreateVmWizard/steps/BasicSettings.js +++ b/src/components/CreateVmWizard/steps/BasicSettings.js @@ -521,6 +521,7 @@ class BasicSettings extends React.Component { autoComplete='off' type='text' value={data.name} + validated={indicators.name} onChange={value => this.handleChange('name', value)} /> diff --git a/src/components/CreateVmWizard/steps/Storage.js b/src/components/CreateVmWizard/steps/Storage.js index 09b929fb3..ad7e9870a 100644 --- a/src/components/CreateVmWizard/steps/Storage.js +++ b/src/components/CreateVmWizard/steps/Storage.js @@ -368,7 +368,7 @@ const Storage = ({ ariaLabel: msg.cancel(), id: `${id}-cancel`, icon: (), - // TODO template cannot be edited again + // template cannot be edited again isDisabled: isFromTemplate && !isValid, onClick: () => onUpdate({ valid: true, diff --git a/src/components/CreateVmWizard/steps/SummaryReview.js b/src/components/CreateVmWizard/steps/SummaryReview.js index 40535f273..3ca4f1d06 100644 --- a/src/components/CreateVmWizard/steps/SummaryReview.js +++ b/src/components/CreateVmWizard/steps/SummaryReview.js @@ -4,7 +4,7 @@ import { connect } from 'react-redux' import { Label } from '@patternfly/react-core' import { InfoCircleIcon } from '@patternfly/react-icons' -import { MsgContext, enumMsg } from '_/intl' +import { MsgContext, enumMsg, withMsg } from '_/intl' import { templateNameRenderer, userFormatOfBytes } from '_/helpers' import { Grid, Row, Col } from '_/components/Grid' import { Tooltip } from '_/components/tooltips' @@ -17,6 +17,8 @@ import DiskNameWithLabels from './DiskNameWithLabels' import style from './style.css' import { EMPTY_VNIC_PROFILE_ID } from '_/constants' +import { VirtualMachineIcon } from '@patternfly/react-icons/dist/esm/icons' + const Item = ({ id, label, children }) => (
{label}
@@ -251,6 +253,7 @@ const SummaryReview = ({ basic, vnicProfiles, storageDomains, + msg, }) => { const { locale } = useContext(MsgContext) const id = propsId ? `${propsId}-review` : 'create-vm-wizard-review' @@ -260,6 +263,14 @@ const SummaryReview = ({ return (
+
+
+ +
+
+ {msg.createVmWizardReviewConfirm()} +
+
@@ -309,6 +320,8 @@ SummaryReview.propTypes = { storageDomains: PropTypes.object, templates: PropTypes.object, vnicProfiles: PropTypes.object, + + msg: PropTypes.object.isRequired, } export default connect( @@ -328,4 +341,4 @@ export default connect( templates: state.templates, vnicProfiles: state.vnicProfiles, }) -)(SummaryReview) +)(withMsg(SummaryReview)) diff --git a/src/components/CreateVmWizard/steps/style.css b/src/components/CreateVmWizard/steps/style.css index 434999a88..563908a75 100644 --- a/src/components/CreateVmWizard/steps/style.css +++ b/src/components/CreateVmWizard/steps/style.css @@ -62,6 +62,7 @@ .sub-heading { text-transform: uppercase; margin-left: -15px; + margin-top: 15px; } /* diff --git a/src/components/utils/disks.test.js b/src/components/utils/disks.test.js index 1d27befca..b6542cc99 100644 --- a/src/components/utils/disks.test.js +++ b/src/components/utils/disks.test.js @@ -119,8 +119,18 @@ describe('disk sorting', () => { }) test('undefined(falsy) names go last', () => { - expect(sortNicsDisks([{ name: undefined }, { name: 'b' }, { name: undefined }, { name: 'a' }], 'en')) - .toEqual([{ name: 'a' }, { name: 'b' }, { name: undefined }, { name: undefined }]) + expect(sortNicsDisks([ + { name: undefined }, + { name: 'b' }, + { name: undefined }, + { name: 'a' }, + ], 'en')) + .toEqual([ + { name: 'a' }, + { name: 'b' }, + { name: undefined }, + { name: undefined }, + ]) }) test('templates go before string comparison on names', () => { From b1cb62d8741d1d935700242d93da21f28cdc17ce Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Fri, 8 Apr 2022 16:18:45 +0200 Subject: [PATCH 07/10] Migrate page layout to PF4 Page/Masthead Includes: 1. Breadcrumb 2. NotificationDrawer 3. AboutDialog refactoring to functional component --- branding/brand.css | 1 - src/App.js | 42 ++++-- src/components/About/About.js | 126 ++++++++---------- src/components/Header.js | 25 ++-- src/components/ToastNotifications.js | 43 +----- src/components/VmUserMessages/Bellicon.js | 39 ------ src/components/VmUserMessages/index.js | 114 ++++++++++------ src/components/VmUserMessages/style.css | 15 --- src/components/VmsPageHeader/LogoutItem.js | 31 +++++ src/components/VmsPageHeader/UserMenu.js | 64 ++++----- src/components/VmsPageHeader/index.js | 148 +++++++++++++++------ src/components/VmsPageHeader/style.css | 3 + src/helpers.js | 33 +++++ 13 files changed, 394 insertions(+), 290 deletions(-) delete mode 100644 src/components/VmUserMessages/Bellicon.js delete mode 100644 src/components/VmUserMessages/style.css create mode 100644 src/components/VmsPageHeader/LogoutItem.js create mode 100644 src/components/VmsPageHeader/style.css diff --git a/branding/brand.css b/branding/brand.css index ee4d8a4c6..49e6561c0 100644 --- a/branding/brand.css +++ b/branding/brand.css @@ -25,7 +25,6 @@ width: 272px; height: 16px; position: relative; - top: 9px; border: 0px; background-image: url(images/ovirt_masthead_logo.png); background-size: contain; diff --git a/src/App.js b/src/App.js index 05f568d26..8a7870182 100644 --- a/src/App.js +++ b/src/App.js @@ -1,4 +1,4 @@ -import React, { useContext } from 'react' +import React, { useContext, useState } from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' @@ -12,12 +12,19 @@ import RefreshIntervalChangeHandler from '_/components/RefreshIntervalChangeHand import SessionActivityTracker from '_/components/SessionActivityTracker' import ToastNotifications from '_/components/ToastNotifications' import VmsPageHeader from '_/components/VmsPageHeader' +import VmUserMessages from '_/components/VmUserMessages' import ConsoleNotificationsDialog from '_/components/VmActions/ConsoleNotificationsDialog' import getRoutes from '_/routes' import { fixedStrings } from '_/branding' import { MsgContext } from '_/intl' +import { + Page, +} from '@patternfly/react-core' + +import Header from './components/Header' + function isLoginMissing (config) { return !config.get('loginToken') || config.get('isTokenExpired') } @@ -56,6 +63,9 @@ function isBrowserUnsupported () { */ const App = ({ history, config, appReady, activateSessionTracker }) => { const { msg } = useContext(MsgContext) + const [isDrawerExpanded, setDrawerExpanded] = useState(false) + const toggleNotificationDrawer = () => setDrawerExpanded(!isDrawerExpanded) + if (isBrowserUnsupported()) { return } @@ -66,16 +76,28 @@ const App = ({ history, config, appReady, activateSessionTracker }) => { return ( -
- - - - - - { appReady && activateSessionTracker && } + + + + + + { appReady && activateSessionTracker && } + { appReady && } + + + + )} + notificationDrawer={} + isNotificationDrawerExpanded={isDrawerExpanded} + > { appReady && renderRoutes(getRoutes()) } - { appReady && } -
+
) } diff --git a/src/components/About/About.js b/src/components/About/About.js index d96a53787..395924482 100644 --- a/src/components/About/About.js +++ b/src/components/About/About.js @@ -25,88 +25,76 @@ const LegalInfo = () => { ) } -class AboutDialog extends React.Component { - constructor (props) { - super(props) - this.state = { openModal: false } - } - - render () { - const { oVirtApiVersion, msg } = this.props - const idPrefix = 'about' +const AboutDialog = ({ closeDialog, isOpen, oVirtApiVersion, msg }) => { + const idPrefix = 'about' - const webUiVersionText = msg.aboutDialogVersion({ - version: `${Product.version}-${Product.release}`, - }) - - let apiVersion = 'unknown' - if (oVirtApiVersion && oVirtApiVersion.get('major')) { - apiVersion = `${oVirtApiVersion.get('major')}.${oVirtApiVersion.get('minor')}` - } - const apiVersionText = msg.aboutDialogApiVersion({ - brandName: fixedStrings.BRAND_NAME, - version: `${apiVersion}`, - }) + const webUiVersionText = msg.aboutDialogVersion({ + version: `${Product.version}-${Product.release}`, + }) - const trackText = fixedStrings.ISSUES_TRACKER_TEXT || 'Github Issue Tracker' - const trackUrl = fixedStrings.ISSUES_TRACKER_URL || 'https://github.com/oVirt/ovirt-web-ui/issues' - const reportLink = msg.aboutDialogReportIssuesLink({ - link: `${trackText}`, - }) + let apiVersion = 'unknown' + if (oVirtApiVersion && oVirtApiVersion.get('major')) { + apiVersion = `${oVirtApiVersion.get('major')}.${oVirtApiVersion.get('minor')}` + } + const apiVersionText = msg.aboutDialogApiVersion({ + brandName: fixedStrings.BRAND_NAME, + version: `${apiVersion}`, + }) - const docLink = msg.aboutDialogDocumentationLink({ - link: `${msg.aboutDialogDocumentationText()}`, - }) - const closeModal = () => this.setState({ openModal: false }) - const openModal = () => this.setState({ openModal: true }) + const trackText = fixedStrings.ISSUES_TRACKER_TEXT || 'Github Issue Tracker' + const trackUrl = fixedStrings.ISSUES_TRACKER_URL || 'https://github.com/oVirt/ovirt-web-ui/issues' + const reportLink = msg.aboutDialogReportIssuesLink({ + link: `${trackText}`, + }) - return ( - <> - {msg.about()} - { this.state.openModal && ( - ${msg.aboutDialogDocumentationText()}`, + }) + const title = `${fixedStrings.BRAND_NAME} ${msg.vmPortal()}` - > + return ( + -

{fixedStrings.BRAND_NAME} {msg.vmPortal()}

-
-
    -
  • -
    -
  • -
  • -
    -
  • - {fixedStrings.DOCUMENTATION_LINK && ( -
  • - -
  • - )} -
  • -
    -
  • -
-
+

{title}

+
+
    +
  • +
    +
  • +
  • +
    +
  • + {fixedStrings.DOCUMENTATION_LINK && ( +
  • + +
  • + )} +
  • +
    +
  • +
+
- -
+ +
- - )} - - ) - } + + ) } AboutDialog.propTypes = { oVirtApiVersion: PropTypes.object, msg: PropTypes.object, + isOpen: PropTypes.bool.isRequired, + closeDialog: PropTypes.func.isRequired, } export default connect( diff --git a/src/components/Header.js b/src/components/Header.js index 75096d1b8..478763450 100644 --- a/src/components/Header.js +++ b/src/components/Header.js @@ -1,17 +1,26 @@ import React from 'react' import PropTypes from 'prop-types' import { resourcesUrls } from '_/branding' +import { + Brand, + Masthead, + MastheadBrand, + MastheadContent, + MastheadMain, +} from '@patternfly/react-core' const Header = ({ children }) => { return ( - + + + + + + {children} + + ) } diff --git a/src/components/ToastNotifications.js b/src/components/ToastNotifications.js index 964df4521..72cbb427a 100644 --- a/src/components/ToastNotifications.js +++ b/src/components/ToastNotifications.js @@ -5,51 +5,22 @@ import { connect } from 'react-redux' import { Alert, AlertGroup, AlertActionCloseButton } from '@patternfly/react-core' import { setNotificationNotified } from '_/actions' import { withMsg } from '_/intl' -import { buildMessageFromRecord, translate } from '_/helpers' +import { + buildMessageFromRecord, + normalizeNotificationType, + buildNotificationTitle, +} from '_/helpers' import style from './sharedStyle.css' -function normalizeType (theType) { - theType = String(theType).toLowerCase() - // PF4 statuses - if (['default', 'warning', 'success', 'info', 'danger'].includes(theType)) { - return theType - } - - // 'error' (used in PF3) was replaced by 'danger' - return theType === 'error' ? 'danger' : 'warning' -} - -function buildTitle ({ id, params } = {}, msg, type) { - if (!id) { - // no title provide - generate one based on type - return mapTypeToTitle(msg, type) - } - return translate({ id, params, msg }) -} - -function mapTypeToTitle (msg, type) { - switch (type) { - case 'warning': - return msg.warning() - case 'danger': - return msg.error() - case 'success': - return msg.success() - case 'info': - default: - return msg.info() - } -} - const ToastNotifications = ({ userMessages, onDismissNotification, msg }) => { return ( { userMessages.get('records').toJS().filter(({ notified }) => !notified).map(r => ( onDismissNotification(r.id)} actionClose={( diff --git a/src/components/VmUserMessages/Bellicon.js b/src/components/VmUserMessages/Bellicon.js deleted file mode 100644 index beac90d6b..000000000 --- a/src/components/VmUserMessages/Bellicon.js +++ /dev/null @@ -1,39 +0,0 @@ -import React, { useContext } from 'react' -import PropTypes from 'prop-types' -import { connect } from 'react-redux' - -import { hrefWithoutHistory } from '_/helpers' -import { MsgContext } from '_/intl' -import { Tooltip } from '../tooltips' -import { BellIcon } from '@patternfly/react-icons/dist/esm/icons' - -const Bellicon = ({ userMessages, handleclick }) => { - const { msg } = useContext(MsgContext) - const messagesCount = userMessages.get('records').size - const idPrefix = 'usermsgs' - const badgeElement = messagesCount === 0 - ? null - : {messagesCount} - - return ( -
  • - - - - {badgeElement} - - - -
  • - ) -} -Bellicon.propTypes = { - handleclick: PropTypes.func.isRequired, - userMessages: PropTypes.object.isRequired, -} - -export default connect( - (state) => ({ - userMessages: state.userMessages, - }) -)(Bellicon) diff --git a/src/components/VmUserMessages/index.js b/src/components/VmUserMessages/index.js index 60ebc6577..2925ddeaf 100644 --- a/src/components/VmUserMessages/index.js +++ b/src/components/VmUserMessages/index.js @@ -2,32 +2,60 @@ import React, { useContext, useState } from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' -import { Notification, NotificationDrawer, MenuItem, Icon, Button } from 'patternfly-react' - -import style from './style.css' +import { + Button, + Dropdown, + DropdownItem, + DropdownPosition, + KebabToggle, + NotificationDrawer, + NotificationDrawerBody, + NotificationDrawerHeader, + NotificationDrawerList, + NotificationDrawerListItem, + NotificationDrawerListItemBody, + NotificationDrawerListItemHeader, +} from '@patternfly/react-core' import { clearUserMessages, dismissEvent } from '_/actions' -import { getFormatedDateTime, buildMessageFromRecord, toJS } from '_/helpers' +import { + getFormatedDateTime, + buildMessageFromRecord, + toJS, + normalizeNotificationType, + buildNotificationTitle, +} from '_/helpers' import { MsgContext } from '_/intl' const UserMessage = ({ record, id, onDismissMessage }) => { const { msg } = useContext(MsgContext) - const time = getFormatedDateTime(record.get('time')) + const { isOpen, setOpen } = useState(false) + const { date, time } = getFormatedDateTime(record.time) + const variant = normalizeNotificationType(record.type) return ( - - - - { msg.clear() } - - - - - - { buildMessageFromRecord(record.toJS(), msg) } - - - - + + + } + isOpen={isOpen} + isPlain + dropdownItems={[ + + {msg.clear()} + , + ]} + /> + + + { buildMessageFromRecord(record, msg) } + + ) } UserMessage.propTypes = { @@ -36,42 +64,44 @@ UserMessage.propTypes = { onDismissMessage: PropTypes.func.isRequired, } -const VmUserMessages = ({ userMessages, onClearMessages, onDismissMessage, onClose, show }) => { +const VmUserMessages = ({ userMessages, onClearMessages, onDismissMessage, onClose }) => { const { msg } = useContext(MsgContext) - const [expanded, setExpanded] = useState(false) const idPrefix = 'usermsgs' const messagesCount = userMessages.get('records').size - const messagesList = messagesCount - ? userMessages.get('records').map(r => ( + const messagesList = userMessages + .get('records') + .map(r => ( onDismissMessage(r.toJS())} /> )) - : return ( - - setExpanded(!expanded)} /> - -
    + + + + + + {messagesList} -
    - { messagesCount > 0 && ( - - - - - - )} -
    + +
    ) } @@ -80,7 +110,7 @@ VmUserMessages.propTypes = { onClearMessages: PropTypes.func.isRequired, onDismissMessage: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, - show: PropTypes.bool.isRequired, + // show: PropTypes.bool.isRequired, } export default connect( diff --git a/src/components/VmUserMessages/style.css b/src/components/VmUserMessages/style.css deleted file mode 100644 index 2fc0a1cc1..000000000 --- a/src/components/VmUserMessages/style.css +++ /dev/null @@ -1,15 +0,0 @@ -.panel-body { - padding: 0; - background-color: #fff; - overflow-y: auto; - display: flex; - flex-direction: column; -} - -.notifications-list { - overflow-y: auto; -} - -.action-panel { - border-top: solid 1px #d1d1d1; -} diff --git a/src/components/VmsPageHeader/LogoutItem.js b/src/components/VmsPageHeader/LogoutItem.js new file mode 100644 index 000000000..e46d1a0e6 --- /dev/null +++ b/src/components/VmsPageHeader/LogoutItem.js @@ -0,0 +1,31 @@ +import React, { useContext } from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' + +import { logout } from '_/actions' + +import { MsgContext } from '_/intl' +import { + DropdownItem, +} from '@patternfly/react-core' + +const LogoutItem = ({ onLogout }) => { + const { msg } = useContext(MsgContext) + const idPrefix = 'usermenu' + return ( + + {msg.logOut()} + + ) +} + +LogoutItem.propTypes = { + onLogout: PropTypes.func.isRequired, +} + +export default connect( + null, + (dispatch) => ({ + onLogout: () => dispatch(logout(true)), + }) +)(LogoutItem) diff --git a/src/components/VmsPageHeader/UserMenu.js b/src/components/VmsPageHeader/UserMenu.js index 0a3d22756..1386fc19f 100644 --- a/src/components/VmsPageHeader/UserMenu.js +++ b/src/components/VmsPageHeader/UserMenu.js @@ -1,46 +1,46 @@ -import React, { useContext } from 'react' +import React, { useState } from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' - -import { logout } from '_/actions' - -import { MsgContext } from '_/intl' -import AboutDialog from '_/components/About' -import { Tooltip } from '_/components/tooltips' +import { withMsg } from '_/intl' import { UserIcon } from '@patternfly/react-icons/dist/esm/icons' +import { + Dropdown, + DropdownItem, + DropdownToggle, + ToolbarItem, +} from '@patternfly/react-core' + +import LogoutItem from './LogoutItem' -const UserMenu = ({ config, onLogout }) => { - const { msg } = useContext(MsgContext) - const idPrefix = 'usermenu' +const UserMenu = ({ username, openAboutDialog, msg }) => { + const [isDropdownOpen, setDropdownOpen] = useState(false) + const onDropdownSelect = () => {} return ( -
  • - - - - - - -
  • + + } onToggle={setDropdownOpen}>{username} } + dropdownItems={[ + + {msg.about()} + , + ]} + /> + ) } UserMenu.propTypes = { - config: PropTypes.object.isRequired, - onLogout: PropTypes.func.isRequired, + username: PropTypes.string.isRequired, + msg: PropTypes.object.isRequired, + openAboutDialog: PropTypes.func.isRequired, } export default connect( (state) => ({ - config: state.config, + username: state.config.getIn(['user', 'name'], ''), }), - (dispatch) => ({ - onLogout: () => dispatch(logout(true)), - }) -)(UserMenu) + null +)(withMsg(UserMenu)) diff --git a/src/components/VmsPageHeader/index.js b/src/components/VmsPageHeader/index.js index 62df14268..0963ae360 100644 --- a/src/components/VmsPageHeader/index.js +++ b/src/components/VmsPageHeader/index.js @@ -1,68 +1,140 @@ import React, { useContext, useState } from 'react' import PropTypes from 'prop-types' - +import { push } from 'connected-react-router' import { connect } from 'react-redux' -import { Link } from 'react-router-dom' -import VmUserMessages from '../VmUserMessages' -import Bellicon from '../VmUserMessages/Bellicon' import UserMenu from './UserMenu' -import Header from '../Header' -import { hrefWithoutHistory } from '_/helpers' import { manualRefresh } from '_/actions' import { MsgContext } from '_/intl' import { Tooltip } from '../tooltips' -import { CogIcon, SyncAltIcon } from '@patternfly/react-icons/dist/esm/icons' +import { + CogIcon, + SyncAltIcon, + BellIcon, +} from '@patternfly/react-icons/dist/esm/icons' +import { + Button, + ButtonVariant, + Divider, + Dropdown, + DropdownItem, + KebabToggle, + NotificationBadge, + Toolbar, + ToolbarItem, + ToolbarGroup, + ToolbarContent, +} from '@patternfly/react-core' +import styles from './style.css' +import LogoutItem from './LogoutItem' +import AboutDialog from '../About' /** * Main application header on top of the page */ -const VmsPageHeader = ({ appReady, onRefresh }) => { +const VmsPageHeader = ({ appReady, onRefresh, onCloseNotificationDrawer, isDrawerExpanded, unreadNotificationCount, goToSettings }) => { const { msg } = useContext(MsgContext) - const [show, setShow] = useState(false) const idPrefix = 'pageheader' + const [isKebabDropdownOpen, setKebabDropdownOpen] = useState(false) + const [isAboutDialogOpen, setIsAboutDialogOpen] = useState(false) return ( -
    -
    - setShow(!show)} /> - -
    -
    + + + + + + + + + + {appReady && ( + + + + + + )} + + {appReady && ( + + + + + + )} + + setIsAboutDialogOpen(true)}/> + + + + setKebabDropdownOpen(!isKebabDropdownOpen)} + toggle={ setKebabDropdownOpen(!isKebabDropdownOpen)} />} + isOpen={isKebabDropdownOpen} + dropdownItems={[ + appReady && ( + + {msg.refresh()} + + ), + appReady && ( + + {msg.accountSettings()} + + ), + appReady && , + setIsAboutDialogOpen(true)}> + {msg.about()} + , + , + ].filter(Boolean)} + /> + + + + + + setIsAboutDialogOpen(false)} isOpen={isAboutDialogOpen}/> + ) } VmsPageHeader.propTypes = { appReady: PropTypes.bool.isRequired, + isDrawerExpanded: PropTypes.bool.isRequired, + unreadNotificationCount: PropTypes.number.isRequired, + + goToSettings: PropTypes.func.isRequired, onRefresh: PropTypes.func.isRequired, + onCloseNotificationDrawer: PropTypes.func.isRequired, } export default connect( - (state) => ({ - appReady: !!state.config.get('appConfigured'), // When is the app ready to display data components? + ({ config, userMessages }) => ({ + appReady: !!config.get('appConfigured'), // When is the app ready to display data components? + // all known messages are marked as unread + unreadNotificationCount: userMessages.get('records')?.size ?? 0, }), (dispatch) => ({ onRefresh: () => dispatch(manualRefresh()), + goToSettings: () => dispatch(push('/settings')), }) )(VmsPageHeader) diff --git a/src/components/VmsPageHeader/style.css b/src/components/VmsPageHeader/style.css new file mode 100644 index 000000000..1f6ca8c0b --- /dev/null +++ b/src/components/VmsPageHeader/style.css @@ -0,0 +1,3 @@ +.noBackgroundColor { +background-color: transparent; +} \ No newline at end of file diff --git a/src/helpers.js b/src/helpers.js index 973c3f8f2..955b763f2 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -40,6 +40,39 @@ export function buildMessageFromRecord ({ messageDescriptor: { id, params } = {} return `${translate({ id, params, msg })}\n${message}` } +export function normalizeNotificationType (theType) { + theType = String(theType).toLowerCase() + // PF4 statuses + if (['default', 'warning', 'success', 'info', 'danger'].includes(theType)) { + return theType + } + + // 'error' (used in PF3) was replaced by 'danger' + return theType === 'error' ? 'danger' : 'warning' +} + +export function buildNotificationTitle ({ id, params } = {}, msg, type) { + if (!id) { + // no title provide - generate one based on type + return mapNotificationTypeToTitle(msg, type) + } + return translate({ id, params, msg }) +} + +function mapNotificationTypeToTitle (msg, type) { + switch (type) { + case 'warning': + return msg.warning() + case 'danger': + return msg.error() + case 'success': + return msg.success() + case 'info': + default: + return msg.info() + } +} + // "payload":{"message":"Not Found","messageDescriptor":{"id": "loginFailed"},"type":404,"action":{"type":"LOGIN","payload":{"credentials":{"username":"admin@internal","password":"admi"}}}}} export function hidePassword ({ action, param }) { if (action) { From eae541e710cae7e23d42f030643bcc7b0bb86abe Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Mon, 6 Jun 2022 13:39:01 +0200 Subject: [PATCH 08/10] Remove PF3 styles --- branding/brand.css | 8 - package.json | 27 +- src/App.js | 45 +- src/components/Breadcrumb/index.js | 47 +- src/components/Breadcrumb/styles.css | 4 + src/components/Confirmation/index.js | 48 - src/components/Confirmation/style.css | 28 - .../CreateVmWizard/steps/BasicSettings.js | 2 +- src/components/CreateVmWizard/steps/style.css | 4 - src/components/ErrorAlert.js | 21 - src/components/FieldHelp/index.js | 79 - src/components/FieldHelp/style.css | 19 - src/components/IconUpload/index.js | 99 - src/components/IconUpload/style.css | 12 - src/components/OvirtApiCheckFailed.js | 8 +- src/components/Settings/SettingsBase.js | 26 +- src/components/Settings/SettingsToolbar.js | 3 +- src/components/Settings/style.css | 46 +- src/components/Toolbar/VmsListToolbar.js | 2 +- src/components/Toolbar/index.js | 4 +- src/components/Toolbar/style.css | 5 - src/components/UserSettings/GlobalSettings.js | 117 +- src/components/UserSettings/style.css | 8 +- src/components/VmIcon/index.js | 3 +- src/components/VmUserMessages/index.js | 9 +- src/components/VmsList/BaseCard.js | 3 +- src/components/VmsList/VmCardList.js | 18 +- src/components/VmsList/style.css | 30 +- src/components/VmsPageHeader/index.js | 3 +- src/components/VmsPageHeader/style.css | 3 - src/index-nomodules.css | 167 +- src/index.js | 12 - src/intl/messages.js | 2 +- src/intl/translated-messages.json | 14 +- yarn.lock | 4933 ++--------------- 35 files changed, 628 insertions(+), 5231 deletions(-) create mode 100644 src/components/Breadcrumb/styles.css delete mode 100644 src/components/Confirmation/index.js delete mode 100644 src/components/Confirmation/style.css delete mode 100644 src/components/ErrorAlert.js delete mode 100644 src/components/FieldHelp/index.js delete mode 100644 src/components/FieldHelp/style.css delete mode 100644 src/components/IconUpload/index.js delete mode 100644 src/components/IconUpload/style.css delete mode 100644 src/components/VmsPageHeader/style.css diff --git a/branding/brand.css b/branding/brand.css index 49e6561c0..cbda9551d 100644 --- a/branding/brand.css +++ b/branding/brand.css @@ -7,16 +7,8 @@ *********************************************/ -:root { - --ovirt-masthead-height: 60px; - --ovirt-masthead-topborder-size: 4px; -} - /* --- Application: masthead --- */ .obrand_masthead { - border-top: var(--ovirt-masthead-topborder-size) solid #00659c; - height: var(--ovirt-masthead-height); - background-image: url(images/ovirt_masthead_bg.png); background-size: cover; } diff --git a/package.json b/package.json index 9592cdf44..aa545b54d 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,8 @@ "eslint-webpack-plugin": "^2.6.0", "file-loader": "6.2.0", "filesize": "8.0.6", - "flow-bin": "0.164.0", - "flow-typed": "^3.3.1", + "flow-bin": "0.176.0", + "flow-typed": "^3.7.0", "gzip-size": "6.0.0", "handlebars": "4.7.7", "handlebars-loader": "1.7.1", @@ -66,14 +66,11 @@ }, "dependencies": { "@closeio/use-infinite-scroll": "1.0.0", - "@patternfly/react-charts": "6.34.1", - "@patternfly/react-console": "^2.0.18", - "@patternfly/react-core": "4.181.1", - "@patternfly/react-icons": "4.32.1", - "@patternfly/react-table": "4.50.1", - "bootstrap": "3.4.1", - "bootstrap-select": "1.13.18", - "bootstrap-switch": "3.3.4", + "@patternfly/react-charts": "6.55.16", + "@patternfly/react-console": "4.53.20", + "@patternfly/react-core": "4.202.16", + "@patternfly/react-icons": "4.53.16", + "@patternfly/react-table": "4.71.16", "classnames": "2.3.1", "connected-react-router": "6.9.1", "core-js": "3.19.1", @@ -85,13 +82,8 @@ "lodash": "4.17.21", "moment": "2.29.1", "moment-duration-format": "2.3.2", - "patternfly": "3.59.5", - "patternfly-bootstrap-combobox": "1.1.7", - "patternfly-react": "2.40.0", "prop-types": "15.7.2", "react": "^16.8.6", - "react-bootstrap": "^0.33.0", - "react-bootstrap-switch": "15.5.3", "react-dom": "^16.8.6", "react-intl-po": "2.2.2", "react-redux": "7.2.6", @@ -104,11 +96,8 @@ "semver": "7.3.5" }, "resolutions": { - "bootstrap-select": ">=1.13.6", "webpack/**/glob-parent": "^5.1.2", - "patternfly/**/jquery": ">=3.5.1", - "immer": ">=9.0.6", - "@patternfly/react-console/**/@novnc/novnc": "1.1.0" + "immer": ">=9.0.6" }, "scripts": { "preinstall": "test -e scripts/yarn-preinstall.sh && scripts/yarn-preinstall.sh || :", diff --git a/src/App.js b/src/App.js index 8a7870182..a2a969d65 100644 --- a/src/App.js +++ b/src/App.js @@ -76,28 +76,29 @@ const App = ({ history, config, appReady, activateSessionTracker }) => { return ( - - - - - - { appReady && activateSessionTracker && } - { appReady && } - - - - )} - notificationDrawer={} - isNotificationDrawerExpanded={isDrawerExpanded} - > - { appReady && renderRoutes(getRoutes()) } - +
    + + + + { appReady && activateSessionTracker && } + { appReady && } + + + + )} + notificationDrawer={} + isNotificationDrawerExpanded={isDrawerExpanded} + > + + { appReady && renderRoutes(getRoutes()) } + +
    ) } diff --git a/src/components/Breadcrumb/index.js b/src/components/Breadcrumb/index.js index 57a3f43a7..55078441d 100644 --- a/src/components/Breadcrumb/index.js +++ b/src/components/Breadcrumb/index.js @@ -3,6 +3,9 @@ import PropTypes from 'prop-types' import { Link } from 'react-router-dom' import { connect } from 'react-redux' import { MsgContext } from '_/intl' +import { Breadcrumb, BreadcrumbItem } from '@patternfly/react-core' + +import styles from './styles.css' const NONE_VM_ROUTES = ['/settings'] @@ -33,30 +36,36 @@ const buildPath = ({ vms, branches, msg }) => { return res } -const Breadcrumb = ({ vms, branches }) => { +const PageBreadcrumb = ({ vms, branches }) => { const { msg } = useContext(MsgContext) const crumbs = buildPath({ vms, branches, msg }) const idPrefix = 'breadcrumb' - + const lastSegment = crumbs.pop() return ( -
      - {crumbs.map((path, index, array) => - (index === (array.length - 1)) - ? ( -
    1. - {path.title} -
    2. - ) - : ( -
    3. - {path.title} -
    4. - ) - )} -
    + + + { + crumbs.map((path, index, array) => ( + + {path.title} + + )) + } + + + {lastSegment.title} + + + ) } -Breadcrumb.propTypes = { +PageBreadcrumb.propTypes = { branches: PropTypes.array.isRequired, vms: PropTypes.object.isRequired, } @@ -65,4 +74,4 @@ export default connect( state => ({ vms: state.vms, }) -)(Breadcrumb) +)(PageBreadcrumb) diff --git a/src/components/Breadcrumb/styles.css b/src/components/Breadcrumb/styles.css new file mode 100644 index 000000000..0134bdf6e --- /dev/null +++ b/src/components/Breadcrumb/styles.css @@ -0,0 +1,4 @@ +.breadcrumb { + border-bottom: solid gray; + padding: 8px 15px; +} \ No newline at end of file diff --git a/src/components/Confirmation/index.js b/src/components/Confirmation/index.js deleted file mode 100644 index 01c567179..000000000 --- a/src/components/Confirmation/index.js +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' - -import { Button, ButtonToolbar } from 'react-bootstrap' - -import style from './style.css' - -const Confirmation = ({ okButton, cancelButton, extraButton, text, height, uniqueId }) => { - const idPrefix = `confirmation-${uniqueId || ''}` - - const s = {} - if (height) { - s.height = height - } - - return ( - - {typeof text === 'string' - ? (

    {text}

    ) - : (
    {text}
    ) } - - - - - {extraButton && ( - - )} - -
    - ) -} - -Confirmation.propTypes = { - okButton: PropTypes.object.isRequired, - cancelButton: PropTypes.object.isRequired, - text: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, - height: PropTypes.number, - extraButton: PropTypes.object, - uniqueId: PropTypes.string, -} - -export default Confirmation diff --git a/src/components/Confirmation/style.css b/src/components/Confirmation/style.css deleted file mode 100644 index 9aaae85da..000000000 --- a/src/components/Confirmation/style.css +++ /dev/null @@ -1,28 +0,0 @@ -.confirmation-body { - display: flex; - flex-direction: column; - align-content: center; -} - -.confirmation-text { - margin: 0 0 5px 0; - padding: .3em 0; - font-size: small; - text-align: center; -} - -.confirmation-toolbar { - margin-top: 5px; - display: flex; - flex-direction: row; - align-content: center; - justify-content: center; -} - -.confirmation-extra-button { - margin-left: 15px !important; -} - -.confirmation-ok-button { - margin-right: 2px !important; -} diff --git a/src/components/CreateVmWizard/steps/BasicSettings.js b/src/components/CreateVmWizard/steps/BasicSettings.js index f29531711..50e006f7b 100644 --- a/src/components/CreateVmWizard/steps/BasicSettings.js +++ b/src/components/CreateVmWizard/steps/BasicSettings.js @@ -641,7 +641,7 @@ class BasicSettings extends React.Component { {/* -- Cloud-Init -- */} - + { - return ( -
    - - - {message && ({message})} - {children} - -
    - ) -} -ErrorAlert.propTypes = { - id: PropTypes.string.isRequired, - message: PropTypes.string, - children: PropTypes.node, -} - -export default ErrorAlert diff --git a/src/components/FieldHelp/index.js b/src/components/FieldHelp/index.js deleted file mode 100644 index f5c2114df..000000000 --- a/src/components/FieldHelp/index.js +++ /dev/null @@ -1,79 +0,0 @@ -import React from 'react' -import ReactDOM from 'react-dom' -import PropTypes from 'prop-types' -import $ from 'jquery' - -import { Popover, OverlayTrigger } from 'react-bootstrap' - -import { withMsg } from '_/intl' -import style from './style.css' - -/** - * Renders underlined `text` with `tooltip`. - * A popover is shown consisting of `title` and `content` on click. - */ -class FieldHelp extends React.Component { - constructor (props) { - super(props) - this.state = { style: null, placement: 'top' } - this.position = null - } - - componentDidMount () { - const position = ReactDOM.findDOMNode(this).getBoundingClientRect() - this.setState({ position }) - } - - componentWillReceiveProps (nextProps) { - let placement = 'top' - const popoverStyle = {} - const parent = $(ReactDOM.findDOMNode(this)).parents('[container]') - if (parent.length) { - const position = ReactDOM.findDOMNode(this).getBoundingClientRect() - const parentPosition = parent.get(0).getBoundingClientRect() - const maxHeight = position.top - parentPosition.top - if (maxHeight > 80) { - popoverStyle.maxHeight = maxHeight - } else { - placement = 'bottom' - } - - popoverStyle.maxWidth = parentPosition.right - position.left - popoverStyle.maxWidth = popoverStyle.maxWidth > 250 ? 250 : popoverStyle.maxWidth - } - this.setState({ style: popoverStyle, placement }) - } - - render () { - const { msg } = this.props - const tooltip = this.props.tooltip || msg.clickForHelp() - - const popover = ( - - {this.props.content} - - ) - - const container = this.props.container === null ? undefined : this.props.container || this - return ( - -
    - {this.props.text} - {this.props.children} -
    -
    - ) - } -} - -FieldHelp.propTypes = { - title: PropTypes.string, // popover title - content: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), // popover content - text: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), // decorated text - tooltip: PropTypes.string, // tooltip shown when hovering the text - children: PropTypes.any, - container: PropTypes.any, - msg: PropTypes.object.isRequired, -} - -export default withMsg(FieldHelp) diff --git a/src/components/FieldHelp/style.css b/src/components/FieldHelp/style.css deleted file mode 100644 index 78ead6d95..000000000 --- a/src/components/FieldHelp/style.css +++ /dev/null @@ -1,19 +0,0 @@ -.field-help { - float: right; - margin-right: 0.4em; -} - -.field-text { - display: inline-block; - border-bottom: 1px dotted #888; - line-height: 95%; -} - -.field-text:focus { - outline: 0px solid transparent; -} - -.field-help-min-width { - min-width: 150px; - width: max-content; -} diff --git a/src/components/IconUpload/index.js b/src/components/IconUpload/index.js deleted file mode 100644 index c045c8178..000000000 --- a/src/components/IconUpload/index.js +++ /dev/null @@ -1,99 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' - -import { withMsg } from '_/intl' -import FieldHelp from '../FieldHelp/index' - -import style from './style.css' - -const MAX_ICON_SIZE = 24 // in KiB; checked by oVirt API - -class IconUpload extends React.Component { - constructor (props) { - super(props) - this.handleIconChange = this.handleIconChange.bind(this) - } - - handleIconChange (e) { - const { msg } = this.props - const that = this - const files = e.target.files - - if (files.length > 0) { - const file = files[0] - - if (file.size > MAX_ICON_SIZE * 1024) { - that.props.onErrorChange(msg.uploadIconFilesizeTooLarge({ maxIconSize: MAX_ICON_SIZE })) - return - } - - const reader = new FileReader() - - reader.onload = function (upload) { - let iconBase64 = upload.target.result - iconBase64 = iconBase64.replace('data:', '') - const semiIndex = iconBase64.indexOf(';') - const mimeType = iconBase64.slice(0, semiIndex) - - if (mimeType.includes('image')) { - iconBase64 = iconBase64.slice(semiIndex + 1).replace('base64,', '') - that.props.onIconChange({ - mediaType: mimeType, - data: iconBase64, - }) - } else { - that.props.onErrorChange(msg.uploadIconNotImage()) - } - } - reader.readAsDataURL(file) - } - } - - render () { - const { error, msg } = this.props - - const iconError = this.props.error - ? ({this.props.error}) - : null - - return ( - <> -
    - -
    -
    - - - {iconError} -
    - - ) - } -} - -IconUpload.propTypes = { - /* eslint-disable-next-line react/no-unused-prop-types */ - onErrorChange: PropTypes.func.isRequired, - onIconChange: PropTypes.func.isRequired, - error: PropTypes.string, - msg: PropTypes.object.isRequired, -} - -export default withMsg(IconUpload) diff --git a/src/components/IconUpload/style.css b/src/components/IconUpload/style.css deleted file mode 100644 index 71c440b65..000000000 --- a/src/components/IconUpload/style.css +++ /dev/null @@ -1,12 +0,0 @@ - -.upload-button { - margin: 5px; -} - -.hide { - display: none !important; -} - -.error-text{ - font-size: 12px; -} diff --git a/src/components/OvirtApiCheckFailed.js b/src/components/OvirtApiCheckFailed.js index a9506679e..9c096336e 100644 --- a/src/components/OvirtApiCheckFailed.js +++ b/src/components/OvirtApiCheckFailed.js @@ -5,7 +5,8 @@ import { connect } from 'react-redux' import Product from '../version' import { MsgContext } from '_/intl' import { fixedStrings } from '../branding' -import ErrorAlert from './ErrorAlert' + +import { Alert } from '@patternfly/react-core' const OvirtApiCheckFailed = ({ config }) => { const { msg } = useContext(MsgContext) @@ -23,15 +24,14 @@ const OvirtApiCheckFailed = ({ config }) => { const version = major ? `${major}.${minor}` : `"${msg.unknown()}"` const required = `${Product.ovirtApiVersionRequired.major}.${Product.ovirtApiVersionRequired.minor}` - const htmlMessage = msg.htmlUnsupportedOvirtVersionFoundButVersionAtLeastRequired({ + const message = msg.htmlUnsupportedOvirtVersionFoundButVersionAtLeastRequired({ version, productName: fixedStrings.BRAND_NAME, requiredVersion: required, }) - const message = () return ( - {message} + ) } OvirtApiCheckFailed.propTypes = { diff --git a/src/components/Settings/SettingsBase.js b/src/components/Settings/SettingsBase.js index 40d7c8c13..dc36045c6 100644 --- a/src/components/Settings/SettingsBase.js +++ b/src/components/Settings/SettingsBase.js @@ -4,6 +4,7 @@ import { Card, CardBody, CardTitle, + Form, FormGroup, Hint, HintBody, @@ -12,8 +13,8 @@ import { InfoTooltip } from '_/components/tooltips' import style from './style.css' -const Section = ({ name, section, className }) => ( - +const Section = ({ name, section }) => ( + {section.title} @@ -27,15 +28,17 @@ const Section = ({ name, section, className }) => ( )} { section.fields.map((field) => ( - + { field.title && ( - } - fieldId={field.fieldId} - > - {field.body} - +
    + } + fieldId={field.fieldId} + > + {field.body} + +
    )} {!field.title && field.body} @@ -49,7 +52,6 @@ const Section = ({ name, section, className }) => ( Section.propTypes = { name: PropTypes.string.isRequired, section: PropTypes.object.isRequired, - className: PropTypes.string, } const SettingsBase = ({ name, section }) => { @@ -57,7 +59,7 @@ const SettingsBase = ({ name, section }) => { return (
    { sections.map(([name, section]) => ( -
    +
    ) )}
    diff --git a/src/components/Settings/SettingsToolbar.js b/src/components/Settings/SettingsToolbar.js index 556520946..15f1f1976 100644 --- a/src/components/Settings/SettingsToolbar.js +++ b/src/components/Settings/SettingsToolbar.js @@ -10,7 +10,6 @@ import { } from '@patternfly/react-core' import { MsgContext } from '_/intl' -import style from './style.css' import ConfirmationModal from '_/components/VmActions/ConfirmationModal' import ChangesList from './ChangesList' @@ -69,7 +68,7 @@ const SettingsToolbar = ({ onSave, onReset, onCancel, enableSave, enableReset, t onClick: onResetConfirm, }} /> - + diff --git a/src/components/Settings/style.css b/src/components/Settings/style.css index c7da0bbed..e41f81b24 100644 --- a/src/components/Settings/style.css +++ b/src/components/Settings/style.css @@ -1,13 +1,3 @@ -.settings-box { - display: flex; - margin-top: 20px; -} - -.navigation-content { - padding: 0; - margin-right: 30px; - display: table; -} .search-content-box { flex-grow: 1; @@ -18,28 +8,12 @@ } .main-content { - margin-top: 20px; - margin-left: 0; - margin-right: 0; -} - -.main-content-container { - display: grid; -} - -.field-label { - text-align: right; + margin-bottom: 20px; } .settings-field { padding-top: 20px; - padding-bottom: 20px; border-bottom: solid 1px #0000001f; - margin-left: -20px; - margin-right: -20px; - padding-left: 20px; - padding-right: 20px; - margin-bottom: 0; } .alert-container { @@ -49,26 +23,12 @@ width: 50%; } -.toolbar :global(.toolbar-pf.row) { - padding-bottom: 10px; - margin-left: 0; -} - -.toolbar :global(.toolbar-pf-action-right) button { - margin-right: 10px; -} - -.settings-toolbar { - padding-top: 5px; - padding-bottom: 5px; -} - -.section-list{ +.section-list { list-style-type: disc; padding-left: var(--pf-c-modal-box__body--PaddingLeft);; } -.field-list{ +.field-list { list-style-type: circle; margin-inline-start: 1em; } diff --git a/src/components/Toolbar/VmsListToolbar.js b/src/components/Toolbar/VmsListToolbar.js index ac2989cc2..e799cd47c 100644 --- a/src/components/Toolbar/VmsListToolbar.js +++ b/src/components/Toolbar/VmsListToolbar.js @@ -28,7 +28,7 @@ const VmsListToolbar = ({ match, vms, pools, filters = {}, onClearFilters, msg } return ( <> - + diff --git a/src/components/Toolbar/index.js b/src/components/Toolbar/index.js index 376eb08ff..2bb2daec2 100644 --- a/src/components/Toolbar/index.js +++ b/src/components/Toolbar/index.js @@ -19,7 +19,7 @@ const VmDetailToolbar = ({ match, vms }) => { const poolId = vms.getIn(['vms', match.params.id, 'pool', 'id']) const pool = vms.getIn(['pools', poolId]) return ( - + @@ -49,7 +49,7 @@ const VmConsoleToolbar = ({ match: { params: { id, consoleType } } = {}, vms }) } return ( - + - ({ id, value, isDefault: id === DEFAULT_LOCALE }))} - selected={draftValues[name]} - onChange={onChange(name)} - /> -
    + + ({ id, value, isDefault: id === DEFAULT_LOCALE }))} + selected={draftValues[name]} + onChange={onChange(name)} + /> + ), }))('language'), ], @@ -225,15 +234,15 @@ class GlobalSettings extends Component { key: name, fieldId: toId(name), body: ( -
    - ({ id, value, isDefault: id === AppConfiguration.schedulerFixedDelayInSeconds }))} - selected={draftValues[name]} - onChange={onChange(name)} - /> -
    + + ({ id, value, isDefault: id === AppConfiguration.schedulerFixedDelayInSeconds }))} + selected={draftValues[name]} + onChange={onChange(name)} + /> + ), }))('refreshInterval'), ], @@ -261,16 +270,16 @@ class GlobalSettings extends Component { key: name, fieldId: toId(name), body: ( -
    - ({ id, value, isDefault: id === AppConfiguration.notificationSnoozeDurationInMinutes }))} - selected={draftValues[name]} - onChange={onChange(name)} - disabled={draftValues.showNotifications} - /> -
    + + ({ id, value, isDefault: id === AppConfiguration.notificationSnoozeDurationInMinutes }))} + selected={draftValues[name]} + onChange={onChange(name)} + disabled={draftValues.showNotifications} + /> + ), }))('notificationSnoozeDuration'), ], @@ -293,20 +302,20 @@ class GlobalSettings extends Component { key: name, fieldId: toId(name), body: ( -
    - ({ - id, - value, - isDefault: id === config.defaultUiConsole, - })) + + ({ + id, + value, + isDefault: id === config.defaultUiConsole, + })) } - selected={draftValues[name]} - onChange={onChange(name)} - /> -
    + selected={draftValues[name]} + onChange={onChange(name)} + /> + ), }))('preferredConsole'), ((name) => ({ @@ -315,13 +324,13 @@ class GlobalSettings extends Component { key: name, fieldId: toId(name), body: ( -
    - onChange(name)(vmId)} - /> -
    + + onChange(name)(vmId)} + /> + ), }))('autoconnect'), ], @@ -436,6 +445,7 @@ class GlobalSettings extends Component { tooltip: msg.sshKeyTooltip(), key: name, fieldId: toId(name), + fullSize: true, body: (