From 2d3c37e0f5678be7ab9f6af549dd162d852ee341 Mon Sep 17 00:00:00 2001 From: dat Date: Wed, 23 Apr 2025 21:15:24 +0700 Subject: [PATCH 1/2] Topcoder Admin App - Roles Management Update --- .../FieldSingleSelect/FieldSingleSelect.tsx | 20 ++- .../RoleMembersFilters/RoleMembersFilters.tsx | 11 ++ .../RoleMembersTable/RoleMembersTable.tsx | 71 +++----- .../components/RolesFilter/RolesFilter.tsx | 168 +++++++++++++----- .../components/common/ReactSelectExport.tsx | 3 + .../TableMobile/TableMobile.module.scss | 7 + .../common/TableMobile/TableMobile.tsx | 3 + .../hooks/useManagePermissionRoleMembers.ts | 71 +++----- .../models/FormRoleMembersFilters.model.ts | 1 + .../src/lib/models/FormRolesFilter.type.ts | 5 +- .../src/lib/models/MobileTableColumn.model.ts | 2 +- .../src/lib/models/RoleMemberInfo.model.ts | 3 +- .../admin/src/lib/models/UserRole.model.ts | 6 +- src/apps/admin/src/lib/utils/validation.ts | 12 +- .../PermissionRolesPage.tsx | 1 + 15 files changed, 246 insertions(+), 138 deletions(-) create mode 100644 src/apps/admin/src/lib/components/common/ReactSelectExport.tsx diff --git a/src/apps/admin/src/lib/components/FieldSingleSelect/FieldSingleSelect.tsx b/src/apps/admin/src/lib/components/FieldSingleSelect/FieldSingleSelect.tsx index 939ef2759..871377b66 100644 --- a/src/apps/admin/src/lib/components/FieldSingleSelect/FieldSingleSelect.tsx +++ b/src/apps/admin/src/lib/components/FieldSingleSelect/FieldSingleSelect.tsx @@ -9,12 +9,14 @@ import { useMemo, useRef, } from 'react' -import ReactSelect, { components, SingleValue } from 'react-select' +import { components, SingleValue } from 'react-select' +import CreatableReactSelect from 'react-select/creatable' import classNames from 'classnames' import { IconOutline, InputWrapper, LoadingSpinner } from '~/libs/ui' import { SelectOption } from '../../models' +import ReactSelect from '../common/ReactSelectExport' import styles from './FieldSingleSelect.module.scss' @@ -22,7 +24,7 @@ interface Props { label?: string className?: string placeholder?: string - readonly value?: SelectOption + readonly value?: SelectOption | null readonly onChange?: (event: SelectOption) => void readonly disabled?: boolean readonly dirty?: boolean @@ -32,6 +34,10 @@ interface Props { readonly onBlur?: (event: FocusEvent) => void readonly options: SelectOption[] readonly isLoading?: boolean + readonly classNameWrapper?: string + readonly onSearchChange?: (value: string) => void + readonly creatable?: boolean + readonly createLabel?: (inputValue: string) => string } // eslint-disable-next-line react/function-component-definition @@ -60,6 +66,10 @@ export const FieldSingleSelect: FC = (props: Props) => { [], ) + const Input = useMemo(() => ( + props.creatable ? CreatableReactSelect : ReactSelect + ), [props.creatable]) + return ( = (props: Props) => { hideInlineErrors={props.hideInlineErrors} ref={wrapRef as MutableRefObject} > - = (props: Props) => { } }} value={props.value} + defaultValue={props.value} isDisabled={props.disabled || props.isLoading} onBlur={props.onBlur} options={props.options} + onInputChange={props.onSearchChange} + createOptionPosition='first' + formatCreateLabel={props.createLabel} /> {props.isLoading && (
diff --git a/src/apps/admin/src/lib/components/RoleMembersFilters/RoleMembersFilters.tsx b/src/apps/admin/src/lib/components/RoleMembersFilters/RoleMembersFilters.tsx index fded1fc95..38220f494 100644 --- a/src/apps/admin/src/lib/components/RoleMembersFilters/RoleMembersFilters.tsx +++ b/src/apps/admin/src/lib/components/RoleMembersFilters/RoleMembersFilters.tsx @@ -66,6 +66,17 @@ export const RoleMembersFilters: FC = props => { inputControl={register('userHandle')} disabled={props.isLoading} /> +
diff --git a/src/apps/admin/src/lib/components/RoleMembersTable/RoleMembersTable.tsx b/src/apps/admin/src/lib/components/RoleMembersTable/RoleMembersTable.tsx index eb1e53f1f..a34d5fe73 100644 --- a/src/apps/admin/src/lib/components/RoleMembersTable/RoleMembersTable.tsx +++ b/src/apps/admin/src/lib/components/RoleMembersTable/RoleMembersTable.tsx @@ -1,16 +1,15 @@ /** * Role members table. */ -import { FC, useContext, useEffect, useMemo } from 'react' +import { FC, useMemo } from 'react' import _ from 'lodash' import classNames from 'classnames' import { useWindowSize, WindowSize } from '~/libs/shared' import { Button, InputCheckbox, Table, TableColumn } from '~/libs/ui' -import { AdminAppContext } from '../../contexts' import { useTableFilterLocal, useTableFilterLocalProps } from '../../hooks' -import { AdminAppContextType, RoleMemberInfo } from '../../models' +import { RoleMemberInfo } from '../../models' import { MobileTableColumn } from '../../models/MobileTableColumn.model' import { Pagination } from '../common/Pagination' import { TableMobile } from '../common/TableMobile' @@ -28,8 +27,6 @@ interface Props { } export const RoleMembersTable: FC = (props: Props) => { - const { loadUser, usersMapping, cancelLoadUser }: AdminAppContextType - = useContext(AdminAppContext) const { page, setPage, @@ -51,27 +48,6 @@ export const RoleMembersTable: FC = (props: Props) => { unselectAll, }: useTableSelectionProps = useTableSelection(datasIds) - useEffect(() => { - // clear queue of currently loading user handles - cancelLoadUser() - // load user handles for members visible on the current page - _.forEach(results, result => { - loadUser(result.id) - }) - - return () => { - // clear queue of currently loading user handles after exit ui - cancelLoadUser() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [results]) - - useEffect(() => { - _.forEach(results, result => { - result.handle = usersMapping[result.id] - }) - }, [usersMapping, results]) - const columns = useMemo[]>( () => [ { @@ -108,20 +84,12 @@ export const RoleMembersTable: FC = (props: Props) => { { label: 'Handle', propertyName: 'handle', - renderer: (data: RoleMemberInfo) => { - if (!data.id) { - return <> - } - - return ( - <> - {!usersMapping[data.id] - ? 'loading...' - : usersMapping[data.id]} - - ) - }, - type: 'element', + type: 'text', + }, + { + label: 'Email', + propertyName: 'email', + type: 'text', }, { className: styles.blockColumnAction, @@ -145,7 +113,6 @@ export const RoleMembersTable: FC = (props: Props) => { [ isSelectAll, selectedDatas, - usersMapping, props.isRemoving, props.isRemovingBool, props.doRemoveRoleMember, @@ -171,12 +138,30 @@ export const RoleMembersTable: FC = (props: Props) => { ), type: 'element', }, + ], [ + { + ...columns[3], + className: '', + label: `${columns[3].label as string} label`, + mobileType: 'label', + renderer: () => ( +
+ {columns[3].label as string} + : +
+ ), + type: 'element', + }, + { + ...columns[3], + mobileType: 'last-value', + }, ], [ { - ...columns[3], + ...columns[4], className: classNames( - columns[3].className, + columns[4].className, styles.blockRightColumn, ), colSpan: 2, diff --git a/src/apps/admin/src/lib/components/RolesFilter/RolesFilter.tsx b/src/apps/admin/src/lib/components/RolesFilter/RolesFilter.tsx index 5374cb2bf..142baee22 100644 --- a/src/apps/admin/src/lib/components/RolesFilter/RolesFilter.tsx +++ b/src/apps/admin/src/lib/components/RolesFilter/RolesFilter.tsx @@ -1,15 +1,33 @@ /** * Roles filter ui. */ -import { FC, useCallback, useEffect } from 'react' -import { useForm, UseFormReturn } from 'react-hook-form' +import { + FC, + FocusEvent, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import { + Controller, + ControllerRenderProps, + useForm, + UseFormReturn, +} from 'react-hook-form' import _ from 'lodash' import classNames from 'classnames' -import { Button, InputText } from '~/libs/ui' +import { Button } from '~/libs/ui' import { yupResolver } from '@hookform/resolvers/yup' -import { FormRolesFilter, TableRolesFilter } from '../../models' +import { FieldSingleSelect } from '../FieldSingleSelect' +import { + FormRolesFilter, + SelectOption, + TableRolesFilter, + UserRole, +} from '../../models' import { formRolesFilterSchema } from '../../utils' import styles from './RolesFilter.module.scss' @@ -20,66 +38,135 @@ interface Props { isAdding?: boolean setFilters: (filterDatas: TableRolesFilter) => void doAddRole: (roleName: string, success: () => void) => void -} - -const defaultValues: FormRolesFilter = { - roleName: '', + roles: UserRole[] } export const RolesFilter: FC = props => { + const [searchKey, setSearchKey] = useState('') + const [newOption, setNewOption] = useState() const { - register, - handleSubmit, watch, reset, - formState: { isValid }, + control, + setValue, }: UseFormReturn = useForm({ defaultValues: { - roleName: '', + // eslint-disable-next-line unicorn/no-null + roleName: null, // the react-select only accept null in this case }, mode: 'all', resolver: yupResolver(formRolesFilterSchema), }) + + const roleName = watch('roleName') + const createRoleName = useMemo(() => ( + searchKey || (newOption?.label as string) || roleName?.label || '' + ), [searchKey, newOption, roleName]) + const isValid = useMemo( + () => !_.find(props.roles, { roleName: createRoleName }), + [props.roles, createRoleName], + ) + const resetAllValue = useCallback(() => { + reset({ + // eslint-disable-next-line unicorn/no-null + roleName: null, // the react-select only accept null in this case + }) + setNewOption(undefined) + setSearchKey('') + }, []) + const onSubmit = useCallback( - (data: FormRolesFilter) => { - props.doAddRole(data.roleName, () => { - reset({ - roleName: '', - }) + (value: string) => { + setNewOption({ + label: value, + value, + }) + setValue('roleName', { + label: value, + value, + }) + props.doAddRole(value, () => { + resetAllValue() }) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [props.doAddRole], + [props.doAddRole, createRoleName], ) - const roleName = watch('roleName') - useEffect(() => { props.setFilters({ - createdAtString: roleName, - createdByHandle: roleName, - id: roleName, - modifiedAtString: roleName, - modifiedByHandle: roleName, - roleName, + createdAtString: createRoleName, + createdByHandle: createRoleName, + id: createRoleName, + modifiedAtString: createRoleName, + modifiedByHandle: createRoleName, + roleName: createRoleName, }) - }, [roleName]) // eslint-disable-line react-hooks/exhaustive-deps + }, [createRoleName]) // eslint-disable-line react-hooks/exhaustive-deps return (
- + }) { + return ( + ({ + label: role.roleName, + value: role.id, + })), + ...(newOption ? [newOption] : []), + ]} + label='Search/create role' + placeholder='Select' + value={controlProps.field.value} + onChange={function onChange(newValue: SelectOption) { + if (newValue.label === newValue.value) { + if (newValue.value) { + setNewOption(newValue) + controlProps.field.onChange(newValue) + } + } else { + setNewOption(undefined) + controlProps.field.onChange(newValue) + } + }} + onBlur={function onBlur(event: FocusEvent) { + controlProps.field.onBlur() + if (event.target.value) { + setNewOption({ + label: event.target.value, + value: event.target.value, + }) + setValue('roleName', { + label: event.target.value, + value: event.target.value, + }) + } + }} + dirty + disabled={props.isAdding} + isLoading={props.isLoading} + classNameWrapper={styles.field} + onSearchChange={setSearchKey} + creatable + createLabel={function createLabel(inputValue: string) { + return `Select "${inputValue}"` + }} + /> + ) + }} />
@@ -87,8 +174,10 @@ export const RolesFilter: FC = props => { primary className={styles.searchButton} size='lg' - type='submit' disabled={!isValid || props.isLoading || props.isAdding} + onClick={function onClick() { + onSubmit(createRoleName) + }} > Create Role @@ -96,10 +185,9 @@ export const RolesFilter: FC = props => { primary className={styles.searchButton} size='lg' - type='submit' variant='danger' onClick={function onClick() { - reset(defaultValues) + resetAllValue() }} > Clear diff --git a/src/apps/admin/src/lib/components/common/ReactSelectExport.tsx b/src/apps/admin/src/lib/components/common/ReactSelectExport.tsx new file mode 100644 index 000000000..8ddac89d4 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/ReactSelectExport.tsx @@ -0,0 +1,3 @@ +import ReactSelect from 'react-select' + +export default ReactSelect diff --git a/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.module.scss b/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.module.scss index 2e04aee7f..5a513a5c8 100644 --- a/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.module.scss +++ b/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.module.scss @@ -59,3 +59,10 @@ text-transform: uppercase; white-space: nowrap !important; } + +.blockCellLastValue { + :global(.TableCell_blockCell) { + justify-content: flex-end; + text-align: right; + } +} diff --git a/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx b/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx index d1207ef53..79bcb4295 100644 --- a/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx +++ b/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx @@ -81,6 +81,9 @@ export const TableMobile: ( [styles.blockCellLabel]: itemItemColumns.mobileType === 'label', + [styles.blockCellLastValue]: + itemItemColumns.mobileType + === 'last-value', }, styles.blockCell, )} diff --git a/src/apps/admin/src/lib/hooks/useManagePermissionRoleMembers.ts b/src/apps/admin/src/lib/hooks/useManagePermissionRoleMembers.ts index df67b4205..7becc63bd 100644 --- a/src/apps/admin/src/lib/hooks/useManagePermissionRoleMembers.ts +++ b/src/apps/admin/src/lib/hooks/useManagePermissionRoleMembers.ts @@ -10,7 +10,7 @@ import { RoleMemberInfo, UserRole, } from '../models' -import { fetchRole, searchUsers, unassignRole } from '../services' +import { fetchRole, unassignRole } from '../services' import { handleError } from '../utils' /// ///////////////// @@ -78,9 +78,15 @@ const reducer = ( case RolesActionType.FETCH_ROLE_MEMBERS_DONE: { const roleInfo = action.payload - const allRoleMembers = (roleInfo.subjects || []).map( - memberId => ({ id: memberId }), + const allRoleMembers = _.filter( + roleInfo.subjects || [], + roleMember => (!!roleMember.handle || !!roleMember.email), ) + .map(roleMember => ({ + email: roleMember.email, + handle: roleMember.handle, + id: roleMember.userId, + })) return { ...previousState, allRoleMembers, @@ -225,56 +231,29 @@ export function useManagePermissionRoleMembers( (filterData: FormRoleMembersFilters) => { let filteredMembers = _.clone(state.allRoleMembers) - // filter by ids first, it works immediately as we know all the data - // so we don't need to show loader for this if (filterData.userId) { - filteredMembers = _.filter(filteredMembers, { - id: filterData.userId, - }) + filteredMembers = _.filter( + filteredMembers, + member => `${member.id}` === filterData.userId, + ) } - // if handle filter is defined and we still have some rows to filter - if (filterData.userHandle && filteredMembers.length > 0) { - // we show loader as we need to make request to the server - dispatch({ - type: RolesActionType.FILTER_ROLE_MEMBERS_INIT, - }) - - // As there is no server API to filter role members and we don't have - // user handles to filter, we first have to find user ids by it's handle - // and after we can filter users by id - searchUsers({ - fields: 'id', - filter: `handle=*${filterData.userHandle}*&like=true`, - limit: 1000000, // set big limit to make sure server returns all records + if (filterData.email) { + filteredMembers = _.filter(filteredMembers, { + email: filterData.email, }) - .then(result => { - const foundIds = _.map(result, 'id') - - filteredMembers = _.filter( - filteredMembers, - (member: RoleMemberInfo) => _.includes(foundIds, member.id), - ) - dispatch({ - payload: filteredMembers, - type: RolesActionType.FILTER_ROLE_MEMBERS_DONE, - }) - }) - .catch(e => { - dispatch({ - type: RolesActionType.FILTER_ROLE_MEMBERS_FAILED, - }) - handleError(e) - }) + } - // if we don't filter by handle which makes server request - // redraw table immediately - } else { - dispatch({ - payload: filteredMembers, - type: RolesActionType.FILTER_ROLE_MEMBERS_DONE, + if (filterData.userHandle) { + filteredMembers = _.filter(filteredMembers, { + handle: filterData.userHandle, }) } + + dispatch({ + payload: filteredMembers, + type: RolesActionType.FILTER_ROLE_MEMBERS_DONE, + }) }, [dispatch, state.allRoleMembers], ) diff --git a/src/apps/admin/src/lib/models/FormRoleMembersFilters.model.ts b/src/apps/admin/src/lib/models/FormRoleMembersFilters.model.ts index f517c412a..6c690eab3 100644 --- a/src/apps/admin/src/lib/models/FormRoleMembersFilters.model.ts +++ b/src/apps/admin/src/lib/models/FormRoleMembersFilters.model.ts @@ -4,4 +4,5 @@ export interface FormRoleMembersFilters { userId?: string userHandle?: string + email?: string } diff --git a/src/apps/admin/src/lib/models/FormRolesFilter.type.ts b/src/apps/admin/src/lib/models/FormRolesFilter.type.ts index 1c44aa6ea..d1ff6ecdc 100644 --- a/src/apps/admin/src/lib/models/FormRolesFilter.type.ts +++ b/src/apps/admin/src/lib/models/FormRolesFilter.type.ts @@ -2,5 +2,8 @@ * Model for roles filter form */ export type FormRolesFilter = { - roleName: string + roleName: { + label: string + value: string + } | null } diff --git a/src/apps/admin/src/lib/models/MobileTableColumn.model.ts b/src/apps/admin/src/lib/models/MobileTableColumn.model.ts index 4f24b98a4..01229aa43 100644 --- a/src/apps/admin/src/lib/models/MobileTableColumn.model.ts +++ b/src/apps/admin/src/lib/models/MobileTableColumn.model.ts @@ -4,5 +4,5 @@ import { TableColumn } from '~/libs/ui' export interface MobileTableColumn extends TableColumn { - readonly mobileType?: 'label' + readonly mobileType?: 'label' | 'last-value' } diff --git a/src/apps/admin/src/lib/models/RoleMemberInfo.model.ts b/src/apps/admin/src/lib/models/RoleMemberInfo.model.ts index 0930bee91..f89a8a290 100644 --- a/src/apps/admin/src/lib/models/RoleMemberInfo.model.ts +++ b/src/apps/admin/src/lib/models/RoleMemberInfo.model.ts @@ -3,5 +3,6 @@ */ export interface RoleMemberInfo { id: string - handle?: string + handle: string | null + email: string | null } diff --git a/src/apps/admin/src/lib/models/UserRole.model.ts b/src/apps/admin/src/lib/models/UserRole.model.ts index 1be364325..38b766acd 100644 --- a/src/apps/admin/src/lib/models/UserRole.model.ts +++ b/src/apps/admin/src/lib/models/UserRole.model.ts @@ -16,7 +16,11 @@ export interface UserRole { modifiedAt: Date modifiedAtString?: string modifiedByHandle?: string - subjects?: string[] + subjects?: { + email: string | null + handle: string | null + userId: string + }[] } /** diff --git a/src/apps/admin/src/lib/utils/validation.ts b/src/apps/admin/src/lib/utils/validation.ts index a66ecb490..84922f7ba 100644 --- a/src/apps/admin/src/lib/utils/validation.ts +++ b/src/apps/admin/src/lib/utils/validation.ts @@ -206,6 +206,9 @@ export const formEditClientSchema: Yup.ObjectSchema */ export const formRoleMembersFiltersSchema: Yup.ObjectSchema = Yup.object({ + email: Yup.string() + .trim() + .optional(), userHandle: Yup.string() .trim() .optional(), @@ -250,8 +253,13 @@ export const formGroupMembersFiltersSchema: Yup.ObjectSchema = Yup.object({ - roleName: Yup.string() - .trim() + roleName: Yup.object() + .shape({ + label: Yup.string() + .required('label id is required.'), + value: Yup.string() + .required('value is required.'), + }) .required('Role is required.'), }) diff --git a/src/apps/admin/src/permission-management/PermissionRolesPage/PermissionRolesPage.tsx b/src/apps/admin/src/permission-management/PermissionRolesPage/PermissionRolesPage.tsx index f42bf1dd2..73af3cb5b 100644 --- a/src/apps/admin/src/permission-management/PermissionRolesPage/PermissionRolesPage.tsx +++ b/src/apps/admin/src/permission-management/PermissionRolesPage/PermissionRolesPage.tsx @@ -54,6 +54,7 @@ export const PermissionRolesPage: FC = (props: Props) => { doFilterRole(filterDatas) }} doAddRole={doAddRole} + roles={roles} /> {isLoading ? ( From 26face4cf884480dfeb7fb94d9902551616927e9 Mon Sep 17 00:00:00 2001 From: dat Date: Tue, 29 Apr 2025 07:35:54 +0700 Subject: [PATCH 2/2] Fix for GitHub bot suggestions --- src/apps/admin/src/lib/components/RolesFilter/RolesFilter.tsx | 3 +++ src/apps/admin/src/lib/utils/validation.ts | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/apps/admin/src/lib/components/RolesFilter/RolesFilter.tsx b/src/apps/admin/src/lib/components/RolesFilter/RolesFilter.tsx index 142baee22..a28ff0e89 100644 --- a/src/apps/admin/src/lib/components/RolesFilter/RolesFilter.tsx +++ b/src/apps/admin/src/lib/components/RolesFilter/RolesFilter.tsx @@ -107,6 +107,9 @@ export const RolesFilter: FC = props => { return (
diff --git a/src/apps/admin/src/lib/utils/validation.ts b/src/apps/admin/src/lib/utils/validation.ts index 84922f7ba..ee0a82c51 100644 --- a/src/apps/admin/src/lib/utils/validation.ts +++ b/src/apps/admin/src/lib/utils/validation.ts @@ -256,9 +256,9 @@ export const formRolesFilterSchema: Yup.ObjectSchema roleName: Yup.object() .shape({ label: Yup.string() - .required('label id is required.'), + .required('Label is required.'), value: Yup.string() - .required('value is required.'), + .required('Value is required.'), }) .required('Role is required.'), })