1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-01-10 01:17:40 +03:00

F #5862: Add label filter component (#2142)

This commit is contained in:
Sergio Betanzos 2022-06-10 13:43:28 +02:00 committed by GitHub
parent 782660a00b
commit 205cafb92c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 637 additions and 227 deletions

View File

@ -19,25 +19,44 @@ import PropTypes from 'prop-types'
import { Lock, User, Group, Cart } from 'iconoir-react'
import { Typography } from '@mui/material'
import { useViews } from 'client/features/Auth'
import MultipleTags from 'client/components/MultipleTags'
import Timer from 'client/components/Timer'
import { StatusCircle, StatusChip } from 'client/components/Status'
import { Tr } from 'client/components/HOC'
import { rowStyles } from 'client/components/Tables/styles'
import { getState, getType } from 'client/models/MarketplaceApp'
import { timeFromMilliseconds } from 'client/models/Helper'
import {
timeFromMilliseconds,
getUniqueLabels,
getColorFromString,
} from 'client/models/Helper'
import { prettyBytes } from 'client/utils'
import { T, MarketplaceApp } from 'client/constants'
import {
T,
MarketplaceApp,
MARKETPLACE_APP_ACTIONS,
RESOURCE_NAMES,
} from 'client/constants'
const MarketplaceAppCard = memo(
/**
* @param {object} props - Props
* @param {MarketplaceApp} props.app - Marketplace App resource
* @param {object} props.rootProps - Props to root component
* @param {function(string):Promise} [props.onClickLabel] - Callback to click label
* @param {function(string):Promise} [props.onDeleteLabel] - Callback to delete label
* @returns {ReactElement} - Card
*/
({ app, rootProps }) => {
({ app, rootProps, onClickLabel, onDeleteLabel }) => {
const classes = rowStyles()
const { [RESOURCE_NAMES.VM]: vmView } = useViews()
const enableEditLabels =
vmView?.actions?.[MARKETPLACE_APP_ACTIONS.EDIT_LABELS] === true &&
!!onDeleteLabel
const {
ID,
NAME,
@ -48,6 +67,7 @@ const MarketplaceAppCard = memo(
MARKETPLACE,
ZONE_ID,
SIZE,
TEMPLATE: { LABELS } = {},
} = app
const state = useMemo(() => getState(app), [app?.STATE])
@ -56,6 +76,17 @@ const MarketplaceAppCard = memo(
const time = useMemo(() => timeFromMilliseconds(+REGTIME), [REGTIME])
const type = useMemo(() => getType(app), [app?.TYPE])
const labels = useMemo(
() =>
getUniqueLabels(LABELS).map((label) => ({
text: label,
stateColor: getColorFromString(label),
onClick: onClickLabel,
onDelete: enableEditLabels && onDeleteLabel,
})),
[LABELS, enableEditLabels, onClickLabel, onDeleteLabel]
)
return (
<div {...rootProps} data-cy={`app-${ID}`}>
<div className={classes.main}>
@ -67,6 +98,7 @@ const MarketplaceAppCard = memo(
{LOCK && <Lock />}
<span className={classes.labels}>
<StatusChip text={type} />
<MultipleTags tags={labels} />
</span>
</div>
<div className={classes.caption}>
@ -104,6 +136,8 @@ MarketplaceAppCard.propTypes = {
rootProps: PropTypes.shape({
className: PropTypes.string,
}),
onClickLabel: PropTypes.func,
onDeleteLabel: PropTypes.func,
actions: PropTypes.any,
}

View File

@ -48,11 +48,12 @@ const VirtualMachineCard = memo(
* @param {object} props - Props
* @param {VM} props.vm - Virtual machine resource
* @param {object} props.rootProps - Props to root component
* @param {function(string):Promise} [props.onClickLabel] - Callback to click label
* @param {function(string):Promise} [props.onDeleteLabel] - Callback to delete label
* @param {ReactElement} [props.actions] - Actions
* @returns {ReactElement} - Card
*/
({ vm, rootProps, actions, onDeleteLabel }) => {
({ vm, rootProps, actions, onClickLabel, onDeleteLabel }) => {
const classes = rowStyles()
const { [RESOURCE_NAMES.VM]: vmView } = useViews()
@ -90,9 +91,10 @@ const VirtualMachineCard = memo(
getUniqueLabels(LABELS).map((label) => ({
text: label,
stateColor: getColorFromString(label),
onClick: onClickLabel,
onDelete: enableEditLabels && onDeleteLabel,
})),
[LABELS, enableEditLabels, onDeleteLabel]
[LABELS, enableEditLabels, onClickLabel, onDeleteLabel]
)
return (
@ -159,6 +161,7 @@ VirtualMachineCard.propTypes = {
rootProps: PropTypes.shape({
className: PropTypes.string,
}),
onClickLabel: PropTypes.func,
onDeleteLabel: PropTypes.func,
actions: PropTypes.any,
}

View File

@ -48,10 +48,11 @@ const VmTemplateCard = memo(
* @param {object} props - Props
* @param {VM} props.template - Virtual machine resource
* @param {object} props.rootProps - Props to root component
* @param {function(string):Promise} [props.onClickLabel] - Callback to click label
* @param {function(string):Promise} [props.onDeleteLabel] - Callback to delete label
* @returns {ReactElement} - Card
*/
({ template, rootProps, onDeleteLabel }) => {
({ template, rootProps, onClickLabel, onDeleteLabel }) => {
const classes = rowStyles()
const { [RESOURCE_NAMES.VM_TEMPLATE]: templateView } = useViews()
@ -83,9 +84,10 @@ const VmTemplateCard = memo(
getUniqueLabels(LABELS).map((label) => ({
text: label,
stateColor: getColorFromString(label),
onClick: onClickLabel,
onDelete: enableEditLabels && onDeleteLabel,
})),
[LABELS, enableEditLabels, onDeleteLabel]
[LABELS, enableEditLabels, onClickLabel, onDeleteLabel]
)
return (
@ -134,6 +136,7 @@ VmTemplateCard.propTypes = {
rootProps: PropTypes.shape({
className: PropTypes.string,
}),
onClickLabel: PropTypes.func,
onDeleteLabel: PropTypes.func,
}

View File

@ -14,36 +14,49 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { memo, useState, useMemo, useEffect } from 'react'
import { memo, useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { Cancel as CloseIcon, NavArrowDown as CaretIcon } from 'iconoir-react'
import {
Paper,
styled,
useMediaQuery,
Paper,
Popper,
Typography,
useTheme,
IconButton,
Button,
Fade,
Box,
buttonClasses,
ClickAwayListener,
} from '@mui/material'
const callAll =
(...fns) =>
(...args) =>
fns.forEach((fn) => fn && fn?.(...args))
const StyledPopper = styled(Popper)(({ theme }) => ({
boxShadow: theme.shadows[1],
zIndex: theme.zIndex.modal + 1,
[theme.breakpoints.down('xs')]: { width: '100%', height: '100%' },
}))
const StyledPaper = styled(Paper)(({ theme }) => ({
[theme.breakpoints.down('xs')]: { width: '100%', height: '100%' },
}))
const HeaderPopover = memo(
({
id,
icon,
buttonLabel,
buttonProps,
onMouseHover,
buttonProps: { onClick, ...buttonProps } = {},
headerTitle,
popperProps,
onClickAway,
children,
}) => {
const { zIndex } = useTheme()
const isMobile = useMediaQuery((theme) => theme.breakpoints.only('xs'))
const [open, setOpen] = useState(false)
@ -54,18 +67,7 @@ const HeaderPopover = memo(
setOpen((previousOpen) => !previousOpen)
}
const handleClose = () => setOpen(false)
const mobileStyles = useMemo(
() => ({
...(isMobile && {
width: '100%',
height: '100%',
}),
}),
[isMobile]
)
const handleClose = callAll(onClickAway, () => setOpen(false))
const canBeOpen = open && Boolean(anchorEl)
const hasId = canBeOpen ? id : undefined
@ -79,9 +81,7 @@ const HeaderPopover = memo(
aria-haspopup
aria-describedby={hasId}
aria-expanded={open ? 'true' : 'false'}
{...(onMouseHover
? { onMouseEnter: handleClick, onMouseLeave: handleClose }
: { onClick: handleClick })}
onClick={callAll(handleClick, onClick)}
size="small"
endIcon={<CaretIcon />}
startIcon={icon}
@ -94,50 +94,38 @@ const HeaderPopover = memo(
>
{!isMobile && buttonLabel}
</Button>
<Popper
<StyledPopper
id={hasId}
open={open}
anchorEl={anchorEl}
transition
placement="bottom-end"
keepMounted={false}
style={{
zIndex: zIndex.modal + 1,
...mobileStyles,
}}
{...popperProps}
>
{({ TransitionProps }) => (
<ClickAwayListener onClickAway={handleClose}>
<Fade {...TransitionProps} timeout={300}>
<Paper
variant="outlined"
sx={{ p: headerTitle ? 2 : 0, ...mobileStyles }}
<ClickAwayListener onClickAway={handleClose}>
<StyledPaper variant="outlined" sx={{ p: headerTitle ? 2 : 0 }}>
{(headerTitle || isMobile) && (
<Box
display="flex"
alignItems="center"
justifyContent="space-between"
borderBottom="1px solid"
borderColor="divider"
>
{(headerTitle || isMobile) && (
<Box
display="flex"
alignItems="center"
justifyContent="space-between"
borderBottom="1px solid"
borderColor="divider"
>
{headerTitle && (
<Typography variant="body1">{headerTitle}</Typography>
)}
{isMobile && (
<IconButton onClick={handleClose} size="large">
<CloseIcon />
</IconButton>
)}
</Box>
{headerTitle && (
<Typography variant="body1">{headerTitle}</Typography>
)}
{children({ handleClose: handleClose })}
</Paper>
</Fade>
</ClickAwayListener>
)}
</Popper>
{isMobile && (
<IconButton onClick={handleClose} size="large">
<CloseIcon />
</IconButton>
)}
</Box>
)}
{children({ handleClose })}
</StyledPaper>
</ClickAwayListener>
</StyledPopper>
</>
)
}
@ -146,13 +134,13 @@ const HeaderPopover = memo(
HeaderPopover.propTypes = {
id: PropTypes.string,
icon: PropTypes.node,
buttonLabel: PropTypes.string,
buttonLabel: PropTypes.any,
buttonProps: PropTypes.object,
tooltip: PropTypes.any,
headerTitle: PropTypes.any,
onMouseHover: PropTypes.bool,
disablePadding: PropTypes.bool,
popperProps: PropTypes.object,
onClickAway: PropTypes.func,
children: PropTypes.func,
}
@ -164,8 +152,8 @@ HeaderPopover.defaultProps = {
buttonProps: {},
headerTitle: undefined,
disablePadding: false,
onMouseHover: false,
popperProps: {},
onClickAway: undefined,
children: () => undefined,
}

View File

@ -19,45 +19,49 @@ import { styled, Typography, alpha } from '@mui/material'
import { Copy as CopyIcon, Check as CopiedIcon, Cancel } from 'iconoir-react'
import { useClipboard } from 'client/hooks'
import { SCHEMES } from 'client/constants'
const callAll =
(...fns) =>
(...args) =>
fns.forEach((fn) => fn && fn?.(...args))
const Chip = styled(Typography)(
({ theme: { palette, typography }, state = 'debug', white, icon }) => {
({ theme: { palette }, state = 'debug', ownerState }) => {
const { dark = state } = palette[state] ?? {}
const bgColor = alpha(dark, 0.2)
const color = white ? palette.common.white : palette.text.primary
const isWhite = ownerState.forceWhiteColor
const bgColor = alpha(dark, palette.mode === SCHEMES.DARK ? 0.5 : 0.2)
const color = isWhite ? palette.common.white : palette.text.primary
const iconColor = isWhite ? palette.getContrastText(color) : dark
return {
color,
backgroundColor: bgColor,
padding: icon ? '0.1rem 0.5rem' : '0.25rem 0.5rem',
borderRadius: 6,
textTransform: 'uppercase',
fontSize: typography.overline.fontSize,
fontWeight: 500,
lineHeight: 'normal',
padding: ownerState.hasIcon ? '0.1rem 0.5rem' : '0.25rem 0.5rem',
cursor: 'default',
...(icon && {
userSelect: 'none',
...(ownerState.hasIcon && {
display: 'inline-flex',
alignItems: 'center',
gap: '0.5em',
'& > .icon': {
cursor: 'pointer',
color,
'&:hover': {
color: white ? palette.getContrastText(color) : dark,
},
'&:hover': { color: iconColor },
},
}),
...(ownerState.clickable && {
WebkitTapHighlightColor: 'transparent',
cursor: 'pointer',
'&:hover, &:focus': {
backgroundColor: alpha(bgColor, 0.3),
},
}),
}
}
)
const callAll =
(...fns) =>
(...args) =>
fns.forEach((fn) => fn && fn?.(...args))
const StatusChip = memo(
({
stateColor,
@ -71,6 +75,12 @@ const StatusChip = memo(
}) => {
const { copy, isCopied } = useClipboard()
const ownerState = {
forceWhiteColor,
hasIcon: clipboard || onDelete ? 'true' : undefined,
clickable: !!onClick,
}
const handleCopy = useCallback(
(evt) => {
const textToCopy = typeof clipboard === 'string' ? clipboard : text
@ -83,19 +93,29 @@ const StatusChip = memo(
const handleDelete = useCallback(
(evt) => {
onDelete(text)
onDelete?.(text)
evt.stopPropagation()
},
[text, onDelete]
)
const handleClick = useCallback(
(evt) => {
onClick?.(text)
evt.stopPropagation()
},
[text, onClick]
)
return (
<Chip
component="span"
variant="overline"
lineHeight="normal"
borderRadius="0.5em"
state={stateColor}
white={forceWhiteColor ? 'true' : undefined}
icon={clipboard || onDelete ? 'true' : undefined}
onClick={callAll(onClick, clipboard && handleCopy)}
ownerState={ownerState}
onClick={callAll(handleClick, clipboard && handleCopy)}
data-cy={dataCy}
{...props}
>

View File

@ -13,78 +13,89 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { Fragment, useMemo, ReactElement } from 'react'
import { ReactElement, Fragment, memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import { Stack, Button } from '@mui/material'
import { Filter } from 'iconoir-react'
import { UseTableInstanceProps, UseFiltersState } from 'react-table'
import { UseFiltersInstanceProps, UseFiltersState } from 'react-table'
import { LABEL_COLUMN_ID } from 'client/components/Tables/Enhanced/Utils/GlobalLabel'
import HeaderPopover from 'client/components/Header/Popover'
import { Translate } from 'client/components/HOC'
import { T } from 'client/constants'
/**
* Render all selected sorters.
* Render all selected filters.
*
* @param {object} props - Props
* @param {string} [props.className] - Class name for the container
* @param {UseTableInstanceProps} props.useTableProps - Table props
* @returns {ReactElement} Component JSX
*/
const GlobalFilter = ({ className, useTableProps }) => {
const { rows, columns, setAllFilters, state } = useTableProps
const GlobalFilter = memo(
(tableProps) => {
/** @type {UseFiltersInstanceProps} */
const { rows, columns, setAllFilters, state } = tableProps
/** @type {UseFiltersState} */
const { filters } = state
/** @type {UseFiltersState} */
const { filters } = state
const columnsCanFilter = useMemo(
() => columns.filter(({ canFilter }) => canFilter),
[columns]
)
const columnsCanFilter = useMemo(
() => columns.filter(({ canFilter }) => canFilter),
[]
)
if (columnsCanFilter.length === 0) {
return null
}
if (columnsCanFilter.length === 0) {
return null
}
return (
<Stack className={className} direction="row" gap="0.5em" flexWrap="wrap">
<HeaderPopover
id="filter-by-button"
icon={<Filter />}
buttonLabel={T.FilterBy}
buttonProps={{
'data-cy': 'filter-by-button',
disableElevation: true,
variant: filters?.length > 0 ? 'contained' : 'outlined',
color: 'secondary',
disabled: rows?.length === 0,
}}
popperProps={{ placement: 'bottom-end' }}
>
{() => (
<Stack sx={{ width: { xs: '100%', md: 500 }, p: 2 }}>
{columnsCanFilter.map((column, idx) => (
<Fragment key={idx}>{column.render('Filter')}</Fragment>
))}
<Button
variant="contained"
color="secondary"
onClick={() => setAllFilters([])}
sx={{ mt: 2, alignSelf: 'flex-end' }}
>
<Translate word={T.Clear} />
</Button>
</Stack>
)}
</HeaderPopover>
</Stack>
)
}
const filtersAreNotLabel = useMemo(
() => filters?.filter(({ id }) => id !== LABEL_COLUMN_ID),
[filters]
)
return (
<Stack direction="row" gap="0.5em" flexWrap="wrap">
<HeaderPopover
id="filter-by-button"
icon={<Filter />}
headerTitle={T.FilterBy}
buttonLabel={T.Filter}
buttonProps={{
'data-cy': 'filter-by-button',
disableElevation: true,
variant: filtersAreNotLabel.length > 0 ? 'contained' : 'outlined',
color: 'secondary',
disabled: rows?.length === 0,
}}
popperProps={{ placement: 'bottom-end' }}
>
{() => (
<Stack sx={{ width: { xs: '100%', md: 500 }, p: 2 }}>
{columnsCanFilter.map((column, idx) => (
<Fragment key={idx}>{column.render('Filter')}</Fragment>
))}
<Button
variant="contained"
color="secondary"
onClick={() => setAllFilters([])}
sx={{ mt: 2, alignSelf: 'flex-end' }}
>
<Translate word={T.Clear} />
</Button>
</Stack>
)}
</HeaderPopover>
</Stack>
)
},
(next, prev) =>
next.rows === prev.rows && next.state.filters === prev.state.filters
)
GlobalFilter.propTypes = {
className: PropTypes.string,
useTableProps: PropTypes.object.isRequired,
preFilteredRows: PropTypes.array,
state: PropTypes.object,
}
GlobalFilter.displayName = 'GlobalFilter'
export default GlobalFilter

View File

@ -0,0 +1,160 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement, SyntheticEvent } from 'react'
import PropTypes from 'prop-types'
import CheckIcon from 'iconoir-react/dist/Check'
import CancelIcon from 'iconoir-react/dist/Cancel'
import { styled, Box, InputBase, Typography } from '@mui/material'
import Autocomplete, {
autocompleteClasses,
AutocompleteChangeDetails,
AutocompleteChangeReason,
AutocompleteCloseReason,
} from '@mui/material/Autocomplete'
import { StatusCircle } from 'client/components/Status'
import { getColorFromString } from 'client/models/Helper'
import { Translate, Tr } from 'client/components/HOC'
import { T } from 'client/constants'
const StyledInput = styled(InputBase)(
({ theme: { shape, palette, transitions } }) => ({
padding: 10,
width: '100%',
'& input': {
padding: 6,
transition: transitions.create(['border-color', 'box-shadow']),
border: `1px solid ${palette.divider}`,
borderRadius: shape.borderRadius / 2,
fontSize: 14,
'&:focus': {
boxShadow: `0px 0px 0px 3px ${palette.secondary[palette.mode]}`,
},
},
})
)
const StyledAutocompletePopper = styled('div')(({ theme }) => ({
[`& .${autocompleteClasses.paper}`]: {
boxShadow: 'none',
margin: 0,
color: 'inherit',
fontSize: 13,
},
[`& .${autocompleteClasses.listbox}`]: {
padding: 0,
[`& .${autocompleteClasses.option}`]: {
minHeight: 'auto',
alignItems: 'flex-start',
padding: 8,
borderBottom: `1px solid ${theme.palette.divider}`,
'&[aria-selected="true"]': {
backgroundColor: theme.palette.action.hover,
},
[`&.${autocompleteClasses.focused}, &.${autocompleteClasses.focused}[aria-selected="true"]`]:
{
backgroundColor: theme.palette.action.hover,
},
},
},
[`&.${autocompleteClasses.popperDisablePortal}`]: {
position: 'relative',
},
}))
const PopperComponent = ({ disablePortal, anchorEl, open, ...other }) => (
<StyledAutocompletePopper {...other} />
)
PopperComponent.propTypes = {
anchorEl: PropTypes.any,
disablePortal: PropTypes.bool,
open: PropTypes.bool,
}
/**
* AutoComplete to filter rows by label.
*
* @param {object} props - Component props
* @param {string[]} props.currentValue - The current value of the filter
* @param {function(SyntheticEvent, AutocompleteChangeReason, AutocompleteChangeDetails)} props.handleChange - Handle change event
* @param {function(SyntheticEvent, AutocompleteCloseReason)} props.handleClose - Handle close event
* @param {string[]} props.labels - The list of labels to filter
* @param {string[]} props.filters - The current filters
* @returns {ReactElement} Filter component
*/
const FilterByLabel = ({
currentValue = [],
filters = [],
labels = [],
handleChange,
handleClose,
}) => (
<Autocomplete
open
multiple
onClose={handleClose}
value={currentValue}
onChange={handleChange}
disableCloseOnSelect
PopperComponent={PopperComponent}
renderTags={() => null}
noOptionsText={<Translate word={T.NoLabels} />}
renderOption={(props, option, { selected }) => (
<Box component="li" gap="0.5em" {...props}>
<CheckIcon style={{ visibility: selected ? 'visible' : 'hidden' }} />
<StatusCircle color={getColorFromString(option)} size={18} />
<Typography noWrap variant="body2" sx={{ flexGrow: 1 }}>
{option}
</Typography>
<CancelIcon style={{ visibility: selected ? 'visible' : 'hidden' }} />
</Box>
)}
isOptionEqualToValue={(option, value) =>
Array.isArray(value) ? value.includes(option) : value === option
}
options={[...labels].sort((a, b) => {
// Display the selected labels first.
let ai = filters.indexOf(a)
ai = ai === -1 ? filters.length + labels.indexOf(a) : ai
let bi = filters.indexOf(b)
bi = bi === -1 ? filters.length + labels.indexOf(b) : bi
return ai - bi
})}
renderInput={(params) => (
<StyledInput
ref={params.InputProps.ref}
inputProps={params.inputProps}
autoFocus
placeholder={Tr(T.FilterLabels)}
/>
)}
/>
)
FilterByLabel.propTypes = {
currentValue: PropTypes.array,
filters: PropTypes.array,
labels: PropTypes.array,
handleChange: PropTypes.func,
handleClose: PropTypes.func,
}
FilterByLabel.displayName = 'FilterByLabel'
export default FilterByLabel

View File

@ -0,0 +1,121 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement, useState, memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import SettingsIcon from 'iconoir-react/dist/Settings'
import { Stack } from '@mui/material'
import { UseFiltersInstanceProps } from 'react-table'
import FilterByLabel from 'client/components/Tables/Enhanced/Utils/GlobalLabel/Filter'
import HeaderPopover from 'client/components/Header/Popover'
import { Translate } from 'client/components/HOC'
import { T } from 'client/constants'
export const LABEL_COLUMN_ID = 'label'
const getLabels = (rows) =>
rows
?.map((row) => row.values[LABEL_COLUMN_ID]?.split(','))
.filter(Boolean)
.flat()
.sort((a, b) => a.localeCompare(b))
/**
* Button to filter rows by label or assign labels to selected rows.
*
* @returns {ReactElement} Button component
*/
const GlobalLabel = memo(
(tableProps) => {
const [pendingValue, setPendingValue] = useState([])
/** @type {UseFiltersInstanceProps} */
const { setFilter, preFilteredRows, state } = tableProps
const labels = useMemo(
() => [...new Set(getLabels(preFilteredRows))],
[preFilteredRows]
)
const filters = useMemo(
() =>
state.filters
.filter(({ id }) => id === LABEL_COLUMN_ID)
.map(({ value }) => value),
[state.filters]
)
if (labels.length === 0) {
return null
}
return (
<Stack direction="row" gap="0.5em" flexWrap="wrap">
<HeaderPopover
id="filter-by-label"
icon={<SettingsIcon />}
headerTitle={<Translate word={T.FilterByLabel} />}
buttonLabel={<Translate word={T.Label} />}
buttonProps={{
'data-cy': 'filter-by-label',
disableElevation: true,
variant: filters?.length > 0 ? 'contained' : 'outlined',
color: 'secondary',
disabled: preFilteredRows?.length === 0,
onClick: () => setPendingValue(filters),
}}
popperProps={{ placement: 'bottom-end' }}
onClickAway={() => setFilter(LABEL_COLUMN_ID, pendingValue)}
>
{({ handleClose }) => (
<FilterByLabel
currentValue={pendingValue}
labels={labels}
filters={filters}
handleChange={(event, newValue, reason) => {
if (
event.type === 'keydown' &&
event.key === 'Backspace' &&
reason === 'removeOption'
) {
return
}
setPendingValue(newValue)
}}
handleClose={(event, reason) => {
reason === 'escape' && handleClose()
}}
/>
)}
</HeaderPopover>
</Stack>
)
},
(next, prev) =>
next.preFilteredRows === prev.preFilteredRows &&
next.state.filters === prev.state.filters
)
GlobalLabel.propTypes = {
preFilteredRows: PropTypes.array,
state: PropTypes.object,
}
GlobalLabel.displayName = 'GlobalLabel'
export default GlobalLabel

View File

@ -13,16 +13,12 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useEffect, useMemo, ReactElement } from 'react'
import { ReactElement, useEffect, useMemo, memo } from 'react'
import PropTypes from 'prop-types'
import { SortDown, ArrowDown, ArrowUp } from 'iconoir-react'
import { MenuItem, MenuList, Stack } from '@mui/material'
import {
UseTableInstanceProps,
UseSortByInstanceProps,
UseSortByState,
} from 'react-table'
import { UseSortByInstanceProps, UseSortByState } from 'react-table'
import HeaderPopover from 'client/components/Header/Popover'
import { Translate } from 'client/components/HOC'
@ -31,74 +27,78 @@ import { T } from 'client/constants'
/**
* Render all selected sorters.
*
* @param {object} props - Props
* @param {string} [props.className] - Class name for the container
* @param {UseTableInstanceProps} props.useTableProps - Table props
* @returns {ReactElement} Component JSX
*/
const GlobalSort = ({ className, useTableProps }) => {
const { headers, state } = useTableProps
const GlobalSort = memo(
(useTableProps) => {
/** @type {UseSortByInstanceProps} */
const { headers, state } = useTableProps
/** @type {UseSortByInstanceProps} */
const { setSortBy } = useTableProps
/** @type {UseSortByInstanceProps} */
const { setSortBy } = useTableProps
/** @type {UseSortByState} */
const { sortBy } = state
/** @type {UseSortByState} */
const { sortBy } = state
const sorters = useMemo(
() =>
headers
.filter((header) => header.canSort && header.isVisible)
.map((header) => {
const sorter = sortBy.find((s) => s.id === header.id)
const sorters = useMemo(
() =>
headers
.filter((header) => header.canSort && header.isVisible)
.map((header) => {
const sorter = sortBy.find((s) => s.id === header.id)
return { ...header, ...sorter }
}),
[headers.length, sortBy?.[0]?.id, sortBy?.[0]?.desc]
)
return { ...header, ...sorter }
}),
[headers.length, sortBy?.[0]?.id, sortBy?.[0]?.desc]
)
const handleClick = (id, name, prevDesc = true) => {
setSortBy([{ id, desc: !prevDesc, name }])
}
const handleClick = (id, name, prevDesc = true) => {
setSortBy([{ id, desc: !prevDesc, name }])
}
useEffect(() => () => setSortBy([]), [])
useEffect(() => () => setSortBy([]), [])
if (sorters.length === 0) {
return null
}
if (sorters.length === 0) {
return null
}
return (
<Stack className={className} direction="row" gap="0.5em" flexWrap="wrap">
<HeaderPopover
id="sort-by-button"
icon={<SortDown />}
buttonLabel={T.SortBy}
buttonProps={{
'data-cy': 'sort-by-button',
disableElevation: true,
variant: sortBy?.length > 0 ? 'contained' : 'outlined',
color: 'secondary',
}}
popperProps={{ placement: 'bottom-end' }}
>
{() => (
<MenuList>
{sorters?.map(({ id, Header: name, desc }) => (
<MenuItem key={id} onClick={() => handleClick(id, name, desc)}>
{desc !== undefined && (desc ? <ArrowUp /> : <ArrowDown />)}
<Translate word={name} />
</MenuItem>
))}
</MenuList>
)}
</HeaderPopover>
</Stack>
)
}
return (
<Stack direction="row" gap="0.5em" flexWrap="wrap">
<HeaderPopover
id="sort-by-button"
icon={<SortDown />}
headerTitle={T.SortBy}
buttonLabel={T.Sort}
buttonProps={{
'data-cy': 'sort-by-button',
disableElevation: true,
variant: sortBy?.length > 0 ? 'contained' : 'outlined',
color: 'secondary',
}}
popperProps={{ placement: 'bottom-end' }}
>
{() => (
<MenuList>
{sorters?.map(({ id, Header: name, desc }) => (
<MenuItem key={id} onClick={() => handleClick(id, name, desc)}>
{desc !== undefined && (desc ? <ArrowUp /> : <ArrowDown />)}
<Translate word={name} />
</MenuItem>
))}
</MenuList>
)}
</HeaderPopover>
</Stack>
)
},
(next, prev) =>
next.headers?.length === prev.headers?.length &&
next.state?.sortBy === prev.state?.sortBy
)
GlobalSort.propTypes = {
className: PropTypes.string,
useTableProps: PropTypes.object.isRequired,
preFilteredRows: PropTypes.array,
state: PropTypes.object,
}
GlobalSort.displayName = 'GlobalSort'

View File

@ -15,6 +15,9 @@
* ------------------------------------------------------------------------- */
import CategoryFilter from 'client/components/Tables/Enhanced/Utils/CategoryFilter'
import GlobalActions from 'client/components/Tables/Enhanced/Utils/GlobalActions'
import GlobalLabel, {
LABEL_COLUMN_ID,
} from 'client/components/Tables/Enhanced/Utils/GlobalLabel'
import GlobalFilter from 'client/components/Tables/Enhanced/Utils/GlobalFilter'
import GlobalSearch from 'client/components/Tables/Enhanced/Utils/GlobalSearch'
import GlobalSelectedRows from 'client/components/Tables/Enhanced/Utils/GlobalSelectedRows'
@ -25,11 +28,15 @@ export * from 'client/components/Tables/Enhanced/Utils/GlobalActions/Action'
export * from 'client/components/Tables/Enhanced/Utils/utils'
export {
// Components
CategoryFilter,
GlobalActions,
GlobalLabel,
GlobalFilter,
GlobalSearch,
GlobalSelectedRows,
GlobalSort,
TimeFilter,
// Constants
LABEL_COLUMN_ID,
}

View File

@ -33,11 +33,11 @@ export const createColumns = ({ filters = {}, columns = [] }) => {
if (Object.keys(filters).length === 0) return columns
return columns.map((column) => {
const { id = '', accessor, noFilterIds = [] } = column
const { id = '', accessor } = column
// noFilterIds is a list of column ids that should not have a filter
// it's defined in the resource columns definition
if (noFilterIds.includes(id)) return column
if (columns.noFilterIds?.includes(id)) return column
const filterById = !!filters[String(id.toLowerCase())]

View File

@ -32,14 +32,16 @@ import {
UseRowSelectRowProps,
} from 'react-table'
import Pagination from 'client/components/Tables/Enhanced/pagination'
import {
GlobalActions,
GlobalSearch,
GlobalFilter,
GlobalLabel,
GlobalSort,
GlobalSelectedRows,
LABEL_COLUMN_ID,
} from 'client/components/Tables/Enhanced/Utils'
import Pagination from 'client/components/Tables/Enhanced/pagination'
import EnhancedTableStyles from 'client/components/Tables/Enhanced/styles'
import { Translate } from 'client/components/HOC'
@ -57,6 +59,7 @@ const EnhancedTable = ({
isLoading,
displaySelectedRows,
disableRowSelect,
disableGlobalLabel,
disableGlobalSort,
onSelectedRowsChange,
pageSize = 10,
@ -125,11 +128,12 @@ const EnhancedTable = ({
page,
gotoPage,
pageCount,
state: { pageIndex, selectedRowIds, ...state },
setFilter,
state,
} = useTableProps
const gotoRowPage = async (row) => {
const pageIdx = Math.floor(row.index / state.pageSize)
const pageIdx = Math.floor(row.index / pageSize)
await gotoPage(pageIdx)
@ -141,11 +145,11 @@ const EnhancedTable = ({
useMountedLayoutEffect(() => {
const selectedRows = preFilteredRows
.filter((row) => !!selectedRowIds[row.id])
.filter((row) => !!state.selectedRowIds[row.id])
.map((row) => ({ ...row, gotoPage: () => gotoRowPage(row) }))
onSelectedRowsChange?.(selectedRows)
}, [selectedRowIds])
}, [state.selectedRowIds])
const handleChangePage = (newPage) => {
gotoPage(newPage)
@ -153,7 +157,7 @@ const EnhancedTable = ({
const canNextPage =
pageCount === -1 ? page.length >= pageSize : newPage < pageCount - 1
newPage > pageIndex && !canNextPage && fetchMore?.()
newPage > state.pageIndex && !canNextPage && fetchMore?.()
}
return (
@ -192,8 +196,9 @@ const EnhancedTable = ({
{/* FILTERS */}
<div className={styles.filters}>
<GlobalFilter useTableProps={useTableProps} />
{!disableGlobalSort && <GlobalSort useTableProps={useTableProps} />}
{!disableGlobalLabel && <GlobalLabel {...useTableProps} />}
<GlobalFilter {...useTableProps} />
{!disableGlobalSort && <GlobalSort {...useTableProps} />}
</div>
{/* SELECTED ROWS */}
@ -240,6 +245,15 @@ const EnhancedTable = ({
original={original}
value={values}
className={isSelected ? 'selected' : ''}
onClickLabel={(label) => {
const currentFilter =
state.filters
?.filter(({ id }) => id === LABEL_COLUMN_ID)
?.map(({ value }) => value) || []
const nextFilter = [...new Set([...currentFilter, label])]
setFilter(LABEL_COLUMN_ID, nextFilter)
}}
onClick={() => {
typeof onRowClick === 'function' && onRowClick(original)
@ -277,6 +291,7 @@ EnhancedTable.propTypes = {
}),
refetch: PropTypes.func,
isLoading: PropTypes.bool,
disableGlobalLabel: PropTypes.bool,
disableGlobalSort: PropTypes.bool,
disableRowSelect: PropTypes.bool,
displaySelectedRows: PropTypes.bool,

View File

@ -27,11 +27,17 @@ const COLUMNS = [
{ Header: T.State, id: 'state', accessor: (row) => getState(row)?.name },
{ Header: T.Type, id: 'type', accessor: getType },
{ Header: T.Size, id: 'size', accessor: 'SIZE' },
{
Header: T.Label,
id: 'label',
accessor: 'TEMPLATE.LABELS',
filter: 'includesSome',
},
{ Header: T.RegistrationTime, id: 'time', accessor: 'REGTIME' },
{ Header: T.Marketplace, id: 'marketplace', accessor: 'MARKETPLACE' },
{ Header: T.Zone, id: 'zone', accessor: 'ZONE_ID' },
]
COLUMNS.noFilterIds = ['id', 'name', 'time', 'size']
COLUMNS.noFilterIds = ['id', 'name', 'time', 'size', 'label']
export default COLUMNS

View File

@ -13,21 +13,46 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { memo } from 'react'
import { memo, useMemo, useCallback } from 'react'
import PropTypes from 'prop-types'
import api from 'client/features/OneApi/marketplaceApp'
import api, {
useUpdateAppMutation,
} from 'client/features/OneApi/marketplaceApp'
import { MarketplaceAppCard } from 'client/components/Cards'
import { jsonToXml } from 'client/models/Helper'
const Row = memo(
({ original, value, ...props }) => {
({ original, value, onClickLabel, ...props }) => {
const [update] = useUpdateAppMutation()
const state = api.endpoints.getMarketplaceApps.useQueryState(undefined, {
selectFromResult: ({ data = [] }) =>
data.find((app) => +app.ID === +original.ID),
})
return <MarketplaceAppCard app={state ?? original} rootProps={props} />
const memoApp = useMemo(() => state ?? original, [state, original])
const handleDeleteLabel = useCallback(
(label) => {
const currentLabels = memoApp.TEMPLATE?.LABELS?.split(',')
const newLabels = currentLabels.filter((l) => l !== label).join(',')
const newUserTemplate = { ...memoApp.TEMPLATE, LABELS: newLabels }
const templateXml = jsonToXml(newUserTemplate)
update({ id: original.ID, template: templateXml, replace: 0 })
},
[memoApp.TEMPLATE?.LABELS, update]
)
return (
<MarketplaceAppCard
app={memoApp}
rootProps={props}
onClickLabel={onClickLabel}
onDeleteLabel={handleDeleteLabel}
/>
)
},
(prev, next) => prev.className === next.className
)
@ -36,7 +61,9 @@ Row.propTypes = {
original: PropTypes.object,
value: PropTypes.object,
isSelected: PropTypes.bool,
handleClick: PropTypes.func,
className: PropTypes.string,
onClick: PropTypes.func,
onClickLabel: PropTypes.func,
}
Row.displayName = 'MarketplaceAppRow'

View File

@ -23,7 +23,7 @@ import { VmTemplateCard } from 'client/components/Cards'
import { jsonToXml } from 'client/models/Helper'
const Row = memo(
({ original, value, ...props }) => {
({ original, value, onClickLabel, ...props }) => {
const [update] = useUpdateTemplateMutation()
const state = vmTemplateApi.endpoints.getTemplates.useQueryState(
@ -52,6 +52,7 @@ const Row = memo(
<VmTemplateCard
template={memoTemplate}
rootProps={props}
onClickLabel={onClickLabel}
onDeleteLabel={handleDeleteLabel}
/>
)
@ -64,7 +65,8 @@ Row.propTypes = {
value: PropTypes.object,
isSelected: PropTypes.bool,
className: PropTypes.string,
handleClick: PropTypes.func,
onClick: PropTypes.func,
onClickLabel: PropTypes.func,
}
Row.displayName = 'VmTemplateRow'

View File

@ -36,6 +36,12 @@ const COLUMNS = [
{ Header: T.Group, id: 'group', accessor: 'GNAME' },
{ Header: T.StartTime, id: 'time', accessor: 'STIME' },
{ Header: T.Locked, id: 'locked', accessor: 'LOCK' },
{
Header: T.Label,
id: 'label',
accessor: 'USER_TEMPLATE.LABELS',
filter: 'includesSome',
},
{ Header: T.Type, id: 'type', accessor: getType },
{
Header: T.IP,
@ -50,6 +56,6 @@ const COLUMNS = [
},
]
COLUMNS.noFilterIds = ['id', 'name', 'ips', 'time']
COLUMNS.noFilterIds = ['id', 'name', 'ips', 'time', 'label']
export default COLUMNS

View File

@ -26,7 +26,7 @@ const { VNC, RDP, SSH, VMRC } = VM_ACTIONS
const CONNECTION_TYPES = [VNC, RDP, SSH, VMRC]
const Row = memo(
({ original, value, ...props }) => {
({ original, value, onClickLabel, ...props }) => {
const [update] = useUpdateUserTemplateMutation()
const state = vmApi.endpoints.getVms.useQueryState(undefined, {
@ -52,6 +52,7 @@ const Row = memo(
<VirtualMachineCard
vm={memoVm}
rootProps={props}
onClickLabel={onClickLabel}
onDeleteLabel={handleDeleteLabel}
actions={
<>
@ -75,7 +76,8 @@ Row.propTypes = {
value: PropTypes.object,
isSelected: PropTypes.bool,
className: PropTypes.string,
handleClick: PropTypes.func,
onClick: PropTypes.func,
onClickLabel: PropTypes.func,
}
Row.displayName = 'VirtualMachineRow'

View File

@ -18,10 +18,15 @@ module.exports = {
Back: 'Back',
Previous: 'Previous',
Next: 'Next',
Sort: 'Sort',
SortBy: 'Sort by',
FilterBy: 'Filter by',
Filter: 'Filter',
Filters: 'Filters',
FilterBy: 'Filter by',
FilterLabels: 'Filter labels',
FilterByLabel: 'Filter by label',
Label: 'Label',
NoLabels: 'NoLabels',
All: 'All',
On: 'On',
ToggleAllCurrentPageRowsSelected: 'Toggle all current page rows selected',