mirror of
https://github.com/OpenNebula/one.git
synced 2025-03-16 22:50:10 +03:00
parent
3d764ba465
commit
17abe9b749
@ -16,7 +16,7 @@
|
||||
import { ReactElement, Fragment, memo, useMemo } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import { Stack, Button } from '@mui/material'
|
||||
import { Stack } from '@mui/material'
|
||||
import { Filter } from 'iconoir-react'
|
||||
import { UseFiltersInstanceProps, UseFiltersState } from 'react-table'
|
||||
|
||||
@ -33,7 +33,7 @@ import { T } from 'client/constants'
|
||||
const GlobalFilter = memo(
|
||||
(tableProps) => {
|
||||
/** @type {UseFiltersInstanceProps} */
|
||||
const { rows, columns, setAllFilters, state } = tableProps
|
||||
const { rows, columns, state } = tableProps
|
||||
|
||||
/** @type {UseFiltersState} */
|
||||
const { filters } = state
|
||||
@ -57,8 +57,8 @@ const GlobalFilter = memo(
|
||||
<HeaderPopover
|
||||
id="filter-by-button"
|
||||
icon={<Filter />}
|
||||
headerTitle={T.FilterBy}
|
||||
buttonLabel={T.Filter}
|
||||
headerTitle={<Translate word={T.FilterBy} />}
|
||||
buttonLabel={<Translate word={T.Filter} />}
|
||||
buttonProps={{
|
||||
'data-cy': 'filter-by-button',
|
||||
disableElevation: true,
|
||||
@ -69,18 +69,10 @@ const GlobalFilter = memo(
|
||||
popperProps={{ placement: 'bottom-end' }}
|
||||
>
|
||||
{() => (
|
||||
<Stack sx={{ width: { xs: '100%', md: 500 }, p: 2 }}>
|
||||
<Stack sx={{ width: { xs: '100%', md: 500 } }}>
|
||||
{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>
|
||||
|
@ -0,0 +1,159 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* 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, useCallback } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import CheckIcon from 'iconoir-react/dist/Check'
|
||||
import MinusIcon from 'iconoir-react/dist/Minus'
|
||||
import { styled, debounce, Box, Typography, Autocomplete } from '@mui/material'
|
||||
|
||||
import {
|
||||
PopperComponent,
|
||||
StyledInput,
|
||||
} from 'client/components/Tables/Enhanced/Utils/GlobalLabel/styles'
|
||||
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 EmptyIcon = styled((props) => <Box component="span" {...props} />)({
|
||||
width: 20,
|
||||
height: 20,
|
||||
})
|
||||
|
||||
const Label = ({ label, indeterminate, selected, ...props }) => (
|
||||
<Box component="li" gap="0.5em" {...props}>
|
||||
{selected ? <CheckIcon /> : indeterminate ? <MinusIcon /> : <EmptyIcon />}
|
||||
<StatusCircle color={getColorFromString(label)} size={18} />
|
||||
<Typography noWrap variant="body2" sx={{ flexGrow: 1 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
|
||||
Label.propTypes = {
|
||||
label: PropTypes.any,
|
||||
indeterminate: PropTypes.bool,
|
||||
selected: PropTypes.bool,
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocates labels to the selected rows.
|
||||
*
|
||||
* @param {object} props - Component props
|
||||
* @param {string[]} props.labels - The list of available labels
|
||||
* @param {string<string[]>} props.selectedLabels - The list of selected labels
|
||||
* @param {string[]} props.pendingValue - The current value of the filter
|
||||
* @param {function(any)} props.handleChange - Handle change event
|
||||
* @param {function()} props.handleClose - Handle close event
|
||||
* @returns {ReactElement} Allocator component
|
||||
*/
|
||||
const LabelAllocator = ({
|
||||
labels,
|
||||
selectedLabels,
|
||||
pendingValue,
|
||||
handleChange,
|
||||
handleClose,
|
||||
}) => {
|
||||
const getLabelProps = useCallback(
|
||||
(label) => {
|
||||
const labelProps = { label }
|
||||
|
||||
// labels that exists on every row
|
||||
if (pendingValue[0]?.includes(label))
|
||||
return { ...labelProps, selected: true }
|
||||
|
||||
// labels to remove from every row
|
||||
if (pendingValue[1]?.includes(label)) {
|
||||
return { ...labelProps, selected: false }
|
||||
}
|
||||
|
||||
return selectedLabels.reduce((res, rowLabels) => {
|
||||
const hasLabel = rowLabels.includes(label)
|
||||
const prevSelected = [true, undefined].includes(res.selected)
|
||||
|
||||
hasLabel
|
||||
? (res.indeterminate = !prevSelected)
|
||||
: (res.indeterminate ||= res.selected)
|
||||
|
||||
return { ...res, selected: hasLabel && prevSelected }
|
||||
}, labelProps)
|
||||
},
|
||||
[pendingValue, selectedLabels]
|
||||
)
|
||||
|
||||
const handleLabelChange = useCallback(
|
||||
debounce((event, newValue, reason) => {
|
||||
const changeFn = {
|
||||
selectOption: ([, toRemove = []] = []) => [
|
||||
newValue,
|
||||
toRemove?.filter((label) => !newValue.includes(label)),
|
||||
],
|
||||
removeOption: ([toAdd = [], toRemove = []] = []) => {
|
||||
const prevToAdd = toAdd?.filter((label) => !newValue.includes(label))
|
||||
const newToRemove = [...toRemove, ...prevToAdd]
|
||||
|
||||
return [newValue, [...new Set(newToRemove)]]
|
||||
},
|
||||
}[reason]
|
||||
|
||||
changeFn && handleChange(changeFn)
|
||||
}, 200),
|
||||
[handleChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
open
|
||||
multiple
|
||||
value={pendingValue[0]}
|
||||
onClose={(_, reason) => reason === 'escape' && handleClose()}
|
||||
onChange={handleLabelChange}
|
||||
disableCloseOnSelect
|
||||
PopperComponent={PopperComponent}
|
||||
renderTags={() => null}
|
||||
noOptionsText={<Translate word={T.NoLabels} />}
|
||||
renderOption={(props, label) => (
|
||||
<Label key={label} {...props} {...getLabelProps(label)} />
|
||||
)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
Array.isArray(value) ? value.includes(option) : value === option
|
||||
}
|
||||
options={labels}
|
||||
renderInput={(params) => (
|
||||
<StyledInput
|
||||
ref={params.InputProps.ref}
|
||||
inputProps={params.inputProps}
|
||||
autoFocus
|
||||
placeholder={Tr(T.Search)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
LabelAllocator.propTypes = {
|
||||
labels: PropTypes.array,
|
||||
selectedLabels: PropTypes.array,
|
||||
indeterminateLabels: PropTypes.array,
|
||||
pendingValue: PropTypes.array,
|
||||
handleChange: PropTypes.func,
|
||||
handleClose: PropTypes.func,
|
||||
}
|
||||
|
||||
LabelAllocator.displayName = 'LabelAllocator'
|
||||
|
||||
export default LabelAllocator
|
@ -13,144 +13,137 @@
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { ReactElement, SyntheticEvent } from 'react'
|
||||
import { memo, ReactElement, MouseEvent } 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 LockIcon from 'iconoir-react/dist/Lock'
|
||||
import { Box, Typography, Autocomplete } from '@mui/material'
|
||||
|
||||
import { useAddLabelMutation } from 'client/features/OneApi/auth'
|
||||
|
||||
import {
|
||||
PopperComponent,
|
||||
StyledInput,
|
||||
} from 'client/components/Tables/Enhanced/Utils/GlobalLabel/styles'
|
||||
import { SubmitButton } from 'client/components/FormControl'
|
||||
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 Label = memo(({ label, selected, unknown, ...props }) => {
|
||||
const [addLabel, { isLoading }] = useAddLabelMutation()
|
||||
|
||||
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',
|
||||
},
|
||||
}))
|
||||
/**
|
||||
* Adds the label to user labels.
|
||||
*
|
||||
* @param {MouseEvent<HTMLLIElement, MouseEvent>} evt - The click event
|
||||
*/
|
||||
const handleLockLabel = async (evt) => {
|
||||
evt.stopPropagation()
|
||||
await addLabel({ newLabel: label }).unwrap()
|
||||
}
|
||||
|
||||
const PopperComponent = ({ disablePortal, anchorEl, open, ...other }) => (
|
||||
<StyledAutocompletePopper {...other} />
|
||||
)
|
||||
return (
|
||||
<Box component="li" gap="0.5em" {...props}>
|
||||
<CheckIcon
|
||||
style={{
|
||||
minWidth: 'fit-content',
|
||||
visibility: selected ? 'visible' : 'hidden',
|
||||
}}
|
||||
/>
|
||||
<StatusCircle color={getColorFromString(label)} size={18} />
|
||||
<Typography noWrap variant="body2" sx={{ flexGrow: 1 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<SubmitButton
|
||||
onClick={unknown ? handleLockLabel : undefined}
|
||||
isSubmitting={isLoading}
|
||||
title={Tr(T.SavesInTheUserTemplate)}
|
||||
icon={<LockIcon />}
|
||||
sx={{ p: 0, visibility: unknown ? 'visible' : 'hidden' }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
PopperComponent.propTypes = {
|
||||
anchorEl: PropTypes.any,
|
||||
disablePortal: PropTypes.bool,
|
||||
open: PropTypes.bool,
|
||||
Label.propTypes = {
|
||||
label: PropTypes.any,
|
||||
selected: PropTypes.bool,
|
||||
unknown: PropTypes.bool,
|
||||
}
|
||||
|
||||
Label.displayName = 'Label'
|
||||
|
||||
/**
|
||||
* 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 {function(any)} props.handleChange - Handle change event
|
||||
* @param {string[]} props.pendingValue - The current value of the filter
|
||||
* @param {function()} props.handleClose - Handle close event
|
||||
* @param {string[]} props.labels - The list of labels to filter
|
||||
* @param {string[]} props.filters - The current filters
|
||||
* @param {string[]} props.unknownLabels - The list of labels not in the user labels
|
||||
* @returns {ReactElement} Filter component
|
||||
*/
|
||||
const FilterByLabel = ({
|
||||
currentValue = [],
|
||||
filters = [],
|
||||
labels = [],
|
||||
unknownLabels = [],
|
||||
pendingValue = [],
|
||||
handleChange,
|
||||
handleClose,
|
||||
}) => (
|
||||
<Autocomplete
|
||||
open
|
||||
multiple
|
||||
onClose={handleClose}
|
||||
value={currentValue}
|
||||
onChange={handleChange}
|
||||
value={pendingValue}
|
||||
onClose={(event, reason) => {
|
||||
reason === 'escape' && handleClose()
|
||||
}}
|
||||
onChange={(event, newValue, reason) => {
|
||||
if (
|
||||
event.type === 'keydown' &&
|
||||
event.key === 'Backspace' &&
|
||||
reason === 'removeOption'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
handleChange(newValue)
|
||||
}}
|
||||
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>
|
||||
<Label
|
||||
{...props}
|
||||
key={option}
|
||||
label={option}
|
||||
selected={selected}
|
||||
unknown={unknownLabels.includes(option)}
|
||||
/>
|
||||
)}
|
||||
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
|
||||
})}
|
||||
options={labels}
|
||||
renderInput={(params) => (
|
||||
<StyledInput
|
||||
ref={params.InputProps.ref}
|
||||
inputProps={params.inputProps}
|
||||
autoFocus
|
||||
placeholder={Tr(T.FilterLabels)}
|
||||
placeholder={Tr(T.Search)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
FilterByLabel.propTypes = {
|
||||
currentValue: PropTypes.array,
|
||||
filters: PropTypes.array,
|
||||
labels: PropTypes.array,
|
||||
unknownLabels: PropTypes.array,
|
||||
pendingValue: PropTypes.array,
|
||||
handleChange: PropTypes.func,
|
||||
handleClose: PropTypes.func,
|
||||
}
|
||||
|
@ -13,107 +13,191 @@
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { ReactElement, useState, memo, useMemo } from 'react'
|
||||
import { ReactElement, useState, useMemo, useCallback } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import SettingsIcon from 'iconoir-react/dist/Settings'
|
||||
import SettingsIcon from 'iconoir-react/dist/LabelOutline'
|
||||
import { Stack } from '@mui/material'
|
||||
import { UseFiltersInstanceProps } from 'react-table'
|
||||
|
||||
import { useAuth } from 'client/features/Auth'
|
||||
|
||||
import Allocator from 'client/components/Tables/Enhanced/Utils/GlobalLabel/Allocator'
|
||||
import FilterByLabel from 'client/components/Tables/Enhanced/Utils/GlobalLabel/Filter'
|
||||
import HeaderPopover from 'client/components/Header/Popover'
|
||||
import { areStringEqual, jsonToXml } from 'client/models/Helper'
|
||||
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(','))
|
||||
const toUpperCase = (label) => label?.trim()?.toUpperCase()
|
||||
|
||||
const getLabelFromRows = (rows, flatting = true) => {
|
||||
const labels = rows
|
||||
?.map((row) => row.values[LABEL_COLUMN_ID]?.split(',') ?? [])
|
||||
.filter(Boolean)
|
||||
.flat()
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
|
||||
return flatting
|
||||
? labels.flat().map(toUpperCase)
|
||||
: labels.map((label) => [label].flat().filter(Boolean).map(toUpperCase))
|
||||
}
|
||||
|
||||
const sortByFilteredFirst = (labels, filters) =>
|
||||
labels.sort((a, b) => {
|
||||
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
|
||||
})
|
||||
|
||||
/**
|
||||
* Button to filter rows by label or assign labels to selected rows.
|
||||
*
|
||||
* @param {UseFiltersInstanceProps} props - Component props
|
||||
* @param {object} props.selectedRows - Selected rows
|
||||
* @param {Function} props.useUpdateMutation - Callback to update row labels
|
||||
* @returns {ReactElement} Button component
|
||||
*/
|
||||
const GlobalLabel = memo(
|
||||
(tableProps) => {
|
||||
const [pendingValue, setPendingValue] = useState([])
|
||||
const GlobalLabel = ({
|
||||
selectedRows = [],
|
||||
useUpdateMutation,
|
||||
...tableProps
|
||||
}) => {
|
||||
const { setFilter, page, state } = tableProps
|
||||
const [update, { isLoading } = {}] = useUpdateMutation?.() || []
|
||||
|
||||
/** @type {UseFiltersInstanceProps} */
|
||||
const { setFilter, preFilteredRows, state } = tableProps
|
||||
const [pendingValue, setPendingValue] = useState(() => [])
|
||||
const { labels: userLabels } = useAuth()
|
||||
|
||||
const labels = useMemo(
|
||||
() => [...new Set(getLabels(preFilteredRows))],
|
||||
[preFilteredRows]
|
||||
const enableEditLabel = useMemo(
|
||||
() => useUpdateMutation && selectedRows?.length > 0,
|
||||
[useUpdateMutation, selectedRows?.length]
|
||||
)
|
||||
|
||||
const unknownPageLabels = useMemo(
|
||||
() =>
|
||||
getLabelFromRows(page)
|
||||
.filter((label) => !userLabels.includes(label))
|
||||
.sort(areStringEqual),
|
||||
[page]
|
||||
)
|
||||
|
||||
const currentLabelFilters = useMemo(
|
||||
() =>
|
||||
state.filters
|
||||
.filter(({ id }) => id === LABEL_COLUMN_ID)
|
||||
.map(({ value }) => value)
|
||||
.flat(),
|
||||
[state.filters]
|
||||
)
|
||||
|
||||
const allFilterLabels = useMemo(() => {
|
||||
const all = [...userLabels, ...unknownPageLabels, ...currentLabelFilters]
|
||||
const unique = [...new Set(all)]
|
||||
|
||||
return sortByFilteredFirst(unique, currentLabelFilters)
|
||||
}, [userLabels, unknownPageLabels, currentLabelFilters])
|
||||
|
||||
const allocatorProps = useMemo(() => {
|
||||
if (!enableEditLabel) return {}
|
||||
|
||||
const selectedLabels = getLabelFromRows(selectedRows, false)
|
||||
const labels = sortByFilteredFirst(
|
||||
[...allFilterLabels],
|
||||
selectedLabels.flat()
|
||||
)
|
||||
|
||||
const filters = useMemo(
|
||||
() =>
|
||||
state.filters
|
||||
.filter(({ id }) => id === LABEL_COLUMN_ID)
|
||||
.map(({ value }) => value),
|
||||
[state.filters]
|
||||
return { selectedLabels, labels }
|
||||
}, [enableEditLabel, allFilterLabels, selectedRows])
|
||||
|
||||
/**
|
||||
* Handle event when user clicks on the label filter button
|
||||
*/
|
||||
const handleOpenPopover = useCallback(() => {
|
||||
if (!enableEditLabel) return setPendingValue(currentLabelFilters)
|
||||
|
||||
const { labels, selectedLabels } = allocatorProps
|
||||
const labelsInEveryRows = labels.filter((l) =>
|
||||
selectedLabels.every((selected) => selected.includes(l))
|
||||
)
|
||||
|
||||
if (labels.length === 0) {
|
||||
return null
|
||||
}
|
||||
// [labelsToAdd, labelsToRemove]
|
||||
setPendingValue([labelsInEveryRows, []])
|
||||
}, [enableEditLabel, currentLabelFilters, selectedRows])
|
||||
|
||||
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
|
||||
}
|
||||
/**
|
||||
* Handle event when user clicks outside of the popover
|
||||
*/
|
||||
const handleClickAwayPopover = useCallback(async () => {
|
||||
if (!enableEditLabel) return setFilter(LABEL_COLUMN_ID, pendingValue)
|
||||
|
||||
setPendingValue(newValue)
|
||||
}}
|
||||
handleClose={(event, reason) => {
|
||||
reason === 'escape' && handleClose()
|
||||
}}
|
||||
const [labelsToAdd, labelsToRemove] = pendingValue
|
||||
|
||||
await Promise.all(
|
||||
selectedRows.map(({ original: { ID, USER_TEMPLATE, TEMPLATE } }) => {
|
||||
const template = USER_TEMPLATE ?? TEMPLATE
|
||||
const currentLabels = template?.LABELS?.split(',') ?? []
|
||||
const newLabels = currentLabels
|
||||
.map((l) => l?.trim()?.toUpperCase())
|
||||
.filter((l) => labelsToRemove.indexOf(l) === -1)
|
||||
.concat(labelsToAdd)
|
||||
|
||||
const uniqueLabels = [...new Set(newLabels)].join(',')
|
||||
const templateXml = jsonToXml({ ...template, LABELS: uniqueLabels })
|
||||
|
||||
return update({ id: ID, template: templateXml, replace: 0 })
|
||||
})
|
||||
)
|
||||
}, [enableEditLabel, selectedRows, pendingValue, update])
|
||||
|
||||
return (
|
||||
<Stack direction="row" gap="0.5em" flexWrap="wrap">
|
||||
<HeaderPopover
|
||||
id="filter-by-label"
|
||||
icon={<SettingsIcon />}
|
||||
headerTitle={
|
||||
<Translate word={enableEditLabel ? T.ApplyLabels : T.FilterByLabel} />
|
||||
}
|
||||
buttonLabel={<Translate word={T.Label} />}
|
||||
buttonProps={{
|
||||
'data-cy': 'filter-by-label',
|
||||
variant: 'outlined',
|
||||
color: 'secondary',
|
||||
disabled: isLoading,
|
||||
onClick: handleOpenPopover,
|
||||
}}
|
||||
popperProps={{ placement: 'bottom-end' }}
|
||||
onClickAway={handleClickAwayPopover}
|
||||
>
|
||||
{({ handleClose }) =>
|
||||
enableEditLabel ? (
|
||||
<Allocator
|
||||
{...allocatorProps}
|
||||
pendingValue={pendingValue}
|
||||
handleChange={setPendingValue}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
</HeaderPopover>
|
||||
</Stack>
|
||||
)
|
||||
},
|
||||
(next, prev) =>
|
||||
next.preFilteredRows === prev.preFilteredRows &&
|
||||
next.state.filters === prev.state.filters
|
||||
)
|
||||
) : (
|
||||
<FilterByLabel
|
||||
labels={allFilterLabels}
|
||||
unknownLabels={unknownPageLabels}
|
||||
pendingValue={pendingValue}
|
||||
handleChange={setPendingValue}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</HeaderPopover>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
GlobalLabel.propTypes = {
|
||||
preFilteredRows: PropTypes.array,
|
||||
state: PropTypes.object,
|
||||
selectedRows: PropTypes.array,
|
||||
useUpdateMutation: PropTypes.func,
|
||||
}
|
||||
|
||||
GlobalLabel.displayName = 'GlobalLabel'
|
||||
|
@ -0,0 +1,85 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* 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 } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {
|
||||
styled,
|
||||
InputBase,
|
||||
PopperProps,
|
||||
autocompleteClasses,
|
||||
} from '@mui/material'
|
||||
|
||||
export 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]}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
export 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',
|
||||
},
|
||||
}))
|
||||
|
||||
/**
|
||||
* @param {PopperProps} props - The props for the Popper component
|
||||
* @returns {ReactElement} Popper
|
||||
*/
|
||||
export const PopperComponent = ({
|
||||
disablePortal,
|
||||
anchorEl,
|
||||
open,
|
||||
...other
|
||||
}) => <StyledAutocompletePopper {...other} />
|
||||
|
||||
PopperComponent.propTypes = {
|
||||
anchorEl: PropTypes.any,
|
||||
disablePortal: PropTypes.bool,
|
||||
open: PropTypes.bool,
|
||||
}
|
@ -13,7 +13,7 @@
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { JSXElementConstructor, useState, useCallback } from 'react'
|
||||
import { ReactElement, useState, useCallback } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import clsx from 'clsx'
|
||||
@ -68,7 +68,7 @@ const useStyles = makeStyles(({ spacing, palette, shape, breakpoints }) => ({
|
||||
* @param {string} [props.className] - Class name for the container
|
||||
* @param {object} props.searchProps - Props for search input
|
||||
* @param {UseGlobalFiltersInstanceProps} props.useTableProps - Table props
|
||||
* @returns {JSXElementConstructor} Component JSX
|
||||
* @returns {ReactElement} Component JSX
|
||||
*/
|
||||
const GlobalSearch = ({ className, useTableProps, searchProps }) => {
|
||||
const classes = useStyles()
|
||||
|
@ -18,7 +18,8 @@ import { useMemo } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import clsx from 'clsx'
|
||||
import { InfoEmpty } from 'iconoir-react'
|
||||
import InfoEmpty from 'iconoir-react/dist/InfoEmpty'
|
||||
import RemoveIcon from 'iconoir-react/dist/RemoveSquare'
|
||||
import { Box } from '@mui/material'
|
||||
import {
|
||||
useGlobalFilter,
|
||||
@ -57,6 +58,7 @@ const EnhancedTable = ({
|
||||
initialState,
|
||||
refetch,
|
||||
isLoading,
|
||||
useUpdateMutation,
|
||||
displaySelectedRows,
|
||||
disableRowSelect,
|
||||
disableGlobalLabel,
|
||||
@ -129,6 +131,9 @@ const EnhancedTable = ({
|
||||
gotoPage,
|
||||
pageCount,
|
||||
setFilter,
|
||||
setAllFilters,
|
||||
setSortBy,
|
||||
setGlobalFilter,
|
||||
state,
|
||||
} = useTableProps
|
||||
|
||||
@ -143,12 +148,15 @@ const EnhancedTable = ({
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
useMountedLayoutEffect(() => {
|
||||
const selectedRows = preFilteredRows
|
||||
.filter((row) => !!state.selectedRowIds[row.id])
|
||||
.map((row) => ({ ...row, gotoPage: () => gotoRowPage(row) }))
|
||||
const selectedRows = useMemo(
|
||||
() => preFilteredRows.filter((row) => !!state.selectedRowIds[row.id]),
|
||||
[data, state.selectedRowIds]
|
||||
)
|
||||
|
||||
onSelectedRowsChange?.(selectedRows)
|
||||
useMountedLayoutEffect(() => {
|
||||
onSelectedRowsChange?.(
|
||||
selectedRows.map((row) => ({ ...row, gotoPage: () => gotoRowPage(row) }))
|
||||
)
|
||||
}, [state.selectedRowIds])
|
||||
|
||||
const handleChangePage = (newPage) => {
|
||||
@ -160,6 +168,18 @@ const EnhancedTable = ({
|
||||
newPage > state.pageIndex && !canNextPage && fetchMore?.()
|
||||
}
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setGlobalFilter()
|
||||
setAllFilters([])
|
||||
setSortBy([])
|
||||
}
|
||||
|
||||
const cannotFilterByLabel = useMemo(
|
||||
() =>
|
||||
disableGlobalLabel || !columns.some((col) => col.id === LABEL_COLUMN_ID),
|
||||
[disableGlobalLabel]
|
||||
)
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...getTableProps()}
|
||||
@ -196,7 +216,13 @@ const EnhancedTable = ({
|
||||
|
||||
{/* FILTERS */}
|
||||
<div className={styles.filters}>
|
||||
{!disableGlobalLabel && <GlobalLabel {...useTableProps} />}
|
||||
{!cannotFilterByLabel && (
|
||||
<GlobalLabel
|
||||
{...useTableProps}
|
||||
selectedRows={selectedRows}
|
||||
useUpdateMutation={useUpdateMutation}
|
||||
/>
|
||||
)}
|
||||
<GlobalFilter {...useTableProps} />
|
||||
{!disableGlobalSort && <GlobalSort {...useTableProps} />}
|
||||
</div>
|
||||
@ -212,6 +238,20 @@ const EnhancedTable = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RESET FILTERS */}
|
||||
<Box
|
||||
visibility={
|
||||
state.filters?.length > 0 || state.sortBy?.length > 0
|
||||
? 'visible'
|
||||
: 'hidden'
|
||||
}
|
||||
>
|
||||
<span className={styles.resetFilters} onClick={handleResetFilters}>
|
||||
<RemoveIcon />
|
||||
<Translate word={T.ResetFilters} />
|
||||
</span>
|
||||
</Box>
|
||||
|
||||
<div className={clsx(styles.body, classes.body)}>
|
||||
{/* NO DATA MESSAGE */}
|
||||
{!isLoading &&
|
||||
@ -245,15 +285,18 @@ const EnhancedTable = ({
|
||||
original={original}
|
||||
value={values}
|
||||
className={isSelected ? 'selected' : ''}
|
||||
onClickLabel={(label) => {
|
||||
const currentFilter =
|
||||
state.filters
|
||||
?.filter(({ id }) => id === LABEL_COLUMN_ID)
|
||||
?.map(({ value }) => value) || []
|
||||
{...(!cannotFilterByLabel && {
|
||||
onClickLabel: (label) => {
|
||||
const currentFilter =
|
||||
state.filters
|
||||
?.filter(({ id }) => id === LABEL_COLUMN_ID)
|
||||
?.map(({ value }) => value)
|
||||
?.flat() || []
|
||||
|
||||
const nextFilter = [...new Set([...currentFilter, label])]
|
||||
setFilter(LABEL_COLUMN_ID, nextFilter)
|
||||
}}
|
||||
const nextFilter = [...new Set([...currentFilter, label])]
|
||||
setFilter(LABEL_COLUMN_ID, nextFilter)
|
||||
},
|
||||
})}
|
||||
onClick={() => {
|
||||
typeof onRowClick === 'function' && onRowClick(original)
|
||||
|
||||
@ -295,6 +338,7 @@ EnhancedTable.propTypes = {
|
||||
disableGlobalSort: PropTypes.bool,
|
||||
disableRowSelect: PropTypes.bool,
|
||||
displaySelectedRows: PropTypes.bool,
|
||||
useUpdateMutation: PropTypes.func,
|
||||
onSelectedRowsChange: PropTypes.func,
|
||||
onRowClick: PropTypes.func,
|
||||
pageSize: PropTypes.number,
|
||||
|
@ -24,7 +24,7 @@ export default makeStyles(({ palette, typography, breakpoints }) => ({
|
||||
},
|
||||
toolbar: {
|
||||
...typography.body1,
|
||||
marginBottom: 16,
|
||||
marginBottom: '1em',
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto auto',
|
||||
gridTemplateAreas: `
|
||||
@ -55,6 +55,16 @@ export default makeStyles(({ palette, typography, breakpoints }) => ({
|
||||
justifySelf: 'end',
|
||||
gap: '1em',
|
||||
},
|
||||
resetFilters: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5em',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '1em',
|
||||
'&:hover': {
|
||||
color: palette.secondary.dark,
|
||||
},
|
||||
},
|
||||
body: {
|
||||
overflow: 'auto',
|
||||
display: 'grid',
|
||||
|
@ -59,6 +59,7 @@ export const SCHEMES = Setting.SCHEMES
|
||||
export const DEFAULT_SCHEME = Setting.SCHEMES.SYSTEM
|
||||
|
||||
export const CURRENCY = SERVER_CONFIG?.currency ?? 'EUR'
|
||||
export const LOCALE = SERVER_CONFIG?.lang?.replace('_', '-') ?? undefined
|
||||
export const DEFAULT_LANGUAGE = SERVER_CONFIG?.default_lang ?? 'en'
|
||||
export const LANGUAGES_URL = `${STATIC_FILES_URL}/languages`
|
||||
export const LANGUAGES = SERVER_CONFIG.langs ?? {
|
||||
|
@ -34,6 +34,7 @@ module.exports = {
|
||||
NumberOfResourcesSelected: 'All %s resources are selected',
|
||||
SelectAllResources: 'Select all %s resources',
|
||||
ClearSelection: 'Clear selection',
|
||||
ResetFilters: 'Clear current search query, filters, and sorts',
|
||||
|
||||
/* actions */
|
||||
Accept: 'Accept',
|
||||
@ -301,6 +302,8 @@ module.exports = {
|
||||
Labels: 'Labels',
|
||||
NewLabelOrSearch: 'New label or search',
|
||||
LabelAlreadyExists: 'Label already exists',
|
||||
PressToCreateLabel: 'Press enter to create a new label',
|
||||
SavesInTheUserTemplate: "Saved in the User's template",
|
||||
|
||||
/* sections - system */
|
||||
User: 'User',
|
||||
|
@ -15,18 +15,24 @@
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { ReactElement, useState, memo } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { BookmarkEmpty } from 'iconoir-react'
|
||||
import { Typography, Box, Stack, Chip, IconButton } from '@mui/material'
|
||||
import GotoIcon from 'iconoir-react/dist/Pin'
|
||||
import RefreshDouble from 'iconoir-react/dist/RefreshDouble'
|
||||
import Cancel from 'iconoir-react/dist/Cancel'
|
||||
import { Typography, Box, Stack, Chip } from '@mui/material'
|
||||
import { Row } from 'react-table'
|
||||
|
||||
import hostApi from 'client/features/OneApi/host'
|
||||
import {
|
||||
useLazyGetHostQuery,
|
||||
useUpdateHostMutation,
|
||||
} from 'client/features/OneApi/host'
|
||||
import { HostsTable } from 'client/components/Tables'
|
||||
import HostTabs from 'client/components/Tabs/Host'
|
||||
import HostActions from 'client/components/Tables/Hosts/actions'
|
||||
import SplitPane from 'client/components/SplitPane'
|
||||
import MultipleTags from 'client/components/MultipleTags'
|
||||
import { SubmitButton } from 'client/components/FormControl'
|
||||
import { Tr } from 'client/components/HOC'
|
||||
import { T } from 'client/constants'
|
||||
import { T, Host } from 'client/constants'
|
||||
|
||||
/**
|
||||
* Displays a list of Hosts with a split pane between the list and selected row(s).
|
||||
@ -47,6 +53,7 @@ function Hosts() {
|
||||
<HostsTable
|
||||
onSelectedRowsChange={onSelectedRowsChange}
|
||||
globalActions={actions}
|
||||
useUpdateMutation={useUpdateHostMutation}
|
||||
/>
|
||||
|
||||
{hasSelectedRows && (
|
||||
@ -56,8 +63,9 @@ function Hosts() {
|
||||
<GroupedTags tags={selectedRows} />
|
||||
) : (
|
||||
<InfoTabs
|
||||
id={selectedRows[0]?.original?.ID}
|
||||
host={selectedRows[0]?.original}
|
||||
gotoPage={selectedRows[0]?.gotoPage}
|
||||
unselect={() => selectedRows[0]?.toggleRowSelected(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@ -71,26 +79,45 @@ function Hosts() {
|
||||
/**
|
||||
* Displays details of a Host.
|
||||
*
|
||||
* @param {string} id - Host id to display
|
||||
* @param {Host} host - Host to display
|
||||
* @param {Function} [gotoPage] - Function to navigate to a page of a Host
|
||||
* @param {Function} [unselect] - Function to unselect a Host
|
||||
* @returns {ReactElement} Host details
|
||||
*/
|
||||
const InfoTabs = memo(({ id, gotoPage }) => {
|
||||
const host = hostApi.endpoints.getHosts.useQueryState(undefined, {
|
||||
selectFromResult: ({ data = [] }) => data.find((item) => +item.ID === +id),
|
||||
})
|
||||
const InfoTabs = memo(({ host, gotoPage, unselect }) => {
|
||||
const [getVm, { data: lazyData, isFetching }] = useLazyGetHostQuery()
|
||||
const id = lazyData?.ID ?? host.ID
|
||||
const name = lazyData?.NAME ?? host.NAME
|
||||
|
||||
return (
|
||||
<Stack overflow="auto">
|
||||
<Stack direction="row" alignItems="center" gap={1} mb={1}>
|
||||
<Typography color="text.primary" noWrap>
|
||||
{`#${id} | ${host.NAME}`}
|
||||
</Typography>
|
||||
{gotoPage && (
|
||||
<IconButton title={Tr(T.LocateOnTable)} onClick={gotoPage}>
|
||||
<BookmarkEmpty />
|
||||
</IconButton>
|
||||
<SubmitButton
|
||||
data-cy="detail-refresh"
|
||||
icon={<RefreshDouble />}
|
||||
tooltip={Tr(T.Refresh)}
|
||||
isSubmitting={isFetching}
|
||||
onClick={() => getVm({ id })}
|
||||
/>
|
||||
{typeof gotoPage === 'function' && (
|
||||
<SubmitButton
|
||||
data-cy="locate-on-table"
|
||||
icon={<GotoIcon />}
|
||||
tooltip={Tr(T.LocateOnTable)}
|
||||
onClick={() => gotoPage()}
|
||||
/>
|
||||
)}
|
||||
{typeof unselect === 'function' && (
|
||||
<SubmitButton
|
||||
data-cy="unselect"
|
||||
icon={<Cancel />}
|
||||
tooltip={Tr(T.Close)}
|
||||
onClick={() => unselect()}
|
||||
/>
|
||||
)}
|
||||
<Typography color="text.primary" noWrap>
|
||||
{`#${id} | ${name}`}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<HostTabs id={id} />
|
||||
</Stack>
|
||||
@ -98,8 +125,9 @@ const InfoTabs = memo(({ id, gotoPage }) => {
|
||||
})
|
||||
|
||||
InfoTabs.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
host: PropTypes.object,
|
||||
gotoPage: PropTypes.func,
|
||||
unselect: PropTypes.func,
|
||||
}
|
||||
|
||||
InfoTabs.displayName = 'InfoTabs'
|
||||
|
@ -15,18 +15,24 @@
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { ReactElement, useState, memo } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { BookmarkEmpty } from 'iconoir-react'
|
||||
import { Typography, Box, Stack, Chip, IconButton } from '@mui/material'
|
||||
import GotoIcon from 'iconoir-react/dist/Pin'
|
||||
import RefreshDouble from 'iconoir-react/dist/RefreshDouble'
|
||||
import Cancel from 'iconoir-react/dist/Cancel'
|
||||
import { Typography, Box, Stack, Chip } from '@mui/material'
|
||||
import { Row } from 'react-table'
|
||||
|
||||
import marketAppApi from 'client/features/OneApi/marketplaceApp'
|
||||
import {
|
||||
useUpdateAppMutation,
|
||||
useLazyGetMarketplaceAppQuery,
|
||||
} from 'client/features/OneApi/marketplaceApp'
|
||||
import { MarketplaceAppsTable } from 'client/components/Tables'
|
||||
import MarketplaceAppActions from 'client/components/Tables/MarketplaceApps/actions'
|
||||
import MarketplaceAppsTabs from 'client/components/Tabs/MarketplaceApp'
|
||||
import SplitPane from 'client/components/SplitPane'
|
||||
import MultipleTags from 'client/components/MultipleTags'
|
||||
import { SubmitButton } from 'client/components/FormControl'
|
||||
import { Tr } from 'client/components/HOC'
|
||||
import { T } from 'client/constants'
|
||||
import { T, MarketplaceApp } from 'client/constants'
|
||||
|
||||
/**
|
||||
* Displays a list of Marketplace Apps with a split pane between the list and selected row(s).
|
||||
@ -47,6 +53,7 @@ function MarketplaceApps() {
|
||||
<MarketplaceAppsTable
|
||||
onSelectedRowsChange={onSelectedRowsChange}
|
||||
globalActions={actions}
|
||||
useUpdateMutation={useUpdateAppMutation}
|
||||
/>
|
||||
|
||||
{hasSelectedRows && (
|
||||
@ -56,8 +63,9 @@ function MarketplaceApps() {
|
||||
<GroupedTags tags={selectedRows} />
|
||||
) : (
|
||||
<InfoTabs
|
||||
id={selectedRows[0]?.original?.ID}
|
||||
app={selectedRows[0]?.original}
|
||||
gotoPage={selectedRows[0]?.gotoPage}
|
||||
unselect={() => selectedRows[0]?.toggleRowSelected(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@ -71,30 +79,47 @@ function MarketplaceApps() {
|
||||
/**
|
||||
* Displays details of a Marketplace App.
|
||||
*
|
||||
* @param {string} id - Marketplace App id to display
|
||||
* @param {MarketplaceApp} app - Marketplace App to display
|
||||
* @param {Function} [gotoPage] - Function to navigate to a page of a Marketplace App
|
||||
* @param {Function} [unselect] - Function to unselect a Marketplace App
|
||||
* @returns {ReactElement} Marketplace App details
|
||||
*/
|
||||
const InfoTabs = memo(({ id, gotoPage }) => {
|
||||
const app = marketAppApi.endpoints.getMarketplaceApps.useQueryState(
|
||||
undefined,
|
||||
{
|
||||
selectFromResult: ({ data = [] }) =>
|
||||
data.find((item) => +item.ID === +id),
|
||||
}
|
||||
)
|
||||
const InfoTabs = memo(({ app, gotoPage, unselect }) => {
|
||||
const [getApp, queryState] = useLazyGetMarketplaceAppQuery()
|
||||
const { data: lazyData, isFetching } = queryState
|
||||
|
||||
const id = lazyData?.ID ?? app.ID
|
||||
const name = lazyData?.NAME ?? app.NAME
|
||||
|
||||
return (
|
||||
<Stack overflow="auto">
|
||||
<Stack direction="row" alignItems="center" gap={1} mb={1}>
|
||||
<Typography color="text.primary" noWrap>
|
||||
{`#${id} | ${app.NAME}`}
|
||||
</Typography>
|
||||
{gotoPage && (
|
||||
<IconButton title={Tr(T.LocateOnTable)} onClick={gotoPage}>
|
||||
<BookmarkEmpty />
|
||||
</IconButton>
|
||||
<SubmitButton
|
||||
data-cy="detail-refresh"
|
||||
icon={<RefreshDouble />}
|
||||
tooltip={Tr(T.Refresh)}
|
||||
isSubmitting={isFetching}
|
||||
onClick={() => getApp({ id })}
|
||||
/>
|
||||
{typeof gotoPage === 'function' && (
|
||||
<SubmitButton
|
||||
data-cy="locate-on-table"
|
||||
icon={<GotoIcon />}
|
||||
tooltip={Tr(T.LocateOnTable)}
|
||||
onClick={() => gotoPage()}
|
||||
/>
|
||||
)}
|
||||
{typeof unselect === 'function' && (
|
||||
<SubmitButton
|
||||
data-cy="unselect"
|
||||
icon={<Cancel />}
|
||||
tooltip={Tr(T.Close)}
|
||||
onClick={() => unselect()}
|
||||
/>
|
||||
)}
|
||||
<Typography color="text.primary" noWrap>
|
||||
{`#${id} | ${name}`}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<MarketplaceAppsTabs id={id} />
|
||||
</Stack>
|
||||
@ -102,8 +127,9 @@ const InfoTabs = memo(({ id, gotoPage }) => {
|
||||
})
|
||||
|
||||
InfoTabs.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
app: PropTypes.object,
|
||||
gotoPage: PropTypes.func,
|
||||
unselect: PropTypes.func,
|
||||
}
|
||||
|
||||
InfoTabs.displayName = 'InfoTabs'
|
||||
|
@ -13,61 +13,78 @@
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { ReactElement, useMemo, useCallback } from 'react'
|
||||
import { ReactElement, useEffect, useCallback } from 'react'
|
||||
import TrashIcon from 'iconoir-react/dist/Trash'
|
||||
import { Paper, Stack, Box, Typography, TextField } from '@mui/material'
|
||||
import { styled, Paper, Stack, Box, Typography, TextField } from '@mui/material'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import { useForm } from 'react-hook-form'
|
||||
|
||||
import {
|
||||
useAddLabelMutation,
|
||||
useRemoveLabelMutation,
|
||||
} from 'client/features/OneApi/auth'
|
||||
import { useAuth } from 'client/features/Auth'
|
||||
import { useUpdateUserMutation } from 'client/features/OneApi/user'
|
||||
import { useGeneralApi } from 'client/features/General'
|
||||
import { useSearch } from 'client/hooks'
|
||||
|
||||
import { StatusChip } from 'client/components/Status'
|
||||
import { SubmitButton } from 'client/components/FormControl'
|
||||
import { jsonToXml, getColorFromString } from 'client/models/Helper'
|
||||
import { getColorFromString } from 'client/models/Helper'
|
||||
import { Translate, Tr } from 'client/components/HOC'
|
||||
import { T } from 'client/constants'
|
||||
|
||||
const NEW_LABEL_ID = 'new-label'
|
||||
|
||||
const LabelWrapper = styled(Box)(({ theme, ownerState }) => ({
|
||||
display: 'flex',
|
||||
direction: 'row',
|
||||
alignItems: 'center',
|
||||
paddingInline: '0.5rem',
|
||||
borderRadius: theme.shape.borderRadius * 2,
|
||||
animation: ownerState.highlight ? 'highlight 2s ease-in-out' : undefined,
|
||||
'@keyframes highlight': {
|
||||
from: { backgroundColor: 'yellow' },
|
||||
to: { backgroundColor: 'transparent' },
|
||||
},
|
||||
}))
|
||||
|
||||
/**
|
||||
* Section to change labels.
|
||||
*
|
||||
* @returns {ReactElement} Settings configuration UI
|
||||
*/
|
||||
const Settings = () => {
|
||||
const { user, settings } = useAuth()
|
||||
const { labels } = useAuth()
|
||||
const { enqueueError } = useGeneralApi()
|
||||
const [updateUser, { isLoading }] = useUpdateUserMutation()
|
||||
|
||||
const currentLabels = useMemo(
|
||||
() => settings?.LABELS?.split(',').filter(Boolean) ?? [],
|
||||
[settings?.LABELS]
|
||||
)
|
||||
const [removeLabel, { isLoading: removeLoading }] = useRemoveLabelMutation()
|
||||
const [addLabel, { isLoading, data, isSuccess }] = useAddLabelMutation()
|
||||
|
||||
const { handleSubmit, register, reset, setFocus } = useForm({
|
||||
reValidateMode: 'onSubmit',
|
||||
})
|
||||
|
||||
const { result, handleChange } = useSearch({
|
||||
list: currentLabels,
|
||||
listOptions: { distance: 50 },
|
||||
wait: 500,
|
||||
list: labels,
|
||||
listOptions: { threshold: 0.2 },
|
||||
wait: 400,
|
||||
condition: !isLoading,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuccess) return
|
||||
|
||||
setTimeout(() => {
|
||||
// scroll to the new label (if it exists)
|
||||
document
|
||||
?.querySelector(`[data-cy='${data}']`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, 450)
|
||||
}, [isSuccess])
|
||||
|
||||
const handleAddLabel = useCallback(
|
||||
async (newLabel) => {
|
||||
async (formData) => {
|
||||
try {
|
||||
const exists = currentLabels.some((label) => label === newLabel)
|
||||
|
||||
if (exists) throw new Error(T.LabelAlreadyExists)
|
||||
|
||||
const newLabels = currentLabels.concat(newLabel).join()
|
||||
const template = jsonToXml({ LABELS: newLabels })
|
||||
await updateUser({ id: user.ID, template, replace: 1 })
|
||||
await addLabel({ newLabel: formData[NEW_LABEL_ID] }).unwrap()
|
||||
} catch (error) {
|
||||
enqueueError(error.message ?? T.SomethingWrong)
|
||||
} finally {
|
||||
@ -77,42 +94,22 @@ const Settings = () => {
|
||||
setFocus(NEW_LABEL_ID)
|
||||
}
|
||||
},
|
||||
[updateUser, currentLabels, handleChange, reset]
|
||||
[addLabel, handleChange, reset]
|
||||
)
|
||||
|
||||
const handleDeleteLabel = useCallback(
|
||||
async (label) => {
|
||||
try {
|
||||
const newLabels = currentLabels.filter((l) => l !== label).join()
|
||||
const template = jsonToXml({ LABELS: newLabels })
|
||||
await updateUser({ id: user.ID, template, replace: 1 })
|
||||
|
||||
// Reset the search after deleting the label
|
||||
handleChange()
|
||||
} catch {
|
||||
enqueueError(T.SomethingWrong)
|
||||
await removeLabel({ label }).unwrap()
|
||||
} catch (error) {
|
||||
enqueueError(error.message ?? T.SomethingWrong)
|
||||
}
|
||||
},
|
||||
[updateUser, currentLabels, handleChange]
|
||||
[removeLabel, handleChange]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(evt) => {
|
||||
if (evt.key !== 'Enter') return
|
||||
|
||||
handleSubmit(async (formData) => {
|
||||
const newLabel = formData[NEW_LABEL_ID]
|
||||
|
||||
if (newLabel) await handleAddLabel(newLabel)
|
||||
|
||||
// scroll to the new label (if it exists)
|
||||
setTimeout(() => {
|
||||
document
|
||||
?.querySelector(`[data-cy='${newLabel}']`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, 500)
|
||||
})(evt)
|
||||
},
|
||||
(evt) => evt.key === 'Enter' && handleSubmit(handleAddLabel)(evt),
|
||||
[handleAddLabel, handleSubmit]
|
||||
)
|
||||
|
||||
@ -123,11 +120,16 @@ const Settings = () => {
|
||||
<Translate word={T.Labels} />
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack height={1} gap="0.5rem" p="0.5rem 1rem" overflow="auto">
|
||||
<Stack height={1} gap="0.5rem" p="0.5rem" overflow="auto">
|
||||
{result?.map((label) => (
|
||||
<Stack key={label} direction="row" alignItems="center">
|
||||
<Box flexGrow={1}>
|
||||
<LabelWrapper
|
||||
key={label}
|
||||
// highlight the label when it is added
|
||||
ownerState={{ highlight: data === label }}
|
||||
>
|
||||
<Box display="inline-flex" flexGrow={1} width="80%">
|
||||
<StatusChip
|
||||
noWrap
|
||||
dataCy={label}
|
||||
text={label}
|
||||
stateColor={getColorFromString(label)}
|
||||
@ -135,11 +137,11 @@ const Settings = () => {
|
||||
</Box>
|
||||
<SubmitButton
|
||||
data-cy={`delete-label-${label}`}
|
||||
disabled={isLoading}
|
||||
disabled={removeLoading}
|
||||
onClick={() => handleDeleteLabel(label)}
|
||||
icon={<TrashIcon />}
|
||||
/>
|
||||
</Stack>
|
||||
</LabelWrapper>
|
||||
))}
|
||||
</Stack>
|
||||
<TextField
|
||||
@ -154,7 +156,7 @@ const Settings = () => {
|
||||
) : undefined,
|
||||
}}
|
||||
{...register(NEW_LABEL_ID, { onChange: handleChange })}
|
||||
helperText={'Press enter to create a new label'}
|
||||
helperText={Tr(T.PressToCreateLabel)}
|
||||
/>
|
||||
</Paper>
|
||||
)
|
||||
|
@ -34,7 +34,7 @@ const Settings = () => (
|
||||
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateColumns={{ sm: '1fr', md: '1fr 1fr' }}
|
||||
gridTemplateColumns={{ sm: '1fr', md: 'repeat(2, minmax(49%, 1fr))' }}
|
||||
gridTemplateRows="minmax(0, 18em)"
|
||||
gap="1em"
|
||||
>
|
||||
|
@ -15,11 +15,16 @@
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { ReactElement, useState, memo } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Pin as GotoIcon, RefreshDouble, Cancel } from 'iconoir-react'
|
||||
import GotoIcon from 'iconoir-react/dist/Pin'
|
||||
import RefreshDouble from 'iconoir-react/dist/RefreshDouble'
|
||||
import Cancel from 'iconoir-react/dist/Cancel'
|
||||
import { Typography, Box, Stack, Chip } from '@mui/material'
|
||||
import { Row } from 'react-table'
|
||||
|
||||
import { useLazyGetVmQuery } from 'client/features/OneApi/vm'
|
||||
import {
|
||||
useLazyGetVmQuery,
|
||||
useUpdateUserTemplateMutation,
|
||||
} from 'client/features/OneApi/vm'
|
||||
import { VmsTable } from 'client/components/Tables'
|
||||
import VmActions from 'client/components/Tables/Vms/actions'
|
||||
import VmTabs from 'client/components/Tabs/Vm'
|
||||
@ -48,6 +53,7 @@ function VirtualMachines() {
|
||||
<VmsTable
|
||||
onSelectedRowsChange={onSelectedRowsChange}
|
||||
globalActions={actions}
|
||||
useUpdateMutation={useUpdateUserTemplateMutation}
|
||||
/>
|
||||
|
||||
{hasSelectedRows && (
|
||||
|
@ -19,7 +19,10 @@ import { Pin as GotoIcon, RefreshDouble, Cancel } from 'iconoir-react'
|
||||
import { Typography, Box, Stack, Chip } from '@mui/material'
|
||||
import { Row } from 'react-table'
|
||||
|
||||
import { useLazyGetTemplateQuery } from 'client/features/OneApi/vmTemplate'
|
||||
import {
|
||||
useLazyGetTemplateQuery,
|
||||
useUpdateTemplateMutation,
|
||||
} from 'client/features/OneApi/vmTemplate'
|
||||
import { VmTemplatesTable } from 'client/components/Tables'
|
||||
import VmTemplateActions from 'client/components/Tables/VmTemplates/actions'
|
||||
import VmTemplateTabs from 'client/components/Tabs/VmTemplate'
|
||||
@ -48,6 +51,7 @@ function VmTemplates() {
|
||||
<VmTemplatesTable
|
||||
onSelectedRowsChange={onSelectedRowsChange}
|
||||
globalActions={actions}
|
||||
useUpdateMutation={useUpdateTemplateMutation}
|
||||
/>
|
||||
|
||||
{hasSelectedRows && (
|
||||
|
@ -22,6 +22,7 @@ import { name as authSlice, actions, logout } from 'client/features/Auth/slice'
|
||||
import groupApi from 'client/features/OneApi/group'
|
||||
import systemApi from 'client/features/OneApi/system'
|
||||
import { ResourceView } from 'client/apps/sunstone/routes'
|
||||
import { areStringEqual } from 'client/models/Helper'
|
||||
import {
|
||||
_APPS,
|
||||
RESOURCE_NAMES,
|
||||
@ -61,6 +62,15 @@ export const useAuth = () => {
|
||||
}
|
||||
)
|
||||
|
||||
const userLabels = useMemo(() => {
|
||||
const labels = user?.TEMPLATE?.LABELS?.split(',') ?? []
|
||||
|
||||
return labels
|
||||
.filter(Boolean)
|
||||
.map((label) => label.toUpperCase())
|
||||
.sort(areStringEqual({ numeric: true, ignorePunctuation: true }))
|
||||
}, [user?.TEMPLATE?.LABELS])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
...auth,
|
||||
@ -75,6 +85,7 @@ export const useAuth = () => {
|
||||
...(user?.TEMPLATE ?? {}),
|
||||
...(user?.TEMPLATE?.FIREEDGE ?? {}),
|
||||
},
|
||||
labels: userLabels ?? [],
|
||||
isLogged:
|
||||
!!jwt &&
|
||||
!!user &&
|
||||
|
@ -20,6 +20,7 @@ import { dismissSnackbar } from 'client/features/General/actions'
|
||||
import { oneApi, ONE_RESOURCES_POOL } from 'client/features/OneApi'
|
||||
import userApi from 'client/features/OneApi/user'
|
||||
|
||||
import { jsonToXml } from 'client/models/Helper'
|
||||
import { storage } from 'client/utils'
|
||||
import { JWT_NAME, FILTER_POOL, ONEADMIN_ID } from 'client/constants'
|
||||
|
||||
@ -88,8 +89,8 @@ const authApi = oneApi.injectEndpoints({
|
||||
}),
|
||||
changeAuthGroup: builder.mutation({
|
||||
/**
|
||||
* @param {object} data - User credentials
|
||||
* @param {string} data.group - Group id
|
||||
* @param {object} params - Request parameters
|
||||
* @param {string} params.group - Group id
|
||||
* @returns {Promise} Response data from request
|
||||
* @throws Fails when response isn't code 200
|
||||
*/
|
||||
@ -118,6 +119,67 @@ const authApi = oneApi.injectEndpoints({
|
||||
},
|
||||
invalidatesTags: [...Object.values(restOfPool)],
|
||||
}),
|
||||
addLabel: builder.mutation({
|
||||
/**
|
||||
* @param {object} params - Request parameters
|
||||
* @param {string} params.newLabel - Label to add
|
||||
* @returns {Promise} Response data from request
|
||||
* @throws Fails when response isn't code 200
|
||||
*/
|
||||
queryFn: async ({ newLabel } = {}, { getState, dispatch }) => {
|
||||
try {
|
||||
if (!newLabel) return { data: '' }
|
||||
|
||||
const authUser = getState().auth.user
|
||||
const currentLabels = authUser?.TEMPLATE?.LABELS?.split(',') ?? []
|
||||
const upperCaseLabels = currentLabels.map((l) => l.toUpperCase())
|
||||
const upperCaseNewLabel = newLabel.toUpperCase()
|
||||
|
||||
const exists = upperCaseLabels.some((l) => l === upperCaseNewLabel)
|
||||
if (exists) return { data: upperCaseNewLabel }
|
||||
|
||||
const newLabels = currentLabels.concat(upperCaseNewLabel).join()
|
||||
const template = jsonToXml({ LABELS: newLabels })
|
||||
const queryData = { id: authUser.ID, template, replace: 1 }
|
||||
|
||||
await dispatch(
|
||||
userApi.endpoints.updateUser.initiate(queryData)
|
||||
).unwrap()
|
||||
|
||||
return { data: upperCaseNewLabel }
|
||||
} catch (error) {
|
||||
return { error }
|
||||
}
|
||||
},
|
||||
}),
|
||||
removeLabel: builder.mutation({
|
||||
/**
|
||||
* @param {object} params - Request parameters
|
||||
* @param {string} params.label - Label to remove
|
||||
* @returns {Promise} Response data from request
|
||||
* @throws Fails when response isn't code 200
|
||||
*/
|
||||
queryFn: async ({ label } = {}, { getState, dispatch }) => {
|
||||
try {
|
||||
if (!label) return { data: '' }
|
||||
|
||||
const authUser = getState().auth.user
|
||||
const currentLabels = authUser?.TEMPLATE?.LABELS?.split(',') ?? []
|
||||
|
||||
const newLabels = currentLabels.filter((l) => l !== label).join()
|
||||
const template = jsonToXml({ LABELS: newLabels })
|
||||
const queryData = { id: authUser.ID, template, replace: 1 }
|
||||
|
||||
await dispatch(
|
||||
userApi.endpoints.updateUser.initiate(queryData)
|
||||
).unwrap()
|
||||
|
||||
return { data: label }
|
||||
} catch (error) {
|
||||
return { error }
|
||||
}
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
@ -129,6 +191,8 @@ export const {
|
||||
// Mutations
|
||||
useLoginMutation,
|
||||
useChangeAuthGroupMutation,
|
||||
useAddLabelMutation,
|
||||
useRemoveLabelMutation,
|
||||
} = authApi
|
||||
|
||||
export default authApi
|
||||
|
@ -27,7 +27,8 @@ import {
|
||||
Permission,
|
||||
UserInputObject,
|
||||
USER_INPUT_TYPES,
|
||||
SERVER_CONFIG,
|
||||
CURRENCY,
|
||||
LOCALE,
|
||||
} from 'client/constants'
|
||||
|
||||
/**
|
||||
@ -90,12 +91,9 @@ export const stringToBoolean = (str) =>
|
||||
*/
|
||||
export const formatNumberByCurrency = (number, options) => {
|
||||
try {
|
||||
const currency = SERVER_CONFIG?.currency ?? 'EUR'
|
||||
const locale = SERVER_CONFIG?.lang?.replace('_', '-') ?? undefined
|
||||
|
||||
return Intl.NumberFormat(locale, {
|
||||
return Intl.NumberFormat(LOCALE, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
currency: CURRENCY,
|
||||
currencyDisplay: 'narrowSymbol',
|
||||
notation: 'compact',
|
||||
compactDisplay: 'long',
|
||||
@ -107,6 +105,28 @@ export const formatNumberByCurrency = (number, options) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to compare two values.
|
||||
*
|
||||
* @param {Intl.CollatorOptions} options - Options to compare the values
|
||||
* @returns {function(string, string)} - Function to compare two strings
|
||||
* Negative when the referenceStr occurs before compareString
|
||||
* Positive when the referenceStr occurs after compareString
|
||||
* Returns 0 if they are equivalent
|
||||
*/
|
||||
export const areStringEqual = (options) => (a, b) => {
|
||||
try {
|
||||
const collator = new Intl.Collator(LOCALE, {
|
||||
sensitivity: 'base',
|
||||
...options,
|
||||
})
|
||||
|
||||
return collator.compare(a, b)
|
||||
} catch {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the given value is an instance of Date.
|
||||
*
|
||||
|
Loading…
x
Reference in New Issue
Block a user