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,