1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-03-16 22:50:10 +03:00

F #5862: Add labels to host, apps, vm & templates (#2165)

This commit is contained in:
Sergio Betanzos 2022-06-21 13:51:19 +02:00 committed by GitHub
parent 3d764ba465
commit 17abe9b749
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 828 additions and 296 deletions

View File

@ -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>

View File

@ -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

View File

@ -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,
}

View File

@ -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'

View File

@ -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,
}

View File

@ -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()

View File

@ -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,

View File

@ -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',

View File

@ -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 ?? {

View File

@ -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',

View File

@ -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'

View File

@ -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'

View File

@ -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>
)

View File

@ -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"
>

View File

@ -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 && (

View File

@ -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 && (

View File

@ -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 &&

View File

@ -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

View File

@ -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.
*