1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-03-29 18:50:08 +03:00

F #5422: Reformat datatables (#1975)

This commit is contained in:
Sergio Betanzos 2022-04-27 18:23:30 +02:00 committed by GitHub
parent 723854107b
commit 41c956f143
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
111 changed files with 1924 additions and 1683 deletions

View File

@ -37,9 +37,12 @@ actions:
filters:
label: true
owner: true
group: true
state: true
type: true
marketplace: true
zone: true
# Info Tabs - Which info tabs are used to show extended information

View File

@ -29,8 +29,6 @@ actions:
deploy: true
migrate: true
migrate_live: true
migrate_poff: false
migrate_poff_hard: false
hold: true
release: true
suspend: true
@ -56,13 +54,19 @@ actions:
vnc: true
ssh: true
rdp: true
edit_labels: true
# Filters - List of criteria to filter the resources
filters:
label: true
state: true
owner: true
group: true
type: true
locked: true
ips: true
hostname: true
# Info Tabs - Which info tabs are used to show extended information

View File

@ -37,11 +37,16 @@ actions:
unlock: true
share: true
unshare: true
edit_labels: true
# Filters - List of criteria to filter the resources
filters:
label: true
owner: true
group: true
locked: true
vrouter: true
# Info Tabs - Which info tabs are used to show extended information

View File

@ -12,7 +12,7 @@ vcenter_prepend_command: ''
# Prepend for sunstone commands
sunstone_prepend: ''
# Directory to store temp files when uploading images
# Directory to store temporal files when uploading images
tmpdir: /var/tmp
################################################################################

View File

@ -25,11 +25,10 @@ resource_name: "VM"
actions:
create_dialog: true
create_app_dialog: true # reference to create_dialog in marketplace-app-tab.yaml
deploy: true
migrate: true
migrate_live: true
migrate_poff: false
migrate_poff_hard: false
hold: true
release: true
suspend: true
@ -55,12 +54,19 @@ actions:
vnc: true
ssh: true
rdp: true
edit_labels: false
# Filters - List of criteria to filter the resources
filters:
label: true
state: true
owner: true
group: true
type: true
locked: true
ips: true
hostname: true
# Info Tabs - Which info tabs are used to show extended information
@ -130,6 +136,7 @@ info-tabs:
enabled: true
not_on:
- vcenter
- firecracker
network:
enabled: true

View File

@ -28,6 +28,7 @@ actions:
import_dialog: false
update_dialog: true
instantiate_dialog: true
create_app_dialog: false # reference to create_dialog in marketplace-app-tab.yaml
clone: true
delete: true
chown: false
@ -36,11 +37,16 @@ actions:
unlock: true
share: true
unshare: true
edit_labels: false
# Filters - List of criteria to filter the resources
filters:
label: true
owner: true
group: true
locked: true
vrouter: true
# Info Tabs - Which info tabs are used to show extended information
@ -73,6 +79,7 @@ dialogs:
information: true
ownership: true
capacity: true
showback: true
vm_group: true
vcenter:
enabled: true
@ -86,7 +93,6 @@ dialogs:
sched_action: true
booting: true
create_dialog:
information: true
capacity: true
ownership: true
vm_group: true

View File

@ -83,7 +83,7 @@ const DiskCard = memo(({ disk = {}, actions = [], snapshotActions = [] }) => {
>
<div className={classes.main}>
<div className={classes.title}>
<Typography component="span" data-cy="name">
<Typography noWrap component="span" data-cy="name">
{getDiskName(disk)}
</Typography>
<span className={classes.labels}>

View File

@ -0,0 +1,110 @@
/* ------------------------------------------------------------------------- *
* 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, memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import { Lock, User, Group, Cart } from 'iconoir-react'
import { Typography } from '@mui/material'
import Timer from 'client/components/Timer'
import { StatusCircle, StatusChip } from 'client/components/Status'
import { Tr } from 'client/components/HOC'
import { rowStyles } from 'client/components/Tables/styles'
import { getState, getType } from 'client/models/MarketplaceApp'
import { timeFromMilliseconds } from 'client/models/Helper'
import { prettyBytes } from 'client/utils'
import { T, MarketplaceApp } from 'client/constants'
const MarketplaceAppCard = memo(
/**
* @param {object} props - Props
* @param {MarketplaceApp} props.app - Marketplace App resource
* @param {object} props.rootProps - Props to root component
* @returns {ReactElement} - Card
*/
({ app, rootProps }) => {
const classes = rowStyles()
const {
ID,
NAME,
UNAME,
GNAME,
LOCK,
REGTIME,
MARKETPLACE,
ZONE_ID,
SIZE,
} = app
const state = useMemo(() => getState(app), [app?.STATE])
const { color: stateColor, name: stateName } = state
const time = useMemo(() => timeFromMilliseconds(+REGTIME), [REGTIME])
const type = useMemo(() => getType(app), [app?.TYPE])
return (
<div {...rootProps} data-cy={`app-${ID}`}>
<div className={classes.main}>
<div className={classes.title}>
<StatusCircle color={stateColor} tooltip={stateName} />
<Typography component="span">{NAME}</Typography>
{LOCK && <Lock />}
<span className={classes.labels}>
<StatusChip text={type} />
</span>
</div>
<div className={classes.caption}>
<span data-cy="id">{`#${ID}`}</span>
<span title={useMemo(() => time.toFormat('ff'), [REGTIME])}>
<Timer translateWord={T.RegisteredAt} initial={time} />
</span>
<span title={`${Tr(T.Owner)}: ${UNAME}`}>
<User />
<span data-cy="owner">{UNAME}</span>
</span>
<span title={`${Tr(T.Group)}: ${GNAME}`}>
<Group />
<span data-cy="group">{GNAME}</span>
</span>
<span title={`${Tr(T.Marketplace)}: ${MARKETPLACE}`}>
<Cart />
<span data-cy="marketplace">{MARKETPLACE}</span>
</span>
</div>
</div>
<div className={classes.secondary}>
<span className={classes.labels}>
<StatusChip text={`${Tr(T.Zone)} ${ZONE_ID}`} />
<StatusChip text={prettyBytes(+SIZE, 'MB')} />
</span>
</div>
</div>
)
}
)
MarketplaceAppCard.propTypes = {
app: PropTypes.object,
rootProps: PropTypes.shape({
className: PropTypes.string,
}),
actions: PropTypes.any,
}
MarketplaceAppCard.displayName = 'MarketplaceAppCard'
export default MarketplaceAppCard

View File

@ -99,6 +99,7 @@ const NicCard = memo(
const isAlias = !!PARENT?.length
const isPciDevice = PCI_ID !== undefined
const isAdditionalIp = NIC_ID !== undefined || NETWORK === 'Additional IP'
const dataCy = isAlias ? 'alias' : 'nic'
@ -149,7 +150,9 @@ const NicCard = memo(
</span>
</div>
</Box>
{!isPciDevice && <div className={classes.actions}>{actions}</div>}
{!isPciDevice && !isAdditionalIp && (
<div className={classes.actions}>{actions}</div>
)}
{!!ALIAS?.length && (
<Box flexBasis="100%">
{ALIAS?.map((alias) => (
@ -182,7 +185,12 @@ const NicCard = memo(
return (
<AccordionDetails key={key}>
<SecurityGroupRules id={ID} rules={rules} actions={acts} />
<SecurityGroupRules
parentKey={key}
id={ID}
rules={rules}
actions={acts}
/>
</AccordionDetails>
)
})}
@ -205,7 +213,7 @@ NicCard.propTypes = {
NicCard.displayName = 'NicCard'
const SecurityGroupRules = memo(({ id, actions, rules }) => {
const SecurityGroupRules = memo(({ parentKey, id, actions, rules }) => {
const classes = rowStyles()
const COLUMNS = useMemo(
@ -222,7 +230,7 @@ const SecurityGroupRules = memo(({ id, actions, rules }) => {
noWrap
component="span"
variant="subtitle1"
data-cy={`${id}-rule-name`}
data-cy={`${parentKey}-rule-name`}
>
{`#${id} ${name}`}
</Typography>
@ -230,14 +238,19 @@ const SecurityGroupRules = memo(({ id, actions, rules }) => {
</Stack>
<Box display="grid" gridTemplateColumns="repeat(5, 1fr)" gap="0.5em">
{COLUMNS.map((col) => (
<Typography key={col} noWrap component="span" variant="subtitle2">
<Typography
key={`${parentKey}-${col}`}
noWrap
component="span"
variant="subtitle2"
>
<Translate word={col} />
</Typography>
))}
{rules.map((rule, ruleIdx) => (
{rules.map((rule) => (
<SecurityGroupRule
data-cy={`${id}-rule-${ruleIdx}`}
key={`${id}-rule-${ruleIdx}`}
key={`${parentKey}-rule-${rule.RULE_TYPE}`}
data-cy={`${parentKey}-rule-${rule.RULE_TYPE}`}
rule={rule}
/>
))}
@ -247,6 +260,7 @@ const SecurityGroupRules = memo(({ id, actions, rules }) => {
})
SecurityGroupRules.propTypes = {
parentKey: PropTypes.string,
id: PropTypes.string,
rules: PropTypes.array,
actions: PropTypes.node,
@ -254,7 +268,7 @@ SecurityGroupRules.propTypes = {
SecurityGroupRules.displayName = 'SecurityGroupRule'
const SecurityGroupRule = memo(({ rule, 'data-cy': cy }) => {
const SecurityGroupRule = memo(({ rule, 'data-cy': parentCy }) => {
/** @type {PrettySecurityGroupRule} */
const { PROTOCOL, RULE_TYPE, ICMP_TYPE, RANGE, NETWORK_ID } = rule
@ -266,11 +280,11 @@ const SecurityGroupRule = memo(({ rule, 'data-cy': cy }) => {
{ text: RANGE, dataCy: 'range' },
{ text: NETWORK_ID, dataCy: 'networkid' },
{ text: ICMP_TYPE, dataCy: 'icmp-type' },
].map(({ text, dataCy }, index) => (
].map(({ text, dataCy }) => (
<Typography
noWrap
key={`${index}-${cy}`}
data-cy={`${cy}-${dataCy}`}
key={`${parentCy}-${dataCy}`}
data-cy={`${parentCy}-${dataCy}`.toLowerCase()}
variant="subtitle2"
>
{text}

View File

@ -13,49 +13,90 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement, memo } from 'react'
import { ReactElement, memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import {
User,
Group,
Lock,
HardDrive,
Cpu,
Network,
WarningCircledOutline as WarningIcon,
} from 'iconoir-react'
import { Box, Stack, Typography, Tooltip } from '@mui/material'
import Timer from 'client/components/Timer'
import { useViews } from 'client/features/Auth'
import MultipleTags from 'client/components/MultipleTags'
import Timer from 'client/components/Timer'
import { MemoryIcon } from 'client/components/Icons'
import { StatusCircle, StatusChip } from 'client/components/Status'
import { Tr } from 'client/components/HOC'
import { rowStyles } from 'client/components/Tables/styles'
import {
getState,
getLastHistory,
getHypervisor,
getIps,
getErrorMessage,
} from 'client/models/VirtualMachine'
import { timeFromMilliseconds } from 'client/models/Helper'
import { VM } from 'client/constants'
import {
timeFromMilliseconds,
getUniqueLabels,
getColorFromString,
} from 'client/models/Helper'
import { prettyBytes } from 'client/utils'
import { T, VM, ACTIONS, RESOURCE_NAMES } from 'client/constants'
const VirtualMachineCard = memo(
/**
* @param {object} props - Props
* @param {VM} props.vm - Virtual machine resource
* @param {object} props.rootProps - Props to root component
* @param {function(string):Promise} [props.onDeleteLabel] - Callback to delete label
* @param {ReactElement} [props.actions] - Actions
* @returns {ReactElement} - Card
*/
({ vm, rootProps, actions }) => {
({ vm, rootProps, actions, onDeleteLabel }) => {
const classes = rowStyles()
const { ID, NAME, UNAME, GNAME, IPS, STIME, ETIME, LOCK } = vm
const { [RESOURCE_NAMES.VM]: vmView } = useViews()
const HOSTNAME = getLastHistory(vm)?.HOSTNAME ?? '--'
const hypervisor = getHypervisor(vm)
const time = timeFromMilliseconds(+ETIME || +STIME)
const error = getErrorMessage(vm)
const enableEditLabels =
vmView?.actions?.[ACTIONS.EDIT_LABELS] === true && !!onDeleteLabel
const {
ID,
NAME,
STIME,
ETIME,
LOCK,
USER_TEMPLATE: { LABELS } = {},
TEMPLATE: { CPU, MEMORY } = {},
} = vm
const { HOSTNAME = '--', VM_MAD: hypervisor } = useMemo(
() => getLastHistory(vm) ?? '--',
[vm.HISTORY_RECORDS]
)
const time = useMemo(
() => timeFromMilliseconds(+ETIME || +STIME),
[ETIME, STIME]
)
const { color: stateColor, name: stateName } = getState(vm)
const error = useMemo(() => getErrorMessage(vm), [vm])
const ips = useMemo(() => getIps(vm), [vm])
const memValue = useMemo(() => prettyBytes(+MEMORY, 'MB'), [MEMORY])
const labels = useMemo(
() =>
getUniqueLabels(LABELS).map((label) => ({
text: label,
stateColor: getColorFromString(label),
onDelete: enableEditLabels && onDeleteLabel,
})),
[LABELS, enableEditLabels, onDeleteLabel]
)
return (
<div {...rootProps} data-cy={`vm-${ID}`}>
@ -79,34 +120,40 @@ const VirtualMachineCard = memo(
<span className={classes.labels}>
{hypervisor && <StatusChip text={hypervisor} />}
{LOCK && <Lock data-cy="lock" />}
<MultipleTags tags={labels} />
</span>
</div>
<div className={classes.caption}>
<span title={time.toFormat('ff')}>
{`#${ID} ${+ETIME ? 'done' : 'started'} `}
<span data-cy="id">{`#${ID}`}</span>
<span title={useMemo(() => time.toFormat('ff'), [ETIME, STIME])}>
{`${+ETIME ? T.Done : T.Started} `}
<Timer initial={time} />
</span>
<span title={`Owner: ${UNAME}`}>
<User />
<span data-cy="uname">{` ${UNAME}`}</span>
<span title={`${Tr(T.PhysicalCpu)}: ${CPU}`}>
<Cpu />
<span data-cy="cpu">{CPU}</span>
</span>
<span title={`Group: ${GNAME}`}>
<Group />
<span data-cy="gname">{` ${GNAME}`}</span>
<span title={`${Tr(T.Memory)}: ${memValue}`}>
<MemoryIcon width={20} height={20} />
<span data-cy="memory">{memValue}</span>
</span>
<span title={`Hostname: ${HOSTNAME}`}>
<span
className={classes.captionItem}
title={`${Tr(T.Hostname)}: ${HOSTNAME}`}
>
<HardDrive />
<span data-cy="hostname">{` ${HOSTNAME}`}</span>
<span data-cy="hostname">{HOSTNAME}</span>
</span>
{!!ips?.length && (
<span className={classes.captionItem}>
<Network />
<Stack direction="row" justifyContent="end" alignItems="center">
<MultipleTags tags={ips} clipboard />
</Stack>
</span>
)}
</div>
</div>
{!!IPS?.length && (
<div className={classes.secondary}>
<Stack flexWrap="wrap" justifyContent="end" alignItems="center">
<MultipleTags tags={IPS.split(',')} />
</Stack>
</div>
)}
{actions && <div className={classes.actions}>{actions}</div>}
</div>
)
@ -118,6 +165,7 @@ VirtualMachineCard.propTypes = {
rootProps: PropTypes.shape({
className: PropTypes.string,
}),
onDeleteLabel: PropTypes.func,
actions: PropTypes.any,
}

View File

@ -19,24 +19,44 @@ import PropTypes from 'prop-types'
import { User, Group, Lock } from 'iconoir-react'
import { Typography } from '@mui/material'
import { useViews } from 'client/features/Auth'
import MultipleTags from 'client/components/MultipleTags'
import Timer from 'client/components/Timer'
import { StatusChip } from 'client/components/Status'
import { rowStyles } from 'client/components/Tables/styles'
import Image from 'client/components/Image'
import { StatusChip } from 'client/components/Status'
import { Tr } from 'client/components/HOC'
import { rowStyles } from 'client/components/Tables/styles'
import { timeFromMilliseconds } from 'client/models/Helper'
import {
timeFromMilliseconds,
getUniqueLabels,
getColorFromString,
} from 'client/models/Helper'
import { isExternalURL } from 'client/utils'
import { VM, STATIC_FILES_URL, DEFAULT_TEMPLATE_LOGO } from 'client/constants'
import {
T,
VM,
ACTIONS,
RESOURCE_NAMES,
STATIC_FILES_URL,
DEFAULT_TEMPLATE_LOGO,
} from 'client/constants'
const VmTemplateCard = memo(
/**
* @param {object} props - Props
* @param {VM} props.template - Virtual machine resource
* @param {object} props.rootProps - Props to root component
* @param {function(string):Promise} [props.onDeleteLabel] - Callback to delete label
* @returns {ReactElement} - Card
*/
({ template, rootProps }) => {
({ template, rootProps, onDeleteLabel }) => {
const classes = rowStyles()
const { [RESOURCE_NAMES.VM_TEMPLATE]: templateView } = useViews()
const enableEditLabels =
templateView?.actions?.[ACTIONS.EDIT_LABELS] === true && !!onDeleteLabel
const {
ID,
NAME,
@ -45,10 +65,11 @@ const VmTemplateCard = memo(
REGTIME,
LOCK,
VROUTER,
TEMPLATE: { LOGO = '' } = {},
TEMPLATE: { HYPERVISOR, LABELS, LOGO = '' } = {},
} = template
const isExternalImage = useMemo(() => isExternalURL(LOGO), [LOGO])
const time = useMemo(() => timeFromMilliseconds(+REGTIME), [REGTIME])
const logoSource = useMemo(() => {
if (!LOGO) return `${STATIC_FILES_URL}/${DEFAULT_TEMPLATE_LOGO}`
@ -56,7 +77,15 @@ const VmTemplateCard = memo(
return isExternalImage ? LOGO : `${STATIC_FILES_URL}/${LOGO}`
}, [isExternalImage, LOGO])
const time = useMemo(() => timeFromMilliseconds(+REGTIME), [REGTIME])
const labels = useMemo(
() =>
getUniqueLabels(LABELS).map((label) => ({
text: label,
stateColor: getColorFromString(label),
onDelete: enableEditLabels && onDeleteLabel,
})),
[LABELS, enableEditLabels, onDeleteLabel]
)
return (
<div {...rootProps} data-cy={`template-${ID}`}>
@ -71,20 +100,22 @@ const VmTemplateCard = memo(
<div className={classes.title}>
<Typography component="span">{NAME}</Typography>
<span className={classes.labels}>
{HYPERVISOR && <StatusChip text={HYPERVISOR} />}
{LOCK && <Lock />}
{VROUTER && <StatusChip text={VROUTER} />}
<MultipleTags tags={labels} />
</span>
</div>
<div className={classes.caption}>
<span title={time.toFormat('ff')} className="full-width">
{`#${ID} registered `}
<Timer initial={time} />
<span data-cy="id">{`#${ID}`}</span>
<span title={time.toFormat('ff')}>
<Timer translateWord={T.RegisteredAt} initial={time} />
</span>
<span title={`Owner: ${UNAME}`}>
<span title={`${Tr(T.Owner)}: ${UNAME}`}>
<User />
<span>{` ${UNAME}`}</span>
</span>
<span title={`Group: ${GNAME}`}>
<span title={`${Tr(T.Group)}: ${GNAME}`}>
<Group />
<span>{` ${GNAME}`}</span>
</span>
@ -100,6 +131,7 @@ VmTemplateCard.propTypes = {
rootProps: PropTypes.shape({
className: PropTypes.string,
}),
onDeleteLabel: PropTypes.func,
}
VmTemplateCard.displayName = 'VmTemplateCard'

View File

@ -22,6 +22,7 @@ import DiskCard from 'client/components/Cards/DiskCard'
import DiskSnapshotCard from 'client/components/Cards/DiskSnapshotCard'
import EmptyCard from 'client/components/Cards/EmptyCard'
import HostCard from 'client/components/Cards/HostCard'
import MarketplaceAppCard from 'client/components/Cards/MarketplaceAppCard'
import NetworkCard from 'client/components/Cards/NetworkCard'
import NicCard from 'client/components/Cards/NicCard'
import PolicyCard from 'client/components/Cards/PolicyCard'
@ -29,8 +30,8 @@ import ProvisionCard from 'client/components/Cards/ProvisionCard'
import ProvisionTemplateCard from 'client/components/Cards/ProvisionTemplateCard'
import ScheduleActionCard from 'client/components/Cards/ScheduleActionCard'
import SecurityGroupCard from 'client/components/Cards/SecurityGroupCard'
import SnapshotCard from 'client/components/Cards/SnapshotCard'
import SelectCard from 'client/components/Cards/SelectCard'
import SnapshotCard from 'client/components/Cards/SnapshotCard'
import TierCard from 'client/components/Cards/TierCard'
import VirtualMachineCard from 'client/components/Cards/VirtualMachineCard'
import VmTemplateCard from 'client/components/Cards/VmTemplateCard'
@ -46,6 +47,7 @@ export {
DiskSnapshotCard,
EmptyCard,
HostCard,
MarketplaceAppCard,
NetworkCard,
NicCard,
PolicyCard,
@ -53,8 +55,8 @@ export {
ProvisionTemplateCard,
ScheduleActionCard,
SecurityGroupCard,
SnapshotCard,
SelectCard,
SnapshotCard,
TierCard,
VirtualMachineCard,
VmTemplateCard,

View File

@ -17,8 +17,6 @@ import { useCallback, ReactElement } from 'react'
import PropTypes from 'prop-types'
import { AnySchema } from 'yup'
import clsx from 'clsx'
import makeStyles from '@mui/styles/makeStyles'
import { useForm, FormProvider } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
@ -26,19 +24,6 @@ import DialogConfirmation, {
DialogPropTypes,
} from 'client/components/Dialogs/DialogConfirmation'
const useStyles = makeStyles((theme) => ({
content: {
width: '80vw',
height: '60vh',
maxWidth: '100%',
maxHeight: '100%',
[theme.breakpoints.only('xs')]: {
width: '100vw',
height: '100vh',
},
},
}))
/**
* Creates dialog with a form inside.
*
@ -57,13 +42,8 @@ const DialogForm = ({
dialogProps,
children,
}) => {
const classes = useStyles()
const { className, ...contentProps } = dialogProps.contentProps ?? {}
dialogProps.contentProps = {
className: clsx(classes.content, className),
...contentProps,
}
dialogProps.fixedWidth ??= true
dialogProps.fixedHeight ??= true
const methods = useForm({
mode: 'onBlur',

View File

@ -19,7 +19,8 @@ import { styled, Link, Typography } from '@mui/material'
import { useGetOneVersionQuery } from 'client/features/OneApi/system'
import { StatusChip } from 'client/components/Status'
import { BY } from 'client/constants'
import { Translate } from 'client/components/HOC'
import { BY, T } from 'client/constants'
const FooterBox = styled('footer')(({ theme }) => ({
color: theme.palette.primary.contrastText,
@ -48,12 +49,19 @@ const Footer = memo(() => {
return (
<FooterBox>
<Typography variant="body2">
{'Made with'}
<Translate word={T.MadeWith} />
<HeartIcon role="img" aria-label="heart-emoji" />
<Link href={BY.url} color="primary.contrastText">
{BY.text}
</Link>
{version && <StatusChip stateColor="secondary" text={version} mx={1} />}
{version && (
<StatusChip
forceWhiteColor
stateColor="secondary"
text={version}
mx={1}
/>
)}
</Typography>
</FooterBox>
)

View File

@ -69,13 +69,13 @@ const TableController = memo(
<ErrorHelper data-cy={`${cy}-error`} label={error?.message} mb={2} />
)}
<Table
pageSize={4}
pageSize={5}
disableGlobalSort
displaySelectedRows
disableRowSelect={readOnly}
singleSelect={singleSelect}
onlyGlobalSearch
onlyGlobalSelectedRows
getRowId={getRowId}
initialState={{ ...initialState, selectedRowIds: initialRows }}
disableRowSelect={readOnly}
onSelectedRowsChange={(rows) => {
if (readOnly) return

View File

@ -36,7 +36,7 @@ import { isDevelopment } from 'client/utils'
const ButtonToTriggerForm = ({ buttonProps = {}, options = [] }) => {
const buttonId = buttonProps['data-cy'] ?? 'main-button'
const isGroupButton = options.length > 1
const moreThanOneOption = options.length > 1
const [anchorEl, setAnchorEl] = useState(() => null)
const open = Boolean(anchorEl)
@ -76,16 +76,16 @@ const ButtonToTriggerForm = ({ buttonProps = {}, options = [] }) => {
aria-describedby={buttonId}
aria-controls={open ? `${buttonId}-button` : undefined}
aria-expanded={open ? 'true' : undefined}
aria-haspopup={isGroupButton ? 'true' : false}
aria-haspopup={moreThanOneOption ? 'true' : false}
disabled={!options.length}
endicon={isGroupButton ? <NavArrowDown /> : undefined}
endicon={moreThanOneOption ? <NavArrowDown /> : undefined}
onClick={(evt) =>
!isGroupButton ? openDialogForm(options[0]) : handleToggle(evt)
moreThanOneOption ? handleToggle(evt) : openDialogForm(options[0])
}
{...buttonProps}
/>
{isGroupButton && (
{moreThanOneOption && !buttonProps.disabled && (
<Menu
id={`${buttonId}-menu`}
anchorEl={anchorEl}

View File

@ -41,8 +41,9 @@ const Content = ({ data, setFormData }) => {
return (
<ClustersTable
singleSelect
onlyGlobalSearch
onlyGlobalSelectedRows
disableGlobalSort
displaySelectedRows
pageSize={5}
initialState={{ selectedRowIds: { [ID]: true } }}
onSelectedRowsChange={handleSelectedRows}
/>

View File

@ -40,8 +40,9 @@ const Content = ({ data }) => {
return (
<MarketplacesTable
singleSelect
onlyGlobalSearch
onlyGlobalSelectedRows
disableGlobalSort
displaySelectedRows
pageSize={5}
getRowId={(market) => String(market.NAME)}
useQuery={() =>
useGetMarketplacesQuery(undefined, {

View File

@ -43,8 +43,9 @@ const Content = ({ data, app }) => {
return (
<DatastoresTable
singleSelect
onlyGlobalSearch
onlyGlobalSelectedRows
disableGlobalSort
displaySelectedRows
pageSize={5}
getRowId={(row) => String(row.NAME)}
initialState={{
selectedRowIds: { [NAME]: true },

View File

@ -37,8 +37,9 @@ const Content = ({ data, app }) => {
<DockerHubTagsTable
app={app}
singleSelect
onlyGlobalSearch
onlyGlobalSelectedRows
disableGlobalSort
displaySelectedRows
pageSize={5}
initialState={{ selectedRowIds: { [name]: true } }}
onSelectedRowsChange={handleSelectedRows}
/>

View File

@ -40,8 +40,8 @@ const Content = ({ data, setFormData }) => {
return (
<ImagesTable
singleSelect
onlyGlobalSearch
onlyGlobalSelectedRows
disableGlobalSort
pageSize={5}
initialState={{ selectedRowIds: { [ID]: true } }}
onSelectedRowsChange={handleSelectedRows}
/>

View File

@ -41,8 +41,9 @@ const Content = ({ data, setFormData }) => {
return (
<VNetworksTable
singleSelect
onlyGlobalSearch
onlyGlobalSelectedRows
disableGlobalSort
displaySelectedRows
pageSize={5}
getRowId={(row) => String(row.NAME)}
initialState={{ selectedRowIds: { [NAME]: true } }}
onSelectedRowsChange={handleSelectedRows}

View File

@ -41,8 +41,9 @@ const Content = ({ data, setFormData }) => {
return (
<HostsTable
singleSelect
onlyGlobalSearch
onlyGlobalSelectedRows
disableGlobalSort
displaySelectedRows
pageSize={5}
initialState={{ selectedRowIds: { [ID]: true } }}
onSelectedRowsChange={handleSelectedRows}
/>

View File

@ -23,7 +23,7 @@ import {
} from 'client/utils'
import { T, HYPERVISORS, USER_INPUT_TYPES, VmTemplate } from 'client/constants'
const { number, numberFloat } = USER_INPUT_TYPES
const { number, numberFloat, range, rangeFloat } = USER_INPUT_TYPES
const TRANSLATES = {
MEMORY: { name: 'MEMORY', label: T.Memory, tooltip: T.MemoryConcept },
@ -60,6 +60,7 @@ export const FIELDS = (vmTemplate) => {
const isMemory = name === 'MEMORY'
const isVCenter = HYPERVISOR === HYPERVISORS.vcenter
const divisibleBy4 = isVCenter && isMemory
const isRange = [range, rangeFloat].includes(userInput.type)
// set default type to number
userInput.type ??= name === 'CPU' ? numberFloat : number
@ -71,18 +72,17 @@ export const FIELDS = (vmTemplate) => {
const schemaUi = schemaUserInput({ options: ensuredOptions, ...userInput })
const isNumber = schemaUi.validation instanceof NumberSchema
if (isNumber) {
// add positive number validator
isNumber && (schemaUi.validation &&= schemaUi.validation.positive())
// add positive number validator
isNumber && (schemaUi.validation &&= schemaUi.validation.positive())
if (isMemory && isRange) {
// add label format on pretty bytes
isMemory &&
(schemaUi.fieldProps = { ...schemaUi.fieldProps, valueLabelFormat })
schemaUi.fieldProps = { ...schemaUi.fieldProps, valueLabelFormat }
}
if (divisibleBy4) {
schemaUi.validation &&= schemaUi.validation.isDivisibleBy(4)
schemaUi.fieldProps = { ...schemaUi.fieldProps, step: 4 }
}
if (isNumber && divisibleBy4) {
schemaUi.validation &&= schemaUi.validation.isDivisibleBy(4)
schemaUi.fieldProps = { ...schemaUi.fieldProps, step: 4 }
}
return { ...TRANSLATES[name], ...schemaUi, grid: { md: 12 } }

View File

@ -43,6 +43,9 @@ export default makeStyles((theme) => ({
scrollable: {
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
[theme.breakpoints.up('xs')]: {
padding: 0,
},
height: '100%',
overflow: 'auto',
'&::-webkit-scrollbar': {

View File

@ -0,0 +1,44 @@
/* ------------------------------------------------------------------------- *
* 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 { memo } from 'react'
import { number, string, oneOfType } from 'prop-types'
const MemoryIcon = memo(({ viewBox, width, height, color, ...props }) => (
<svg viewBox={viewBox} width={width} height={height} {...props}>
<path
fill={color}
d="M13.78 13.25V3.84a3.841 3.841 0 0 1 7.68 0v9.41h9.73V3.84a3.841 3.841 0 0 1 7.68 0v9.41h9.73V3.84a3.841 3.841 0 0 1 7.68 0v9.41h9.73V3.84a3.841 3.841 0 0 1 7.68 0v9.41h9.73V3.84a3.841 3.841 0 0 1 7.68 0v9.41h9.73V3.84a3.841 3.841 0 0 1 7.68 0v9.41h8.6c1.59 0 3.03.65 4.07 1.69a5.744 5.744 0 0 1 1.69 4.07v60.66c0 1.57-.65 3.01-1.69 4.06l.01.01a5.744 5.744 0 0 1-4.07 1.69h-8.6v8.82a3.841 3.841 0 0 1-7.68 0v-8.82h-9.73v8.82a3.841 3.841 0 0 1-7.68 0v-8.82H73.7v8.82a3.841 3.841 0 0 1-7.68 0v-8.82h-9.73v8.82a3.841 3.841 0 0 1-7.68 0v-8.82h-9.73v8.82a3.841 3.841 0 0 1-7.68 0v-8.82h-9.73v8.82a3.841 3.841 0 0 1-7.68 0v-8.82H5.75c-1.59 0-3.03-.65-4.07-1.69A5.814 5.814 0 0 1 0 79.67V19.01c0-1.59.65-3.03 1.69-4.07.12-.12.25-.23.38-.33a5.748 5.748 0 0 1 3.69-1.35h8.02v-.01zm16.98 49.52-5.2-9.85v9.85h-8.61V35.31h12.8c2.22 0 4.12.39 5.7 1.18 1.58.79 2.76 1.86 3.55 3.22s1.18 2.89 1.18 4.6c0 1.84-.51 3.47-1.53 4.89-1.02 1.42-2.49 2.44-4.4 3.06l5.97 10.51h-9.46zm-5.2-15.59h3.41c.83 0 1.45-.19 1.86-.56.41-.38.62-.96.62-1.77 0-.72-.21-1.29-.64-1.71-.43-.41-1.04-.62-1.84-.62h-3.41v4.66zm34.99 11.44H51.4l-1.36 4.15H41l10.05-27.46h9.93l10.01 27.46h-9.08l-1.36-4.15zm-2.1-6.47-2.48-7.64-2.48 7.64h4.96zm47.48-16.84v27.46h-8.57V49.08l-4.23 13.69h-7.37l-4.23-13.69v13.69h-8.61V35.31h10.55l6.05 16.49 5.9-16.49h10.51zm9.27-14.38H7.68v56.81H115.2V20.93z"
/>
</svg>
))
MemoryIcon.propTypes = {
width: oneOfType([number, string]).isRequired,
height: oneOfType([number, string]).isRequired,
viewBox: string,
color: string,
}
MemoryIcon.defaultProps = {
width: 24,
height: 24,
viewBox: '0 0 122.88 98.08',
color: 'currentColor',
}
MemoryIcon.displayName = 'MemoryIcon'
export default MemoryIcon

View File

@ -15,7 +15,8 @@
* ------------------------------------------------------------------------- */
import DockerLogo from 'client/components/Icons/DockerIcon'
import GuacamoleLogo from 'client/components/Icons/GuacamoleIcon'
import MemoryIcon from 'client/components/Icons/MemoryIcon'
import OpenNebulaLogo from 'client/components/Icons/OpenNebulaIcon'
import WebMKSLogo from 'client/components/Icons/WebMKSIcon'
export { DockerLogo, GuacamoleLogo, OpenNebulaLogo, WebMKSLogo }
export { DockerLogo, GuacamoleLogo, MemoryIcon, OpenNebulaLogo, WebMKSLogo }

View File

@ -13,55 +13,63 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { ReactElement, useMemo, isValidElement } from 'react'
import PropTypes from 'prop-types'
import { Tooltip, Typography } from '@mui/material'
import { Stack, Tooltip, Typography } from '@mui/material'
import { StatusChip } from 'client/components/Status'
import { Translate } from 'client/components/HOC'
import { T } from 'client/constants'
const MultipleTags = ({ tags, limitTags = 1, clipboard }) => {
/**
* @typedef TagType
* @property {string} text - The text to display in the chip
* @property {string} [dataCy] - Data-cy to be used by Cypress
*/
/**
* Render a number of tags with a tooltip to show the full list.
*
* @param {object} props - Props
* @param {string[]|TagType} props.tags - Tags to display
* @param {number} [props.limitTags] - Limit the number of tags to display
* @param {boolean} [props.clipboard] - If true, the chip will be clickable
* @returns {ReactElement} - Tag list
*/
const MultipleTags = ({ tags, limitTags = 1, clipboard = false }) => {
if (tags?.length === 0) {
return null
}
const more = tags.length - limitTags
const [tagsToDisplay, tagsToHide, more] = useMemo(() => {
const ensureTags = (dirtyTags = [], isHidden) =>
dirtyTags.map((tag) => {
if (isValidElement(tag)) return tag
const Tags = tags.splice(0, limitTags).map((tag, idx) => {
const text = tag.text ?? tag
const text = tag.text ?? tag
return (
<StatusChip
key={`${idx}-${text}`}
text={text}
clipboard={clipboard}
dataCy={tag.dataCy ?? ''}
/>
)
})
return (
<StatusChip
key={text}
clipboard={clipboard}
forceWhiteColor={isHidden}
{...(typeof tag === 'string' ? { text } : tag)}
/>
)
})
return [
ensureTags(tags.slice(0, limitTags)),
ensureTags(tags.slice(limitTags), true),
tags.length - limitTags,
]
}, [tags, limitTags])
return (
<>
{Tags}
{tagsToDisplay}
{more > 0 && (
<Tooltip
arrow
title={tags.map((tag, idx) => {
const text = tag.text ?? tag
return (
<Typography
key={`${idx}-${text}`}
variant="subtitle2"
sx={{ height: 'max-content' }}
{...(tag.dataCy && { dataCy: tag.dataCy })}
>
{text}
</Typography>
)
})}
>
<Tooltip arrow title={<Stack>{tagsToHide}</Stack>}>
<Typography component="span" variant="subtitle2" sx={{ ml: 1 }}>
{`+${more} `}
<Translate word={T.More} />

View File

@ -13,54 +13,45 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo } from 'react'
import { memo, useCallback } from 'react'
import PropTypes from 'prop-types'
import { Typography, Tooltip, lighten, darken } from '@mui/material'
import makeStyles from '@mui/styles/makeStyles'
import { Copy as CopyIcon } from 'iconoir-react'
import { styled, Typography, alpha } from '@mui/material'
import { Copy as CopyIcon, Check as CopiedIcon, Cancel } from 'iconoir-react'
import { useClipboard } from 'client/hooks'
import { Tr, Translate } from 'client/components/HOC'
import { addOpacityToColor } from 'client/utils'
import { T, SCHEMES } from 'client/constants'
const useStyles = makeStyles(({ spacing, palette, typography }) => {
const getBackgroundColor = palette.mode === SCHEMES.DARK ? lighten : darken
const defaultStateColor =
palette.grey[palette.mode === SCHEMES.DARK ? 300 : 700]
const Chip = styled(Typography)(
({ theme: { palette, typography }, state = 'debug', white, icon }) => {
const { dark = state } = palette[state] ?? {}
return {
text: ({ stateColor = defaultStateColor, clipboard }) => {
const paletteColor = palette[stateColor]
const bgColor = alpha(dark, 0.2)
const color = white ? palette.common.white : palette.text.primary
const color =
paletteColor?.contrastText ?? getBackgroundColor(stateColor, 0.75)
const bgColor = paletteColor?.dark ?? stateColor
return {
color,
backgroundColor: addOpacityToColor(bgColor, 0.2),
padding: spacing('0.25rem', '0.5rem'),
borderRadius: 2,
textTransform: 'uppercase',
fontSize: typography.overline.fontSize,
fontWeight: 500,
lineHeight: 'normal',
cursor: 'default',
...(clipboard && {
return {
color,
backgroundColor: bgColor,
padding: icon ? '0.1rem 0.5rem' : '0.25rem 0.5rem',
borderRadius: 6,
textTransform: 'uppercase',
fontSize: typography.overline.fontSize,
fontWeight: 500,
lineHeight: 'normal',
cursor: 'default',
...(icon && {
display: 'inline-flex',
alignItems: 'center',
gap: '0.5em',
'& > .icon': {
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: '0.5em',
'&:hover > .copy-icon': {
color: bgColor,
color,
'&:hover': {
color: white ? palette.getContrastText(color) : dark,
},
}),
}
},
},
}),
}
}
})
)
const callAll =
(...fns) =>
@ -72,57 +63,66 @@ const StatusChip = memo(
stateColor,
text = '',
dataCy = '',
clipboard = false,
clipboard,
onClick,
onDelete,
forceWhiteColor,
...props
}) => {
const { copy, isCopied } = useClipboard()
const textToCopy = typeof clipboard === 'string' ? clipboard : text
const classes = useStyles({ stateColor, clipboard })
const handleCopy = (evt) => {
!isCopied && copy(textToCopy)
evt.stopPropagation()
}
const handleCopy = useCallback(
(evt) => {
const textToCopy = typeof clipboard === 'string' ? clipboard : text
!isCopied && copy(textToCopy)
evt.stopPropagation()
},
[clipboard, copy, text, isCopied]
)
const handleDelete = useCallback(
(evt) => {
onDelete(text)
evt.stopPropagation()
},
[text, onDelete]
)
return (
<Tooltip
arrow
open={isCopied}
title={
<>
{'✔️'}
<Translate word={T.CopiedToClipboard} />
</>
}
<Chip
component="span"
state={stateColor}
white={forceWhiteColor ? 'true' : undefined}
icon={clipboard || onDelete ? 'true' : undefined}
onClick={callAll(onClick, clipboard && handleCopy)}
data-cy={dataCy}
{...props}
>
<Typography
component="span"
className={classes.text}
onClick={callAll(onClick, clipboard && handleCopy)}
{...(dataCy && { 'data-cy': dataCy })}
{...props}
>
{text}
{clipboard && (
<CopyIcon className="copy-icon" title={Tr(T.ClickToCopy)} />
)}
</Typography>
</Tooltip>
{text}
{clipboard &&
(isCopied ? <CopiedIcon /> : <CopyIcon className="icon" />)}
{typeof onDelete === 'function' && (
<Cancel onClick={handleDelete} className="icon" />
)}
</Chip>
)
},
(prev, next) =>
prev.stateColor === next.stateColor &&
prev.text === next.text &&
prev.clipboard === next.clipboard
prev.clipboard === next.clipboard &&
prev.onDelete === next.onDelete
)
StatusChip.propTypes = {
stateColor: PropTypes.string,
text: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
clipboard: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]),
forceWhiteColor: PropTypes.bool,
dataCy: PropTypes.string,
onClick: PropTypes.func,
onDelete: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]),
}
StatusChip.displayName = 'StatusChip'

View File

@ -15,6 +15,7 @@
* ------------------------------------------------------------------------- */
import { memo } from 'react'
import PropTypes from 'prop-types'
import api from 'client/features/OneApi/datastore'
import { DatastoreCard } from 'client/components/Cards'

View File

@ -13,133 +13,116 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useEffect, useMemo, JSXElementConstructor } from 'react'
import { useMemo, ReactElement } from 'react'
import PropTypes from 'prop-types'
import {
List,
ListSubheader,
ListItemButton,
Typography,
IconButton,
Tooltip,
} from '@mui/material'
import { Cancel } from 'iconoir-react'
import { styled, TextField, Popper, Chip } from '@mui/material'
import Autocomplete, { autocompleteClasses } from '@mui/material/Autocomplete'
import { UseFiltersInstanceProps } from 'react-table'
import { Translate } from 'client/components/HOC'
import { T } from 'client/constants'
import { Tr } from 'client/components/HOC'
const StyledAutocompletePopper = styled(Popper)(
({ theme: { palette, zIndex } }) => ({
[`& .${autocompleteClasses.paper}`]: {
boxShadow: 'none',
margin: 0,
color: 'inherit',
fontSize: '0.75rem',
border: `1px solid ${palette.secondary[palette.mode]}`,
},
[`& .${autocompleteClasses.listbox}`]: {
padding: 0,
backgroundColor: palette.background.default,
[`& .${autocompleteClasses.option}`]: {
minHeight: 'auto',
alignItems: 'flex-start',
padding: '0.7em',
'&[aria-selected="true"]': {
backgroundColor: 'transparent',
},
'&[data-focus="true"], &[data-focus="true"][aria-selected="true"]': {
backgroundColor: palette.action.hover,
},
[`&:not(:last-child)`]: {
borderBottom: `1px solid ${palette.divider}`,
},
},
},
[`&.${autocompleteClasses.popper}`]: {
zIndex: zIndex.modal + 2,
},
})
)
/**
* Render category filter to table.
*
* @param {object} props - Props
* @param {string} props.title - Title category
* @param {UseFiltersInstanceProps} props.column - Column to filter by
* @param {string} [props.accessorOption] - Name of property option
* @param {boolean} [props.multiple] - If `true`, can be more than one filter
* @returns {JSXElementConstructor} Component JSX
* @param {UseFiltersInstanceProps} props.column - Props
* @returns {ReactElement} Component JSX
*/
const CategoryFilter = ({
title,
column,
accessorOption,
multiple = false,
column: { Header, filterValue = [], setFilter, preFilteredRows, id },
}) => {
const {
setFilter,
id,
preFilteredRows,
filterValue = multiple ? [] : undefined,
} = column
useEffect(() => () => setFilter(undefined), [])
// Calculate the options for filtering using the preFilteredRows
const options = useMemo(() => {
const filteredOptions = {}
const uniqueOptions = new Set()
preFilteredRows?.forEach((row) => {
const value = row.values[id]
if (!value) return
const count = filteredOptions[value[accessorOption] ?? value] || 0
filteredOptions[value[accessorOption] ?? value] = count + 1
const rowValue = row.values[id]
rowValue !== undefined && uniqueOptions.add(rowValue)
})
return filteredOptions
return [...uniqueOptions.values()]
}, [id, preFilteredRows])
const handleSelect = (value) => {
setFilter(multiple ? [...filterValue, value] : value)
}
const handleUnselect = (value) => {
setFilter(multiple ? filterValue.filter((v) => v !== value) : undefined)
}
const handleClear = () => setFilter(multiple ? [] : undefined)
const isFiltered = useMemo(
() => (multiple ? filterValue?.length > 0 : filterValue !== undefined),
[filterValue]
)
if (Object.keys(options).length === 0) {
if (options.length === 0) {
return null
}
return (
<List>
{title && (
<ListSubheader
disableSticky
disableGutters
sx={{ display: 'flex', alignItems: 'center' }}
>
<Translate word={title} />
{isFiltered && (
<Tooltip title={<Translate word={T.Clear} />}>
<IconButton disableRipple size="small" onClick={handleClear}>
<Cancel />
</IconButton>
</Tooltip>
)}
</ListSubheader>
<Autocomplete
fullWidth
multiple
disableCloseOnSelect
limitTags={2}
color="secondary"
value={filterValue}
sx={{ minWidth: 300, position: 'relative' }}
options={options}
onChange={(_, newValue) => setFilter(newValue)}
PopperComponent={StyledAutocompletePopper}
renderInput={({ inputProps, ...inputParams }) => (
<TextField
label={Tr(Header)}
ref={inputParams.InputProps.ref}
inputProps={{ ...inputProps, 'data-cy': id }}
{...inputParams}
/>
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const { key, ...tagProps } = getTagProps({ index })
{Object.entries(options).map(([option, count], i) => {
const value = option[accessorOption] ?? option
const isSelected = multiple
? filterValue?.includes?.(value)
: value === filterValue
return (
<ListItemButton
key={i}
selected={isSelected}
onClick={() =>
isSelected ? handleUnselect(value) : handleSelect(value)
}
>
<Typography noWrap variant="subtitle2" title={value}>
{`${value} (${count})`}
</Typography>
</ListItemButton>
)
})}
</List>
return (
<Chip
key={key}
variant="outlined"
size="small"
label={option}
onClick={tagProps.onDelete}
{...tagProps}
/>
)
})
}
/>
)
}
CategoryFilter.propTypes = {
column: PropTypes.object,
accessorOption: PropTypes.string,
icon: PropTypes.node,
title: PropTypes.string,
multiple: PropTypes.bool,
}
export default CategoryFilter

View File

@ -14,7 +14,7 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
// eslint-disable-next-line no-unused-vars
import { memo, ReactElement, useCallback } from 'react'
import { memo, ReactElement, useCallback, useMemo } from 'react'
import PropTypes from 'prop-types'
// eslint-disable-next-line no-unused-vars
import { Row } from 'react-table'
@ -69,15 +69,28 @@ const ActionItem = memo(
action,
disabled,
useQuery,
selected = false,
} = item
const isDisabledByNumberOfSelectedRows = useMemo(() => {
const numberOfRowSelected = selectedRows.length
const min = selected?.min ?? 1
const max = selected?.max ?? Number.MAX_SAFE_INTEGER
return (
(selected === true && !numberOfRowSelected) ||
(selected && min > numberOfRowSelected && numberOfRowSelected < max)
)
}, [selectedRows.length, selected])
const buttonProps = {
color,
variant,
'data-cy':
(dataCy && `action-${dataCy}`) ?? (accessor && `action-${accessor}`),
disabled:
typeof disabled === 'function' ? disabled(selectedRows) : disabled,
isDisabledByNumberOfSelectedRows ||
(typeof disabled === 'function' ? disabled(selectedRows) : disabled),
icon: Icon && <Icon />,
label: label && Tr(label),
title: tooltip && Tr(tooltip),

View File

@ -13,10 +13,11 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { JSXElementConstructor, useMemo } from 'react'
import { ReactElement } from 'react'
import PropTypes from 'prop-types'
import { Checkbox } from '@mui/material'
import { Stack, Checkbox } from '@mui/material'
import { RefreshDouble } from 'iconoir-react'
import {
UseTableInstanceProps,
UseRowSelectState,
@ -28,6 +29,7 @@ import {
Action,
GlobalAction,
} from 'client/components/Tables/Enhanced/Utils/GlobalActions/Action'
import { SubmitButton } from 'client/components/FormControl'
import { Tr } from 'client/components/HOC'
import { T } from 'client/constants'
@ -35,78 +37,80 @@ import { T } from 'client/constants'
* Render bulk actions.
*
* @param {object} props - Props
* @param {function():Promise} props.refetch - Function to refetch data
* @param {object} [props.className] - Class name for the container
* @param {boolean} props.isLoading - The data is fetching
* @param {boolean} props.singleSelect - If true, only one row can be selected
* @param {boolean} props.disableRowSelect - Rows can't select
* @param {GlobalAction[]} props.globalActions - Possible bulk actions
* @param {UseTableInstanceProps} props.useTableProps - Table props
* @param {boolean} props.disableRowSelect - Rows can't select
* @returns {JSXElementConstructor} Component JSX with all actions
* @returns {ReactElement} Component JSX with all actions
*/
const GlobalActions = ({
refetch,
className,
isLoading,
singleSelect = false,
disableRowSelect = false,
globalActions = [],
useTableProps,
useTableProps = {},
}) => {
/** @type {UseRowSelectInstanceProps} */
const { getToggleAllPageRowsSelectedProps, getToggleAllRowsSelectedProps } =
useTableProps
/** @type {UseRowSelectState} */
const { selectedRowIds } = useTableProps?.state ?? {}
/** @type {UseFiltersInstanceProps} */
const { preFilteredRows } = useTableProps ?? {}
const { preFilteredRows } = useTableProps
/** @type {UseRowSelectState} */
const { selectedRowIds } = useTableProps?.state
const selectedRows = preFilteredRows.filter((row) => !!selectedRowIds[row.id])
const numberOfRowSelected = selectedRows.length
const [actionsSelected, actionsNoSelected] = useMemo(
() =>
globalActions.reduce(
(memoResult, item) => {
const { selected = false } = item
selected ? memoResult[0].push(item) : memoResult[1].push(item)
return memoResult
},
[[], []]
),
[globalActions]
)
return (
<>
{!disableRowSelect && (
<Checkbox
{...getToggleAllPageRowsSelectedProps()}
title={Tr(T.ToggleAllCurrentPageRowsSelected)}
indeterminate={getToggleAllRowsSelectedProps().indeterminate}
color="secondary"
<Stack
className={className}
direction="row"
flexWrap="wrap"
alignItems="center"
gap="0.5em"
>
{refetch && (
<SubmitButton
data-cy="refresh"
icon={<RefreshDouble />}
tooltip={Tr(T.Refresh)}
isSubmitting={isLoading}
onClick={refetch}
/>
)}
{actionsNoSelected?.map((item) => (
<Action key={item.accessor} item={item} />
))}
{!disableRowSelect &&
numberOfRowSelected > 0 &&
actionsSelected?.map((item, idx) => {
const { min = 1, max = Number.MAX_SAFE_INTEGER } =
item?.selected ?? {}
const key = item.accessor ?? item.label ?? item.tooltip ?? idx
{!singleSelect && !disableRowSelect && (
<>
<Checkbox
{...getToggleAllPageRowsSelectedProps()}
title={Tr(T.ToggleAllCurrentPageRowsSelected)}
indeterminate={getToggleAllRowsSelectedProps().indeterminate}
color="secondary"
/>
{globalActions?.map((item, idx) => {
const key = item.accessor ?? item.label ?? item.tooltip ?? idx
if (min < numberOfRowSelected && numberOfRowSelected > max) {
return null
}
return <Action key={key} item={item} selectedRows={selectedRows} />
})}
</>
return <Action key={key} item={item} selectedRows={selectedRows} />
})}
</>
)}
</Stack>
)
}
GlobalActions.propTypes = {
refetch: PropTypes.func,
className: PropTypes.string,
isLoading: PropTypes.bool,
singleSelect: PropTypes.bool,
disableRowSelect: PropTypes.bool,
globalActions: PropTypes.array,
useTableProps: PropTypes.object,
disableRowSelect: PropTypes.bool,
}
export default GlobalActions

View File

@ -13,103 +13,71 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { JSXElementConstructor, useState, useCallback } from 'react'
import { Fragment, useMemo, ReactElement } from 'react'
import PropTypes from 'prop-types'
import clsx from 'clsx'
import { alpha, debounce, InputBase } from '@mui/material'
import makeStyles from '@mui/styles/makeStyles'
import { Search as SearchIcon } from 'iconoir-react'
import {
UseGlobalFiltersInstanceProps,
UseGlobalFiltersState,
} from 'react-table'
import { Stack, Button } from '@mui/material'
import { Filter } from 'iconoir-react'
import { TableInstance, UseTableInstanceProps } from 'react-table'
const useStyles = makeStyles(({ spacing, palette, shape, breakpoints }) => ({
search: {
position: 'relative',
borderRadius: shape.borderRadius,
backgroundColor: alpha(palette.divider, 0.15),
'&:hover': {
backgroundColor: alpha(palette.divider, 0.25),
},
width: '100%',
[breakpoints.up('sm')]: {
width: 'auto',
},
},
searchIcon: {
padding: spacing(0, 2),
height: '100%',
position: 'absolute',
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
inputRoot: {
color: 'inherit',
width: '100%',
},
inputInput: {
padding: spacing(1, 1, 1, 0),
// vertical padding + font size from searchIcon
paddingLeft: `calc(1em + ${spacing(4)})`,
},
}))
import HeaderPopover from 'client/components/Header/Popover'
import { Translate } from 'client/components/HOC'
import { T } from 'client/constants'
/**
* Render search input.
* Render all selected sorters.
*
* @param {object} props - Props
* @param {string} props.className - Class to wrapper root
* @param {object} props.searchProps - Props for search input
* @param {UseGlobalFiltersInstanceProps} props.useTableProps - Table props
* @returns {JSXElementConstructor} Component JSX
* @param {string} [props.className] - Class name for the container
* @param {TableInstance} props.useTableProps - Table props
* @returns {ReactElement} Component JSX
*/
const GlobalFilter = ({ useTableProps, className, searchProps }) => {
const classes = useStyles()
const GlobalFilter = ({ className, useTableProps }) => {
/** @type {UseTableInstanceProps} */
const { rows, columns, setAllFilters } = useTableProps
const { setGlobalFilter, state } = useTableProps
/** @type {UseGlobalFiltersState} */
const { globalFilter } = state
const [value, setValue] = useState(() => globalFilter)
const handleChange = useCallback(
// Set undefined to remove the filter entirely
debounce((newFilter) => setGlobalFilter(newFilter || undefined), 200),
[setGlobalFilter]
const columnsCanFilter = useMemo(
() => columns.filter(({ canFilter }) => canFilter),
[columns]
)
return (
<div className={clsx(classes.search, className)}>
<div className={classes.searchIcon}>
<SearchIcon />
</div>
<InputBase
value={value ?? ''}
type="search"
onChange={(event) => {
setValue(event.target.value)
handleChange(event.target.value)
return !columnsCanFilter.length ? null : (
<Stack className={className} direction="row" gap="0.5em" flexWrap="wrap">
<HeaderPopover
id="filter-by-button"
icon={<Filter />}
buttonLabel={T.FilterBy}
buttonProps={{
'data-cy': 'filter-by-button',
variant: 'outlined',
color: 'secondary',
disabled: rows?.length === 0,
}}
placeholder={'Search...'}
classes={{
root: classes.inputRoot,
input: classes.inputInput,
}}
inputProps={{ 'aria-label': 'search', ...(searchProps ?? {}) }}
/>
</div>
popperProps={{ placement: 'bottom-end' }}
>
{() => (
<Stack sx={{ width: { xs: '100%', md: 500 }, p: 2 }}>
{columnsCanFilter.map((column, idx) => (
<Fragment key={idx}>{column.render('Filter')}</Fragment>
))}
<Button
variant="contained"
color="secondary"
onClick={() => setAllFilters([])}
sx={{ mt: 2, alignSelf: 'flex-end' }}
>
<Translate word={T.Clear} />
</Button>
</Stack>
)}
</HeaderPopover>
</Stack>
)
}
GlobalFilter.propTypes = {
className: PropTypes.string,
useTableProps: PropTypes.object.isRequired,
searchProps: PropTypes.object,
}
export default GlobalFilter

View File

@ -0,0 +1,115 @@
/* ------------------------------------------------------------------------- *
* 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 { JSXElementConstructor, useState, useCallback } from 'react'
import PropTypes from 'prop-types'
import clsx from 'clsx'
import { alpha, debounce, InputBase } from '@mui/material'
import makeStyles from '@mui/styles/makeStyles'
import { Search as SearchIcon } from 'iconoir-react'
import {
UseGlobalFiltersInstanceProps,
UseGlobalFiltersState,
} from 'react-table'
import { Tr } from 'client/components/HOC'
import { T } from 'client/constants'
const useStyles = makeStyles(({ spacing, palette, shape, breakpoints }) => ({
search: {
position: 'relative',
borderRadius: shape.borderRadius,
backgroundColor: alpha(palette.divider, 0.15),
'&:hover': {
backgroundColor: alpha(palette.divider, 0.25),
},
width: '100%',
[breakpoints.up('sm')]: {
width: 'auto',
},
},
searchIcon: {
padding: spacing(0, 2),
height: '100%',
position: 'absolute',
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
inputRoot: {
color: 'inherit',
width: '100%',
},
inputInput: {
padding: spacing(1, 1, 1, 0),
// vertical padding + font size from searchIcon
paddingLeft: `calc(1em + ${spacing(4)})`,
},
}))
/**
* Render search input.
*
* @param {object} props - Props
* @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
*/
const GlobalSearch = ({ className, useTableProps, searchProps }) => {
const classes = useStyles()
const { setGlobalFilter, state } = useTableProps
/** @type {UseGlobalFiltersState} */
const { globalFilter } = state
const [value, setValue] = useState(() => globalFilter)
const handleChange = useCallback(
// Set undefined to remove the filter entirely
debounce((newFilter) => setGlobalFilter(newFilter || undefined), 200),
[setGlobalFilter]
)
return (
<div className={clsx(classes.search, className)}>
<div className={classes.searchIcon}>
<SearchIcon />
</div>
<InputBase
value={value ?? ''}
type="search"
onChange={(event) => {
setValue(event.target.value)
handleChange(event.target.value)
}}
placeholder={`${Tr(T.Search)}...`}
classes={{ root: classes.inputRoot, input: classes.inputInput }}
inputProps={{ 'aria-label': 'search', ...(searchProps ?? {}) }}
/>
</div>
)
}
GlobalSearch.propTypes = {
className: PropTypes.string,
useTableProps: PropTypes.object.isRequired,
searchProps: PropTypes.object,
}
export default GlobalSearch

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useEffect, useMemo, JSXElementConstructor } from 'react'
import { useEffect, useMemo, ReactElement } from 'react'
import PropTypes from 'prop-types'
import { SortDown, ArrowDown, ArrowUp } from 'iconoir-react'
@ -25,16 +25,18 @@ import {
} from 'react-table'
import HeaderPopover from 'client/components/Header/Popover'
import { Translate } from 'client/components/HOC'
import { T } from 'client/constants'
/**
* Render all selected sorters.
*
* @param {object} props - Props
* @param {string} [props.className] - Class name for the container
* @param {TableInstance} props.useTableProps - Table props
* @returns {JSXElementConstructor} Component JSX
* @returns {ReactElement} Component JSX
*/
const GlobalSort = ({ useTableProps }) => {
const GlobalSort = ({ className, useTableProps }) => {
const { headers, state } = useTableProps
/** @type {UseSortByInstanceProps} */
@ -66,7 +68,7 @@ const GlobalSort = ({ useTableProps }) => {
useEffect(() => () => setSortBy([]), [])
return !headersNotSorted.length && !sortBy.length ? null : (
<Stack direction="row" gap="0.5em" flexWrap="wrap">
<Stack className={className} direction="row" gap="0.5em" flexWrap="wrap">
{useMemo(
() => (
<HeaderPopover
@ -79,24 +81,15 @@ const GlobalSort = ({ useTableProps }) => {
variant: 'outlined',
color: 'secondary',
}}
popperProps={{ placement: 'bottom-start' }}
popperProps={{ placement: 'bottom-end' }}
>
{() => (
<MenuList>
{headersNotSorted.length ? (
headersNotSorted?.map(({ id, Header: name }) => (
<MenuItem
key={id}
onClick={() => {
handleClick(id, name)
}}
>
{name}
</MenuItem>
))
) : (
<span>{T.Empty}</span>
)}
{headersNotSorted?.map(({ id, Header: name }) => (
<MenuItem key={id} onClick={() => handleClick(id, name)}>
<Translate word={name} />
</MenuItem>
))}
</MenuList>
)}
</HeaderPopover>
@ -122,6 +115,7 @@ const GlobalSort = ({ useTableProps }) => {
}
GlobalSort.propTypes = {
className: PropTypes.string,
useTableProps: PropTypes.object.isRequired,
}

View File

@ -1,143 +0,0 @@
/* ------------------------------------------------------------------------- *
* 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. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useEffect, useMemo } from 'react'
import PropTypes from 'prop-types'
import { Cancel } from 'iconoir-react'
import { List, ListSubheader, IconButton } from '@mui/material'
import { TreeView, TreeItem } from '@mui/lab'
import { UseFiltersInstanceProps } from 'react-table'
import { Translate } from 'client/components/HOC'
const buildTree = (data = [], separator = '/') => {
const mapper = {}
const tree = {
id: 'root',
name: 'Labels',
children: [],
}
for (const labelString of data) {
const splits = labelString.split(separator)
let label = ''
splits.reduce((parent, place) => {
if (label) {
label += `${separator}${place}`
} else {
label = place
}
if (place && !mapper[label]) {
const o = { id: label }
mapper[label] = o
mapper[label].name = place
parent.children = parent.children || []
parent.children.push(o)
}
return mapper[label]
}, tree)
}
return tree
}
const LabelFilter = ({ title, column }) => {
/** @type {UseFiltersInstanceProps} */
const { setFilter, id, preFilteredRows, filterValue = [] } = column
useEffect(() => () => setFilter([]), [])
const labels = useMemo(() => {
const uniqueLabels = new Set()
preFilteredRows?.forEach((row) => {
const labelsFromTemplate = row.values[id]
labelsFromTemplate.forEach(uniqueLabels.add, uniqueLabels)
})
return [...uniqueLabels.values()]
}, [id, preFilteredRows])
const tree = useMemo(() => buildTree(labels), [labels])
const handleSelect = (value) => setFilter([...filterValue, value])
const handleUnselect = (value) =>
setFilter(filterValue.filter((v) => v !== value))
const handleClear = () => setFilter(undefined)
const isFiltered = useMemo(() => filterValue?.length > 0, [filterValue])
const renderTree = ({ id: treeId, name, children }) => (
<TreeItem key={treeId} nodeId={treeId} label={name}>
{Array.isArray(children)
? children.map((node) => renderTree(node))
: null}
</TreeItem>
)
return (
<List>
{title && (
<ListSubheader
disableSticky
disableGutters
sx={{ display: 'flex', alignItems: 'center' }}
>
<Translate word={title} />
{isFiltered && (
<IconButton
disableRipple
disablePadding
size="small"
onClick={handleClear}
>
<Cancel />
</IconButton>
)}
</ListSubheader>
)}
<TreeView
selected={filterValue}
defaultExpanded={['root', ...labels]}
onNodeSelect={(evt, value) => {
evt.preventDefault()
filterValue.includes?.(value)
? handleUnselect(value)
: handleSelect(value)
}}
>
{renderTree(tree)}
</TreeView>
</List>
)
}
LabelFilter.propTypes = {
column: PropTypes.object,
icon: PropTypes.node,
title: PropTypes.string,
}
export default LabelFilter

View File

@ -13,43 +13,50 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useCallback } from 'react'
import { ReactElement } from 'react'
import PropTypes from 'prop-types'
import { Row as RowType } from 'react-table'
const Row = ({ row, handleClick }) => {
/** @type {RowType} */
const { getRowProps, cells, isSelected } = row
import { Stack, TextField } from '@mui/material'
import { UseFiltersInstanceProps } from 'react-table'
const renderCell = useCallback(
(cell) => (
<div {...cell.getCellProps()} data-header={cell.column.Header}>
{cell.render('Cell')}
</div>
),
[]
)
import { Tr } from 'client/components/HOC'
return (
<div
{...getRowProps()}
className={isSelected ? 'selected' : ''}
onClick={handleClick}
>
{cells?.map(renderCell)}
</div>
)
/**
* Render category filter to table.
*
* @param {object} props - Props
* @param {UseFiltersInstanceProps} props.column - Props
* @returns {ReactElement} Component JSX
*/
const CategoryFilter = ({ column: { Header, filterValue, setFilter, id } }) => (
<Stack direction="row">
<TextField
fullWidth
label={Tr(Header)}
value={new Date(filterValue)}
onChange={(evt) => {
console.log(evt.target.value)
}}
color="secondary"
type="date"
inputProps={{ 'data-cy': `after-${id}` }}
/>
<TextField
fullWidth
label={Tr(Header)}
value={new Date(filterValue)}
onChange={(evt) => {
console.log(evt.target.value)
}}
color="secondary"
type="date"
inputProps={{ 'data-cy': `before-${id}` }}
/>
</Stack>
)
CategoryFilter.propTypes = {
column: PropTypes.object,
}
Row.propTypes = {
row: PropTypes.object,
handleClick: PropTypes.func,
}
Row.defaultProps = {
row: {},
handleClick: undefined,
}
export default Row
export default CategoryFilter

View File

@ -16,9 +16,10 @@
import CategoryFilter from 'client/components/Tables/Enhanced/Utils/CategoryFilter'
import GlobalActions from 'client/components/Tables/Enhanced/Utils/GlobalActions'
import GlobalFilter from 'client/components/Tables/Enhanced/Utils/GlobalFilter'
import GlobalSearch from 'client/components/Tables/Enhanced/Utils/GlobalSearch'
import GlobalSelectedRows from 'client/components/Tables/Enhanced/Utils/GlobalSelectedRows'
import GlobalSort from 'client/components/Tables/Enhanced/Utils/GlobalSort'
import LabelFilter from 'client/components/Tables/Enhanced/Utils/LabelFilter'
import TimeFilter from 'client/components/Tables/Enhanced/Utils/TimeFilter'
export * from 'client/components/Tables/Enhanced/Utils/GlobalActions/Action'
export * from 'client/components/Tables/Enhanced/Utils/utils'
@ -27,7 +28,8 @@ export {
CategoryFilter,
GlobalActions,
GlobalFilter,
GlobalSearch,
GlobalSelectedRows,
GlobalSort,
LabelFilter,
TimeFilter,
}

View File

@ -14,22 +14,30 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { Column } from 'react-table'
import CategoryFilter from 'client/components/Tables/Enhanced/Utils/CategoryFilter'
import { GlobalAction } from 'client/components/Tables/Enhanced/Utils/GlobalActions/Action'
import {
GlobalAction,
CategoryFilter,
TimeFilter,
} from 'client/components/Tables/Enhanced/Utils'
/**
* Add filters defined in view yaml to columns.
*
* @param {object} config - Config
* @param {object} config.filters - List of criteria to filter the columns.
* @param {object} config.filters - List of criteria to filter the columns
* @param {Column[]} config.columns - Columns
* @returns {object} Column with filters
* @returns {Column[]} Column with filters
*/
export const createColumns = ({ filters = {}, columns = [] }) => {
if (Object.keys(filters).length === 0) return columns
return columns.map((column) => {
const { Header, id = '', accessor } = column
const { id = '', accessor, noFilterIds = [] } = column
// noFilterIds is a list of column ids that should not have a filter
// it's defined in the resource columns definition
if (noFilterIds.includes(id)) return column
const filterById = !!filters[String(id.toLowerCase())]
@ -38,26 +46,53 @@ export const createColumns = ({ filters = {}, columns = [] }) => {
return {
...column,
...((filterById || filterByAccessor) && createCategoryFilter(Header)),
...((filterById || filterByAccessor) &&
(
{
// TODO: Add label to filters
// label: createLabelFilter,
time: createTimeFilter,
}[`${id}`.toLowerCase()] ?? createCategoryFilter
)(column)),
}
})
}
/**
* Create label filter as column.
*
* @param {Column} column - Column
* @returns {Column} - Label filter
*/
// export const createLabelFilter = (column) => ({
// disableFilters: false,
// Filter: LabelFilter,
// ...column,
// })
/**
* Create time filter as column.
*
* @param {Column} column - Column
* @returns {Column} - Time filter
*/
export const createTimeFilter = (column) => ({
disableFilters: false,
Filter: TimeFilter,
...column,
})
/**
* Create category filter as column.
*
* @param {string} title - Title
* @param {Column} column - Column
* @returns {Column} - Category filter
*/
export const createCategoryFilter = (title) => ({
export const createCategoryFilter = (column) => ({
disableFilters: false,
Filter: ({ column }) =>
CategoryFilter({
column,
multiple: true,
title,
}),
Filter: CategoryFilter,
filter: 'includesValue',
...column,
})
/**

View File

@ -1,117 +0,0 @@
/* ------------------------------------------------------------------------- *
* 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 { useMemo, Fragment, JSXElementConstructor } from 'react'
import PropTypes from 'prop-types'
import clsx from 'clsx'
import { UseTableInstanceProps } from 'react-table'
import { useMediaQuery, Card, CardContent } from '@mui/material'
import makeStyles from '@mui/styles/makeStyles'
import { GlobalFilter } from 'client/components/Tables/Enhanced/Utils'
const useToolbarStyles = makeStyles({
root: {
display: 'flex',
},
rootNoFilters: {
gridColumn: '1 / -1',
'& ~ div': {
gridColumn: '1 / -1',
},
},
content: {
flexGrow: 1,
},
contentWithFilter: {
display: 'flex',
flexDirection: 'column',
gap: '1em',
},
filters: {
flexGrow: 1,
overflow: 'auto',
},
})
/**
* @param {object} props - Props
* @param {boolean} props.onlyGlobalSearch - Show only the global search
* @param {object} props.searchProps - Props for search input
* @param {UseTableInstanceProps} props.useTableProps - Table props
* @returns {JSXElementConstructor} Returns table toolbar
*/
const Filters = ({ onlyGlobalSearch, useTableProps, searchProps }) => {
const classes = useToolbarStyles()
const isMobile = useMediaQuery((theme) => theme.breakpoints.down('md'))
/** @type {UseTableInstanceProps} */
const { rows, columns } = useTableProps
const filters = useMemo(
() =>
columns
.filter(({ canFilter }) => canFilter)
.map((column, idx) =>
column.canFilter ? (
<Fragment key={idx}>{column.render('Filter')}</Fragment>
) : null
),
[rows]
)
if (isMobile || onlyGlobalSearch) {
return (
<GlobalFilter
className={classes.rootNoFilters}
useTableProps={useTableProps}
searchProps={searchProps}
/>
)
}
const noFilters = filters.length === 0
return (
<Card
variant="outlined"
className={clsx(classes.root, { [classes.rootNoFilters]: noFilters })}
>
<CardContent
className={clsx(classes.content, {
[classes.contentWithFilter]: !noFilters,
})}
>
<GlobalFilter useTableProps={useTableProps} searchProps={searchProps} />
{!noFilters && <div className={classes.filters}>{filters}</div>}
</CardContent>
</Card>
)
}
Filters.propTypes = {
onlyGlobalSearch: PropTypes.bool,
useTableProps: PropTypes.object,
searchProps: PropTypes.object,
}
Filters.defaultProps = {
onlyGlobalSearch: false,
useTableProps: {},
}
export default Filters

View File

@ -32,9 +32,14 @@ import {
UseRowSelectRowProps,
} from 'react-table'
import Toolbar from 'client/components/Tables/Enhanced/toolbar'
import Pagination from 'client/components/Tables/Enhanced/pagination'
import Filters from 'client/components/Tables/Enhanced/filters'
import {
GlobalActions,
GlobalSearch,
GlobalFilter,
GlobalSort,
GlobalSelectedRows,
} from 'client/components/Tables/Enhanced/Utils'
import EnhancedTableStyles from 'client/components/Tables/Enhanced/styles'
import { Translate } from 'client/components/HOC'
@ -50,8 +55,7 @@ const EnhancedTable = ({
initialState,
refetch,
isLoading,
onlyGlobalSearch,
onlyGlobalSelectedRows,
displaySelectedRows,
disableRowSelect,
disableGlobalSort,
onSelectedRowsChange,
@ -68,7 +72,7 @@ const EnhancedTable = ({
const isUninitialized = useMemo(
() => isLoading && data === undefined,
[isLoading, data]
[isLoading, data?.length]
)
const defaultColumn = useMemo(() => ({ disableFilters: true }), [])
@ -149,80 +153,88 @@ const EnhancedTable = ({
{...rootProps}
>
<div className={styles.toolbar}>
{/* TOOLBAR */}
<Toolbar
{/* ACTIONS */}
<GlobalActions
className={styles.actions}
refetch={refetch}
isLoading={isLoading}
globalActions={globalActions}
singleSelect={singleSelect}
disableRowSelect={disableRowSelect}
disableGlobalSort={disableGlobalSort}
onlyGlobalSelectedRows={onlyGlobalSelectedRows}
globalActions={globalActions}
useTableProps={useTableProps}
/>
{/* PAGINATION */}
<div className={styles.pagination}>
<Pagination
handleChangePage={handleChangePage}
useTableProps={useTableProps}
count={rows.length}
showPageCount={showPageCount}
/>
<Pagination
className={styles.pagination}
handleChangePage={handleChangePage}
useTableProps={useTableProps}
count={rows.length}
showPageCount={showPageCount}
/>
{/* SEARCH */}
<GlobalSearch
className={styles.search}
useTableProps={useTableProps}
searchProps={searchProps}
/>
{/* FILTERS */}
<div className={styles.filters}>
<GlobalFilter useTableProps={useTableProps} />
{!disableGlobalSort && <GlobalSort useTableProps={useTableProps} />}
</div>
{/* SELECTED ROWS */}
{displaySelectedRows && (
<div>
<GlobalSelectedRows useTableProps={useTableProps} />
</div>
)}
</div>
<div className={styles.table}>
{/* FILTERS */}
{!isUninitialized && (
<Filters
onlyGlobalSearch={onlyGlobalSearch}
useTableProps={useTableProps}
searchProps={searchProps}
/>
<div className={clsx(styles.body, classes.body)}>
{/* NO DATA MESSAGE */}
{!isUninitialized && page?.length === 0 && (
<span className={styles.noDataMessage}>
<InfoEmpty />
<Translate word={T.NoDataAvailable} />
</span>
)}
<div className={clsx(styles.body, classes.body)}>
{/* NO DATA MESSAGE */}
{!isUninitialized && page?.length === 0 && (
<span className={styles.noDataMessage}>
<InfoEmpty />
<Translate word={T.NoDataAvailable} />
</span>
)}
{/* DATALIST PER PAGE */}
{page.map((row) => {
prepareRow(row)
{/* DATALIST PER PAGE */}
{page.map((row) => {
prepareRow(row)
/** @type {UseRowSelectRowProps} */
const {
getRowProps,
original,
values,
toggleRowSelected,
isSelected,
} = row
const { key, ...rowProps } = getRowProps()
/** @type {UseRowSelectRowProps} */
const {
getRowProps,
original,
values,
toggleRowSelected,
isSelected,
} = row
const { key, ...rowProps } = getRowProps()
return (
<RowComponent
{...rowProps}
key={key}
original={original}
value={values}
className={isSelected ? 'selected' : ''}
onClick={() => {
typeof onRowClick === 'function' && onRowClick(original)
return (
<RowComponent
{...rowProps}
key={key}
original={original}
value={values}
className={isSelected ? 'selected' : ''}
onClick={() => {
typeof onRowClick === 'function' && onRowClick(original)
if (!disableRowSelect) {
singleSelect && toggleAllRowsSelected?.(false)
toggleRowSelected?.(!isSelected)
}
}}
/>
)
})}
</div>
if (!disableRowSelect) {
singleSelect && toggleAllRowsSelected?.(false)
toggleRowSelected?.(!isSelected)
}
}}
/>
)
})}
</div>
</Box>
)
@ -251,8 +263,7 @@ EnhancedTable.propTypes = {
isLoading: PropTypes.bool,
disableGlobalSort: PropTypes.bool,
disableRowSelect: PropTypes.bool,
onlyGlobalSearch: PropTypes.bool,
onlyGlobalSelectedRows: PropTypes.bool,
displaySelectedRows: PropTypes.bool,
onSelectedRowsChange: PropTypes.func,
onRowClick: PropTypes.func,
pageSize: PropTypes.number,

View File

@ -17,13 +17,14 @@
import { useMemo } from 'react'
import PropTypes from 'prop-types'
import { Button, Typography } from '@mui/material'
import { Stack, Button, Typography } from '@mui/material'
import { NavArrowLeft, NavArrowRight } from 'iconoir-react'
import { UsePaginationState } from 'react-table'
import { T } from 'client/constants'
const Pagination = ({
className,
count = 0,
handleChangePage,
useTableProps,
@ -43,7 +44,13 @@ const Pagination = ({
}
return (
<>
<Stack
className={className}
direction="row"
alignItems="center"
justifyContent="end"
gap="1em"
>
<Button
aria-label="previous page"
disabled={pageIndex === 0}
@ -67,11 +74,12 @@ const Pagination = ({
{T.Next}
<NavArrowRight />
</Button>
</>
</Stack>
)
}
Pagination.propTypes = {
className: PropTypes.string,
handleChangePage: PropTypes.func.isRequired,
useTableProps: PropTypes.object.isRequired,
count: PropTypes.number.isRequired,

View File

@ -26,39 +26,35 @@ export default makeStyles(({ palette, typography, breakpoints }) => ({
...typography.body1,
marginBottom: 16,
display: 'grid',
gridTemplateColumns: '1fr auto',
gridTemplateRows: 'auto auto',
gridTemplateAreas: `
'actions actions pagination'
'search search filters'`,
alignItems: 'start',
gap: '1em',
'& > .summary': {
// global sort and selected rows
gridRow: '2',
gridColumn: '1 / -1',
display: 'grid',
gridTemplateColumns: 'minmax(auto, 300px) 1fr',
gap: '0.8em',
[breakpoints.down('md')]: {
gridTemplateColumns: '1fr',
},
[breakpoints.down('md')]: {
gridTemplateAreas: `
'actions actions actions'
'pagination pagination pagination'
'search search filters'`,
},
},
actions: {
gridArea: 'actions',
},
pagination: {
gridArea: 'pagination',
},
search: {
gridArea: 'search',
},
filters: {
gridArea: 'filters',
display: 'flex',
alignItems: 'center',
justifyContent: 'end',
justifySelf: 'end',
gap: '1em',
},
loading: {
transition: '200ms',
},
table: {
display: 'grid',
gridTemplateColumns: 'minmax(auto, 300px) 1fr',
gap: '0.8em',
overflow: 'auto',
[breakpoints.down('md')]: {
gridTemplateColumns: 'minmax(0, 1fr)',
},
},
body: {
overflow: 'auto',
display: 'grid',

View File

@ -1,114 +0,0 @@
/* ------------------------------------------------------------------------- *
* 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 { JSXElementConstructor } from 'react'
import PropTypes from 'prop-types'
import { Stack, useMediaQuery } from '@mui/material'
import { UseTableInstanceProps, UseRowSelectState } from 'react-table'
import { RefreshDouble } from 'iconoir-react'
import {
GlobalActions,
GlobalAction,
GlobalSelectedRows,
GlobalSort,
} from 'client/components/Tables/Enhanced/Utils'
import { SubmitButton } from 'client/components/FormControl'
import { T } from 'client/constants'
/**
* @param {object} props - Props
* @param {GlobalAction[]} props.globalActions - Global actions
* @param {boolean} props.onlyGlobalSelectedRows - Show only the selected rows
* @param {boolean} props.disableRowSelect - Rows can't select
* @param {boolean} props.disableGlobalSort - Hide the sort filters
* @param {UseTableInstanceProps} props.useTableProps - Table props
* @param {function():Promise} props.refetch - Function to refetch data
* @param {boolean} props.isLoading - The data is fetching
* @returns {JSXElementConstructor} Returns table toolbar
*/
const Toolbar = ({
globalActions,
onlyGlobalSelectedRows,
disableGlobalSort = false,
disableRowSelect = false,
useTableProps,
refetch,
isLoading,
}) => {
const isSmallDevice = useMediaQuery((theme) => theme.breakpoints.down('md'))
/** @type {UseRowSelectState} */
const { selectedRowIds } = useTableProps?.state ?? {}
const enableGlobalSort = !isSmallDevice && !disableGlobalSort
const enableGlobalSelect =
!onlyGlobalSelectedRows && !!Object.keys(selectedRowIds).length
return (
<>
<Stack direction="row" flexWrap="wrap" alignItems="center" gap="1em">
{refetch && (
<SubmitButton
data-cy="refresh"
icon={<RefreshDouble />}
title={T.Tooltip}
isSubmitting={isLoading}
onClick={refetch}
/>
)}
{onlyGlobalSelectedRows && !disableRowSelect ? (
<GlobalSelectedRows useTableProps={useTableProps} />
) : (
<GlobalActions
refetch={refetch}
isLoading={isLoading}
disableRowSelect={disableRowSelect}
globalActions={globalActions}
useTableProps={useTableProps}
/>
)}
</Stack>
{(enableGlobalSort || enableGlobalSelect) && (
<Stack
className="summary"
direction="row"
flexWrap="wrap"
alignItems="center"
gap={'1em'}
width={1}
>
{enableGlobalSort && <GlobalSort useTableProps={useTableProps} />}
{enableGlobalSelect && (
<GlobalSelectedRows withAlert useTableProps={useTableProps} />
)}
</Stack>
)}
</>
)
}
Toolbar.propTypes = {
globalActions: PropTypes.array,
onlyGlobalSelectedRows: PropTypes.bool,
disableRowSelect: PropTypes.bool,
disableGlobalSort: PropTypes.bool,
useTableProps: PropTypes.object,
refetch: PropTypes.func,
isLoading: PropTypes.bool,
}
export default Toolbar

View File

@ -13,54 +13,25 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { CategoryFilter } from 'client/components/Tables/Enhanced/Utils'
import * as MarketplaceAppModel from 'client/models/MarketplaceApp'
import { Column } from 'react-table'
export default [
{ Header: 'ID', accessor: 'ID', sortType: 'number' },
{ Header: 'Name', accessor: 'NAME' },
{ Header: 'Owner', accessor: 'UNAME' },
{ Header: 'Group', accessor: 'GNAME' },
{
Header: 'State',
id: 'STATE',
accessor: (row) => MarketplaceAppModel.getState(row)?.name,
disableFilters: false,
Filter: ({ column }) =>
CategoryFilter({
column,
multiple: true,
title: 'State',
}),
filter: 'includesValue',
},
{
Header: 'Type',
id: 'TYPE',
accessor: (row) => MarketplaceAppModel.getType(row),
disableFilters: false,
Filter: ({ column }) =>
CategoryFilter({
column,
multiple: true,
title: 'Type',
}),
filter: 'includesValue',
},
{ Header: 'Size', accessor: 'SIZE' },
{ Header: 'Registration Time', accessor: 'REGTIME' },
{
Header: 'Marketplace',
accessor: 'MARKETPLACE',
disableFilters: false,
Filter: ({ column }) =>
CategoryFilter({
column,
multiple: true,
title: 'Marketplace',
}),
filter: 'includesValue',
},
{ Header: 'Zone ID', accessor: 'ZONE_ID' },
import { getState, getType } from 'client/models/MarketplaceApp'
import { T } from 'client/constants'
/** @type {Column[]} Marketplace Apps columns */
const COLUMNS = [
{ Header: T.ID, id: 'id', accessor: 'ID', sortType: 'number' },
{ Header: T.Name, id: 'name', accessor: 'NAME' },
{ Header: T.Owner, id: 'owner', accessor: 'UNAME' },
{ Header: T.Group, id: 'group', accessor: 'GNAME' },
{ Header: T.State, id: 'state', accessor: (row) => getState(row)?.name },
{ Header: T.Type, id: 'type', accessor: getType },
{ Header: T.Size, id: 'size', accessor: 'SIZE' },
{ Header: T.RegistrationTime, id: 'time', accessor: 'REGTIME' },
{ Header: T.Marketplace, id: 'marketplace', accessor: 'MARKETPLACE' },
{ Header: T.Zone, id: 'zone', accessor: 'ZONE_ID' },
]
COLUMNS.noFilterIds = ['id', 'name', 'time', 'size']
export default COLUMNS

View File

@ -14,75 +14,23 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { memo } from 'react'
import PropTypes from 'prop-types'
import { Lock, User, Group, Cart } from 'iconoir-react'
import { Typography } from '@mui/material'
import api from 'client/features/OneApi/marketplaceApp'
import { MarketplaceAppCard } from 'client/components/Cards'
import { StatusCircle, StatusChip } from 'client/components/Status'
import { rowStyles } from 'client/components/Tables/styles'
const Row = memo(
({ original, value, ...props }) => {
const state = api.endpoints.getMarketplaceApps.useQueryState(undefined, {
selectFromResult: ({ data = [] }) =>
data.find((app) => +app.ID === +original.ID),
})
import * as MarketplaceAppModel from 'client/models/MarketplaceApp'
import * as Helper from 'client/models/Helper'
import { prettyBytes } from 'client/utils'
const Row = ({ original, value, ...props }) => {
const classes = rowStyles()
const {
ID,
NAME,
UNAME,
GNAME,
LOCK,
TYPE,
REGTIME,
MARKETPLACE,
ZONE_ID,
SIZE,
} = value
const { color: stateColor, name: stateName } =
MarketplaceAppModel.getState(original)
const time = Helper.timeFromMilliseconds(+REGTIME)
const timeAgo = `registered ${time.toRelative()}`
return (
<div {...props} data-cy={`app-${ID}`}>
<div className={classes.main}>
<div className={classes.title}>
<StatusCircle color={stateColor} tooltip={stateName} />
<Typography component="span">{NAME}</Typography>
{LOCK && <Lock />}
<span className={classes.labels}>
<StatusChip text={TYPE} />
</span>
</div>
<div className={classes.caption}>
<span title={time.toFormat('ff')}>{`#${ID} ${timeAgo}`}</span>
<span title={`Owner: ${UNAME}`}>
<User />
<span>{` ${UNAME}`}</span>
</span>
<span title={`Group: ${GNAME}`}>
<Group />
<span>{` ${GNAME}`}</span>
</span>
<span title={`Marketplace: ${MARKETPLACE}`}>
<Cart />
<span>{` ${MARKETPLACE}`}</span>
</span>
</div>
</div>
<div className={classes.secondary}>
<span className={classes.labels}>
<StatusChip text={`Zone ${ZONE_ID}`} />
<StatusChip text={prettyBytes(+SIZE, 'MB')} />
</span>
</div>
</div>
)
}
return <MarketplaceAppCard app={state ?? original} rootProps={props} />
},
(prev, next) => prev.className === next.className
)
Row.propTypes = {
original: PropTypes.object,
@ -91,4 +39,6 @@ Row.propTypes = {
handleClick: PropTypes.func,
}
Row.displayName = 'MarketplaceAppRow'
export default Row

View File

@ -136,7 +136,7 @@ const Actions = () => {
dialogProps: {
title: (rows) => {
const isMultiple = rows?.length > 1
const { ID, NAME } = rows?.[0]?.original
const { ID, NAME } = rows?.[0]?.original ?? {}
return [
Tr(

View File

@ -13,34 +13,30 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { CategoryFilter } from 'client/components/Tables/Enhanced/Utils'
import * as Helper from 'client/models/Helper'
import {} from 'client/constants'
import { Column } from 'react-table'
export default [
{ Header: 'ID', accessor: 'ID', sortType: 'number' },
{ Header: 'Name', accessor: 'NAME' },
{ Header: 'Owner', accessor: 'UNAME' },
{ Header: 'Group', accessor: 'GNAME' },
{ Header: 'Start Time', accessor: 'REGTIME' },
{ Header: 'Locked', accessor: 'LOCK' },
import { T } from 'client/constants'
/** @type {Column[]} VM Template columns */
const COLUMNS = [
{ Header: T.ID, id: 'id', accessor: 'ID', sortType: 'number' },
{ Header: T.Name, id: 'name', accessor: 'NAME' },
{ Header: T.Owner, id: 'owner', accessor: 'UNAME' },
{ Header: T.Group, id: 'group', accessor: 'GNAME' },
{ Header: T.RegistrationTime, id: 'time', accessor: 'REGTIME' },
{ Header: T.Locked, id: 'locked', accessor: 'LOCK' },
{
Header: 'Logo',
id: 'LOGO',
accessor: (row) => row?.TEMPLATE?.LOGO,
Header: T.Logo,
id: 'logo',
accessor: 'TEMPLATE.LOGO',
},
{
Header: 'Virtual Router',
id: 'VROUTER',
accessor: (row) =>
Helper.stringToBoolean(row?.TEMPLATE?.VROUTER) && 'VROUTER',
disableFilters: false,
Filter: ({ column }) =>
CategoryFilter({
column,
title: 'Virtual Router',
}),
filter: 'exact',
Header: T.VirtualRouter,
id: 'vrouter',
accessor: 'TEMPLATE.VROUTER',
},
]
COLUMNS.noFilterIds = ['id', 'name', 'time', 'logo']
export default COLUMNS

View File

@ -13,13 +13,19 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo } from 'react'
import { memo, useMemo, useCallback } from 'react'
import PropTypes from 'prop-types'
import vmTemplateApi from 'client/features/OneApi/vmTemplate'
import vmTemplateApi, {
useUpdateTemplateMutation,
} from 'client/features/OneApi/vmTemplate'
import { VmTemplateCard } from 'client/components/Cards'
import { jsonToXml } from 'client/models/Helper'
const Row = memo(
({ original, value, ...props }) => {
const [update] = useUpdateTemplateMutation()
const state = vmTemplateApi.endpoints.getTemplates.useQueryState(
undefined,
{
@ -28,7 +34,27 @@ const Row = memo(
}
)
return <VmTemplateCard template={state ?? original} rootProps={props} />
const memoTemplate = useMemo(() => state ?? original, [state, original])
const handleDeleteLabel = useCallback(
(label) => {
const currentLabels = memoTemplate.TEMPLATE?.LABELS?.split(',')
const newLabels = currentLabels.filter((l) => l !== label).join(',')
const newUserTemplate = { ...memoTemplate.TEMPLATE, LABELS: newLabels }
const templateXml = jsonToXml(newUserTemplate)
update({ id: original.ID, template: templateXml, replace: 0 })
},
[memoTemplate.TEMPLATE?.LABELS, update]
)
return (
<VmTemplateCard
template={memoTemplate}
rootProps={props}
onDeleteLabel={handleDeleteLabel}
/>
)
},
(prev, next) => prev.className === next.className
)

View File

@ -66,7 +66,7 @@ const useTableStyles = makeStyles({
})
const isDisabled = (action) => (rows) =>
isAvailableAction(action)(rows, ({ values }) => values?.STATE)
isAvailableAction(action)(rows, ({ values }) => values?.state)
const ListVmNames = ({ rows = [] }) => {
const { data: datastores = [] } = useGetDatastoresQuery()
@ -147,7 +147,6 @@ const Actions = () => {
return (
<VmTemplatesTable
onlyGlobalSearch
disableGlobalSort
disableRowSelect
classes={classes}

View File

@ -13,31 +13,43 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import * as VirtualMachineModel from 'client/models/VirtualMachine'
import { Column } from 'react-table'
export default [
{ Header: 'ID', accessor: 'ID', sortType: 'number' },
{ Header: 'Name', accessor: 'NAME' },
import {
getState,
getIps,
getType,
getLastHistory,
} from 'client/models/VirtualMachine'
import { T } from 'client/constants'
/** @type {Column[]} VM columns */
const COLUMNS = [
{ Header: T.ID, id: 'id', accessor: 'ID', sortType: 'number' },
{ Header: T.Name, id: 'name', accessor: 'NAME' },
{
Header: 'State',
id: 'STATE',
accessor: (row) => VirtualMachineModel.getState(row)?.name,
Header: T.State,
id: 'state',
accessor: (row) => getState(row)?.name,
},
{ Header: 'Owner', accessor: 'UNAME' },
{ Header: 'Group', accessor: 'GNAME' },
{ Header: 'Start Time', accessor: 'STIME' },
{ Header: 'End Time', accessor: 'ETIME' },
{ Header: 'Locked', accessor: 'LOCK' },
{ Header: T.Owner, id: 'owner', accessor: 'UNAME' },
{ Header: T.Group, id: 'group', accessor: 'GNAME' },
{ Header: T.StartTime, id: 'time', accessor: 'STIME' },
{ Header: T.Locked, id: 'locked', accessor: 'LOCK' },
{ Header: T.Type, id: 'type', accessor: getType },
{
Header: 'Ips',
id: 'IPS',
accessor: (row) => VirtualMachineModel.getIps(row).join(','),
Header: T.IP,
id: 'ips',
accessor: (row) => getIps(row).join(),
sortType: 'length',
},
{
Header: 'Hostname',
id: 'HOSTNAME',
accessor: (row) => VirtualMachineModel.getLastHistory(row)?.HOSTNAME,
Header: T.Hostname,
id: 'hostname',
accessor: (row) => getLastHistory(row)?.HOSTNAME,
},
]
COLUMNS.noFilterIds = ['id', 'name', 'ips', 'time']
export default COLUMNS

View File

@ -13,19 +13,22 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo, useMemo } from 'react'
import { memo, useMemo, useCallback } from 'react'
import PropTypes from 'prop-types'
import vmApi from 'client/features/OneApi/vm'
import vmApi, { useUpdateUserTemplateMutation } from 'client/features/OneApi/vm'
import { VirtualMachineCard } from 'client/components/Cards'
import { ConsoleButton } from 'client/components/Buttons'
import { jsonToXml } from 'client/models/Helper'
import { VM_ACTIONS } from 'client/constants'
const { VNC, RDP, SSH, VMRC } = VM_ACTIONS
const CONNECTION_TYPES = [VNC, RDP, SSH, VMRC]
const Row = memo(
({ original, ...props }) => {
({ original, value, ...props }) => {
const [update] = useUpdateUserTemplateMutation()
const state = vmApi.endpoints.getVms.useQueryState(undefined, {
selectFromResult: ({ data = [] }) =>
data.find((vm) => +vm.ID === +original.ID),
@ -33,10 +36,23 @@ const Row = memo(
const memoVm = useMemo(() => state ?? original, [state, original])
const handleDeleteLabel = useCallback(
(label) => {
const currentLabels = memoVm.USER_TEMPLATE?.LABELS?.split(',')
const newLabels = currentLabels.filter((l) => l !== label).join(',')
const newUserTemplate = { ...memoVm.USER_TEMPLATE, LABELS: newLabels }
const templateXml = jsonToXml(newUserTemplate)
update({ id: original.ID, template: templateXml, replace: 0 })
},
[memoVm.USER_TEMPLATE?.LABELS, update]
)
return (
<VirtualMachineCard
vm={memoVm}
rootProps={props}
onDeleteLabel={handleDeleteLabel}
actions={
<>
{CONNECTION_TYPES.map((connectionType) => (

View File

@ -32,18 +32,13 @@ export const rowStyles = makeStyles(
},
},
figure: {
flexBasis: '20%',
paddingTop: '12.5%',
overflow: 'hidden',
position: 'relative',
flexBasis: '10%',
aspectRatio: '16/9',
},
image: {
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'contain',
position: 'absolute',
userSelect: 'none',
},
main: {
@ -68,18 +63,18 @@ export const rowStyles = makeStyles(
color: palette.text.secondary,
marginTop: 4,
display: 'flex',
gap: '0.75em',
gap: '0.5em',
alignItems: 'center',
flexWrap: 'wrap',
wordWrap: 'break-word',
'& > .full-width': {
flexBasis: '100%',
},
},
captionItem: {
display: 'flex',
gap: '0.5em',
alignItems: 'center',
'& > span': {
display: 'flex',
alignItems: 'center',
gap: '0.5em',
},
},
secondary: {
width: '25%',

View File

@ -80,8 +80,8 @@ const ClusterInfoTab = ({ tabProps = {}, id }) => {
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(480px, 1fr))"
padding="0.8em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
{informationPanel?.enabled && (
<Information

View File

@ -52,7 +52,7 @@ const ClusterTabs = memo(({ id }) => {
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (
<Tabs tabs={tabsAvailable ?? []} />
<Tabs addBorder tabs={tabsAvailable ?? []} />
)
})

View File

@ -102,8 +102,8 @@ const DatastoreInfoTab = ({ tabProps = {}, id }) => {
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(480px, 1fr))"
padding="0.8em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
{informationPanel?.enabled && (
<Information

View File

@ -52,7 +52,7 @@ const DatastoreTabs = memo(({ id }) => {
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (
<Tabs tabs={tabsAvailable ?? []} />
<Tabs addBorder tabs={tabsAvailable ?? []} />
)
})

View File

@ -50,7 +50,7 @@ const GroupInfoTab = ({ tabProps = {}, id }) => {
} = tabProps
const [update] = useUpdateGroupMutation()
const { data: group } = useGetGroupQuery(id)
const { data: group } = useGetGroupQuery({ id })
const { TEMPLATE } = group
const handleAttributeInXml = async (path, newValue) => {
@ -80,8 +80,8 @@ const GroupInfoTab = ({ tabProps = {}, id }) => {
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(480px, 1fr))"
padding="0.8em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
{informationPanel?.enabled && (
<Information

View File

@ -32,7 +32,7 @@ const getTabComponent = (tabName) =>
const GroupTabs = memo(({ id }) => {
const { view, getResourceView } = useViews()
const { isLoading, isError, error } = useGetGroupQuery(id)
const { isLoading, isError, error } = useGetGroupQuery({ id })
const tabsAvailable = useMemo(() => {
const resource = RESOURCE_NAMES.GROUP
@ -52,7 +52,7 @@ const GroupTabs = memo(({ id }) => {
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (
<Tabs tabs={tabsAvailable ?? []} />
<Tabs addBorder tabs={tabsAvailable ?? []} />
)
})

View File

@ -93,8 +93,8 @@ const HostInfoTab = ({ tabProps = {}, id }) => {
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(480px, 1fr))"
padding="0.8em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
{informationPanel?.enabled && (
<Information

View File

@ -21,6 +21,7 @@ import { Box, List, ListItem, Paper, Typography } from '@mui/material'
import { Translate } from 'client/components/HOC'
import { useStyles } from 'client/components/Tabs/Host/Numa/Hugepage/styles'
import { prettyBytes } from 'client/utils'
import { T } from 'client/constants'
/**
@ -55,7 +56,7 @@ const NumaHugepage = ({ hugepage }) => {
{hugepage.length > 0 &&
hugepage.map(({ FREE, PAGES, SIZE, USAGE }, index) => (
<ListItem key={index} className={classes.item} dense>
<Typography noWrap>{SIZE}</Typography>
<Typography noWrap>{prettyBytes(SIZE)}</Typography>
<Typography noWrap>{FREE}</Typography>
<Typography noWrap>{PAGES}</Typography>
<Typography noWrap>{USAGE}</Typography>

View File

@ -13,24 +13,20 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement, useEffect } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { ReactElement, useEffect, useCallback } from 'react'
import PropTypes from 'prop-types'
import { debounce } from '@mui/material'
import { FormProvider, useForm } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import { useGeneralApi } from 'client/features/General'
import { useUpdateHostMutation } from 'client/features/OneApi/host'
import {
FORM_FIELDS_PIN_POLICY,
FORM_SCHEMA_PIN_POLICY,
} from 'client/components/Tabs/Host/Numa/UpdatePinPolicy/schema'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import { useGeneralApi } from 'client/features/General'
import { useUpdateHostMutation } from 'client/features/OneApi/host'
import { FormWithSchema } from 'client/components/Forms'
import { T, Host } from 'client/constants'
/**
* @param {object} props - Props
@ -43,26 +39,31 @@ const UpdatePinPolicyForm = ({ host }) => {
const { enqueueError } = useGeneralApi()
const [updateUserTemplate] = useUpdateHostMutation()
const { watch, ...methods } = useForm({
const { watch, handleSubmit, ...methods } = useForm({
reValidateMode: 'onSubmit',
defaultValues: {
PIN_POLICY: TEMPLATE.PIN_POLICY,
},
defaultValues: { PIN_POLICY: TEMPLATE.PIN_POLICY },
resolver: yupResolver(FORM_SCHEMA_PIN_POLICY),
})
useEffect(() => {
watch((data) => {
const handleUpdatePinPolicy = useCallback(
debounce(async ({ PIN_POLICY } = {}) => {
try {
updateUserTemplate({
await updateUserTemplate({
id: host.ID,
template: `PIN_POLICY = ${data.PIN_POLICY}`,
template: `PIN_POLICY = ${PIN_POLICY}`,
replace: 1,
})
} catch {
enqueueError(T.SomethingWrong)
}
})
}, 500),
[updateUserTemplate]
)
useEffect(() => {
const subscription = watch(() => handleSubmit(handleUpdatePinPolicy)())
return () => subscription.unsubscribe()
}, [watch])
return (

View File

@ -37,8 +37,8 @@ const InformationPanel = ({ node = {} }) => {
<>
<Stack
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(480px, 1fr))"
padding="0.8em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
<Typography gutterBottom variant="h2" component="h2">
<Translate word={T.NumaNodeItem} values={node.NODE_ID} />
@ -56,8 +56,8 @@ const InformationPanel = ({ node = {} }) => {
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(480px, 1fr))"
padding="0.8em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
<NumaHugepage hugepage={[HUGEPAGE].flat()} />
<NumaMemory node={node} />

View File

@ -37,8 +37,8 @@ const WildsInfoTab = ({ id }) => {
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(480px, 1fr))"
padding="0.8em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
<WildsTable wilds={wilds} />
</Stack>

View File

@ -37,8 +37,8 @@ const ZombiesInfoTab = ({ id }) => {
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(480px, 1fr))"
padding="0.8em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
<ZombiesTable disableRowSelect disableGlobalSort zombies={zombies} />
</Stack>

View File

@ -60,7 +60,7 @@ const HostTabs = memo(({ id }) => {
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (
<Tabs tabs={tabsAvailable} />
<Tabs addBorder tabs={tabsAvailable} />
)
})

View File

@ -89,8 +89,8 @@ const ImageInfoTab = ({ tabProps = {}, id }) => {
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(480px, 1fr))"
padding="0.8em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
{informationPanel?.enabled && (
<Information

View File

@ -52,7 +52,7 @@ const ImageTabs = memo(({ id }) => {
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (
<Tabs tabs={tabsAvailable ?? []} />
<Tabs addBorder tabs={tabsAvailable ?? []} />
)
})

View File

@ -89,8 +89,8 @@ const MarketplaceInfoTab = ({ tabProps = {}, id }) => {
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(480px, 1fr))"
padding="0.8em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
{informationPanel?.enabled && (
<Information

View File

@ -52,7 +52,7 @@ const MarketplaceTabs = memo(({ id }) => {
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (
<Tabs tabs={tabsAvailable ?? []} />
<Tabs addBorder tabs={tabsAvailable ?? []} />
)
})

View File

@ -92,8 +92,8 @@ const MarketplaceAppInfoTab = ({ tabProps = {}, id }) => {
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(480px, 1fr))"
padding="0.8em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
{informationPanel?.enabled && (
<Information

View File

@ -54,7 +54,7 @@ const MarketplaceAppTabs = memo(({ id }) => {
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (
<Tabs tabs={tabsAvailable ?? []} />
<Tabs addBorder tabs={tabsAvailable ?? []} />
)
})

View File

@ -47,7 +47,7 @@ const UserInfoTab = ({ tabProps = {}, id }) => {
} = tabProps
const [updateUser] = useUpdateUserMutation()
const { data: user = {} } = useGetUserQuery(id)
const { data: user = {} } = useGetUserQuery({ id })
const { TEMPLATE } = user
const handleAttributeInXml = async (path, newValue) => {
@ -74,8 +74,8 @@ const UserInfoTab = ({ tabProps = {}, id }) => {
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(480px, 1fr))"
padding="0.8em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
{informationPanel?.enabled && (
<Information

View File

@ -32,7 +32,7 @@ const getTabComponent = (tabName) =>
const UserTabs = memo(({ id }) => {
const { view, getResourceView } = useViews()
const { isLoading, isError, error } = useGetUserQuery(id)
const { isLoading, isError, error } = useGetUserQuery({ id })
const tabsAvailable = useMemo(() => {
const resource = RESOURCE_NAMES.USER
@ -52,7 +52,7 @@ const UserTabs = memo(({ id }) => {
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (
<Tabs tabs={tabsAvailable ?? []} />
<Tabs addBorder tabs={tabsAvailable ?? []} />
)
})

View File

@ -112,8 +112,8 @@ const VNetworkInfoTab = ({ tabProps = {}, id }) => {
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(480px, 1fr))"
padding="0.8em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
{informationPanel?.enabled && (
<Information

View File

@ -52,7 +52,7 @@ const VNetworkTabs = memo(({ id }) => {
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (
<Tabs tabs={tabsAvailable ?? []} />
<Tabs addBorder tabs={tabsAvailable ?? []} />
)
})

View File

@ -112,8 +112,8 @@ const VNetTemplateInfoTab = ({ tabProps = {}, id }) => {
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(480px, 1fr))"
padding="0.8em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
{informationPanel?.enabled && (
<Information

View File

@ -52,7 +52,7 @@ const VNetTemplateTabs = memo(({ id }) => {
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (
<Tabs tabs={tabsAvailable ?? []} />
<Tabs addBorder tabs={tabsAvailable ?? []} />
)
})

View File

@ -13,91 +13,132 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement } from 'react'
import { ReactElement, memo, useCallback } from 'react'
import PropTypes from 'prop-types'
import { Folder, User } from 'iconoir-react'
import { Typography, Paper } from '@mui/material'
import { Folder, User, Group, InfoEmpty } from 'iconoir-react'
import { Typography, Paper, Stack, Divider } from '@mui/material'
import { useGetDatastoresQuery } from 'client/features/OneApi/datastore'
import { useGetUsersQuery } from 'client/features/OneApi/user'
import { useGetGroupsQuery } from 'client/features/OneApi/group'
import { Tr, Translate } from 'client/components/HOC'
import { rowStyles } from 'client/components/Tables/styles'
import { getHistoryAction } from 'client/models/VirtualMachine'
import { timeFromMilliseconds, timeDiff } from 'client/models/Helper'
import { HistoryRecord } from 'client/constants'
import { T, HistoryRecord } from 'client/constants'
/**
* Renders history record card.
*
* @param {object} props - Props
* @param {HistoryRecord} props.history - History
* @returns {ReactElement} History record card component
*/
const HistoryRecordCard = ({ history }) => {
const classes = rowStyles()
const HistoryRecordCard = memo(
/**
* Renders history record card.
*
* @param {object} props - Props
* @param {HistoryRecord} props.history - History
* @returns {ReactElement} History record card component
*/
({ history }) => {
const classes = rowStyles()
const {
SEQ,
UID,
GID,
REQUEST_ID,
HOSTNAME,
HID,
DS_ID,
ACTION,
STIME,
ETIME,
PSTIME,
PETIME,
} = history
const {
SEQ,
UID,
GID,
REQUEST_ID,
HOSTNAME,
HID,
DS_ID,
ACTION,
STIME,
ETIME,
PSTIME,
PETIME,
} = history
const now = Math.round(Date.now() / 1000)
const now = Math.round(Date.now() / 1000)
const startTime = timeFromMilliseconds(+STIME)
const startTime = timeFromMilliseconds(+STIME)
const monitorEndTime = +ETIME === 0 ? now : +ETIME
const monitorDiffTime = timeDiff(+STIME, monitorEndTime)
const monitorEndTime = +ETIME === 0 ? now : +ETIME
const monitorDiffTime = timeDiff(+STIME, monitorEndTime)
const prologEndTime = +PSTIME === 0 ? 0 : +PETIME === 0 ? now : +PETIME
const prologDiffTime = timeDiff(+PSTIME, prologEndTime)
const prologEndTime = +PSTIME === 0 ? 0 : +PETIME === 0 ? now : +PETIME
const prologDiffTime = timeDiff(+PSTIME, prologEndTime)
const action = getHistoryAction(+ACTION)
const ownerInfo = `${UID} | ${GID} | ${REQUEST_ID}`
const getNameFromResult = useCallback(
(id, result) => ({
name: result?.find((item) => +item.ID === +id)?.NAME ?? '...',
}),
[]
)
const action = getHistoryAction(+ACTION)
const { name: dsName } = useGetDatastoresQuery(undefined, {
selectFromResult: ({ data }) => getNameFromResult(DS_ID, data),
})
return (
<Paper variant="outlined" className={classes.root}>
<div className={classes.main}>
<div className={classes.title}>
<Typography component="span">
{`#${SEQ} | #${HID} ${HOSTNAME} | Action: ${action}`}
</Typography>
</div>
<div className={classes.caption}>
<span title={`Datastore ID: ${DS_ID}`}>
<Folder />
<span>{` ${DS_ID}`}</span>
</span>
{+UID !== -1 && (
<span title={`Owner | Group | Request ID: ${ownerInfo}`}>
<User />
<span>{` ${ownerInfo}`}</span>
const { name: userName } = useGetUsersQuery(undefined, {
selectFromResult: ({ data }) => getNameFromResult(UID, data),
})
const { name: groupName } = useGetGroupsQuery(undefined, {
selectFromResult: ({ data }) => getNameFromResult(GID, data),
})
return (
<Paper variant="outlined" className={classes.root}>
<div className={classes.main}>
<div className={classes.title}>
<Typography component="span">
{`#${SEQ} | #${HID} ${HOSTNAME} | ${Tr(T.Action)}: ${action}`}
</Typography>
</div>
<div className={classes.caption}>
<span title={`${Tr(T.Datastore)}: #${DS_ID} ${dsName}`}>
<Folder />
<span data-cy="datastore">{`#${DS_ID} ${dsName}`}</span>
</span>
)}
<span
title={`Time when the state changed: ${startTime.toFormat('ff')}`}
>
{`| start ${startTime.toRelative()}`}
</span>
<span title={'Total time in this state'}>
{`| total ${monitorDiffTime}`}
</span>
<span title={'Prolog time for this state'}>
{`| prolog ${prologDiffTime}`}
</span>
{+UID !== -1 && (
<>
<span title={`${Tr(T.Owner)}: #${UID} ${userName}`}>
<User />
<span data-cy="owner">{`#${UID} ${userName}`}</span>
</span>
<span title={`${Tr(T.Group)}: #${GID} ${groupName}`}>
<Group />
<span data-cy="group">{`#${GID} ${groupName}`}</span>
</span>
<span title={`${Tr(T.RequestId)}: ${REQUEST_ID}`}>
<InfoEmpty />
<span data-cy="request">{`#${REQUEST_ID}`}</span>
</span>
</>
)}
<Stack
direction="row"
component="span"
divider={<Divider orientation="vertical" flexItem />}
>
<span title={Tr(T.TimeWhenTheStateChanged)}>
<Translate
word={T.StartedOnTime}
values={[startTime.toFormat('ff')]}
/>
</span>
<span title={Tr(T.TotalTimeInThisState)}>
<Translate word={T.Total} />
{` ${monitorDiffTime}`}
</span>
<span title={Tr(T.PrologTimeForThisState)}>
<Translate word={T.Prolog} />
{` ${prologDiffTime}`}
</span>
</Stack>
</div>
</div>
</div>
</Paper>
)
}
</Paper>
)
}
)
HistoryRecordCard.propTypes = {
history: PropTypes.object.isRequired,

View File

@ -132,8 +132,8 @@ const VmInfoTab = ({ tabProps = {}, id }) => {
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(380px, 1fr))"
padding="0.8em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
<Fade in={!!error} unmountOnExit>
<Alert

View File

@ -13,24 +13,31 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement, useMemo } from 'react'
import { ReactElement } from 'react'
import PropTypes from 'prop-types'
import { useHistory, generatePath } from 'react-router-dom'
import { Card, CardActionArea, CardMedia } from '@mui/material'
import { generatePath } from 'react-router-dom'
import { useGetClusterQuery } from 'client/features/OneApi/cluster'
import { useRenameVmMutation } from 'client/features/OneApi/vm'
import { useGuacamole } from 'client/features/Guacamole'
import { StatusChip } from 'client/components/Status'
import { List } from 'client/components/Tabs/Common'
import { Translate } from 'client/components/HOC'
import MultipleTags from 'client/components/MultipleTags'
import { getState, getLastHistory, getIps } from 'client/models/VirtualMachine'
import * as Helper from 'client/models/Helper'
import {
getState,
getLastHistory,
getIps,
getNicWithPortForwarding,
} from 'client/models/VirtualMachine'
import {
booleanToString,
levelLockToString,
timeToString,
} from 'client/models/Helper'
import { T, VM, VM_ACTIONS } from 'client/constants'
import { PATH } from 'client/apps/sunstone/routesOne'
import { PATH as DEFAULT_PATH } from 'client/apps/sunstone/routes'
/**
* Renders mainly information tab.
@ -41,21 +48,15 @@ import { PATH as DEFAULT_PATH } from 'client/apps/sunstone/routes'
* @returns {ReactElement} Information tab
*/
const InformationPanel = ({ vm = {}, actions }) => {
const history = useHistory()
const [renameVm] = useRenameVmMutation()
const sessions = useGuacamole(vm?.ID)
const [connectionType, { thumbnail: firstThumbnail } = {}] = useMemo(
() =>
Object.entries(sessions).find(
([_, { thumbnail }]) => !!thumbnail?.canvas
) ?? [],
[sessions]
)
const { ID, NAME, RESCHED, STIME, ETIME, LOCK, DEPLOY_ID } = vm
const { name: stateName, color: stateColor } = getState(vm)
const ips = getIps(vm)
const { EXTERNAL_PORT_RANGE, INTERNAL_PORT_RANGE } =
getNicWithPortForwarding(vm) ?? {}
const {
HID: hostId,
HOSTNAME: hostname = '--',
@ -90,27 +91,42 @@ const InformationPanel = ({ vm = {}, actions }) => {
},
{
name: T.Reschedule,
value: Helper.booleanToString(+RESCHED),
value: booleanToString(+RESCHED),
dataCy: 'reschedule',
},
{
name: T.Locked,
value: Helper.levelLockToString(LOCK?.LOCKED),
value: levelLockToString(LOCK?.LOCKED),
dataCy: 'locked',
},
{
name: T.IP,
value: ips?.length ? <MultipleTags tags={ips} /> : '--',
value: ips?.length ? <MultipleTags tags={ips} clipboard /> : '--',
dataCy: 'ips',
},
EXTERNAL_PORT_RANGE &&
INTERNAL_PORT_RANGE && {
name: T.PortForwarding,
value: (
<Translate
word={T.HostnamePortsForwardedToVmPorts}
values={[
hostname,
EXTERNAL_PORT_RANGE,
INTERNAL_PORT_RANGE?.split('/')[0]?.replace('-', ':'),
]}
/>
),
dataCy: 'port_forwarding',
},
{
name: T.StartTime,
value: Helper.timeToString(STIME),
value: timeToString(STIME),
dataCy: 'starttime',
},
{
name: T.EndTime,
value: Helper.timeToString(ETIME),
value: timeToString(ETIME),
dataCy: 'endtime',
},
hostId && {
@ -134,32 +150,6 @@ const InformationPanel = ({ vm = {}, actions }) => {
value: DEPLOY_ID,
dataCy: 'deployid',
},
firstThumbnail && {
name: T.LastConnection,
value: (
<Card sx={{ my: 1 }} data-cy={`${vm.ID}-${connectionType}-thumbnail`}>
<CardActionArea
disableTouchRipple={false}
onClick={() =>
history.push(
generatePath(DEFAULT_PATH.GUACAMOLE, {
id: vm.ID,
type: connectionType,
})
)
}
>
<CardMedia
component="img"
sx={{ bgcolor: 'text.primary', opacity: 0.8 }}
src={firstThumbnail?.canvas}
alt={`thumbnail-${vm.ID}-${connectionType}`}
/>
</CardActionArea>
</Card>
),
dataCy: 'last_connection',
},
].filter(Boolean)
return (

View File

@ -68,7 +68,7 @@ const VmTabs = memo(({ id }) => {
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (
<Tabs tabs={tabsAvailable} />
<Tabs addBorder tabs={tabsAvailable} />
)
})

View File

@ -60,8 +60,8 @@ const VmTemplateInfoTab = ({ tabProps = {}, id }) => {
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(480px, 1fr))"
padding="0.8em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
{informationPanel?.enabled && (
<Information

View File

@ -54,7 +54,7 @@ const VmTemplateTabs = memo(({ id }) => {
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (
<Tabs tabs={tabsAvailable ?? []} />
<Tabs addBorder tabs={tabsAvailable ?? []} />
)
})

View File

@ -70,8 +70,8 @@ const ZoneInfoTab = ({ tabProps = {}, id }) => {
<Stack
display="grid"
gap="1em"
gridTemplateColumns="repeat(auto-fit, minmax(480px, 1fr))"
padding="0.8em"
gridTemplateColumns="repeat(auto-fit, minmax(49%, 1fr))"
padding={{ sm: '0.8em' }}
>
{informationPanel?.enabled && (
<Information

View File

@ -52,7 +52,7 @@ const ZoneTabs = memo(({ id }) => {
return isLoading ? (
<LinearProgress color="secondary" sx={{ width: '100%' }} />
) : (
<Tabs tabs={tabsAvailable ?? []} />
<Tabs addBorder tabs={tabsAvailable ?? []} />
)
})

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useState, useMemo, JSXElementConstructor } from 'react'
import { useState, useMemo, ReactElement } from 'react'
import PropTypes from 'prop-types'
import {
@ -21,7 +21,6 @@ import {
Tabs as MTabs,
TabsProps,
Tab as MTab,
Box,
Fade,
} from '@mui/material'
import { WarningCircledOutline } from 'iconoir-react'
@ -30,33 +29,57 @@ const WarningIcon = styled(WarningCircledOutline)(({ theme }) => ({
color: theme.palette.error.main,
}))
const Content = ({ name, renderContent: RenderContent, hidden }) => (
<Fade in timeout={400} key={`tab-${name}`}>
<Box
sx={{
p: (theme) => theme.spacing(2, 1),
height: '100%',
display: hidden ? 'none' : 'flex',
flexDirection: 'column',
overflow: 'auto',
}}
>
{typeof RenderContent === 'function' ? <RenderContent /> : RenderContent}
</Box>
</Fade>
const TabContent = styled('div')(({ hidden, addBorder, theme }) => ({
height: '100%',
display: hidden ? 'none' : 'flex',
flexDirection: 'column',
overflow: 'auto',
...(addBorder && {
backgroundColor: theme.palette.background.paper,
border: `thin solid ${theme.palette.secondary.main}`,
borderTop: 'none',
borderRadius: `0 0 8px 8px`,
}),
}))
const Content = ({
id,
name,
renderContent: RenderContent,
hidden,
addBorder = false,
}) => (
<TabContent
key={`tab-${id ?? name}`}
data-cy={`tab-content-${id ?? name}`}
hidden={hidden}
addBorder={addBorder}
>
<Fade in timeout={400}>
<TabContent sx={{ p: '1em .5em' }}>
{typeof RenderContent === 'function' ? (
<RenderContent />
) : (
RenderContent
)}
</TabContent>
</Fade>
</TabContent>
)
/**
* @param {object} props - Props
* @param {Array} props.tabs - Tabs
* @param {TabsProps} props.tabsProps - Props to tabs component
* @param {boolean} props.renderHiddenTabs - If `true`, will be render hidden tabs
* @returns {JSXElementConstructor} Tabs component with content
* @param {boolean} [props.renderHiddenTabs] - If `true`, will be render hidden tabs
* @param {boolean} [props.addBorder] - If `true`, will be add a border to tab content
* @returns {ReactElement} Tabs component with content
*/
const Tabs = ({
tabs = [],
tabsProps: { sx, ...tabsProps } = {},
renderHiddenTabs = false,
addBorder = false,
}) => {
const [tabSelected, setTab] = useState(() => 0)
@ -70,7 +93,7 @@ const Tabs = ({
sx={{
position: 'sticky',
top: 0,
zIndex: (theme) => theme.zIndex.appBar,
zIndex: ({ zIndex }) => zIndex.appBar,
...sx,
}}
{...tabsProps}
@ -110,6 +133,7 @@ const Tabs = ({
renderAllHiddenTabContents
) : (
<Content
addBorder={addBorder}
{...tabs.find(({ value }, idx) => (value ?? idx) === tabSelected)}
/>
)}
@ -124,12 +148,15 @@ Tabs.propTypes = {
tabs: PropTypes.array,
tabsProps: PropTypes.object,
renderHiddenTabs: PropTypes.bool,
addBorder: PropTypes.bool,
}
Content.propTypes = {
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
name: PropTypes.string,
renderContent: PropTypes.oneOfType([PropTypes.object, PropTypes.func]),
hidden: PropTypes.bool,
addBorder: PropTypes.bool,
}
export default Tabs

View File

@ -24,6 +24,9 @@ export const ADD_ATTRIBUTE = 'add'
export const EDIT_ATTRIBUTE = 'edit'
export const DELETE_ATTRIBUTE = 'delete'
// LABELS
export const EDIT_LABELS = 'edit_labels'
// PERMISSION
export const CHANGE_MODE = 'chmod'

View File

@ -19,6 +19,7 @@ module.exports = {
Previous: 'Previous',
Next: 'Next',
SortBy: 'Sort by',
FilterBy: 'Filter by',
Filter: 'Filter',
Filters: 'Filters',
All: 'All',
@ -201,6 +202,9 @@ module.exports = {
HoursBetween0_168: 'Hours should be between 0 and 168',
WhenYouWantThatTheActionFinishes: 'When you want that the action finishes',
/* dashboard */
MadeWith: 'Made with',
/* dashboard */
InTotal: 'In Total',
Used: 'Used',
@ -272,7 +276,9 @@ module.exports = {
ConfigurationUI: 'Configuration UI',
Authentication: 'Authentication',
SshPrivateKey: 'SSH private key',
AddUserSshPrivateKey: 'Add user SSH private key',
SshPassphraseKey: 'SSH private key passphrase',
AddUserSshPassphraseKey: 'Add user SSH private key passphrase',
/* sections - system */
User: 'User',
@ -361,7 +367,10 @@ module.exports = {
Description: 'Description',
RegistrationTime: 'Registration time',
StartTime: 'Start time',
Started: 'Started',
StartedOnTime: 'Started on %s',
Total: 'Total',
Prolog: 'Prolog',
EndTime: 'End time',
Locked: 'Locked',
Attributes: 'Attributes',
@ -461,6 +470,8 @@ module.exports = {
AttachSecurityGroup: 'Attach Security Group',
DetachSecurityGroup: 'Detach Security Group',
DetachSecurityGroupFromNic: 'Detach Security Group %1$s from NIC %2$s',
PortForwarding: 'Port forwarding',
HostnamePortsForwardedToVmPorts: '%1$s ports %2$s forwarded to VM ports %3$s',
/* VM schema - snapshot */
VmSnapshotNameConcept: 'The new snapshot name. It can be empty',
/* VM schema - actions */
@ -484,6 +495,11 @@ module.exports = {
OperationConceptDeleteDb: `
No recover action possible, delete the VM from the DB.
It does not trigger any action on the hypervisor`,
/* VM schema - history */
RequestId: 'Request ID',
TimeWhenTheStateChanged: 'Time when the state changed',
TotalTimeInThisState: 'Total time in this state',
PrologTimeForThisState: 'Prolog time for this state',
/* VM Template schema */
/* VM Template schema - general */
@ -783,6 +799,7 @@ module.exports = {
Wilds: 'Wilds',
Zombies: 'Zombies',
Numa: 'NUMA',
Hostname: 'Hostname',
/* Host schema - capacity */
AllocatedMemory: 'Allocated Memory',
AllocatedCpu: 'Allocated CPU',
@ -801,6 +818,7 @@ module.exports = {
/* Marketplace App schema */
/* Marketplace App - general */
RegisteredAt: 'Registered %s',
Version: 'Version',
AppTemplate: 'App Template',
TemplatesForTheApp: 'Templates for the App',

View File

@ -819,6 +819,7 @@ export const VM_ACTIONS_BY_STATE = {
STATES.INIT,
STATES.PENDING,
STATES.HOLD,
STATES.ACTIVE,
STATES.STOPPED,
STATES.SUSPENDED,
STATES.POWEROFF,
@ -1126,40 +1127,24 @@ export const HISTORY_ACTIONS = [
]
/**
* @enum {(
* 'IP'|
* 'IP6'|
* 'IP6_GLOBAL'|
* 'IP6_ULA'|
* 'VROUTER_IP'|
* 'VROUTER_IP6_GLOBAL'|
* 'VROUTER_IP6_ULA'
* )} Possible attribute names for nic alias ip
* @type {(string|string[])[]} Possible attribute names for nic alias ip
*/
export const NIC_ALIAS_IP_ATTRS = [
export const NIC_IP_ATTRS = [
'EXTERNAL_IP', // external IP must be first
'IP',
'IP6',
'IP6_GLOBAL',
'IP6_ULA',
'VROUTER_IP',
'VROUTER_IP6_GLOBAL',
'VROUTER_IP6_ULA',
['IP6_ULA', 'IP6_GLOBAL'],
'MAC',
]
/**
* @enum {(
* 'GUEST_IP'|
* 'GUEST_IP_ADDRESSES'|
* 'AWS_IP_ADDRESS'|
* 'AWS_PUBLIC_IP_ADDRESS'|
* 'AWS_PRIVATE_IP_ADDRESS'|
* 'AZ_IPADDRESS'|
* 'SL_PRIMARYIPADDRESS'
* )} Possible attribute names for external ip
* @type {string[]} Possible attribute names for external ip
*/
export const EXTERNAL_IP_ATTRS = [
'GUEST_IP',
'GUEST_IP_ADDRESSES',
// unsupported by OpenNebula
'AWS_IP_ADDRESS',
'AWS_PUBLIC_IP_ADDRESS',
'AWS_PRIVATE_IP_ADDRESS',

View File

@ -42,7 +42,7 @@ function MarketplaceApps() {
{selectedRows?.length > 0 && (
<Stack overflow="auto">
{selectedRows?.length === 1 ? (
<MarketplaceAppsTabs id={selectedRows[0]?.values.ID} />
<MarketplaceAppsTabs id={selectedRows[0]?.original.ID} />
) : (
<Stack
direction="row"

View File

@ -44,9 +44,10 @@ const Datastores = memo(
return (
<DatastoresTable
onlyGlobalSearch
disableRowSelect
disableGlobalSort
disableRowSelect
displaySelectedRows
pageSize={5}
useQuery={() =>
useGetProvisionResourceQuery(
{ resource: 'datastore' },

View File

@ -83,9 +83,10 @@ const Hosts = memo(({ id }) => {
</Stack>
</Stack>
<HostsTable
onlyGlobalSearch
disableRowSelect
disableGlobalSort
disableRowSelect
displaySelectedRows
pageSize={5}
useQuery={() =>
useGetProvisionResourceQuery(
{ resource: 'host' },

View File

@ -75,9 +75,10 @@ const Networks = memo(({ id }) => {
</Stack>
</Stack>
<VNetworksTable
onlyGlobalSearch
disableRowSelect
disableGlobalSort
disableRowSelect
displaySelectedRows
pageSize={5}
useQuery={() =>
useGetProvisionResourceQuery(
{ resource: 'network' },

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement, useMemo, memo, useState } from 'react'
import { ReactElement, memo, useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import {
Paper,
@ -24,134 +24,208 @@ import {
TextField,
} from '@mui/material'
import { Edit } from 'iconoir-react'
import { useForm } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import { useForm, FormProvider, useFormContext } from 'react-hook-form'
import { useAuth } from 'client/features/Auth'
import { useUpdateUserMutation } from 'client/features/OneApi/user'
import { useGeneralApi } from 'client/features/General'
import {
FIELDS,
SCHEMA,
} from 'client/containers/Settings/Authentication/schema'
import { Translate } from 'client/components/HOC'
import { Legend } from 'client/components/Forms'
import { jsonToXml } from 'client/models/Helper'
import { sanitize } from 'client/utils'
import { T } from 'client/constants'
/** @returns {ReactElement} Settings authentication */
const Settings = () => (
<Paper variant="outlined" sx={{ py: '1.5em' }}>
<Stack gap="1em">
{FIELDS.map((field) => (
<FieldComponent
key={'settings-authentication-field-' + field.name}
field={field}
/>
))}
</Stack>
</Paper>
const FIELDS = [
{
name: 'SSH_PUBLIC_KEY',
label: T.SshPublicKey,
tooltip: T.AddUserSshPublicKey,
},
{
name: 'SSH_PRIVATE_KEY',
label: T.SshPrivateKey,
tooltip: T.AddUserSshPrivateKey,
},
{
name: 'SSH_PASSPHRASE',
label: T.SshPassphraseKey,
tooltip: T.AddUserSshPassphraseKey,
},
]
const removeProperty = (propKey, { [propKey]: _, ...rest }) => rest
// -------------------------------------
// FIELD COMPONENT
// -------------------------------------
const FieldComponent = memo(
({ field, defaultValue, onSubmit, setIsEnabled }) => {
const { register, reset, handleSubmit } = useFormContext()
const { name } = field
useEffect(() => {
reset({ [name]: defaultValue })
}, [defaultValue])
const handleKeyDown = (evt) => {
if (evt.key === 'Escape') {
setIsEnabled(false)
reset({ [name]: defaultValue })
evt.stopPropagation()
}
if (evt.key === 'Enter') {
handleSubmit(onSubmit)(evt)
}
}
const handleBlur = (evt) => handleSubmit(onSubmit)(evt)
return (
<TextField
fullWidth
autoFocus
multiline
rows={5}
defaultValue={defaultValue}
variant="outlined"
onKeyDown={handleKeyDown}
helperText={<Translate word={T.PressEscapeToCancel} />}
{...register(name, { onBlur: handleBlur, shouldUnregister: true })}
/>
)
}
)
const FieldComponent = memo(({ field }) => {
const [isEnabled, setIsEnabled] = useState(false)
const { name, label, tooltip } = field
const { user, settings } = useAuth()
const [updateUser, { isLoading }] = useUpdateUserMutation()
const { enqueueError } = useGeneralApi()
const defaultValues = useMemo(() => SCHEMA.cast(settings), [settings])
const { watch, register, reset } = useForm({
reValidateMode: 'onSubmit',
defaultValues,
resolver: yupResolver(SCHEMA),
})
const sanitizedValue = useMemo(() => sanitize`${watch(name)}`, [isEnabled])
const handleUpdateUser = async () => {
try {
if (isLoading || !isEnabled) return
const castedData = SCHEMA.cast(watch(), { isSubmit: true })
const template = jsonToXml(castedData)
updateUser({ id: user.ID, template })
setIsEnabled(false)
} catch {
enqueueError(T.SomethingWrong)
}
}
const handleBlur = () => {
handleUpdateUser()
}
const handleKeyDown = (evt) => {
if (evt.key === 'Escape') {
reset(defaultValues)
setIsEnabled(false)
evt.stopPropagation()
}
if (evt.key === 'Enter') {
handleUpdateUser()
}
}
return (
<Stack component="fieldset" sx={{ minInlineSize: 'auto' }}>
<Legend title={label} tooltip={tooltip} />
{isEnabled ? (
<>
<TextField
fullWidth
autoFocus
multiline
rows={5}
variant="outlined"
onKeyDown={handleKeyDown}
helperText={<Translate word={T.PressEscapeToCancel} />}
{...register(field.name, { onBlur: handleBlur })}
/>
</>
) : (
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
gap="1em"
paddingX={1}
>
{isLoading ? (
<>
<Skeleton variant="text" width="100%" height={36} />
<Skeleton variant="circular" width={28} height={28} />
</>
) : (
<>
<Typography noWrap title={sanitizedValue}>
{sanitizedValue}
</Typography>
<IconButton onClick={() => setIsEnabled(true)}>
<Edit />
</IconButton>
</>
)}
</Stack>
)}
</Stack>
)
})
FieldComponent.propTypes = {
field: PropTypes.object.isRequired,
onSubmit: PropTypes.func.isRequired,
setIsEnabled: PropTypes.func.isRequired,
defaultValue: PropTypes.string,
}
FieldComponent.displayName = 'FieldComponent'
// -------------------------------------
// STATIC COMPONENT
// -------------------------------------
const StaticComponent = memo(
({ field, defaultValue, isEnabled, setIsEnabled }) => {
const { formState } = useFormContext()
const { name, tooltip } = field
return (
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
gap="1em"
paddingX={1}
>
{formState.isSubmitting ? (
<>
<Skeleton variant="text" width="100%" height={36} />
<Skeleton variant="circular" width={28} height={28} />
</>
) : (
<>
<Typography
noWrap
title={sanitize`${defaultValue}`}
color="text.secondary"
>
{sanitize`${defaultValue}` || <Translate word={tooltip} />}
</Typography>
<IconButton
disabled={isEnabled && !isEnabled?.[name]}
onClick={() => setIsEnabled({ [name]: true })}
>
<Edit />
</IconButton>
</>
)}
</Stack>
)
}
)
StaticComponent.propTypes = {
field: PropTypes.object.isRequired,
isEnabled: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]),
setIsEnabled: PropTypes.func.isRequired,
defaultValue: PropTypes.string,
}
StaticComponent.displayName = 'StaticComponent'
/**
* Section to change user settings about SSH keys and passphrase.
*
* @returns {ReactElement} Settings authentication
*/
const Settings = () => {
const [isEnabled, setIsEnabled] = useState(false)
const { user, settings } = useAuth()
const { enqueueError } = useGeneralApi()
const [updateUser] = useUpdateUserMutation()
const { ...methods } = useForm({ reValidateMode: 'onSubmit' })
const handleUpdateUser = async (formData) => {
try {
setIsEnabled(false)
const newSettings = FIELDS.reduce(
(result, { name }) =>
result[name] === '' ? removeProperty(name, result) : result,
{ ...settings, ...formData }
)
const template = jsonToXml(newSettings)
await updateUser({ id: user.ID, template, replace: 0 })
} catch {
isEnabled && setIsEnabled(false)
enqueueError(T.SomethingWrong)
}
}
return (
<Paper variant="outlined" sx={{ py: '1.5em' }}>
<FormProvider {...methods}>
<Stack gap="1em">
{FIELDS.map((field) => (
<Stack
component="fieldset"
key={'settings-authentication-field-' + field.name}
sx={{ minInlineSize: 'auto' }}
>
<Legend title={field.label} />
{isEnabled[field.name] ? (
<FieldComponent
field={field}
defaultValue={settings[field.name]}
onSubmit={handleUpdateUser}
setIsEnabled={setIsEnabled}
/>
) : (
<StaticComponent
field={field}
defaultValue={settings[field.name]}
isEnabled={isEnabled}
setIsEnabled={setIsEnabled}
/>
)}
</Stack>
))}
</Stack>
</FormProvider>
</Paper>
)
}
export default Settings

View File

@ -1,53 +0,0 @@
/* ------------------------------------------------------------------------- *
* 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 { object, string } from 'yup'
import { getValidationFromFields } from 'client/utils'
import { T, INPUT_TYPES } from 'client/constants'
const PUBLIC_KEY_FIELD = {
name: 'SSH_PUBLIC_KEY',
label: T.SshPublicKey,
tooltip: T.AddUserSshPublicKey,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.required()
.default(() => undefined),
}
const PRIVATE_KEY_FIELD = {
name: 'SSH_PRIVATE_KEY',
label: T.SshPrivateKey,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.required()
.default(() => undefined),
}
const PASSPHRASE_FIELD = {
name: 'SSH_PASSPHRASE',
label: T.SshPassphraseKey,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.required()
.default(() => undefined),
}
export const FIELDS = [PUBLIC_KEY_FIELD, PRIVATE_KEY_FIELD, PASSPHRASE_FIELD]
export const SCHEMA = object(getValidationFromFields(FIELDS))

View File

@ -13,12 +13,11 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement, useEffect, useMemo, useRef } from 'react'
import { Paper, Stack, CircularProgress } from '@mui/material'
import { ReactElement, useEffect, useMemo, useCallback } from 'react'
import { Paper, debounce } from '@mui/material'
import { useForm, FormProvider } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import { useAuth } from 'client/features/Auth'
import { useAuth, useAuthApi } from 'client/features/Auth'
import { useUpdateUserMutation } from 'client/features/OneApi/user'
import { useGeneralApi } from 'client/features/General'
@ -27,44 +26,58 @@ import {
SCHEMA,
} from 'client/containers/Settings/ConfigurationUI/schema'
import { FormWithSchema } from 'client/components/Forms'
import { Translate } from 'client/components/HOC'
import { jsonToXml } from 'client/models/Helper'
import { T } from 'client/constants'
/** @returns {ReactElement} Settings configuration UI */
/**
* Section to change user configuration about UI.
*
* @returns {ReactElement} Settings configuration UI
*/
const Settings = () => {
const fieldsetRef = useRef([])
const { user, settings } = useAuth()
const [updateUser, { isLoading }] = useUpdateUserMutation()
const { changeAuthUser } = useAuthApi()
const { enqueueError } = useGeneralApi()
const [updateUser] = useUpdateUserMutation()
const { watch, ...methods } = useForm({
reValidateMode: 'onSubmit',
defaultValues: useMemo(() => SCHEMA.cast(settings), [settings]),
resolver: yupResolver(SCHEMA),
const { watch, handleSubmit, ...methods } = useForm({
reValidateMode: 'onChange',
defaultValues: useMemo(
() => SCHEMA.cast(settings, { stripUnknown: true }),
[settings]
),
})
useEffect(() => {
watch((formData) => {
const handleUpdateUser = useCallback(
debounce(async (formData) => {
try {
if (isLoading) return
if (methods?.formState?.isSubmitting) return
const castedData = SCHEMA.cast(formData, { isSubmit: true })
const template = jsonToXml(castedData)
updateUser({ id: user.ID, template })
const template = jsonToXml(formData)
await updateUser({ id: user.ID, template, replace: 1 })
} catch {
enqueueError(T.SomethingWrong)
}
})
}, [watch])
}, 1000),
[updateUser]
)
useEffect(() => {
fieldsetRef.current.disabled = isLoading
}, [isLoading])
const subscription = watch((formData) => {
// update user settings before submit
const newSettings = { TEMPLATE: { ...user.TEMPLATE, ...formData } }
changeAuthUser({ ...user, ...newSettings })
handleSubmit(handleUpdateUser)()
})
return () => subscription.unsubscribe()
}, [watch])
return (
<Paper
component="form"
onSubmit={handleSubmit(handleUpdateUser)}
variant="outlined"
sx={{ p: '1em', maxWidth: { sm: 'auto', md: 550 } }}
>
@ -72,13 +85,7 @@ const Settings = () => {
<FormWithSchema
cy={'settings-ui'}
fields={FIELDS}
rootProps={{ ref: fieldsetRef }}
legend={
<Stack direction="row" alignItems="center" gap="1em">
<Translate word={T.ConfigurationUI} />
{isLoading && <CircularProgress size={20} color="secondary" />}
</Stack>
}
legend={T.ConfigurationUI}
/>
</FormProvider>
</Paper>

View File

@ -38,7 +38,7 @@ function VirtualMachines() {
{selectedRows?.length > 0 && (
<Stack overflow="auto" data-cy={'detail'}>
{selectedRows?.length === 1 ? (
<VmTabs id={selectedRows[0]?.values.ID} />
<VmTabs id={selectedRows[0]?.original.ID} />
) : (
<Stack
direction="row"
@ -52,7 +52,7 @@ function VirtualMachines() {
({ original, id, toggleRowSelected }) => (
<Chip
key={id}
variant="text"
variant="outlined"
label={original?.NAME ?? id}
onDelete={() => toggleRowSelected(false)}
/>

View File

@ -38,7 +38,7 @@ function VmTemplates() {
{selectedRows?.length > 0 && (
<Stack overflow="auto">
{selectedRows?.length === 1 ? (
<VmTemplateTabs id={selectedRows[0]?.values.ID} />
<VmTemplateTabs id={selectedRows[0]?.original.ID} />
) : (
<Stack
direction="row"

Some files were not shown because too many files have changed in this diff Show More