diff --git a/src/fireedge/src/client/components/Forms/Legend.js b/src/fireedge/src/client/components/Forms/Legend.js index 3535aa987c..dd35abbac6 100644 --- a/src/fireedge/src/client/components/Forms/Legend.js +++ b/src/fireedge/src/client/components/Forms/Legend.js @@ -21,22 +21,16 @@ import AdornmentWithTooltip from 'client/components/FormControl/Tooltip' import { Translate } from 'client/components/HOC' const StyledLegend = styled((props) => ( - -))( - ({ theme }) => ({ - padding: '0em 1em 0.2em 0.5em', - borderBottom: `2px solid ${theme.palette.secondary.main}`, + +))(({ 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 }) => ( diff --git a/src/fireedge/src/client/constants/translates.js b/src/fireedge/src/client/constants/translates.js index b77b1d1920..760c3579b4 100644 --- a/src/fireedge/src/client/constants/translates.js +++ b/src/fireedge/src/client/constants/translates.js @@ -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', diff --git a/src/fireedge/src/client/containers/Settings/Authentication/index.js b/src/fireedge/src/client/containers/Settings/Authentication/index.js index 3ceb630b7e..bc5f56c29c 100644 --- a/src/fireedge/src/client/containers/Settings/Authentication/index.js +++ b/src/fireedge/src/client/containers/Settings/Authentication/index.js @@ -195,7 +195,10 @@ const Settings = () => { } return ( - + {FIELDS.map((field) => ( diff --git a/src/fireedge/src/client/containers/Settings/ConfigurationUI/index.js b/src/fireedge/src/client/containers/Settings/ConfigurationUI/index.js index 0d0579333b..f6db452e8d 100644 --- a/src/fireedge/src/client/containers/Settings/ConfigurationUI/index.js +++ b/src/fireedge/src/client/containers/Settings/ConfigurationUI/index.js @@ -79,7 +79,7 @@ const Settings = () => { component="form" onSubmit={handleSubmit(handleUpdateUser)} variant="outlined" - sx={{ p: '1em', maxWidth: { sm: 'auto', md: 550 } }} + sx={{ p: '1em' }} > { + 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 ( + + + + + + + + {result?.map((label) => ( + + + + + handleDeleteLabel(label)} + icon={} + /> + + ))} + + + ) : undefined, + }} + {...register(NEW_LABEL_ID, { onChange: handleChange })} + helperText={'Press enter to create a new label'} + /> + + ) +} + +export default Settings diff --git a/src/fireedge/src/client/containers/Settings/index.js b/src/fireedge/src/client/containers/Settings/index.js index eb53ac1f5c..e5c83da1dd 100644 --- a/src/fireedge/src/client/containers/Settings/index.js +++ b/src/fireedge/src/client/containers/Settings/index.js @@ -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 = () => ( - + + - + ) diff --git a/src/fireedge/src/client/features/OneApi/common.js b/src/fireedge/src/client/features/OneApi/common.js index 81d734aa97..f45e354d7a 100644 --- a/src/fireedge/src/client/features/OneApi/common.js +++ b/src/fireedge/src/client/features/OneApi/common.js @@ -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) + } diff --git a/src/fireedge/src/client/features/OneApi/user.js b/src/fireedge/src/client/features/OneApi/user.js index 271c8e00e0..4189310db0 100644 --- a/src/fireedge/src/client/features/OneApi/user.js +++ b/src/fireedge/src/client/features/OneApi/user.js @@ -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, diff --git a/src/fireedge/src/client/hooks/useSearch.js b/src/fireedge/src/client/hooks/useSearch.js index f08cd7f7c6..663226508b 100644 --- a/src/fireedge/src/client/hooks/useSearch.js +++ b/src/fireedge/src/client/hooks/useSearch.js @@ -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 diff --git a/src/fireedge/src/client/theme/defaults.js b/src/fireedge/src/client/theme/defaults.js index 1a0f60bc18..11dbb9bcb9 100644 --- a/src/fireedge/src/client/theme/defaults.js +++ b/src/fireedge/src/client/theme/defaults.js @@ -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,