1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-03-19 06:50:07 +03:00

F #5862: Add labels to user settings (#2161)

This commit is contained in:
Sergio Betanzos 2022-06-17 11:49:36 +02:00 committed by GitHub
parent 307a6bb153
commit 0fb4ddaa4f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 441 additions and 76 deletions

View File

@ -21,22 +21,16 @@ import AdornmentWithTooltip from 'client/components/FormControl/Tooltip'
import { Translate } from 'client/components/HOC'
const StyledLegend = styled((props) => (
<Typography variant="subtitle1" component="legend" {...props} />
))(
({ theme }) => ({
padding: '0em 1em 0.2em 0.5em',
borderBottom: `2px solid ${theme.palette.secondary.main}`,
<Typography variant="underline" component="legend" {...props} />
))(({ ownerState }) => ({
...(ownerState.tooltip && {
display: 'inline-flex',
alignItems: 'center',
}),
({ ownerState }) => ({
...(ownerState.tooltip && {
display: 'inline-flex',
alignItems: 'center',
}),
...(!ownerState.disableGutters && {
marginBottom: '1em',
}),
})
)
...(!ownerState.disableGutters && {
marginBottom: '1em',
}),
}))
const Legend = memo(
({ 'data-cy': dataCy, title, tooltip, disableGutters }) => (

View File

@ -25,6 +25,7 @@ module.exports = {
FilterBy: 'Filter by',
FilterLabels: 'Filter labels',
FilterByLabel: 'Filter by label',
ApplyLabels: 'Apply labels',
Label: 'Label',
NoLabels: 'NoLabels',
All: 'All',
@ -297,6 +298,9 @@ module.exports = {
AddUserSshPrivateKey: 'Add user SSH private key',
SshPassphraseKey: 'SSH private key passphrase',
AddUserSshPassphraseKey: 'Add user SSH private key passphrase',
Labels: 'Labels',
NewLabelOrSearch: 'New label or search',
LabelAlreadyExists: 'Label already exists',
/* sections - system */
User: 'User',

View File

@ -195,7 +195,10 @@ const Settings = () => {
}
return (
<Paper variant="outlined" sx={{ py: '1.5em' }}>
<Paper
variant="outlined"
sx={{ overflow: 'auto', py: '1.5em', gridColumn: { md: 'span 2' } }}
>
<FormProvider {...methods}>
<Stack gap="1em">
{FIELDS.map((field) => (

View File

@ -79,7 +79,7 @@ const Settings = () => {
component="form"
onSubmit={handleSubmit(handleUpdateUser)}
variant="outlined"
sx={{ p: '1em', maxWidth: { sm: 'auto', md: 550 } }}
sx={{ p: '1em' }}
>
<FormProvider {...methods}>
<FormWithSchema

View File

@ -0,0 +1,163 @@
/* ------------------------------------------------------------------------- *
* 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, useMemo, useCallback } from 'react'
import TrashIcon from 'iconoir-react/dist/Trash'
import { Paper, Stack, Box, Typography, TextField } from '@mui/material'
import CircularProgress from '@mui/material/CircularProgress'
import { useForm } from 'react-hook-form'
import { useAuth } from 'client/features/Auth'
import { useUpdateUserMutation } from 'client/features/OneApi/user'
import { useGeneralApi } from 'client/features/General'
import { useSearch } from 'client/hooks'
import { StatusChip } from 'client/components/Status'
import { SubmitButton } from 'client/components/FormControl'
import { jsonToXml, getColorFromString } from 'client/models/Helper'
import { Translate, Tr } from 'client/components/HOC'
import { T } from 'client/constants'
const NEW_LABEL_ID = 'new-label'
/**
* Section to change labels.
*
* @returns {ReactElement} Settings configuration UI
*/
const Settings = () => {
const { user, settings } = useAuth()
const { enqueueError } = useGeneralApi()
const [updateUser, { isLoading }] = useUpdateUserMutation()
const currentLabels = useMemo(
() => settings?.LABELS?.split(',').filter(Boolean) ?? [],
[settings?.LABELS]
)
const { handleSubmit, register, reset, setFocus } = useForm({
reValidateMode: 'onSubmit',
})
const { result, handleChange } = useSearch({
list: currentLabels,
listOptions: { distance: 50 },
wait: 500,
condition: !isLoading,
})
const handleAddLabel = useCallback(
async (newLabel) => {
try {
const exists = currentLabels.some((label) => label === newLabel)
if (exists) throw new Error(T.LabelAlreadyExists)
const newLabels = currentLabels.concat(newLabel).join()
const template = jsonToXml({ LABELS: newLabels })
await updateUser({ id: user.ID, template, replace: 1 })
} catch (error) {
enqueueError(error.message ?? T.SomethingWrong)
} finally {
// Reset the search after adding the label
handleChange()
reset({ [NEW_LABEL_ID]: '' })
setFocus(NEW_LABEL_ID)
}
},
[updateUser, currentLabels, handleChange, reset]
)
const handleDeleteLabel = useCallback(
async (label) => {
try {
const newLabels = currentLabels.filter((l) => l !== label).join()
const template = jsonToXml({ LABELS: newLabels })
await updateUser({ id: user.ID, template, replace: 1 })
// Reset the search after deleting the label
handleChange()
} catch {
enqueueError(T.SomethingWrong)
}
},
[updateUser, currentLabels, handleChange]
)
const handleKeyDown = useCallback(
(evt) => {
if (evt.key !== 'Enter') return
handleSubmit(async (formData) => {
const newLabel = formData[NEW_LABEL_ID]
if (newLabel) await handleAddLabel(newLabel)
// scroll to the new label (if it exists)
setTimeout(() => {
document
?.querySelector(`[data-cy='${newLabel}']`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 500)
})(evt)
},
[handleAddLabel, handleSubmit]
)
return (
<Paper variant="outlined" sx={{ display: 'flex', flexDirection: 'column' }}>
<Box mt="0.5rem" p="1rem">
<Typography variant="underline">
<Translate word={T.Labels} />
</Typography>
</Box>
<Stack height={1} gap="0.5rem" p="0.5rem 1rem" overflow="auto">
{result?.map((label) => (
<Stack key={label} direction="row" alignItems="center">
<Box flexGrow={1}>
<StatusChip
dataCy={label}
text={label}
stateColor={getColorFromString(label)}
/>
</Box>
<SubmitButton
data-cy={`delete-label-${label}`}
disabled={isLoading}
onClick={() => handleDeleteLabel(label)}
icon={<TrashIcon />}
/>
</Stack>
))}
</Stack>
<TextField
sx={{ flexGrow: 1, p: '0.5rem 1rem' }}
onKeyDown={handleKeyDown}
disabled={isLoading}
placeholder={Tr(T.NewLabelOrSearch)}
inputProps={{ 'data-cy': NEW_LABEL_ID }}
InputProps={{
endAdornment: isLoading ? (
<CircularProgress color="secondary" size={14} />
) : undefined,
}}
{...register(NEW_LABEL_ID, { onChange: handleChange })}
helperText={'Press enter to create a new label'}
/>
</Paper>
)
}
export default Settings

View File

@ -14,13 +14,14 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement } from 'react'
import { Typography, Divider, Stack } from '@mui/material'
import { Typography, Divider, Box } from '@mui/material'
import { Translate } from 'client/components/HOC'
import { T } from 'client/constants'
import ConfigurationUISection from 'client/containers/Settings/ConfigurationUI'
import AuthenticationSection from 'client/containers/Settings/Authentication'
import LabelsSection from 'client/containers/Settings/LabelsSection'
/** @returns {ReactElement} Settings container */
const Settings = () => (
@ -31,10 +32,16 @@ const Settings = () => (
<Divider sx={{ my: '1em' }} />
<Stack gap="1em">
<Box
display="grid"
gridTemplateColumns={{ sm: '1fr', md: '1fr 1fr' }}
gridTemplateRows="minmax(0, 18em)"
gap="1em"
>
<ConfigurationUISection />
<LabelsSection />
<AuthenticationSection />
</Stack>
</Box>
</>
)

View File

@ -294,3 +294,30 @@ export const updateTemplateOnDocument =
? { ...resource.TEMPLATE.BODY, ...template }
: template
}
/**
* Updates the current user groups in the store.
*
* @param {object} params - Request params
* @param {string|number} params.id - The id of the user
* @param {string|number} params.group - The group id to update
* @param {boolean} [remove] - Remove the group from the user
* @returns {function(Draft):ThunkAction} - Dispatches the action
*/
export const updateUserGroups =
({ id: userId, group: groupId }, remove = false) =>
(draft) => {
const updatePool = isUpdateOnPool(draft, userId)
const resource = updatePool
? draft.find(({ ID }) => +ID === +userId)
: draft
if ((updatePool && !resource) || groupId === undefined) return
const currentGroups = [resource.GROUPS.ID].flat()
resource.GROUPS.ID = remove
? currentGroups.filter((id) => +id !== +groupId)
: currentGroups.concat(groupId)
}

View File

@ -20,7 +20,15 @@ import {
ONE_RESOURCES,
ONE_RESOURCES_POOL,
} from 'client/features/OneApi'
import authApi from 'client/features/OneApi/auth'
import { actions as authActions } from 'client/features/Auth/slice'
import {
updateResourceOnPool,
removeResourceOnPool,
updateUserGroups,
updateTemplateOnResource,
updateOwnershipOnResource,
} from 'client/features/OneApi/common'
import { User } from 'client/constants'
const { USER } = ONE_RESOURCES
@ -67,6 +75,28 @@ const userApi = oneApi.injectEndpoints({
},
transformResponse: (data) => data?.USER ?? {},
providesTags: (_, __, { id }) => [{ type: USER, id }],
async onQueryStarted({ id }, { dispatch, queryFulfilled }) {
try {
const { data: resourceFromQuery } = await queryFulfilled
dispatch(
userApi.util.updateQueryData(
'getUsers',
undefined,
updateResourceOnPool({ id, resourceFromQuery })
)
)
} catch {
// if the query fails, we want to remove the resource from the pool
dispatch(
userApi.util.updateQueryData(
'getUsers',
undefined,
removeResourceOnPool({ id })
)
)
}
},
}),
allocateUser: builder.mutation({
/**
@ -75,9 +105,9 @@ const userApi = oneApi.injectEndpoints({
* @param {object} params - Request parameters
* @param {string} params.username - Username for the new user
* @param {string} params.password - Password for the new user
* @param {string} params.driver - Authentication driver for the new user.
* @param {string} [params.driver] - Authentication driver for the new user.
* If it is an empty string, then the default 'core' is used
* @param {string[]} params.group - array of Group IDs.
* @param {string[]} [params.group] - array of Group IDs.
* **The first ID will be used as the main group.**
* This array can be empty, in which case the default group will be used
* @returns {number} The allocated User id
@ -89,7 +119,6 @@ const userApi = oneApi.injectEndpoints({
return { params, command }
},
invalidatesTags: [USER_POOL],
}),
updateUser: builder.mutation({
/**
@ -112,17 +141,37 @@ const userApi = oneApi.injectEndpoints({
return { params, command }
},
invalidatesTags: (_, __, { id }) => [{ type: USER, id }],
async onQueryStarted({ id }, { queryFulfilled, dispatch, getState }) {
async onQueryStarted(params, { dispatch, queryFulfilled, getState }) {
try {
await queryFulfilled
if (+id === +getState().auth.user.ID) {
await dispatch(
authApi.endpoints.getAuthUser.initiate(undefined, {
forceRefetch: true,
})
const patchUser = dispatch(
userApi.util.updateQueryData(
'getUser',
{ id: params.id },
updateTemplateOnResource(params)
)
)
const patchUsers = dispatch(
userApi.util.updateQueryData(
'getUsers',
undefined,
updateTemplateOnResource(params)
)
)
const authUser = getState().auth.user
if (+authUser?.ID === +params.id) {
// optimistic update of the auth user
const cloneAuthUser = { ...authUser }
updateTemplateOnResource(params)(cloneAuthUser)
dispatch(authActions.changeAuthUser({ ...cloneAuthUser }))
}
queryFulfilled.catch(() => {
patchUser.undo()
patchUsers.undo()
})
} catch {}
},
}),
@ -141,7 +190,7 @@ const userApi = oneApi.injectEndpoints({
return { params, command }
},
invalidatesTags: (_, __, { id }) => [{ type: USER, id }, USER_POOL],
invalidatesTags: [USER_POOL],
}),
changePassword: builder.mutation({
/**
@ -183,7 +232,7 @@ const userApi = oneApi.injectEndpoints({
}),
changeGroup: builder.mutation({
/**
* Changes the group of the given user.
* Changes the User's primary group of the given user.
*
* @param {object} params - Request parameters
* @param {string|number} params.id - User id
@ -198,26 +247,32 @@ const userApi = oneApi.injectEndpoints({
return { params, command }
},
invalidatesTags: (_, __, { id }) => [{ type: USER, id }],
async onQueryStarted({ id, group }, { dispatch, queryFulfilled }) {
async onQueryStarted(params, { getState, dispatch, queryFulfilled }) {
try {
await queryFulfilled
dispatch(
userApi.util.updateQueryData('getUsers', undefined, (draft) => {
const user = draft.find(({ ID }) => +ID === +id)
user && (user.GID = group)
})
const patchUser = dispatch(
userApi.util.updateQueryData(
'getUser',
{ id: params.id },
updateOwnershipOnResource(getState(), params)
)
)
dispatch(
userApi.util.updateQueryData('getUser', id, (draftUser) => {
draftUser.GID = group
})
const patchUsers = dispatch(
userApi.util.updateQueryData(
'getUsers',
undefined,
updateOwnershipOnResource(getState(), params)
)
)
queryFulfilled.catch(() => {
patchUser.undo()
patchUsers.undo()
})
} catch {}
},
}),
addToGroup: builder.mutation({
addGroup: builder.mutation({
/**
* Adds the User to a secondary group.
*
@ -234,6 +289,39 @@ const userApi = oneApi.injectEndpoints({
return { params, command }
},
invalidatesTags: (_, __, { id }) => [{ type: USER, id }, USER_POOL],
async onQueryStarted(params, { getState, dispatch, queryFulfilled }) {
try {
const patchUser = dispatch(
userApi.util.updateQueryData(
'getUser',
{ id: params.id },
updateUserGroups(params)
)
)
const patchUsers = dispatch(
userApi.util.updateQueryData(
'getUsers',
undefined,
updateUserGroups(params)
)
)
const authUser = getState().auth.user
if (+authUser?.ID === +params.id) {
// optimistic update of the auth user
const cloneAuthUser = { ...authUser }
updateUserGroups(params)(cloneAuthUser)
dispatch(authActions.changeAuthUser({ ...cloneAuthUser }))
}
queryFulfilled.catch(() => {
patchUser.undo()
patchUsers.undo()
})
} catch {}
},
}),
removeFromGroup: builder.mutation({
/**
@ -252,6 +340,39 @@ const userApi = oneApi.injectEndpoints({
return { params, command }
},
invalidatesTags: (_, __, { id }) => [{ type: USER, id }, USER_POOL],
async onQueryStarted(params, { getState, dispatch, queryFulfilled }) {
try {
const patchUser = dispatch(
userApi.util.updateQueryData(
'getUser',
{ id: params.id },
updateUserGroups(params, true)
)
)
const patchUsers = dispatch(
userApi.util.updateQueryData(
'getUsers',
undefined,
updateUserGroups(params, true)
)
)
const authUser = getState().auth.user
if (+authUser?.ID === +params.id) {
// optimistic update of the auth user
const cloneAuthUser = { ...authUser }
updateUserGroups(params, true)(cloneAuthUser)
dispatch(authActions.changeAuthUser({ ...cloneAuthUser }))
}
queryFulfilled.catch(() => {
patchUser.undo()
patchUsers.undo()
})
} catch {}
},
}),
enableUser: builder.mutation({
/**
@ -352,7 +473,7 @@ export const {
useChangePasswordMutation,
useChangeAuthDriverMutation,
useChangeGroupMutation,
useAddToGroupMutation,
useAddGroupMutation,
useRemoveFromGroupMutation,
useEnableUserMutation,
useDisableUserMutation,

View File

@ -13,50 +13,80 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useState, useMemo, useCallback } from 'react'
import { debounce } from '@mui/material'
import { useState, useEffect, useMemo } from 'react'
import Fuse from 'fuse.js'
/**
* @typedef {object} useSearchHook
* @property {string} query - Search term
* @property {Array} result - Result of the search
* @property {Function} handleChange - Function to handle the change event
*/
/**
* Hook to manage a search in a list.
*
* @param {object} params - Search parameters
* @param {Array} params.list - List of elements
* @param {Fuse.IFuseOptions} params.listOptions - Search options
* @returns {{
* query: string,
* result: Array,
* handleChange: Function
* }} - Returns information about the search
* @param {Fuse.IFuseOptions} [params.listOptions] - Search options
* @param {number} [params.wait] - Wait a certain amount of time before searching again. By default 1 second
* @param {boolean} [params.condition] - Search if the condition is true
* @returns {useSearchHook} - Returns information about the search
*/
const useSearch = ({ list, listOptions }) => {
const [query, setQuery] = useState('')
const useSearch = ({ list, listOptions, wait, condition }) => {
const [searchTerm, setSearchTerm] = useState('')
const [result, setResult] = useState(undefined)
const debouncedSearchTerm = useDebounce(searchTerm, wait, condition)
const listFuse = useMemo(
() =>
new Fuse(list, listOptions, Fuse.createIndex(listOptions?.keys, list)),
[list, listOptions]
)
const listFuse = useMemo(() => {
const indexed = listOptions?.keys
? Fuse.createIndex(listOptions.keys, list)
: undefined
const debounceResult = useCallback(
debounce((value) => {
const search = listFuse.search(value)?.map(({ item }) => item)
return new Fuse(list, listOptions, indexed)
}, [list, listOptions])
setResult(value ? search : undefined)
}, 1000),
[list]
)
useEffect(() => {
const search = debouncedSearchTerm
? listFuse.search(debouncedSearchTerm).map(({ item }) => item)
: undefined
const handleChange = (event) => {
const { value: nextValue } = event?.target
setResult(search)
}, [debouncedSearchTerm])
setQuery(nextValue)
debounceResult(nextValue)
return {
query: searchTerm,
result: result ?? list,
handleChange: (evt) => setSearchTerm(evt?.target?.value),
}
}
return { query, result, handleChange }
/**
* This hook allows you to debounce any fast changing value.
* The debounced value will only reflect the latest value when
* the useDebounce hook has not been called for the specified time period.
*
* @param {string} value - Value to debounce
* @param {number} [delay] - Delay in milliseconds
* @param {boolean} [condition] - Condition to check if the value should be debounced
* @returns {string} Debounced value
*/
const useDebounce = (value, delay = 1000, condition = true) => {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
// Update debounced value after delay
const handler = setTimeout(() => {
condition && setDebouncedValue(value)
}, delay)
// Cancel the timeout if value changes (also on delay change or unmount)
return () => {
clearTimeout(handler)
}
}, [value, delay, condition])
return debouncedValue
}
export default useSearch

View File

@ -286,6 +286,22 @@ const createAppTheme = (appTheme, mode = SCHEMES.DARK) => {
fieldset: { border: 'none' },
},
},
MuiTypography: {
variants: [
{
props: { variant: 'underline' },
style: {
padding: '0 1em 0.2em 0.5em',
borderBottom: `2px solid ${secondary.main}`,
// subtitle1 variant is used for the underline
fontSize: defaultTheme.typography.pxToRem(18),
lineHeight: 24 / 18,
letterSpacing: 0,
fontWeight: 500,
},
},
],
},
MuiPaper: {
defaultProps: {
elevation: 0,