diff --git a/src/apps/admin/src/admin-app.routes.tsx b/src/apps/admin/src/admin-app.routes.tsx index 2f3dd0bfb..b5251bf65 100644 --- a/src/apps/admin/src/admin-app.routes.tsx +++ b/src/apps/admin/src/admin-app.routes.tsx @@ -29,6 +29,14 @@ const ManageUserPage: LazyLoadedComponent = lazyLoad( () => import('./challenge-management/ManageUserPage'), 'ManageUserPage', ) +const ManageResourcePage: LazyLoadedComponent = lazyLoad( + () => import('./challenge-management/ManageResourcePage'), + 'ManageResourcePage', +) +const AddResourcePage: LazyLoadedComponent = lazyLoad( + () => import('./challenge-management/AddResourcePage'), + 'AddResourcePage', +) const UserManagementPage: LazyLoadedComponent = lazyLoad( () => import('./user-management/UserManagementPage'), 'UserManagementPage', @@ -127,6 +135,16 @@ export const adminRoutes: ReadonlyArray = [ id: 'manage-user', route: ':challengeId/manage-user', }, + { + element: , + id: 'manage-resource', + route: ':challengeId/manage-resource', + }, + { + element: , + id: 'add-resource', + route: ':challengeId/manage-resource/add', + }, ], element: , id: manageChallengeRouteId, diff --git a/src/apps/admin/src/challenge-management/AddResourcePage/AddResourcePage.module.scss b/src/apps/admin/src/challenge-management/AddResourcePage/AddResourcePage.module.scss new file mode 100644 index 000000000..776aec7da --- /dev/null +++ b/src/apps/admin/src/challenge-management/AddResourcePage/AddResourcePage.module.scss @@ -0,0 +1,6 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; +} diff --git a/src/apps/admin/src/challenge-management/AddResourcePage/AddResourcePage.tsx b/src/apps/admin/src/challenge-management/AddResourcePage/AddResourcePage.tsx new file mode 100644 index 000000000..f030f04ca --- /dev/null +++ b/src/apps/admin/src/challenge-management/AddResourcePage/AddResourcePage.tsx @@ -0,0 +1,216 @@ +/** + * Add Resource Page. + */ +import { FC, useCallback, useContext } from 'react' +import { + Controller, + ControllerRenderProps, + useForm, + UseFormReturn, +} from 'react-hook-form' +import { NavigateFunction, useNavigate, useParams } from 'react-router-dom' +import _ from 'lodash' +import classNames from 'classnames' + +import { Button, LinkButton } from '~/libs/ui' +import { yupResolver } from '@hookform/resolvers/yup' + +import { + useManageAddChallengeResource, + useManageAddChallengeResourceProps, + useOnComponentDidMount, + useSearchUserInfo, + useSearchUserInfoProps, +} from '../../lib/hooks' +import { + ChallengeManagementContext, + ChallengeManagementContextType, + FieldHandleSelect, + FieldSingleSelect, + FormAddWrapper, + InputTextAdmin, + PageWrapper, +} from '../../lib' +import { FormAddResource, SelectOption } from '../../lib/models' +import { formAddResourceSchema } from '../../lib/utils' + +import styles from './AddResourcePage.module.scss' + +interface Props { + className?: string +} + +export const AddResourcePage: FC = (props: Props) => { + const { challengeId = '' }: { challengeId?: string } = useParams<{ + challengeId: string + }>() + + const { isLoading, doSearchUserInfo, setUserInfo }: useSearchUserInfoProps + = useSearchUserInfo() + + const { resourceRoles, loadResourceRoles, resourceRolesLoading }: ChallengeManagementContextType + = useContext(ChallengeManagementContext) + + const { + doAddChallengeResource, + isAdding, + }: useManageAddChallengeResourceProps = useManageAddChallengeResource(challengeId) + + const navigate: NavigateFunction = useNavigate() + const { + control, + handleSubmit, + register, + formState: { errors, isDirty }, + setValue, + }: UseFormReturn = useForm({ + defaultValues: { + handle: undefined, + resourceRole: undefined, + userId: '', + }, + mode: 'all', + resolver: yupResolver(formAddResourceSchema), + }) + const onSubmit = useCallback((data: FormAddResource) => { + doAddChallengeResource(data, () => { + navigate('./..') + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useOnComponentDidMount(() => { + loadResourceRoles() + }) + + return ( + + + + + Cancel + + + )} + > + { + doSearchUserInfo( + e.target.value, + userInfo => { + setValue( + 'handle', + { + label: userInfo.handle, + value: userInfo.userId, + }, + { + shouldValidate: true, + }, + ) + }, + () => { + // eslint-disable-next-line unicorn/no-null + setValue('handle', null as any, { // only null value work in this place + shouldValidate: true, + }) + }, + ) + }, + })} + disabled={isAdding} + error={_.get(errors, 'userId.message')} + dirty + isLoading={isLoading} + /> + + }) { + return ( + + ) + }} + /> + + }) { + return ( + ({ + label: resourceRole.name, + value: resourceRole.id, + }))} + label='Resource Role' + placeholder='Select' + value={controlProps.field.value} + onChange={controlProps.field.onChange} + onBlur={controlProps.field.onBlur} + error={_.get(errors, 'resourceRole.message')} + dirty + disabled={isAdding} + isLoading={resourceRolesLoading} + /> + ) + }} + /> + + + ) +} + +export default AddResourcePage diff --git a/src/apps/admin/src/challenge-management/AddResourcePage/index.ts b/src/apps/admin/src/challenge-management/AddResourcePage/index.ts new file mode 100644 index 000000000..7e4a35404 --- /dev/null +++ b/src/apps/admin/src/challenge-management/AddResourcePage/index.ts @@ -0,0 +1 @@ +export { default as AddResourcePage } from './AddResourcePage' diff --git a/src/apps/admin/src/challenge-management/ManageResourcePage/ManageResourcePage.module.scss b/src/apps/admin/src/challenge-management/ManageResourcePage/ManageResourcePage.module.scss new file mode 100644 index 000000000..06ebfb150 --- /dev/null +++ b/src/apps/admin/src/challenge-management/ManageResourcePage/ManageResourcePage.module.scss @@ -0,0 +1,8 @@ +.container { + display: flex; + flex-direction: column; +} + +.blockTableContainer { + position: relative; +} diff --git a/src/apps/admin/src/challenge-management/ManageResourcePage/ManageResourcePage.tsx b/src/apps/admin/src/challenge-management/ManageResourcePage/ManageResourcePage.tsx new file mode 100644 index 000000000..bc485739d --- /dev/null +++ b/src/apps/admin/src/challenge-management/ManageResourcePage/ManageResourcePage.tsx @@ -0,0 +1,99 @@ +/** + * Manage Resource Page. + */ +import { FC } from 'react' +import { useParams } from 'react-router-dom' +import classNames from 'classnames' + +import { LinkButton } from '~/libs/ui' +import { PlusIcon } from '@heroicons/react/solid' + +import { + useManageChallengeResources, + useManageChallengeResourcesProps, +} from '../../lib/hooks' +import { + ActionLoading, + PageWrapper, + ResourceTable, + TableLoading, + TableNoRecord, +} from '../../lib' + +import styles from './ManageResourcePage.module.scss' + +interface Props { + className?: string +} + +export const ManageResourcePage: FC = (props: Props) => { + const { challengeId = '' }: { challengeId?: string } = useParams<{ + challengeId: string + }>() + + const { + isLoading, + resources, + totalPages, + page, + setPage, + sort, + setSort, + isRemovingBool, + doRemoveResource, + }: useManageChallengeResourcesProps = useManageChallengeResources( + challengeId, + { + createdString: 'created', + }, + ) + + return ( + + + + Back + + + )} + > + {isLoading ? ( + + ) : ( + <> + {resources.length === 0 ? ( + + ) : ( +
+ + + {isRemovingBool && } +
+ )} + + )} +
+ ) +} + +export default ManageResourcePage diff --git a/src/apps/admin/src/challenge-management/ManageResourcePage/index.ts b/src/apps/admin/src/challenge-management/ManageResourcePage/index.ts new file mode 100644 index 000000000..38d7bc603 --- /dev/null +++ b/src/apps/admin/src/challenge-management/ManageResourcePage/index.ts @@ -0,0 +1 @@ +export { default as ManageResourcePage } from './ManageResourcePage' diff --git a/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx b/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx index 753125c31..840279d1e 100644 --- a/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx +++ b/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx @@ -1,4 +1,4 @@ -import { Dispatch, FC, SetStateAction, useContext, useMemo } from 'react' +import { Dispatch, FC, SetStateAction, useContext, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import _ from 'lodash' import cn from 'classnames' @@ -7,7 +7,7 @@ import moment from 'moment' import { ChevronDownIcon } from '@heroicons/react/solid' import { EnvironmentConfig } from '~/config' import { useWindowSize, WindowSize } from '~/libs/shared' -import { Button, LinkButton, Table, type TableColumn } from '~/libs/ui' +import { Button, Table, type TableColumn } from '~/libs/ui' import { ReactComponent as RegistrantUserIcon } from '../../assets/i/registrant-user-icon.svg' import { ReactComponent as SubmissionIcon } from '../../assets/i/submission-icon.svg' @@ -134,6 +134,7 @@ const Actions: FC<{ challenge: Challenge currentFilters: ChallengeFilterCriteria }> = props => { + const [openDropdown, setOpenDropdown] = useState(false) const navigate = useNavigate() const goToManageUser = useEventCallback(() => { navigate(`${props.challenge.id}/manage-user`, { @@ -141,7 +142,23 @@ const Actions: FC<{ }) }) - const createDropdownMenuTrigger = useEventCallback( + const manageDropdownMenuTrigger = useEventCallback( + (triggerProps: { + open: boolean + setOpen: Dispatch> + }) => { + const createToggle = () => (): void => triggerProps.setOpen(!triggerProps.open) + return ( + + ) + }, + ) + + const goToDropdownMenuTrigger = useEventCallback( (triggerProps: { open: boolean setOpen: Dispatch> @@ -165,15 +182,37 @@ const Actions: FC<{ return (
- - Manage Users - +
    +
  • + Users +
  • +
  • + Resources +
  • +
+ { + if (!queryTerm) { + return Promise.resolve([]) + } + + const result = await getMemberSuggestionsByHandle(queryTerm) + return result +} + +const fetchDatas = ( + queryTerm: string, + callback: (options: SelectOption[]) => void, +): void => { + autoCompleteDatas(queryTerm) + .then(datas => { + callback( + datas.map(data => ({ + label: data.handle, + value: data.userId, + })), + ) + }) +} + +interface Props { + label?: string + className?: string + placeholder?: string + readonly value?: SelectOption + readonly onChange?: (event: SelectOption) => void + readonly disabled?: boolean + readonly error?: string + readonly dirty?: boolean + readonly onBlur?: (event: FocusEvent) => void + readonly isLoading?: boolean +} + +export const FieldHandleSelect: FC = (props: Props) => ( + +) + +export default FieldHandleSelect diff --git a/src/apps/admin/src/lib/components/FieldHandleSelect/index.ts b/src/apps/admin/src/lib/components/FieldHandleSelect/index.ts new file mode 100644 index 000000000..2355df60e --- /dev/null +++ b/src/apps/admin/src/lib/components/FieldHandleSelect/index.ts @@ -0,0 +1 @@ +export * from './FieldHandleSelect' diff --git a/src/apps/admin/src/lib/components/FieldSingleSelect/FieldSingleSelect.module.scss b/src/apps/admin/src/lib/components/FieldSingleSelect/FieldSingleSelect.module.scss new file mode 100644 index 000000000..c52f91285 --- /dev/null +++ b/src/apps/admin/src/lib/components/FieldSingleSelect/FieldSingleSelect.module.scss @@ -0,0 +1,166 @@ +@import '@libs/ui/styles/typography'; +@import '@libs/ui/styles/includes'; + +.select { + margin: 0 -10px; + background: none transparent; + + border-radius: $sp-1; +} + +.select .sel { + display: block; + + &:global(__value-container) { + // display: flex; + align-items: center; + flex: 1; + flex-wrap: wrap; + position: relative; + margin: 0 10px; + padding: 0; + gap: 8px; + overflow: auto; + } + + &:global(__indicators) { + display: flex; + } + + &:global(__indicator-separator) { + display: none; + } + + &:global(__placeholder) { + position: absolute; + font-size: 14px; + line-height: 16px; + color: $black-60; + font-weight: normal; + margin: 0; + } + + &:global(__control) { + border: 0 none; + box-shadow: none; + + align-items: center; + cursor: default; + display: flex; + flex-wrap: nowrap; + justify-content: space-between; + min-height: 0; + outline: 0 !important; + position: relative; + transition: all 100ms; + background: none; + border-radius: 4px; + min-height: $sp-55; + margin-top: 2px; + } + + &:global(__input-container) { + font-size: 14px; + line-height: 16px; + color: $black-60; + display: inline-grid; + flex: 1 1 auto; + margin: 0; + grid-template-columns: 0 min-content; + padding: 0; + visibility: visible; + order: 999; + > input { + min-width: 72px !important; + font-weight: 400; + } + } + + &:global(__single-value) { + @extend .body-small; + color: $black-60; + white-space: break-spaces; + word-break: break-all; + text-align: left; + } +} + +.sel { + &:global(__menu-portal).sel:global(__menu-portal) { + z-index: 1001; + } + &:global(__menu) { + width: 100%; + background-color: $tc-white; + border-radius: 4px; + box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25); + margin-bottom: 5px; + margin-top: 5px; + border: 1px solid $black-40; + &:global(-list) { + max-height: 300px; + overflow-y: auto; + position: relative; + -webkit-overflow-scrolling: touch; + padding: 8px 0; + } + &:global(-notice) { + text-align: center; + color: #999; + padding: 8px 12px; + } + } + &:global(__option) { + cursor: default; + display: block; + width: 100%; + user-select: none; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + background-color: transparent; + color: $black-100; + padding: 8px 16px; + + font-size: 16px; + line-height: 24px; + } + &:global(__option).sel:global(__option) { + &:active, + &:global(--is-focused), + &:global(--is-selected) { + color: $tc-white; + } + + &:global(--is-focused), + &:active { + background-color: $turq-160; + } + + &:global(--is-selected) { + font-weight: bold; + background-color: $tc-white; + color: $tc-black; + } + } +} + +.selected-icon { + color: $turq-160; + > svg { + @include icon-size(14); + } +} + +.blockActionLoading { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + top: 0; + height: 100%; + right: 0; + width: 64px; + + .spinner { + background: none; + } +} diff --git a/src/apps/admin/src/lib/components/FieldSingleSelect/FieldSingleSelect.tsx b/src/apps/admin/src/lib/components/FieldSingleSelect/FieldSingleSelect.tsx new file mode 100644 index 000000000..939ef2759 --- /dev/null +++ b/src/apps/admin/src/lib/components/FieldSingleSelect/FieldSingleSelect.tsx @@ -0,0 +1,104 @@ +/** + * Fidle Single Select. + */ +import { + FC, + FocusEvent, + MutableRefObject, + ReactNode, + useMemo, + useRef, +} from 'react' +import ReactSelect, { components, SingleValue } from 'react-select' +import classNames from 'classnames' + +import { IconOutline, InputWrapper, LoadingSpinner } from '~/libs/ui' + +import { SelectOption } from '../../models' + +import styles from './FieldSingleSelect.module.scss' + +interface Props { + label?: string + className?: string + placeholder?: string + readonly value?: SelectOption + readonly onChange?: (event: SelectOption) => void + readonly disabled?: boolean + readonly dirty?: boolean + readonly hint?: string + readonly hideInlineErrors?: boolean + readonly error?: string + readonly onBlur?: (event: FocusEvent) => void + readonly options: SelectOption[] + readonly isLoading?: boolean +} + +// eslint-disable-next-line react/function-component-definition +const dropdownIndicator + = (dropdownIcon: ReactNode): FC => function dropdownUI(props: any) { + return ( + + {dropdownIcon} + + ) + } + +export const FieldSingleSelect: FC = (props: Props) => { + const wrapRef = useRef() + const asyncSelectComponents = useMemo( + () => ({ + DropdownIndicator: dropdownIndicator( + + + , + ), + }), + [], + ) + + return ( + } + > + styles.select, + menuPortal: () => styles.selectUserHandlesDropdownContainer, + }} + classNamePrefix={styles.sel} + onChange={function onChange(value: SingleValue) { + if (value) { + props.onChange?.(value) + } + }} + value={props.value} + isDisabled={props.disabled || props.isLoading} + onBlur={props.onBlur} + options={props.options} + /> + {props.isLoading && ( +
+ +
+ )} +
+ ) +} + +export default FieldSingleSelect diff --git a/src/apps/admin/src/lib/components/FieldSingleSelect/index.ts b/src/apps/admin/src/lib/components/FieldSingleSelect/index.ts new file mode 100644 index 000000000..d83c0fb72 --- /dev/null +++ b/src/apps/admin/src/lib/components/FieldSingleSelect/index.ts @@ -0,0 +1 @@ +export * from './FieldSingleSelect' diff --git a/src/apps/admin/src/lib/components/FieldSingleSelectAsync/FieldSingleSelectAsync.module.scss b/src/apps/admin/src/lib/components/FieldSingleSelectAsync/FieldSingleSelectAsync.module.scss index 4a5a39c78..c52f91285 100644 --- a/src/apps/admin/src/lib/components/FieldSingleSelectAsync/FieldSingleSelectAsync.module.scss +++ b/src/apps/admin/src/lib/components/FieldSingleSelectAsync/FieldSingleSelectAsync.module.scss @@ -149,3 +149,18 @@ @include icon-size(14); } } + +.blockActionLoading { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + top: 0; + height: 100%; + right: 0; + width: 64px; + + .spinner { + background: none; + } +} diff --git a/src/apps/admin/src/lib/components/FieldSingleSelectAsync/FieldSingleSelectAsync.tsx b/src/apps/admin/src/lib/components/FieldSingleSelectAsync/FieldSingleSelectAsync.tsx index 95c54d493..3325123dd 100644 --- a/src/apps/admin/src/lib/components/FieldSingleSelectAsync/FieldSingleSelectAsync.tsx +++ b/src/apps/admin/src/lib/components/FieldSingleSelectAsync/FieldSingleSelectAsync.tsx @@ -1,13 +1,13 @@ /** - * Input handles selector. + * Single Select Field With Async Search. */ -import { FC, MutableRefObject, ReactNode, useMemo, useRef } from 'react' +import { FC, FocusEvent, MutableRefObject, ReactNode, useMemo, useRef } from 'react' import { components, SingleValue } from 'react-select' import _ from 'lodash' import AsyncSelect from 'react-select/async' import classNames from 'classnames' -import { IconOutline, InputWrapper } from '~/libs/ui' +import { IconOutline, InputWrapper, LoadingSpinner } from '~/libs/ui' import { SelectOption } from '../../models' @@ -28,6 +28,8 @@ interface Props { readonly hint?: string readonly hideInlineErrors?: boolean readonly error?: string + readonly onBlur?: (event: FocusEvent) => void + readonly isLoading?: boolean } // eslint-disable-next-line react/function-component-definition @@ -64,7 +66,7 @@ export const FieldSingleSelectAsync: FC = (props: Props) => { = (props: Props) => { }} value={props.value} loadOptions={fetchDatasDebounce} - isDisabled={props.disabled} + isDisabled={props.disabled || props.isLoading} + onBlur={props.onBlur} /> + {props.isLoading && ( +
+ +
+ )}
) } diff --git a/src/apps/admin/src/lib/components/ResourceTable/ResourceTable.module.scss b/src/apps/admin/src/lib/components/ResourceTable/ResourceTable.module.scss new file mode 100644 index 000000000..01f9304ec --- /dev/null +++ b/src/apps/admin/src/lib/components/ResourceTable/ResourceTable.module.scss @@ -0,0 +1,4 @@ +.container { + display: flex; + flex-direction: column; +} diff --git a/src/apps/admin/src/lib/components/ResourceTable/ResourceTable.tsx b/src/apps/admin/src/lib/components/ResourceTable/ResourceTable.tsx new file mode 100644 index 000000000..f1f271e3c --- /dev/null +++ b/src/apps/admin/src/lib/components/ResourceTable/ResourceTable.tsx @@ -0,0 +1,178 @@ +/** + * Resource Table. + */ +import { Dispatch, FC, SetStateAction, useMemo, useState } from 'react' +import classNames from 'classnames' + +import { Sort } from '~/apps/gamification-admin/src/game-lib' +import { useWindowSize, WindowSize } from '~/libs/shared' +import { Button, Table, TableColumn } from '~/libs/ui' + +import { ConfirmModal } from '../common/ConfirmModal' +import { MobileTableColumn } from '../../models/MobileTableColumn.model' +import { ChallengeResource } from '../../models' +import { Pagination } from '../common/Pagination' +import { TableMobile } from '../common/TableMobile' +import { TableWrapper } from '../common/TableWrapper' + +import styles from './ResourceTable.module.scss' + +interface Props { + className?: string + isRemovingBool: boolean + datas: ChallengeResource[] + totalPages: number + page: number + setPage: Dispatch> + sort: Sort | undefined + setSort: Dispatch> + doRemoveItem: (item: ChallengeResource) => void +} + +export const ResourceTable: FC = (props: Props) => { + const { width: screenWidth }: WindowSize = useWindowSize() + const isTablet = useMemo(() => screenWidth <= 984, [screenWidth]) + const [showConfirmDialog, setShowConfirmDialog] = useState() + + const columns = useMemo[]>( + () => [ + { + className: 'blockCellWrap', + label: 'Resource ID', + renderer: (data: ChallengeResource) => {data.id}, + type: 'element', + }, + { + label: 'User Handle', + renderer: (data: ChallengeResource) => ( + {data.memberHandle} + ), + type: 'element', + }, + { + className: 'blockCellNoWrap', + label: 'User ID', + renderer: (data: ChallengeResource) => ( + {data.memberId} + ), + type: 'element', + }, + { + className: 'blockCellWrap', + label: 'Resource Role', + renderer: (data: ChallengeResource) => ( + {data.roleId} + ), + type: 'element', + }, + { + label: 'Created Date', + propertyName: 'createdString', + type: 'text', + }, + { + className: 'blockCellWrap', + label: 'Created By', + renderer: (data: ChallengeResource) => ( + {data.createdBy} + ), + type: 'element', + }, + { + label: 'Action', + renderer: (data: ChallengeResource) => ( + + ), + type: 'element', + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.isRemovingBool, props.doRemoveItem], + ) + + const columnsMobile = useMemo[][]>( + () => columns.map(column => { + if (column.label === 'Action') { + return [ + { + ...column, + colSpan: 2, + mobileType: 'last-value', + }, + ] + } + + return [ + { + ...column, + className: '', + label: `${column.label as string} label`, + mobileType: 'label', + renderer: () => ( +
+ {column.label as string} + : +
+ ), + type: 'element', + }, + { + ...column, + mobileType: 'last-value', + }, + ] + }), + [columns], + ) + + return ( + + {isTablet ? ( + + ) : ( + + )} + + + {showConfirmDialog ? ( + +
+ Are you sure you want to remove this resource? +
+
+ ) : undefined} + + ) +} + +export default ResourceTable diff --git a/src/apps/admin/src/lib/components/ResourceTable/index.ts b/src/apps/admin/src/lib/components/ResourceTable/index.ts new file mode 100644 index 000000000..d6be1187d --- /dev/null +++ b/src/apps/admin/src/lib/components/ResourceTable/index.ts @@ -0,0 +1 @@ +export { default as ResourceTable } from './ResourceTable' diff --git a/src/apps/admin/src/lib/components/common/ActionLoading/ActionLoading.module.scss b/src/apps/admin/src/lib/components/common/ActionLoading/ActionLoading.module.scss new file mode 100644 index 000000000..f50f5891a --- /dev/null +++ b/src/apps/admin/src/lib/components/common/ActionLoading/ActionLoading.module.scss @@ -0,0 +1,20 @@ +@import '@libs/ui/styles/includes'; + +.container { + position: absolute; + width: 64px; + display: flex; + align-items: center; + justify-content: center; + bottom: 0; + height: 64px; + left: $sp-8; + + .spinner { + background: none; + } + + @include ltelg { + left: $sp-4; + } +} diff --git a/src/apps/admin/src/lib/components/common/ActionLoading/ActionLoading.tsx b/src/apps/admin/src/lib/components/common/ActionLoading/ActionLoading.tsx new file mode 100644 index 000000000..8f3990a16 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/ActionLoading/ActionLoading.tsx @@ -0,0 +1,21 @@ +/** + * Action Loading. + */ +import { FC } from 'react' +import classNames from 'classnames' + +import { LoadingSpinner } from '~/libs/ui' + +import styles from './ActionLoading.module.scss' + +interface Props { + className?: string +} + +export const ActionLoading: FC = (props: Props) => ( +
+ +
+) + +export default ActionLoading diff --git a/src/apps/admin/src/lib/components/common/ActionLoading/index.ts b/src/apps/admin/src/lib/components/common/ActionLoading/index.ts new file mode 100644 index 000000000..bb91f5b96 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/ActionLoading/index.ts @@ -0,0 +1 @@ +export { default as ActionLoading } from './ActionLoading' diff --git a/src/apps/admin/src/lib/components/common/ConfirmModal/ConfirmModal.module.scss b/src/apps/admin/src/lib/components/common/ConfirmModal/ConfirmModal.module.scss new file mode 100644 index 000000000..3c295c38e --- /dev/null +++ b/src/apps/admin/src/lib/components/common/ConfirmModal/ConfirmModal.module.scss @@ -0,0 +1,4 @@ +.bodyClassName { + margin: 0; + padding: 0; +} diff --git a/src/apps/admin/src/lib/components/common/ConfirmModal/ConfirmModal.tsx b/src/apps/admin/src/lib/components/common/ConfirmModal/ConfirmModal.tsx new file mode 100644 index 000000000..fbd73cebd --- /dev/null +++ b/src/apps/admin/src/lib/components/common/ConfirmModal/ConfirmModal.tsx @@ -0,0 +1,22 @@ +/** + * Confirm Modal. + */ +import { FC } from 'react' + +import { ConfirmModalProps } from '~/libs/ui/lib/components/modals/confirm/ConfirmModal' +import { ConfirmModal as ConfirmModalOriginal } from '~/libs/ui' + +import styles from './ConfirmModal.module.scss' + +export const ConfirmModal: FC = ( + props: ConfirmModalProps, +) => ( + +) + +export default ConfirmModal diff --git a/src/apps/admin/src/lib/components/common/ConfirmModal/index.ts b/src/apps/admin/src/lib/components/common/ConfirmModal/index.ts new file mode 100644 index 000000000..b00ce52b7 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/ConfirmModal/index.ts @@ -0,0 +1 @@ +export { default as ConfirmModal } from './ConfirmModal' diff --git a/src/apps/admin/src/lib/components/common/FormAddWrapper/FormAddWrapper.module.scss b/src/apps/admin/src/lib/components/common/FormAddWrapper/FormAddWrapper.module.scss new file mode 100644 index 000000000..7056f5c60 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/FormAddWrapper/FormAddWrapper.module.scss @@ -0,0 +1,29 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + position: relative; + gap: 15px; + padding: $sp-8; + + @include ltelg { + padding: $sp-4; + } +} + +.blockBtns { + display: flex; + gap: 15px; + justify-content: flex-end; +} + +.blockFields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 15px 30px; + + @include ltemd { + grid-template-columns: 1fr; + } +} diff --git a/src/apps/admin/src/lib/components/common/FormAddWrapper/FormAddWrapper.tsx b/src/apps/admin/src/lib/components/common/FormAddWrapper/FormAddWrapper.tsx new file mode 100644 index 000000000..83b67d21f --- /dev/null +++ b/src/apps/admin/src/lib/components/common/FormAddWrapper/FormAddWrapper.tsx @@ -0,0 +1,31 @@ +/** + * Form Add Wrapper. + */ +import { FC, FormEventHandler, PropsWithChildren, ReactNode } from 'react' +import classNames from 'classnames' + +import { ActionLoading } from '../ActionLoading' + +import styles from './FormAddWrapper.module.scss' + +interface Props { + className?: string + onSubmit?: FormEventHandler + actions?: ReactNode + isAdding?: boolean +} + +export const FormAddWrapper: FC> = props => ( +
+
{props.children}
+ +
{props.actions}
+ + {props.isAdding && } + +) + +export default FormAddWrapper diff --git a/src/apps/admin/src/lib/components/common/FormAddWrapper/index.ts b/src/apps/admin/src/lib/components/common/FormAddWrapper/index.ts new file mode 100644 index 000000000..36b5b8afc --- /dev/null +++ b/src/apps/admin/src/lib/components/common/FormAddWrapper/index.ts @@ -0,0 +1 @@ +export { default as FormAddWrapper } from './FormAddWrapper' diff --git a/src/apps/admin/src/lib/components/common/InputTextAdmin/InputTextAdmin.module.scss b/src/apps/admin/src/lib/components/common/InputTextAdmin/InputTextAdmin.module.scss new file mode 100644 index 000000000..ad5faa228 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/InputTextAdmin/InputTextAdmin.module.scss @@ -0,0 +1,6 @@ +.container { + input { + height: 30px; + margin-top: 2px; + } +} diff --git a/src/apps/admin/src/lib/components/common/InputTextAdmin/InputTextAdmin.tsx b/src/apps/admin/src/lib/components/common/InputTextAdmin/InputTextAdmin.tsx new file mode 100644 index 000000000..0e4da1682 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/InputTextAdmin/InputTextAdmin.tsx @@ -0,0 +1,23 @@ +/** + * Input Text. + */ +import { FC } from 'react' +import classNames from 'classnames' + +import { InputText } from '~/libs/ui' +import { InputTextProps } from '~/libs/ui/lib/components/form/form-groups/form-input/input-text/InputText' + +import styles from './InputTextAdmin.module.scss' + +export const InputTextAdmin: FC = (props: InputTextProps) => ( + +) + +export default InputTextAdmin diff --git a/src/apps/admin/src/lib/components/common/InputTextAdmin/index.ts b/src/apps/admin/src/lib/components/common/InputTextAdmin/index.ts new file mode 100644 index 000000000..b168de724 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/InputTextAdmin/index.ts @@ -0,0 +1 @@ +export { default as InputTextAdmin } from './InputTextAdmin' diff --git a/src/apps/admin/src/lib/components/common/PageWrapper/PageWrapper.module.scss b/src/apps/admin/src/lib/components/common/PageWrapper/PageWrapper.module.scss new file mode 100644 index 000000000..4945430b3 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/PageWrapper/PageWrapper.module.scss @@ -0,0 +1,13 @@ +@import "@libs/ui/styles/includes"; + +.container { + display: flex; + flex-direction: column; +} + +.headerActions { + display: flex; + gap: 30px; + margin-left: auto; + margin-top: $sp-2; +} diff --git a/src/apps/admin/src/lib/components/common/PageWrapper/PageWrapper.tsx b/src/apps/admin/src/lib/components/common/PageWrapper/PageWrapper.tsx new file mode 100644 index 000000000..45bb3548d --- /dev/null +++ b/src/apps/admin/src/lib/components/common/PageWrapper/PageWrapper.tsx @@ -0,0 +1,34 @@ +/** + * Page Wrapper. + */ +import { FC, PropsWithChildren, ReactNode } from 'react' +import classNames from 'classnames' + +import { PageContent, PageHeader } from '~/apps/admin/src/lib' +import { PageTitle } from '~/libs/ui' + +import styles from './PageWrapper.module.scss' + +interface Props { + className?: string + pageTitle: string + headerActions?: ReactNode +} + +export const PageWrapper: FC> = props => ( +
+ {props.pageTitle} + +

{props.pageTitle}

+ + {props.headerActions ? ( +
+ {props.headerActions} +
+ ) : undefined} +
+ {props.children} +
+) + +export default PageWrapper diff --git a/src/apps/admin/src/lib/components/common/PageWrapper/index.ts b/src/apps/admin/src/lib/components/common/PageWrapper/index.ts new file mode 100644 index 000000000..2b0d52de0 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/PageWrapper/index.ts @@ -0,0 +1 @@ +export { default as PageWrapper } from './PageWrapper' diff --git a/src/apps/admin/src/lib/components/common/TableLoading/TableLoading.module.scss b/src/apps/admin/src/lib/components/common/TableLoading/TableLoading.module.scss new file mode 100644 index 000000000..79b8025cb --- /dev/null +++ b/src/apps/admin/src/lib/components/common/TableLoading/TableLoading.module.scss @@ -0,0 +1,8 @@ +.container { + position: relative; + height: 100px; + + .spinner { + background: none; + } +} diff --git a/src/apps/admin/src/lib/components/common/TableLoading/TableLoading.tsx b/src/apps/admin/src/lib/components/common/TableLoading/TableLoading.tsx new file mode 100644 index 000000000..74160f529 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/TableLoading/TableLoading.tsx @@ -0,0 +1,21 @@ +/** + * Table Loading. + */ +import { FC } from 'react' +import classNames from 'classnames' + +import { LoadingSpinner } from '~/libs/ui' + +import styles from './TableLoading.module.scss' + +interface Props { + className?: string +} + +export const TableLoading: FC = (props: Props) => ( +
+ +
+) + +export default TableLoading diff --git a/src/apps/admin/src/lib/components/common/TableLoading/index.ts b/src/apps/admin/src/lib/components/common/TableLoading/index.ts new file mode 100644 index 000000000..7d6a0da9d --- /dev/null +++ b/src/apps/admin/src/lib/components/common/TableLoading/index.ts @@ -0,0 +1 @@ +export { default as TableLoading } from './TableLoading' diff --git a/src/apps/admin/src/lib/components/common/TableNoRecord/TableNoRecord.module.scss b/src/apps/admin/src/lib/components/common/TableNoRecord/TableNoRecord.module.scss new file mode 100644 index 000000000..8af3faac7 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/TableNoRecord/TableNoRecord.module.scss @@ -0,0 +1,5 @@ +.container { + padding: 16px 16px 32px; + text-align: center; + width: 100%; +} diff --git a/src/apps/admin/src/lib/components/common/TableNoRecord/TableNoRecord.tsx b/src/apps/admin/src/lib/components/common/TableNoRecord/TableNoRecord.tsx new file mode 100644 index 000000000..be3de96e9 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/TableNoRecord/TableNoRecord.tsx @@ -0,0 +1,22 @@ +/** + * Table No Record. + */ +import { FC } from 'react' +import classNames from 'classnames' + +import { MSG_NO_RECORD_FOUND } from '~/apps/admin/src/config/index.config' + +import styles from './TableNoRecord.module.scss' + +interface Props { + className?: string + message?: string +} + +export const TableNoRecord: FC = (props: Props) => ( +

+ {props.message ?? MSG_NO_RECORD_FOUND} +

+) + +export default TableNoRecord diff --git a/src/apps/admin/src/lib/components/common/TableNoRecord/index.ts b/src/apps/admin/src/lib/components/common/TableNoRecord/index.ts new file mode 100644 index 000000000..9f54e4709 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/TableNoRecord/index.ts @@ -0,0 +1 @@ +export { default as TableNoRecord } from './TableNoRecord' diff --git a/src/apps/admin/src/lib/components/common/TableWrapper/TableWrapper.module.scss b/src/apps/admin/src/lib/components/common/TableWrapper/TableWrapper.module.scss new file mode 100644 index 000000000..9fad2d463 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/TableWrapper/TableWrapper.module.scss @@ -0,0 +1,29 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + width: 100%; + padding: $sp-8 $sp-8 0; + + @include ltelg { + padding: $sp-4 $sp-4 0; + } + + th:first-child { + padding-left: 16px !important; + + @include ltemd { + padding-left: 5px !important; + } + } + + :global(.blockCellWrap) { + white-space: break-spaces; + text-align: left; + } + + :global(.blockCellNoWrap) { + white-space: nowrap; + } +} diff --git a/src/apps/admin/src/lib/components/common/TableWrapper/TableWrapper.tsx b/src/apps/admin/src/lib/components/common/TableWrapper/TableWrapper.tsx new file mode 100644 index 000000000..1dc6e4871 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/TableWrapper/TableWrapper.tsx @@ -0,0 +1,19 @@ +/** + * Table Wrapper. + */ +import { FC, PropsWithChildren } from 'react' +import classNames from 'classnames' + +import styles from './TableWrapper.module.scss' + +interface Props { + className?: string +} + +export const TableWrapper: FC> = props => ( +
+ {props.children} +
+) + +export default TableWrapper diff --git a/src/apps/admin/src/lib/components/common/TableWrapper/index.ts b/src/apps/admin/src/lib/components/common/TableWrapper/index.ts new file mode 100644 index 000000000..111d08fac --- /dev/null +++ b/src/apps/admin/src/lib/components/common/TableWrapper/index.ts @@ -0,0 +1 @@ +export { default as TableWrapper } from './TableWrapper' diff --git a/src/apps/admin/src/lib/components/index.ts b/src/apps/admin/src/lib/components/index.ts index 82ad4f5a9..d0e81c24e 100644 --- a/src/apps/admin/src/lib/components/index.ts +++ b/src/apps/admin/src/lib/components/index.ts @@ -5,6 +5,13 @@ export * from './common/Tab' export * from './common/DropdownMenu' export * from './common/Pagination' export * from './common/Display' +export * from './common/TableLoading' +export * from './common/ActionLoading' +export * from './common/TableNoRecord' +export * from './common/PageWrapper' +export * from './common/FormAddWrapper' +export * from './common/InputTextAdmin' +export * from './common/ConfirmModal' export * from './ChallengeFilters' export * from './ChallengeList' export * from './ChallengeUserFilters' @@ -13,3 +20,6 @@ export * from './ChallengeAddUserDialog' export * from './ReviewSummaryList' export * from './ReviewerList' export * from './RejectPendingConfirmDialog' +export * from './ResourceTable' +export * from './FieldHandleSelect' +export * from './FieldSingleSelect' diff --git a/src/apps/admin/src/lib/contexts/ChallengeManagementContextProvider.tsx b/src/apps/admin/src/lib/contexts/ChallengeManagementContextProvider.tsx index 1be5ea9e7..cda8515f3 100644 --- a/src/apps/admin/src/lib/contexts/ChallengeManagementContextProvider.tsx +++ b/src/apps/admin/src/lib/contexts/ChallengeManagementContextProvider.tsx @@ -19,12 +19,14 @@ import { getChallengeTypes, getResourceRoles, } from '../services' +import { handleError } from '../utils' export type ChallengeManagementContextType = { challengeTypes: ChallengeType[] challengeTracks: ChallengeTrack[] challengeStatuses: ChallengeStatus[] resourceRoles: ResourceRole[] + resourceRolesLoading: boolean loadChallengeTypes: () => void loadChallengeTracks: () => void @@ -40,6 +42,7 @@ export const ChallengeManagementContext: Context loadChallengeTypes: () => undefined, loadResourceRoles: () => undefined, resourceRoles: [], + resourceRolesLoading: false, }) export const ChallengeManagementContextProvider: FC = props => { @@ -52,6 +55,7 @@ export const ChallengeManagementContextProvider: FC = props = ChallengeStatus.Completed, ]) const [resourceRoles, setResourceRoles] = useState([]) + const [resourceRolesLoading, setResourceRolesLoading] = useState(false) const loadChallengeTypes = useCallback(() => { getChallengeTypes() @@ -68,9 +72,15 @@ export const ChallengeManagementContextProvider: FC = props = }, []) const loadResourceRoles = useCallback(() => { + setResourceRolesLoading(true) getResourceRoles() .then(roles => { setResourceRoles(roles) + setResourceRolesLoading(false) + }) + .catch(e => { + handleError(e) + setResourceRolesLoading(false) }) }, []) @@ -83,6 +93,7 @@ export const ChallengeManagementContextProvider: FC = props = loadChallengeTypes, loadResourceRoles, resourceRoles, + resourceRolesLoading, }), [ challengeStatuses, @@ -92,6 +103,7 @@ export const ChallengeManagementContextProvider: FC = props = loadChallengeTypes, loadResourceRoles, resourceRoles, + resourceRolesLoading, ], ) return ( diff --git a/src/apps/admin/src/lib/hooks/index.ts b/src/apps/admin/src/lib/hooks/index.ts index d292e0c26..67c18e0c3 100644 --- a/src/apps/admin/src/lib/hooks/index.ts +++ b/src/apps/admin/src/lib/hooks/index.ts @@ -22,4 +22,6 @@ export * from './useManagePermissionGroups' export * from './useLoadUser' export * from './useLoadGroup' export * from './useManagePermissionGroupMembers' -export * from './useManageAddRoleMembers' +export * from './useManageChallengeResources' +export * from './useSearchUserInfo' +export * from './useManageAddChallengeResource' diff --git a/src/apps/admin/src/lib/hooks/useManageAddChallengeResource.ts b/src/apps/admin/src/lib/hooks/useManageAddChallengeResource.ts new file mode 100644 index 000000000..4c88952b8 --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useManageAddChallengeResource.ts @@ -0,0 +1,118 @@ +/** + * Manage Add Challenge Resource + */ +import { useCallback, useReducer } from 'react' +import { toast } from 'react-toastify' + +import { FormAddResource, UserInfo } from '../models' +import { addChallengeResource } from '../services' +import { handleError } from '../utils' + +/// ///////////////// +// Add challenge resource reducer +/// //////////////// + +type AddChallengeResourceState = { + isAdding: boolean + userInfo?: UserInfo +} + +const AddChallengeResourceActionType = { + ADD_CHALLENGE_RESOURCE_DONE: 'ADD_CHALLENGE_RESOURCE_DONE' as const, + ADD_CHALLENGE_RESOURCE_FAILED: 'ADD_CHALLENGE_RESOURCE_FAILED' as const, + ADD_CHALLENGE_RESOURCE_INIT: 'ADD_CHALLENGE_RESOURCE_INIT' as const, +} + +type AddChallengeResourceReducerAction = { + type: + | typeof AddChallengeResourceActionType.ADD_CHALLENGE_RESOURCE_DONE + | typeof AddChallengeResourceActionType.ADD_CHALLENGE_RESOURCE_INIT + | typeof AddChallengeResourceActionType.ADD_CHALLENGE_RESOURCE_FAILED +} + +const reducer = ( + previousState: AddChallengeResourceState, + action: AddChallengeResourceReducerAction, +): AddChallengeResourceState => { + switch (action.type) { + case AddChallengeResourceActionType.ADD_CHALLENGE_RESOURCE_INIT: { + return { + ...previousState, + isAdding: true, + } + } + + case AddChallengeResourceActionType.ADD_CHALLENGE_RESOURCE_DONE: { + return { + ...previousState, + isAdding: false, + } + } + + case AddChallengeResourceActionType.ADD_CHALLENGE_RESOURCE_FAILED: { + return { + ...previousState, + isAdding: false, + } + } + + default: { + return previousState + } + } +} + +export interface useManageAddChallengeResourceProps { + isAdding: boolean + doAddChallengeResource: ( + data: FormAddResource, + callBack: () => void, + ) => void +} + +/** + * Manage add challenge resource redux state + * @param challengeId challenge id + * @returns state data + */ +export function useManageAddChallengeResource( + challengeId: string, +): useManageAddChallengeResourceProps { + const [state, dispatch] = useReducer(reducer, { + isAdding: false, + }) + + const doAddChallengeResource = useCallback( + (data: FormAddResource, callBack: () => void) => { + dispatch({ + type: AddChallengeResourceActionType.ADD_CHALLENGE_RESOURCE_INIT, + }) + addChallengeResource({ + challengeId, + memberHandle: data.handle.label as string, + roleId: data.resourceRole.value as string, + }) + .then(() => { + toast.success('Challenge resource added successfully', { + toastId: 'Add challenge resource', + }) + dispatch({ + type: AddChallengeResourceActionType.ADD_CHALLENGE_RESOURCE_DONE, + }) + callBack() + }) + .catch(e => { + dispatch({ + type: AddChallengeResourceActionType.ADD_CHALLENGE_RESOURCE_FAILED, + }) + handleError(e) + }) + }, + [dispatch, challengeId], + ) + + return { + doAddChallengeResource, + isAdding: state.isAdding, + } +} diff --git a/src/apps/admin/src/lib/hooks/useManageChallengeResources.ts b/src/apps/admin/src/lib/hooks/useManageChallengeResources.ts new file mode 100644 index 000000000..026f5e80f --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useManageChallengeResources.ts @@ -0,0 +1,269 @@ +/** + * Manage Challenge Resources + */ +import { + Dispatch, + SetStateAction, + useCallback, + useMemo, + useReducer, +} from 'react' +import { toast } from 'react-toastify' +import _ from 'lodash' + +import { Sort } from '~/apps/gamification-admin/src/game-lib' + +import { TABLE_PAGINATION_ITEM_PER_PAGE } from '../../config/index.config' +import { + adjustChallengeResource, + ChallengeResource, + IsRemovingType, +} from '../models' +import { deleteChallengeResource, getChallengeResources } from '../services' +import { handleError } from '../utils' + +import { + useTableFilterBackend, + useTableFilterBackendProps, +} from './useTableFilterBackend' + +/// ///////////////// +// Permission resources reducer +/// //////////////// + +type ResourceState = { + isLoading: boolean + resources: ChallengeResource[] + totalPages: number + isRemoving: IsRemovingType +} + +const ResourceActionType = { + FETCH_RESOURCES_DONE: 'FETCH_RESOURCES_DONE' as const, + FETCH_RESOURCES_FAILED: 'FETCH_RESOURCES_FAILED' as const, + FETCH_RESOURCES_INIT: 'FETCH_RESOURCES_INIT' as const, + REMOVE_RESOURCES_DONE: 'REMOVE_RESOURCES_DONE' as const, + REMOVE_RESOURCES_FAILED: 'REMOVE_RESOURCES_FAILED' as const, + REMOVE_RESOURCES_INIT: 'REMOVE_RESOURCES_INIT' as const, +} + +type ResourceReducerAction = + | { + type: + | typeof ResourceActionType.FETCH_RESOURCES_INIT + | typeof ResourceActionType.FETCH_RESOURCES_FAILED + } + | { + type: typeof ResourceActionType.FETCH_RESOURCES_DONE + payload: { + data: ChallengeResource[] + totalPages: number + } + } + | { + type: + | typeof ResourceActionType.REMOVE_RESOURCES_DONE + | typeof ResourceActionType.REMOVE_RESOURCES_INIT + | typeof ResourceActionType.REMOVE_RESOURCES_FAILED + payload: number + } + +const reducer = ( + previousState: ResourceState, + action: ResourceReducerAction, +): ResourceState => { + switch (action.type) { + case ResourceActionType.FETCH_RESOURCES_INIT: { + return { + ...previousState, + isLoading: true, + resources: [], + } + } + + case ResourceActionType.FETCH_RESOURCES_DONE: { + const payload = action.payload + return { + ...previousState, + isLoading: false, + resources: payload.data, + totalPages: payload.totalPages, + } + } + + case ResourceActionType.FETCH_RESOURCES_FAILED: { + return { + ...previousState, + isLoading: false, + } + } + + case ResourceActionType.REMOVE_RESOURCES_INIT: { + return { + ...previousState, + isRemoving: { + ...previousState.isRemoving, + [action.payload]: true, + }, + } + } + + case ResourceActionType.REMOVE_RESOURCES_DONE: { + return { + ...previousState, + isRemoving: { + ...previousState.isRemoving, + [action.payload]: false, + }, + } + } + + case ResourceActionType.REMOVE_RESOURCES_FAILED: { + return { + ...previousState, + isRemoving: { + ...previousState.isRemoving, + [action.payload]: false, + }, + } + } + + default: { + return previousState + } + } +} + +export interface useManageChallengeResourcesProps { + isLoading: boolean + resources: ChallengeResource[] + totalPages: number + page: number + setPage: Dispatch> + sort: Sort | undefined + setSort: Dispatch> + isRemoving: IsRemovingType + isRemovingBool: boolean + doRemoveResource: (data: ChallengeResource) => void +} + +/** + * Manage permission resources redux state + * @param challengeId challenge id + * @param mappingSortField mapping from property field to sort field + * @returns state data + */ +export function useManageChallengeResources( + challengeId: string, + mappingSortField?: { + [key: string]: string + }, +): useManageChallengeResourcesProps { + const [state, dispatch] = useReducer(reducer, { + isLoading: false, + isRemoving: {}, + resources: [], + totalPages: 1, + }) + const isRemovingBool = useMemo( + () => _.some(state.isRemoving, value => value === true), + [state.isRemoving], + ) + const { page, setPage, sort, setSort, setFilterCriteria }: useTableFilterBackendProps<{}> + = useTableFilterBackend<{}>( + (pageRequest, sortRequest, filterCriteria, success, fail) => { + if (challengeId) { + dispatch({ + type: ResourceActionType.FETCH_RESOURCES_INIT, + }) + let sortFieldName = sortRequest?.fieldName + if ( + mappingSortField + && sortFieldName + && mappingSortField[sortFieldName] + ) { + sortFieldName = mappingSortField[sortFieldName] + } + + getChallengeResources(challengeId, { + page: pageRequest, + perPage: TABLE_PAGINATION_ITEM_PER_PAGE, + ...sortRequest ? { + sortBy: sortFieldName, + sortOrder: sortRequest.direction, + } : {}, + }) + .then(result => { + dispatch({ + payload: { + data: result.data.map( + adjustChallengeResource, + ), + totalPages: result.totalPages, + }, + type: ResourceActionType.FETCH_RESOURCES_DONE, + }) + success() + }) + .catch(e => { + dispatch({ + type: ResourceActionType.FETCH_RESOURCES_FAILED, + }) + handleError(e) + fail() + }) + } else { + fail() + } + }, + {}, + ) + + const doRemoveResource = useCallback( + (item: ChallengeResource) => { + dispatch({ + payload: item.id, + type: ResourceActionType.REMOVE_RESOURCES_INIT, + }) + function handleActionError(error: any): void { + dispatch({ + payload: item.id, + type: ResourceActionType.REMOVE_RESOURCES_FAILED, + }) + handleError(error) + } + + deleteChallengeResource({ + challengeId, + memberHandle: item.memberHandle, + roleId: item.roleId, + }) + .then(() => { + toast.success('Resource removed successfully', { + toastId: 'Remove resource', + }) + dispatch({ + payload: item.id, + type: ResourceActionType.REMOVE_RESOURCES_DONE, + }) + setFilterCriteria({}) // fetch table data again + }) + .catch(handleActionError) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [dispatch, challengeId], + ) + + return { + doRemoveResource, + isLoading: state.isLoading, + isRemoving: state.isRemoving, + isRemovingBool, + page, + resources: state.resources, + setPage, + setSort, + sort, + totalPages: state.totalPages, + } +} diff --git a/src/apps/admin/src/lib/hooks/useSearchUserInfo.ts b/src/apps/admin/src/lib/hooks/useSearchUserInfo.ts new file mode 100644 index 000000000..6903daa36 --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useSearchUserInfo.ts @@ -0,0 +1,84 @@ +/** + * Manage Search User Info + */ +import { useCallback, useRef, useState } from 'react' + +import { SearchUserInfo } from '../models' +import { findUserById } from '../services' +import { handleError } from '../utils' + +export interface useSearchUserInfoProps { + isLoading: boolean + doSearchUserInfo: ( + handle: string, + onSuccess: (userInfo: SearchUserInfo) => void, + onFail: () => void, + ) => void + setUserInfo: (userInfo: SearchUserInfo) => void +} + +/** + * Manage search user info + * @returns state data + */ +export function useSearchUserInfo(): useSearchUserInfoProps { + const [isLoading, setIsLoading] = useState(false) + + const isLoadingRef = useRef(false) + const userInfoRef = useRef() + + const doSearchUserInfo = useCallback( + ( + userId: string, + onSuccess: (userInfo: SearchUserInfo) => void, + onFail: () => void, + ) => { + if (isLoadingRef.current) { + return + } + + function handleErrorResult(e: any): void { + isLoadingRef.current = false + userInfoRef.current = undefined + handleError(e) + setIsLoading(false) + onFail() + } + + if (userId && userId !== userInfoRef.current?.userId) { + isLoadingRef.current = true + setIsLoading(true) + + findUserById(userId) + .then(rs => { + if (rs) { + isLoadingRef.current = false + const searchResult = { + handle: rs.handle, + userId: rs.id, + } + userInfoRef.current = searchResult + onSuccess(searchResult) + setIsLoading(false) + } else { + handleErrorResult({ + message: `Can not find handle with id : ${userId}`, + }) + } + }) + .catch(handleErrorResult) + } + }, + [], + ) + + const setUserInfo = useCallback((userInfo: SearchUserInfo) => { + userInfoRef.current = userInfo + }, []) + + return { + doSearchUserInfo, + isLoading, + setUserInfo, + } +} diff --git a/src/apps/admin/src/lib/hooks/useTableFilterBackend.ts b/src/apps/admin/src/lib/hooks/useTableFilterBackend.ts index 71e08d930..d308b8a6b 100644 --- a/src/apps/admin/src/lib/hooks/useTableFilterBackend.ts +++ b/src/apps/admin/src/lib/hooks/useTableFilterBackend.ts @@ -1,5 +1,5 @@ /** - * Use to manage table filter + * Use To Manage Table Backend Filter */ import { Dispatch, diff --git a/src/apps/admin/src/lib/models/FormAddResource.model.ts b/src/apps/admin/src/lib/models/FormAddResource.model.ts new file mode 100644 index 000000000..e9fdac817 --- /dev/null +++ b/src/apps/admin/src/lib/models/FormAddResource.model.ts @@ -0,0 +1,10 @@ +import { SelectOption } from './SelectOption.model' + +/** + * Model for add resource form + */ +export interface FormAddResource { + userId: string + handle: SelectOption + resourceRole: SelectOption +} diff --git a/src/apps/admin/src/lib/models/IsRemoving.type.ts b/src/apps/admin/src/lib/models/IsRemoving.type.ts index 254787fca..5d84fcc5b 100644 --- a/src/apps/admin/src/lib/models/IsRemoving.type.ts +++ b/src/apps/admin/src/lib/models/IsRemoving.type.ts @@ -1,4 +1,4 @@ /** * Use for removing item in table */ -export type IsRemovingType = { [key: string]: boolean } +export type IsRemovingType = { [key: string | number]: boolean } diff --git a/src/apps/admin/src/lib/models/SearchUserInfo.model.ts b/src/apps/admin/src/lib/models/SearchUserInfo.model.ts index 84585aae2..09cc781b7 100644 --- a/src/apps/admin/src/lib/models/SearchUserInfo.model.ts +++ b/src/apps/admin/src/lib/models/SearchUserInfo.model.ts @@ -2,6 +2,6 @@ * Model for search user info */ export interface SearchUserInfo { - userId: number + userId: number | string handle: string } diff --git a/src/apps/admin/src/lib/models/challenge-management/ChallengeResource.ts b/src/apps/admin/src/lib/models/challenge-management/ChallengeResource.ts index 68b11c9cf..cf6440644 100644 --- a/src/apps/admin/src/lib/models/challenge-management/ChallengeResource.ts +++ b/src/apps/admin/src/lib/models/challenge-management/ChallengeResource.ts @@ -1,3 +1,7 @@ +import moment from 'moment' + +import { TABLE_DATE_FORMAT } from '../../../config/index.config' + export interface ResourceRole { id: string name: string @@ -14,4 +18,34 @@ export interface ChallengeResource { memberHandle: string roleId: ResourceRole['id'] created: string + createdDate?: Date + createdString?: string // this field is calculated at frontend + createdBy: string +} + +/** + * Update challenge resource to show in ui + * @param data data from backend response + * @returns updated data + */ +export function adjustChallengeResource( + data: ChallengeResource, +): ChallengeResource { + if (!data) { + return data + } + + const created = data.created + ? new Date(data.created) + : undefined + + return { + ...data, + createdDate: created, + createdString: created + ? moment(created) + .local() + .format(TABLE_DATE_FORMAT) + : undefined, + } } diff --git a/src/apps/admin/src/lib/models/challenge-management/ChallengeResourceFilterCriteria.ts b/src/apps/admin/src/lib/models/challenge-management/ChallengeResourceFilterCriteria.ts index 1aba18e84..765199c59 100644 --- a/src/apps/admin/src/lib/models/challenge-management/ChallengeResourceFilterCriteria.ts +++ b/src/apps/admin/src/lib/models/challenge-management/ChallengeResourceFilterCriteria.ts @@ -1,4 +1,4 @@ -export interface ChallengeResourceFilterCriteria { +export type ChallengeResourceFilterCriteria = { page: number perPage: number roleId: string diff --git a/src/apps/admin/src/lib/models/index.ts b/src/apps/admin/src/lib/models/index.ts index 729319789..4daa27b2b 100644 --- a/src/apps/admin/src/lib/models/index.ts +++ b/src/apps/admin/src/lib/models/index.ts @@ -16,7 +16,6 @@ export * from './review-management' export * from './BillingAccount.model' export * from './MobileTableColumn.model' export * from './SelectOption.model' -export * from './IsRemoving.type' export * from './BillingAccountResource.model' export * from './FormClientsFilter.model' export * from './FormBillingAccountsFilter.model' @@ -31,7 +30,9 @@ export * from './SearchUserInfo.model' export * from './FormAddGroup.model' export * from './FormGroupMembersFilters.model' export * from './RoleMemberInfo.model' +export * from './FormAddResource.model' export * from './FormAddGroupMembers.type' export * from './TableFilterType.type' export * from './TableRolesFilter.type' export * from './AdminAppContextType.type' +export * from './IsRemoving.type' diff --git a/src/apps/admin/src/lib/services/challenge-management.service.ts b/src/apps/admin/src/lib/services/challenge-management.service.ts index d70394c82..7a78927df 100644 --- a/src/apps/admin/src/lib/services/challenge-management.service.ts +++ b/src/apps/admin/src/lib/services/challenge-management.service.ts @@ -1,3 +1,5 @@ +import _ from 'lodash' + import { EnvironmentConfig } from '~/config' import { PaginatedResponse, @@ -11,7 +13,6 @@ import { Challenge, ChallengeFilterCriteria, ChallengeResource, - ChallengeResourceFilterCriteria, ChallengeTrack, ChallengeType, ResourceEmail, @@ -72,10 +73,12 @@ export const getChallengeByLegacyId = async ( */ export const getChallengeResources = async ( challengeId: string, - filterCriteria: ChallengeResourceFilterCriteria, + filterCriteria: {[key: string]: string | number}, ): Promise> => { - let filter = `&page=${filterCriteria.page}&perPage=${filterCriteria.perPage}` - if (filterCriteria.roleId !== '') filter += `&roleId=${filterCriteria.roleId}` + let filter = '' + _.forOwn(filterCriteria, (value, key) => { + if (!!value) filter += `&${key}=${value}` + }) return xhrGetPaginatedAsync( `${resourceBaseUrl}/resources?challengeId=${challengeId}${filter}`, ) diff --git a/src/apps/admin/src/lib/utils/validation.ts b/src/apps/admin/src/lib/utils/validation.ts index 822fcbc8e..a66ecb490 100644 --- a/src/apps/admin/src/lib/utils/validation.ts +++ b/src/apps/admin/src/lib/utils/validation.ts @@ -4,6 +4,7 @@ import _ from 'lodash' import { FormAddGroup, FormAddGroupMembers, + FormAddResource, FormBillingAccountsFilter, FormClientsFilter, FormEditBillingAccount, @@ -37,6 +38,33 @@ export const formUsersFiltersSchema: Yup.ObjectSchema .optional(), }) +/** + * validation schema for form add resource + */ +export const formAddResourceSchema: Yup.ObjectSchema + = Yup.object({ + handle: Yup.object() + .shape({ + label: Yup.string() + .required('Label is required.'), + value: Yup.number() + .required('Value is required.'), + }) + .default(undefined) + .required('Handle is required.'), + resourceRole: Yup.object() + .shape({ + label: Yup.string() + .required('Label is required.'), + value: Yup.string() + .required('Value is required.'), + }) + .default(undefined) + .required('Role is required.'), + userId: Yup.string() + .required('User id is required.'), + }) + /** * validation schema for form billing accounts filter */