diff --git a/package.json b/package.json index 4836bfad5..0046f4f9b 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "@storybook/react": "7.6.10", "@stripe/react-stripe-js": "1.13.0", "@stripe/stripe-js": "1.41.0", + "@tinymce/tinymce-react": "^6.2.1", + "@types/codemirror": "5.60.15", "apexcharts": "^3.36.0", "axios": "^1.7.9", "browser-cookies": "^1.2.0", @@ -43,6 +45,7 @@ "draft-js-export-html": "^1.2.0", "draft-js-markdown-shortcuts-plugin": "^0.3.0", "draft-js-plugins-editor": "^2.0.3", + "easymde": "2.20.0", "express": "^4.21.2", "express-fileupload": "^1.4.0", "express-interceptor": "^1.2.0", @@ -101,6 +104,7 @@ "styled-components": "^5.3.6", "swr": "^1.3.0", "tc-auth-lib": "topcoder-platform/tc-auth-lib#1.0.27", + "tinymce": "^7.9.1", "typescript": "^4.8.4", "universal-navigation": "https://github.com/topcoder-platform/universal-navigation#9fc50d938be7182", "uuid": "^11.1.0", diff --git a/src/apps/admin/src/admin-app.routes.tsx b/src/apps/admin/src/admin-app.routes.tsx index b64649b3b..96ed6c0dd 100644 --- a/src/apps/admin/src/admin-app.routes.tsx +++ b/src/apps/admin/src/admin-app.routes.tsx @@ -16,6 +16,7 @@ import { permissionManagementRouteId, platformRouteId, rootRoute, + termsRouteId, userManagementRouteId, } from './config/routes.config' import { platformSkillRouteId } from './platform/routes.config' @@ -128,6 +129,22 @@ const BadgeListingPage: LazyLoadedComponent = lazyLoad( const CreateBadgePage: LazyLoadedComponent = lazyLoad( () => import('../../gamification-admin/src/pages/create-badge/CreateBadgePage'), ) +const TermsListPage: LazyLoadedComponent = lazyLoad( + () => import('./platform/terms/TermsListPage'), + 'TermsListPage', +) +const TermsAddPage: LazyLoadedComponent = lazyLoad( + () => import('./platform/terms/TermsAddPage'), + 'TermsAddPage', +) +const TermsEditPage: LazyLoadedComponent = lazyLoad( + () => import('./platform/terms/TermsEditPage'), + 'TermsEditPage', +) +const TermsUsersPage: LazyLoadedComponent = lazyLoad( + () => import('./platform/terms/TermsUsersPage'), + 'TermsUsersPage', +) export const toolTitle: string = ToolTitle.admin @@ -310,6 +327,22 @@ export const adminRoutes: ReadonlyArray = [ element: , route: `${gamificationAdminRouteId}${baseDetailPath}/:id`, }, + { + element: , + route: termsRouteId, + }, + { + element: , + route: `${termsRouteId}/add`, + }, + { + element: , + route: `${termsRouteId}/:id/users`, + }, + { + element: , + route: `${termsRouteId}/:id/edit`, + }, ], element: , id: platformRouteId, diff --git a/src/apps/admin/src/config/routes.config.ts b/src/apps/admin/src/config/routes.config.ts index fe857eb00..7fa82046f 100644 --- a/src/apps/admin/src/config/routes.config.ts +++ b/src/apps/admin/src/config/routes.config.ts @@ -14,4 +14,5 @@ export const userManagementRouteId = 'user-management' export const billingAccountRouteId = 'billing-account' export const permissionManagementRouteId = 'permission-management' export const gamificationAdminRouteId = 'gamification-admin' +export const termsRouteId = 'terms' export const platformRouteId = 'platform' diff --git a/src/apps/admin/src/lib/components/DialogAddTermUser/DialogAddTermUser.module.scss b/src/apps/admin/src/lib/components/DialogAddTermUser/DialogAddTermUser.module.scss new file mode 100644 index 000000000..2104049c7 --- /dev/null +++ b/src/apps/admin/src/lib/components/DialogAddTermUser/DialogAddTermUser.module.scss @@ -0,0 +1,34 @@ +.container { + display: flex; + flex-direction: column; + gap: 20px; + position: relative; +} + +.blockForm { + display: flex; + flex-direction: column; + gap: 20px; + position: relative; +} + +.actionButtons { + display: flex; + justify-content: flex-end; + gap: 6px; +} + +.dialogLoadingSpinnerContainer { + position: absolute; + width: 64px; + display: flex; + align-items: center; + justify-content: center; + bottom: 0; + height: 64px; + left: 0; + + .spinner { + background: none; + } +} diff --git a/src/apps/admin/src/lib/components/DialogAddTermUser/DialogAddTermUser.tsx b/src/apps/admin/src/lib/components/DialogAddTermUser/DialogAddTermUser.tsx new file mode 100644 index 000000000..986ef9bab --- /dev/null +++ b/src/apps/admin/src/lib/components/DialogAddTermUser/DialogAddTermUser.tsx @@ -0,0 +1,145 @@ +/** + * Dialog Add Term User. + */ +import { FC, useCallback } from 'react' +import { + Controller, + ControllerRenderProps, + useForm, + UseFormReturn, +} from 'react-hook-form' +import _ from 'lodash' +import classNames from 'classnames' + +import { yupResolver } from '@hookform/resolvers/yup' +import { BaseModal, Button, LoadingSpinner } from '~/libs/ui' + +import { useEventCallback } from '../../hooks' +import { UserTerm } from '../../models' +import { FormAddTermUser } from '../../models/FormAddTermUser.model' +import { formAddTermUserSchema } from '../../utils' +import { FieldHandleSelect } from '../FieldHandleSelect' + +import styles from './DialogAddTermUser.module.scss' + +interface Props { + className?: string + open: boolean + setOpen: (isOpen: boolean) => void + termInfo: UserTerm + isAdding: boolean + doAddTermUser: ( + userId: number, + userHandle: string, + sucess: () => void, + fail: () => void, + ) => void +} + +export const DialogAddTermUser: FC = (props: Props) => { + const handleClose = useEventCallback(() => props.setOpen(false)) + const { + handleSubmit, + control, + reset, + formState: { errors, isValid, isDirty }, + }: UseFormReturn = useForm({ + defaultValues: { + handle: undefined, + }, + mode: 'all', + resolver: yupResolver(formAddTermUserSchema), + }) + + /** + * Handle submit form event + */ + const onSubmit = useCallback( + (data: FormAddTermUser) => { + props.doAddTermUser( + data.handle?.value ?? 0, + data.handle?.label ?? '', + () => { + props.setOpen(false) + }, + () => { + reset({ + // eslint-disable-next-line unicorn/no-null + handle: null, // only null will reset the handle field + }) + }, + ) + }, + [props.doAddTermUser, reset], + ) + + return ( + +
+
+ + }) { + return ( + + ) + }} + /> +
+
+ + +
+ + {props.isAdding && ( +
+ +
+ )} +
+
+ ) +} + +export default DialogAddTermUser diff --git a/src/apps/admin/src/lib/components/DialogAddTermUser/index.ts b/src/apps/admin/src/lib/components/DialogAddTermUser/index.ts new file mode 100644 index 000000000..9a6be4150 --- /dev/null +++ b/src/apps/admin/src/lib/components/DialogAddTermUser/index.ts @@ -0,0 +1 @@ +export { default as DialogAddTermUser } from './DialogAddTermUser' diff --git a/src/apps/admin/src/lib/components/FieldHandleSelect/FieldHandleSelect.tsx b/src/apps/admin/src/lib/components/FieldHandleSelect/FieldHandleSelect.tsx index 013c03f7a..13db6f37c 100644 --- a/src/apps/admin/src/lib/components/FieldHandleSelect/FieldHandleSelect.tsx +++ b/src/apps/admin/src/lib/components/FieldHandleSelect/FieldHandleSelect.tsx @@ -34,8 +34,9 @@ const fetchDatas = ( interface Props { label?: string className?: string + classNameWrapper?: string placeholder?: string - readonly value?: SelectOption + readonly value?: SelectOption | null readonly onChange?: (event: SelectOption) => void readonly disabled?: boolean readonly error?: string diff --git a/src/apps/admin/src/lib/components/FieldSingleSelectAsync/FieldSingleSelectAsync.tsx b/src/apps/admin/src/lib/components/FieldSingleSelectAsync/FieldSingleSelectAsync.tsx index 3325123dd..3c9d90abf 100644 --- a/src/apps/admin/src/lib/components/FieldSingleSelectAsync/FieldSingleSelectAsync.tsx +++ b/src/apps/admin/src/lib/components/FieldSingleSelectAsync/FieldSingleSelectAsync.tsx @@ -16,8 +16,9 @@ import styles from './FieldSingleSelectAsync.module.scss' interface Props { label?: string className?: string + classNameWrapper?: string placeholder?: string - readonly value?: SelectOption + readonly value?: SelectOption | null readonly onChange?: (event: SelectOption) => void readonly disabled?: boolean readonly loadOptions?: ( diff --git a/src/apps/admin/src/lib/components/TermsAddForm/TermsAddForm.module.scss b/src/apps/admin/src/lib/components/TermsAddForm/TermsAddForm.module.scss new file mode 100644 index 000000000..f60cf3d97 --- /dev/null +++ b/src/apps/admin/src/lib/components/TermsAddForm/TermsAddForm.module.scss @@ -0,0 +1,66 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + position: relative; +} + +.blockBtns { + display: flex; + gap: 15px; + justify-content: flex-end; +} + +.blockActionLoading { + position: absolute; + width: 64px; + display: flex; + align-items: center; + justify-content: center; + height: 64px; + left: $sp-8; + bottom: $sp-8; + + .spinner { + background: none; + } + + @include ltelg { + left: $sp-4; + bottom: $sp-4; + } +} + +.fieldTextContainer, +.fieldTitle { + grid-column: 1 / span 2; +} + +.fieldTextContainer { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; +} + +.fieldAreaContainer { + textarea { + height: 200px; + resize: none; + } +} + +.fieldText { + width: 100%; +} + +.btnDelete { + display: flex; + align-items: center; + gap: 5px; + + strong { + font-weight: bold; + } +} diff --git a/src/apps/admin/src/lib/components/TermsAddForm/TermsAddForm.tsx b/src/apps/admin/src/lib/components/TermsAddForm/TermsAddForm.tsx new file mode 100644 index 000000000..47646c46e --- /dev/null +++ b/src/apps/admin/src/lib/components/TermsAddForm/TermsAddForm.tsx @@ -0,0 +1,396 @@ +/** + * Terms Add Form. + */ +import { + Dispatch, + FC, + SetStateAction, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import { NavigateFunction, useNavigate, useParams } from 'react-router-dom' +import { + Controller, + ControllerRenderProps, + useForm, + UseFormReturn, +} from 'react-hook-form' +import _ from 'lodash' +import classNames from 'classnames' + +import { + Button, + ConfirmModal, + InputSelectReact, + InputText, + InputTextarea, + LinkButton, +} from '~/libs/ui' +import { yupResolver } from '@hookform/resolvers/yup' +import { EnvironmentConfig } from '~/config' + +import { FormAddWrapper } from '../common/FormAddWrapper' +import { FormAddTerm } from '../../models' +import { formAddTermSchema } from '../../utils' +import { useManageAddTerm, useManageAddTermProps } from '../../hooks' +import { FieldHtmlEditor } from '../common/FieldHtmlEditor' + +import styles from './TermsAddForm.module.scss' + +interface Props { + className?: string +} + +const electronicallyAgreeableId = EnvironmentConfig.ADMIN.AGREE_ELECTRONICALLY +const docusignTypeId = EnvironmentConfig.ADMIN.AGREE_FOR_DOCUSIGN_TEMPLATE + +export const TermsAddForm: FC = (props: Props) => { + const [removeConfirmationOpen, setRemoveConfirmationOpen]: [ + boolean, + Dispatch>, + ] = useState(false) + const navigate: NavigateFunction = useNavigate() + const [showEditor, setShowEditor] = useState(false) + const { id = '' }: { id?: string } = useParams<{ + id?: string + }>() + const [hideField, setHideField] = useState<{ [key: string]: boolean }>({ + docusignTemplateId: true, + url: true, + }) + + const { + isFetchingTermsTypes, + isFetchingTermsAgreeabilityTypes, + isLoading, + isRemoving, + doAddTerm, + doRemoveTerm, + doUpdateTerm, + signedUsersTotal, + termsTypes, + termsAgreeabilityTypes, + termInfo, + }: useManageAddTermProps = useManageAddTerm(id) + + const termsTypesOptions = useMemo( + () => termsTypes.map(item => ({ + label: item.name, + value: `${item.id}`, + })), + [termsTypes], + ) + const termsAgreeabilityTypesOptions = useMemo( + () => termsAgreeabilityTypes.map(item => ({ + label: item.name, + value: item.id, + })), + [termsAgreeabilityTypes], + ) + const isEdit = !!id + const { + register, + handleSubmit, + control, + reset, + getValues, + setValue, + watch, + formState: { errors, isDirty }, + }: UseFormReturn = useForm({ + defaultValues: { + agreeabilityTypeId: '', + docusignTemplateId: '', + text: '', + title: '', + typeId: '', + url: '', + }, + mode: 'all', + resolver: yupResolver(formAddTermSchema), + }) + + /** + * Handle submit form event + */ + const onSubmit = useCallback( + (data: FormAddTerm) => { + const requestBody = _.pickBy(data, _.identity) + if (isEdit) { + doUpdateTerm(requestBody, () => { + navigate('./../..') + }) + } else { + doAddTerm(requestBody, () => { + navigate('./..') + }) + } + }, + [isEdit, navigate], + ) + + const agreeabilityTypeId = watch('agreeabilityTypeId') + useEffect(() => { + // check to enable/disable 'Docusign Template ID' and 'URL' fields + if (agreeabilityTypeId) { + const isDocuSignFieldEnabled = agreeabilityTypeId === docusignTypeId + const isUrlEnabled + = agreeabilityTypeId === electronicallyAgreeableId + if (!isDocuSignFieldEnabled) { + const docusignTemplateId = getValues('docusignTemplateId') + if (docusignTemplateId) { + setValue('docusignTemplateId', '') + } + } + + if (!isUrlEnabled) { + const url = getValues('url') + if (url) { + setValue('url', '') + } + } + + setHideField({ + docusignTemplateId: !isDocuSignFieldEnabled, + url: !isUrlEnabled, + }) + } + }, [agreeabilityTypeId]) + + useEffect(() => { + if (termInfo) { + reset({ + agreeabilityTypeId: termInfo.agreeabilityTypeId, + docusignTemplateId: termInfo.docusignTemplateId ?? '', + text: termInfo.text ?? '', + title: termInfo.title, + typeId: `${termInfo.typeId}`, + url: termInfo.url ?? '', + }) + } + }, [termInfo]) + + return ( + + {isEdit && ( +
+ + {signedUsersTotal > 0 && ( + + {signedUsersTotal} + {' '} + {signedUsersTotal > 1 ? 'Users' : 'User'} + {' '} + have Signed + + )} +
+ )} + + + Cancel + + + )} + > + + + }) { + return ( + + ) + }} + /> + + }) { + return ( + + ) + }} + /> + {agreeabilityTypeId && !hideField.docusignTemplateId && ( + + )} + {agreeabilityTypeId && !hideField.url && ( + + )} + +
+ + {showEditor ? ( + + }) { + return ( + + ) + }} + /> + ) : ( + + )} +
+ + { + navigate('./../..') + }) + }} + open={removeConfirmationOpen} + > +
Are you sure want to delete this terms of use?
+
+
+ ) +} + +export default TermsAddForm diff --git a/src/apps/admin/src/lib/components/TermsAddForm/index.ts b/src/apps/admin/src/lib/components/TermsAddForm/index.ts new file mode 100644 index 000000000..36732cf2f --- /dev/null +++ b/src/apps/admin/src/lib/components/TermsAddForm/index.ts @@ -0,0 +1 @@ +export { default as TermsAddForm } from './TermsAddForm' diff --git a/src/apps/admin/src/lib/components/TermsFilters/TermsFilters.module.scss b/src/apps/admin/src/lib/components/TermsFilters/TermsFilters.module.scss new file mode 100644 index 000000000..e2b0c8f60 --- /dev/null +++ b/src/apps/admin/src/lib/components/TermsFilters/TermsFilters.module.scss @@ -0,0 +1,43 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + padding: $sp-8 $sp-8 0; + + @include ltelg { + padding: $sp-4 $sp-4 0; + } +} + +.fields { + display: flex; + gap: 15px; + align-items: flex-start; + flex-wrap: wrap; + + @include ltemd { + flex-direction: column; + gap: 0; + align-items: flex-end; + } +} + +.field { + flex: 1; + max-width: 500px; + + @include ltelg { + width: 100%; + } + + @include ltemd { + max-width: none; + } +} + +.blockBottom { + display: flex; + gap: 10px; + margin-top: 3px; +} diff --git a/src/apps/admin/src/lib/components/TermsFilters/TermsFilters.tsx b/src/apps/admin/src/lib/components/TermsFilters/TermsFilters.tsx new file mode 100644 index 000000000..eabe67afd --- /dev/null +++ b/src/apps/admin/src/lib/components/TermsFilters/TermsFilters.tsx @@ -0,0 +1,96 @@ +/** + * Terms Filters. + */ +import { FC, useCallback } from 'react' +import { useForm, UseFormReturn } from 'react-hook-form' +import _ from 'lodash' +import classNames from 'classnames' + +import { yupResolver } from '@hookform/resolvers/yup' +import { Button, InputText } from '~/libs/ui' + +import { formSearchByKeySchema } from '../../utils' +import { FormSearchByKey } from '../../models' + +import styles from './TermsFilters.module.scss' + +interface Props { + className?: string + isLoading: boolean + onSubmitForm?: (data: FormSearchByKey) => void +} + +const defaultValues: FormSearchByKey = { + searchKey: '', +} + +export const TermsFilters: FC = (props: Props) => { + const { + register, + handleSubmit, + reset, + formState: { isValid, isDirty }, + }: UseFormReturn = useForm({ + defaultValues, + mode: 'all', + resolver: yupResolver(formSearchByKeySchema), + }) + + /** + * Handle submit form event + */ + const onSubmit = useCallback( + (data: FormSearchByKey) => { + props.onSubmitForm?.(data) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.onSubmitForm], + ) + + return ( +
+
+ +
+ + +
+
+
+ ) +} + +export default TermsFilters diff --git a/src/apps/admin/src/lib/components/TermsFilters/index.ts b/src/apps/admin/src/lib/components/TermsFilters/index.ts new file mode 100644 index 000000000..9d0c6b84f --- /dev/null +++ b/src/apps/admin/src/lib/components/TermsFilters/index.ts @@ -0,0 +1 @@ +export { default as TermsFilters } from './TermsFilters' diff --git a/src/apps/admin/src/lib/components/TermsTable/TermsTable.module.scss b/src/apps/admin/src/lib/components/TermsTable/TermsTable.module.scss new file mode 100644 index 000000000..4aa6484b0 --- /dev/null +++ b/src/apps/admin/src/lib/components/TermsTable/TermsTable.module.scss @@ -0,0 +1,31 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + padding-top: 0; + + a { + color: $blue-110; + + &:hover { + color: $blue-110; + } + } +} + +.rowActions { + display: flex; + align-items: center; +} + +.tableCell { + white-space: break-spaces !important; + text-align: left !important; +} + +.desktopTable { + td { + vertical-align: middle; + } +} diff --git a/src/apps/admin/src/lib/components/TermsTable/TermsTable.tsx b/src/apps/admin/src/lib/components/TermsTable/TermsTable.tsx new file mode 100644 index 000000000..6781c8c76 --- /dev/null +++ b/src/apps/admin/src/lib/components/TermsTable/TermsTable.tsx @@ -0,0 +1,158 @@ +/** + * Terms Table. + */ +import { Dispatch, FC, SetStateAction, useMemo } from 'react' +import { Link } from 'react-router-dom' +import _ from 'lodash' +import classNames from 'classnames' + +import { colWidthType, LinkButton, Table, TableColumn } from '~/libs/ui' +import { EnvironmentConfig } from '~/config' +import { useWindowSize, WindowSize } from '~/libs/shared' + +import { MobileTableColumn, UserTerm } from '../../models' +import { TableMobile } from '../common/TableMobile' +import { Pagination } from '../common/Pagination' +import { TableWrapper } from '../common/TableWrapper' + +import styles from './TermsTable.module.scss' + +interface Props { + className?: string + datas: UserTerm[] + totalPages: number + page: number + setPage: Dispatch> + colWidth: colWidthType | undefined + setColWidth: Dispatch> | undefined +} + +const electronicallyAgreeableId = EnvironmentConfig.ADMIN.AGREE_ELECTRONICALLY +const agreeForDocuSignTemplateId + = EnvironmentConfig.ADMIN.AGREE_FOR_DOCUSIGN_TEMPLATE + +export const TermsTable: FC = (props: Props) => { + const { width: screenWidth }: WindowSize = useWindowSize() + const isTablet = useMemo(() => screenWidth <= 1050, [screenWidth]) + const columns = useMemo[]>( + () => [ + { + columnId: 'title', + label: 'Title', + renderer: (data: UserTerm) => ( +
+ {data.title} +
+ ), + type: 'element', + }, + { + columnId: 'type', + label: 'Type', + propertyName: 'type', + type: 'text', + }, + { + className: styles.tableCell, + columnId: 'agreeabilityType', + label: 'Agreeability Type', + propertyName: 'agreeabilityType', + type: 'text', + }, + { + className: styles.tableCell, + columnId: 'Info', + label: 'Info', + renderer: (data: UserTerm) => ( +
+ { + data.agreeabilityTypeId === electronicallyAgreeableId + ? data.url + : data.agreeabilityTypeId === agreeForDocuSignTemplateId + ? data.docusignTemplateId + : '' + } +
+ ), + type: 'element', + }, + { + columnId: 'Action', + label: '', + renderer: (data: UserTerm) => ( +
+ +
+ ), + type: 'element', + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ) + + const columnsMobile = useMemo[][]>( + () => columns.map(column => { + if (column.label === '') { + 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 ? ( + + ) : ( + + )} + + + ) +} + +export default TermsTable diff --git a/src/apps/admin/src/lib/components/TermsTable/index.ts b/src/apps/admin/src/lib/components/TermsTable/index.ts new file mode 100644 index 000000000..5c5f0bbf3 --- /dev/null +++ b/src/apps/admin/src/lib/components/TermsTable/index.ts @@ -0,0 +1 @@ +export { default as TermsTable } from './TermsTable' diff --git a/src/apps/admin/src/lib/components/TermsUsersFilters/TermsUsersFilters.module.scss b/src/apps/admin/src/lib/components/TermsUsersFilters/TermsUsersFilters.module.scss new file mode 100644 index 000000000..eedf63665 --- /dev/null +++ b/src/apps/admin/src/lib/components/TermsUsersFilters/TermsUsersFilters.module.scss @@ -0,0 +1,38 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + padding: $sp-8 $sp-8 0; + + @include ltelg { + padding: $sp-4 $sp-4 0; + } +} + +.fields { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 15px 30px; + + @include ltelg { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + @include ltemd { + grid-template-columns: 1fr; + } +} + +.blockBottom { + display: flex; + justify-content: flex-end; + align-items: flex-start; + gap: 15px; + flex-wrap: wrap; + + @include ltemd { + flex-direction: column; + align-items: flex-end; + } +} diff --git a/src/apps/admin/src/lib/components/TermsUsersFilters/TermsUsersFilters.tsx b/src/apps/admin/src/lib/components/TermsUsersFilters/TermsUsersFilters.tsx new file mode 100644 index 000000000..dca5e12ac --- /dev/null +++ b/src/apps/admin/src/lib/components/TermsUsersFilters/TermsUsersFilters.tsx @@ -0,0 +1,163 @@ +/** + * Terms Users Filters. + */ +import { FC, useCallback } from 'react' +import { + Controller, + ControllerRenderProps, + useForm, + UseFormReturn, +} from 'react-hook-form' +import _ from 'lodash' +import classNames from 'classnames' + +import { Button, InputDatePicker, InputText } from '~/libs/ui' +import { yupResolver } from '@hookform/resolvers/yup' + +import { formTermsUsersFilterSchema } from '../../utils' +import { FormTermsUsersFilter } from '../../models' + +import styles from './TermsUsersFilters.module.scss' + +interface Props { + className?: string + isLoading: boolean + onSubmitForm?: (data: FormTermsUsersFilter) => void +} + +const defaultValues: FormTermsUsersFilter = { + handle: '', + signTermsFrom: undefined, + signTermsTo: undefined, + userId: '', +} + +export const TermsUsersFilters: FC = (props: Props) => { + const { + register, + reset, + handleSubmit, + control, + formState: { isValid, isDirty }, + }: UseFormReturn = useForm({ + defaultValues, + mode: 'all', + resolver: yupResolver(formTermsUsersFilterSchema), + }) + + /** + * Handle submit form event + */ + const onSubmit = useCallback( + (data: FormTermsUsersFilter) => { + props.onSubmitForm?.(data) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.onSubmitForm], + ) + + return ( +
+
+ + + + }) { + return ( + + ) + }} + /> + + }) { + return ( + + ) + }} + /> +
+ +
+ + +
+ + ) +} + +export default TermsUsersFilters diff --git a/src/apps/admin/src/lib/components/TermsUsersFilters/index.ts b/src/apps/admin/src/lib/components/TermsUsersFilters/index.ts new file mode 100644 index 000000000..03d543750 --- /dev/null +++ b/src/apps/admin/src/lib/components/TermsUsersFilters/index.ts @@ -0,0 +1 @@ +export { default as TermsUsersFilters } from './TermsUsersFilters' diff --git a/src/apps/admin/src/lib/components/TermsUsersTable/TermsUsersTable.module.scss b/src/apps/admin/src/lib/components/TermsUsersTable/TermsUsersTable.module.scss new file mode 100644 index 000000000..4c1af00f9 --- /dev/null +++ b/src/apps/admin/src/lib/components/TermsUsersTable/TermsUsersTable.module.scss @@ -0,0 +1,21 @@ +.container { + display: flex; + flex-direction: column; + padding-top: 0; +} + +.rowActions { + display: flex; + align-items: center; +} + +.tableCell { + white-space: break-spaces !important; + text-align: left !important; +} + +.desktopTable { + td { + vertical-align: middle; + } +} diff --git a/src/apps/admin/src/lib/components/TermsUsersTable/TermsUsersTable.tsx b/src/apps/admin/src/lib/components/TermsUsersTable/TermsUsersTable.tsx new file mode 100644 index 000000000..d94b9c4e3 --- /dev/null +++ b/src/apps/admin/src/lib/components/TermsUsersTable/TermsUsersTable.tsx @@ -0,0 +1,213 @@ +/** + * Terms Users Table. + */ +import { Dispatch, FC, SetStateAction, useCallback, useMemo } from 'react' +import _ from 'lodash' +import classNames from 'classnames' + +import { + Button, + colWidthType, + InputCheckbox, + Table, + TableColumn, +} from '~/libs/ui' +import { useWindowSize, WindowSize } from '~/libs/shared' + +import { MobileTableColumn, TermUserInfo, UserMappingType } from '../../models' +import { TableWrapper } from '../common/TableWrapper' +import { TableMobile } from '../common/TableMobile' +import { Pagination } from '../common/Pagination' + +import styles from './TermsUsersTable.module.scss' + +interface Props { + className?: string + datas: TermUserInfo[] + totalPages: number + page: number + setPage: Dispatch> + colWidth: colWidthType | undefined + setColWidth: Dispatch> | undefined + usersMapping: UserMappingType + isRemovingBool: boolean + isRemoving: { [key: string]: boolean } + toggleSelect: (key: number) => void + forceSelect: (key: number) => void + forceUnSelect: (key: number) => void + doRemoveTermUser: (userId: number) => void + selectedDatas: { + [id: number]: boolean + } +} + +export const TermsUsersTable: FC = (props: Props) => { + const { width: screenWidth }: WindowSize = useWindowSize() + const isTablet = useMemo(() => screenWidth <= 744, [screenWidth]) + + const isSelectAll = useMemo( + () => _.every(props.datas, item => props.selectedDatas[item.userId]), + [props.datas, props.selectedDatas], + ) + + /** + * Handle select/unselect all items event + */ + const toggleSelectAll = useCallback(() => { + if (isSelectAll) { + _.forEach(props.datas, item => { + props.forceUnSelect(item.userId) + }) + } else { + _.forEach(props.datas, item => { + props.forceSelect(item.userId) + }) + } + }, [isSelectAll, props.datas]) + + const columns = useMemo[]>( + () => [ + { + className: styles.blockCellCheckBox, + columnId: 'checkbox', + label: () => ( // eslint-disable-line react/no-unstable-nested-components +
+ +
+ ), + renderer: (data: TermUserInfo) => ( + + ), + type: 'element', + }, + { + columnId: 'userId', + label: 'User Id', + propertyName: 'userId', + type: 'text', + }, + { + columnId: 'handle', + label: 'Handle', + renderer: (data: TermUserInfo) => ( + <> + {!props.usersMapping[data.userId] + ? 'loading...' + : props.usersMapping[data.userId]} + + ), + type: 'element', + }, + { + columnId: 'Action', + label: '', + renderer: (data: TermUserInfo) => ( + + ), + type: 'element', + }, + ], + [ + props.usersMapping, + props.selectedDatas, + props.isRemovingBool, + props.isRemoving, + isSelectAll, + props.doRemoveTermUser, + toggleSelectAll, + ], + ) + + const columnsMobile = useMemo[][]>( + () => columns.map(column => { + if (column.columnId === 'checkbox') { + return [ + { + ...column, + colSpan: 2, + }, + ] + } + + if (column.label === '') { + 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 ? ( + + ) : ( +
+ )} + + + ) +} + +export default TermsUsersTable diff --git a/src/apps/admin/src/lib/components/TermsUsersTable/index.ts b/src/apps/admin/src/lib/components/TermsUsersTable/index.ts new file mode 100644 index 000000000..c6fc91097 --- /dev/null +++ b/src/apps/admin/src/lib/components/TermsUsersTable/index.ts @@ -0,0 +1 @@ +export { default as TermsUsersTable } from './TermsUsersTable' diff --git a/src/apps/admin/src/lib/components/common/FieldHtmlEditor/BundledEditor/BundledEditor.module.scss b/src/apps/admin/src/lib/components/common/FieldHtmlEditor/BundledEditor/BundledEditor.module.scss new file mode 100644 index 000000000..b04d3b079 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/FieldHtmlEditor/BundledEditor/BundledEditor.module.scss @@ -0,0 +1,8 @@ +.container { + :global { + .tox-tinymce { + border-radius: 0; + border: none; + } + } +} diff --git a/src/apps/admin/src/lib/components/common/FieldHtmlEditor/BundledEditor/BundledEditor.tsx b/src/apps/admin/src/lib/components/common/FieldHtmlEditor/BundledEditor/BundledEditor.tsx new file mode 100644 index 000000000..1662624d9 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/FieldHtmlEditor/BundledEditor/BundledEditor.tsx @@ -0,0 +1,54 @@ +/** + * Bundled Editor. + */ +import { FC } from 'react' +import classNames from 'classnames' +import 'tinymce/tinymce' // TinyMCE so the global var exists +import 'tinymce/models/dom/model.min.js' // DOM model +import 'tinymce/themes/silver/theme.min.js' // Theme +import 'tinymce/icons/default/icons.min.js' // Toolbar icons +import 'tinymce/skins/ui/oxide/skin' // Editor styles +import 'tinymce/skins/content/default/content' // Content styles, including inline UI like fake cursors +import 'tinymce/skins/ui/oxide/content' +import 'tinymce/plugins/table/plugin.min.js' +import 'tinymce/plugins/link/plugin.min.js' +// import 'tinymce/plugins/advlist/plugin.min.js' // importing the plugin js. +// import 'tinymce/plugins/anchor/plugin.min.js' +// import 'tinymce/plugins/autolink/plugin.min.js' +// import 'tinymce/plugins/autoresize/plugin.min.js' +// import 'tinymce/plugins/autosave/plugin.min.js' +// import 'tinymce/plugins/charmap/plugin.min.js' +// import 'tinymce/plugins/code/plugin.min.js' +// import 'tinymce/plugins/codesample/plugin.min.js' +// import 'tinymce/plugins/directionality/plugin.min.js' +// import 'tinymce/plugins/emoticons/plugin.min.js' +// import 'tinymce/plugins/fullscreen/plugin.min.js' +// import 'tinymce/plugins/help/plugin.min.js' +// import 'tinymce/plugins/image/plugin.min.js' +// import 'tinymce/plugins/importcss/plugin.min.js' +// import 'tinymce/plugins/insertdatetime/plugin.min.js' +// import 'tinymce/plugins/lists/plugin.min.js' +// import 'tinymce/plugins/media/plugin.min.js' +// import 'tinymce/plugins/nonbreaking/plugin.min.js' +// import 'tinymce/plugins/pagebreak/plugin.min.js' +// import 'tinymce/plugins/preview/plugin.min.js' +// import 'tinymce/plugins/quickbars/plugin.min.js' +// import 'tinymce/plugins/save/plugin.min.js' +// import 'tinymce/plugins/searchreplace/plugin.min.js' +// import 'tinymce/plugins/visualblocks/plugin.min.js' +// import 'tinymce/plugins/visualchars/plugin.min.js' +// import 'tinymce/plugins/wordcount/plugin.min.js' +// import 'tinymce/plugins/emoticons/js/emojis' // importing plugin resources +/** if you use a plugin that is not listed here the editor will fail to load */ + +import { Editor } from '@tinymce/tinymce-react' + +import styles from './BundledEditor.module.scss' + +export const BundledEditor: FC = (props: any) => ( +
+ +
+) + +export default BundledEditor diff --git a/src/apps/admin/src/lib/components/common/FieldHtmlEditor/BundledEditor/index.ts b/src/apps/admin/src/lib/components/common/FieldHtmlEditor/BundledEditor/index.ts new file mode 100644 index 000000000..f685e5121 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/FieldHtmlEditor/BundledEditor/index.ts @@ -0,0 +1 @@ +export { default as BundledEditor } from './BundledEditor' diff --git a/src/apps/admin/src/lib/components/common/FieldHtmlEditor/FieldHtmlEditor.tsx b/src/apps/admin/src/lib/components/common/FieldHtmlEditor/FieldHtmlEditor.tsx new file mode 100644 index 000000000..b888a4bc8 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/FieldHtmlEditor/FieldHtmlEditor.tsx @@ -0,0 +1,78 @@ +import { FC, FocusEvent, useEffect, useRef, useState } from 'react' + +import { FormInputAutocompleteOption, InputWrapper } from '~/libs/ui' + +import { BundledEditor } from './BundledEditor' + +interface FieldHtmlEditorProps { + readonly className?: string + readonly autocomplete?: FormInputAutocompleteOption + readonly dirty?: boolean + readonly disabled?: boolean + readonly error?: string + readonly hideInlineErrors?: boolean + readonly hint?: string + readonly label?: string + readonly name: string + readonly onBlur?: (event: FocusEvent) => void + readonly onChange: (event: string) => void + readonly placeholder?: string + readonly spellCheck?: boolean + readonly tabIndex?: number + readonly value?: string | number + readonly classNameWrapper?: string +} + +const FieldHtmlEditor: FC = ( + props: FieldHtmlEditorProps, +) => { + const editorRef = useRef(null) + const [initValue, setInitValue] = useState('') + + useEffect(() => { + if (!initValue) { + setInitValue(props.value as string) + } + }, [props.value]) + + return ( + + + + ) +} + +export default FieldHtmlEditor diff --git a/src/apps/admin/src/lib/components/common/FieldHtmlEditor/index.ts b/src/apps/admin/src/lib/components/common/FieldHtmlEditor/index.ts new file mode 100644 index 000000000..b5bc68a63 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/FieldHtmlEditor/index.ts @@ -0,0 +1 @@ +export { default as FieldHtmlEditor } from './FieldHtmlEditor' 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 index 7056f5c60..aeaf7a421 100644 --- a/src/apps/admin/src/lib/components/common/FormAddWrapper/FormAddWrapper.module.scss +++ b/src/apps/admin/src/lib/components/common/FormAddWrapper/FormAddWrapper.module.scss @@ -16,14 +16,19 @@ display: flex; gap: 15px; justify-content: flex-end; + flex-wrap: wrap; } .blockFields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px 30px; + align-items: start; @include ltemd { grid-template-columns: 1fr; + display: flex; + flex-direction: column; + align-items: stretch; } } diff --git a/src/apps/admin/src/lib/components/common/Layout/Layout.module.scss b/src/apps/admin/src/lib/components/common/Layout/Layout.module.scss index 73d0aadb6..c62f18715 100644 --- a/src/apps/admin/src/lib/components/common/Layout/Layout.module.scss +++ b/src/apps/admin/src/lib/components/common/Layout/Layout.module.scss @@ -14,7 +14,7 @@ padding: $sp-4; } - &.isPlatformPage { + &.isPlatformGamificationAdminPage { padding: 0; background-color: white; diff --git a/src/apps/admin/src/lib/components/common/Layout/Layout.tsx b/src/apps/admin/src/lib/components/common/Layout/Layout.tsx index e9fd0fe26..a4614e092 100644 --- a/src/apps/admin/src/lib/components/common/Layout/Layout.tsx +++ b/src/apps/admin/src/lib/components/common/Layout/Layout.tsx @@ -1,11 +1,10 @@ import { FC, PropsWithChildren, useContext } from 'react' import cn from 'classnames' -import { platformRouteId } from '~/apps/admin/src/config/routes.config' +import { gamificationAdminRouteId, platformRouteId, rootRoute } from '~/apps/admin/src/config/routes.config' import { ContentLayout } from '~/libs/ui' import { routerContext, RouterContextData } from '~/libs/core' import { platformSkillRouteId } from '~/apps/admin/src/platform/routes.config' -import { AppSubdomain, EnvironmentConfig } from '~/config' import { SystemAdminTabs } from '../Tab' @@ -39,8 +38,8 @@ export const Layout: FC = props => ( ) -export const PlatformLayout: FC = props => ( - +export const PlatformGamificationAdminLayout: FC = props => ( + {props.children} ) @@ -56,13 +55,8 @@ export function useLayout(): { Layout: FC } { if (!routerContextData.initialized) return { Layout } - const platformBaseRouteId = EnvironmentConfig.SUBDOMAIN === AppSubdomain.admin - ? `/${platformRouteId}` - : `/${AppSubdomain.admin}/${platformRouteId}` - - const skillManagementRouteId = EnvironmentConfig.SUBDOMAIN === AppSubdomain.admin - ? `/${platformRouteId}/${platformSkillRouteId}` - : `/${AppSubdomain.admin}/${platformRouteId}/${platformSkillRouteId}` + const platformBasePath = `${rootRoute}/${platformRouteId}/${gamificationAdminRouteId}` + const skillManagementRouteId = `${rootRoute}/${platformRouteId}/${platformSkillRouteId}` if (window.location.pathname.toLowerCase() .startsWith(skillManagementRouteId.toLowerCase())) { @@ -70,8 +64,8 @@ export function useLayout(): { Layout: FC } { } if (window.location.pathname.toLowerCase() - .startsWith(platformBaseRouteId.toLowerCase())) { - return { Layout: PlatformLayout } + .startsWith(platformBasePath.toLowerCase())) { + return { Layout: PlatformGamificationAdminLayout } } return { Layout } diff --git a/src/apps/admin/src/lib/components/common/PageWrapper/PageWrapper.tsx b/src/apps/admin/src/lib/components/common/PageWrapper/PageWrapper.tsx index 45bb3548d..14068cbf6 100644 --- a/src/apps/admin/src/lib/components/common/PageWrapper/PageWrapper.tsx +++ b/src/apps/admin/src/lib/components/common/PageWrapper/PageWrapper.tsx @@ -12,6 +12,7 @@ import styles from './PageWrapper.module.scss' interface Props { className?: string pageTitle: string + pageSubTitle?: ReactNode headerActions?: ReactNode } @@ -20,6 +21,7 @@ export const PageWrapper: FC> = props => ( {props.pageTitle}

{props.pageTitle}

+ {props.pageSubTitle} {props.headerActions ? (
diff --git a/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts b/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts index b3f1b0de2..a999d73b2 100644 --- a/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts +++ b/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts @@ -8,6 +8,7 @@ import { manageReviewRouteId, permissionManagementRouteId, platformRouteId, + termsRouteId, userManagementRouteId, } from '~/apps/admin/src/config/routes.config' import { platformSkillRouteId } from '~/apps/admin/src/platform/routes.config' @@ -65,6 +66,11 @@ export const SystemAdminTabsConfig: TabsNavItem[] = [ title: 'Badges', }, + { + id: `${platformRouteId}/${termsRouteId}`, + title: 'Terms', + + }, ], id: platformRouteId, title: 'Platform', diff --git a/src/apps/admin/src/lib/components/index.ts b/src/apps/admin/src/lib/components/index.ts index 1af953242..6115aca72 100644 --- a/src/apps/admin/src/lib/components/index.ts +++ b/src/apps/admin/src/lib/components/index.ts @@ -23,3 +23,9 @@ export * from './RejectPendingConfirmDialog' export * from './FieldHandleSelect' export * from './FieldSingleSelect' export * from './SubmissionTable' +export * from './TermsTable' +export * from './TermsFilters' +export * from './TermsAddForm' +export * from './TermsUsersFilters' +export * from './TermsUsersTable' +export * from './DialogAddTermUser' diff --git a/src/apps/admin/src/lib/hooks/index.ts b/src/apps/admin/src/lib/hooks/index.ts index d0fc0d901..8f8ff2e7a 100644 --- a/src/apps/admin/src/lib/hooks/index.ts +++ b/src/apps/admin/src/lib/hooks/index.ts @@ -26,3 +26,7 @@ export * from './useSearchUserInfo' export * from './useManageBusEvent' export * from './useManageChallengeSubmissions' export * from './useManageUserSSOLogin' +export * from './useManageTerms' +export * from './useManageAddTerm' +export * from './useManageTermsUsers' +export * from './useAutoScrollTopWhenInit' diff --git a/src/apps/admin/src/lib/hooks/useAutoScrollTopWhenInit.ts b/src/apps/admin/src/lib/hooks/useAutoScrollTopWhenInit.ts new file mode 100644 index 000000000..c9b225582 --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useAutoScrollTopWhenInit.ts @@ -0,0 +1,13 @@ +import { useEffect } from 'react' +import { useLocation } from 'react-router-dom' + +/** + * Auto scroll to top when open page + */ +export function useAutoScrollTopWhenInit(): void { + const location = useLocation() + + useEffect(() => { + window.scrollTo(0, 0) + }, [location.pathname]) +} diff --git a/src/apps/admin/src/lib/hooks/useManageAddTerm.ts b/src/apps/admin/src/lib/hooks/useManageAddTerm.ts new file mode 100644 index 000000000..ceff3483d --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useManageAddTerm.ts @@ -0,0 +1,215 @@ +/** + * Manage add term + */ +import { + useCallback, + useEffect, + useRef, + useState, +} from 'react' +import { toast } from 'react-toastify' + +import { + FormAddTerm, + TermAgreeabilityType, + TermType, + UserTerm, +} from '../models' +import { handleError } from '../utils' +import { + createTerm, + editTerm, + fetchAllTermsAgreeabilityTypes, + fetchAllTermsTypes, + fetchAllTermsUsers, + findTermsById, + removeTerm, +} from '../services' + +import { useOnComponentDidMount } from './useOnComponentDidMount' + +export interface useManageAddTermProps { + signedUsersTotal: number + termInfo?: UserTerm + termsTypes: TermType[] + isFetchingTermsTypes: boolean + termsAgreeabilityTypes: TermAgreeabilityType[] + isFetchingTermsAgreeabilityTypes: boolean + doAddTerm: (data: Partial, callBack: () => void) => void + doUpdateTerm: (data: Partial, callBack: () => void) => void + doRemoveTerm: (callBack: () => void) => void + isAdding: boolean + isLoadingTerm: boolean + isRemoving: boolean + isLoading: boolean +} + +/** + * Manage add term + * + * @param termId term id + * @returns add term info + */ +export function useManageAddTerm(termId?: string): useManageAddTermProps { + const [signedUsersTotal, setSignedUsersTotal] = useState(0) + const [termInfo, setTermInfo] = useState() + const [isFetchingTermsTypes, setIsFetchingTermsTypes] = useState(false) + const [termsTypes, setTermsTypes] = useState([]) + const [isAdding, setIsAdding] = useState(false) + const [isRemoving, setIsRemoving] = useState(false) + const [isLoadingTerm, setIsLoadingTerm] = useState(false) + const isLoadingTermRef = useRef(false) + + const [ + isFetchingTermsAgreeabilityTypes, + setIsFetchingTermsAgreeabilityTypes, + ] = useState(false) + const [termsAgreeabilityTypes, setTermsAgreeabilityTypes] = useState< + TermAgreeabilityType[] + >([]) + + useOnComponentDidMount(() => { + setIsFetchingTermsTypes(true) + fetchAllTermsTypes() + .then(result => { + setIsFetchingTermsTypes(false) + setTermsTypes(result) + }) + .catch(e => { + setIsFetchingTermsTypes(false) + handleError(e) + }) + + setIsFetchingTermsAgreeabilityTypes(true) + fetchAllTermsAgreeabilityTypes() + .then(result => { + setIsFetchingTermsAgreeabilityTypes(false) + setTermsAgreeabilityTypes(result) + }) + .catch(e => { + setIsFetchingTermsAgreeabilityTypes(false) + handleError(e) + }) + }) + + /** + * Fetch term info + */ + const doFetchTerm = useCallback(() => { + if (!isLoadingTermRef.current && termId) { + isLoadingTermRef.current = true + setIsLoadingTerm(isLoadingTermRef.current) + findTermsById(termId) + .then(termInfoResult => { + fetchAllTermsUsers(termId) + .then(termsUsers => { + setTermInfo(termInfoResult) + setSignedUsersTotal(termsUsers.total) + + isLoadingTermRef.current = false + setIsLoadingTerm(isLoadingTermRef.current) + }) + .catch(e => { + setTermInfo(termInfoResult) + isLoadingTermRef.current = false + setIsLoadingTerm(isLoadingTermRef.current) + handleError(e) + }) + }) + .catch(e => { + isLoadingTermRef.current = false + setIsLoadingTerm(isLoadingTermRef.current) + handleError(e) + }) + } + }, [termId]) + + /** + * Add new term + */ + const doAddTerm = useCallback( + (data: Partial, callBack: () => void) => { + setIsAdding(true) + createTerm(data) + .then(() => { + toast.success('Term added successfully', { + toastId: 'Add term', + }) + setIsAdding(false) + callBack() + }) + .catch(e => { + setIsAdding(false) + handleError(e) + }) + }, + [setIsAdding], + ) + + /** + * Update term + */ + const doUpdateTerm = useCallback( + (data: Partial, callBack: () => void) => { + setIsAdding(true) + editTerm(termId ?? '', data) + .then(() => { + toast.success('Term updated successfully', { + toastId: 'Update term', + }) + setIsAdding(false) + callBack() + }) + .catch(e => { + setIsAdding(false) + handleError(e) + }) + }, + [setIsAdding, termId], + ) + + /** + * Remove term + */ + const doRemoveTerm = useCallback( + (callBack: () => void) => { + setIsRemoving(true) + removeTerm(termId ?? '') + .then(() => { + toast.success('Term removed successfully', { + toastId: 'Remove term', + }) + setIsRemoving(false) + callBack() + }) + .catch(e => { + setIsRemoving(false) + handleError(e) + }) + }, + [termId], + ) + + /** + * Fetch term info on init + */ + useEffect(() => { + doFetchTerm() + }, [doFetchTerm]) + + return { + doAddTerm, + doRemoveTerm, + doUpdateTerm, + isAdding, + isFetchingTermsAgreeabilityTypes, + isFetchingTermsTypes, + isLoading: isLoadingTerm || isAdding || isRemoving, + isLoadingTerm, + isRemoving, + signedUsersTotal, + termInfo, + termsAgreeabilityTypes, + termsTypes, + } +} diff --git a/src/apps/admin/src/lib/hooks/useManageTerms.ts b/src/apps/admin/src/lib/hooks/useManageTerms.ts new file mode 100644 index 000000000..bd8b99364 --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useManageTerms.ts @@ -0,0 +1,153 @@ +/** + * Manage terms redux state + */ +import { Dispatch, SetStateAction, useReducer } from 'react' + +import { TABLE_PAGINATION_ITEM_PER_PAGE } from '../../config/index.config' +import { FormSearchByKey, UserTerm } from '../models' +import { handleError } from '../utils' +import { fetchAllTerms } from '../services' + +import { + useTableFilterBackend, + useTableFilterBackendProps, +} from './useTableFilterBackend' + +/// ///////////////// +// Terms reducer +/// //////////////// + +type TermsState = { + isLoading: boolean + datas: UserTerm[] + totalPages: number +} + +const TermsActionType = { + FETCH_TERMS_DONE: 'FETCH_TERMS_DONE' as const, + FETCH_TERMS_FAILED: 'FETCH_TERMS_FAILED' as const, + FETCH_TERMS_INIT: 'FETCH_TERMS_INIT' as const, +} + +type TermsReducerAction = + | { + type: + | typeof TermsActionType.FETCH_TERMS_INIT + | typeof TermsActionType.FETCH_TERMS_FAILED + } + | { + type: typeof TermsActionType.FETCH_TERMS_DONE + payload: { + data: UserTerm[] + totalPages: number + } + } + +const reducer = ( + previousState: TermsState, + action: TermsReducerAction, +): TermsState => { + switch (action.type) { + case TermsActionType.FETCH_TERMS_INIT: { + return { + ...previousState, + datas: [], + isLoading: true, + } + } + + case TermsActionType.FETCH_TERMS_DONE: { + return { + ...previousState, + datas: action.payload.data, + isLoading: false, + totalPages: action.payload.totalPages, + } + } + + case TermsActionType.FETCH_TERMS_FAILED: { + return { + ...previousState, + isLoading: false, + } + } + + default: { + return previousState + } + } +} + +export interface useManageTermsProps { + datas: UserTerm[] + isLoading: boolean + page: number + setPage: Dispatch> + setFilterCriteria: (criteria: FormSearchByKey | undefined) => void + totalPages: number +} + +/** + * Manage terms redux state + * + * @returns state data + */ +export function useManageTerms(): useManageTermsProps { + const [state, dispatch] = useReducer(reducer, { + datas: [], + isLoading: false, + totalPages: 1, + }) + + /** + * Manage backend pagination, filtering + */ + const { + page, + setPage, + setFilterCriteria, + }: useTableFilterBackendProps + = useTableFilterBackend( + (pagRequest, sortRequest, filterCriteria, success, fail) => { + dispatch({ + type: TermsActionType.FETCH_TERMS_INIT, + }) + let filter = `page=${pagRequest}&perPage=${TABLE_PAGINATION_ITEM_PER_PAGE}` + if (filterCriteria?.searchKey) { + filter += `&title=${filterCriteria?.searchKey}` + } + + fetchAllTerms(filter) + .then(result => { + dispatch({ + payload: { + data: result.data.result, + totalPages: result.totalPages, + }, + type: TermsActionType.FETCH_TERMS_DONE, + }) + success() + window.scrollTo({ left: 0, top: 0 }) + }) + .catch(e => { + dispatch({ + type: TermsActionType.FETCH_TERMS_FAILED, + }) + handleError(e) + fail() + }) + }, + { + searchKey: '', + }, + ) + + return { + datas: state.datas, + isLoading: state.isLoading, + page, + setFilterCriteria, + setPage, + totalPages: state.totalPages, + } +} diff --git a/src/apps/admin/src/lib/hooks/useManageTermsUsers.ts b/src/apps/admin/src/lib/hooks/useManageTermsUsers.ts new file mode 100644 index 000000000..9ddd3b0aa --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useManageTermsUsers.ts @@ -0,0 +1,508 @@ +/** + * Manage terms users redux state + */ +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react' +import { toast } from 'react-toastify' +import _ from 'lodash' + +import { PaginatedResponse } from '~/libs/core' + +import { TABLE_PAGINATION_ITEM_PER_PAGE } from '../../config/index.config' +import { + FormTermsUsersFilter, + TermUserInfo, + UserIdType, + UserTerm, +} from '../models' +import { handleError } from '../utils' +import { + addUserTerm, + fetchAllTermsUsers, + findTermsById, + getProfile, + removeTermUser, +} from '../services' + +import { + useTableFilterBackend, + useTableFilterBackendProps, +} from './useTableFilterBackend' + +/// ///////////////// +// Terms users reducer +/// //////////////// + +type TermsState = { + isLoading: boolean + datas: TermUserInfo[] + totalPages: number + isRemoving: { [key: string]: boolean } +} + +const TermsActionType = { + FETCH_TERMS_USERS_DONE: 'FETCH_TERMS_USERS_DONE' as const, + FETCH_TERMS_USERS_FAILED: 'FETCH_TERMS_USERS_FAILED' as const, + FETCH_TERMS_USERS_INIT: 'FETCH_TERMS_USERS_INIT' as const, + REMOVE_TERMS_USERS_DONE: 'REMOVE_TERMS_USERS_DONE' as const, + REMOVE_TERMS_USERS_FAILED: 'REMOVE_TERMS_USERS_FAILED' as const, + REMOVE_TERMS_USERS_INIT: 'REMOVE_TERMS_USERS_INIT' as const, +} + +type TermsReducerAction = + | { + type: + | typeof TermsActionType.FETCH_TERMS_USERS_INIT + | typeof TermsActionType.FETCH_TERMS_USERS_FAILED + } + | { + type: typeof TermsActionType.FETCH_TERMS_USERS_DONE + payload: { + data: TermUserInfo[] + totalPages: number + } + } + | { + type: + | typeof TermsActionType.REMOVE_TERMS_USERS_DONE + | typeof TermsActionType.REMOVE_TERMS_USERS_INIT + | typeof TermsActionType.REMOVE_TERMS_USERS_FAILED + payload: number + } + +const reducer = ( + previousState: TermsState, + action: TermsReducerAction, +): TermsState => { + switch (action.type) { + case TermsActionType.FETCH_TERMS_USERS_INIT: { + return { + ...previousState, + isLoading: true, + } + } + + case TermsActionType.FETCH_TERMS_USERS_DONE: { + return { + ...previousState, + datas: action.payload.data, + isLoading: false, + totalPages: action.payload.totalPages, + } + } + + case TermsActionType.FETCH_TERMS_USERS_FAILED: { + return { + ...previousState, + isLoading: false, + } + } + + case TermsActionType.REMOVE_TERMS_USERS_INIT: { + return { + ...previousState, + isRemoving: { + ...previousState.isRemoving, + [action.payload]: true, + }, + } + } + + case TermsActionType.REMOVE_TERMS_USERS_DONE: { + return { + ...previousState, + datas: previousState.datas.filter( + item => `${item.userId}` !== `${action.payload}`, + ), + isRemoving: { + ...previousState.isRemoving, + [action.payload]: false, + }, + } + } + + case TermsActionType.REMOVE_TERMS_USERS_FAILED: { + return { + ...previousState, + isRemoving: { + ...previousState.isRemoving, + [action.payload]: false, + }, + } + } + + default: { + return previousState + } + } +} + +export interface useManageTermsUsersProps { + datas: TermUserInfo[] + isAdding: boolean + isLoading: boolean + isLoadingTerm: boolean + page: number + setPage: Dispatch> + setFilterCriteria: (criteria: FormTermsUsersFilter | undefined) => void + totalPages: number + isRemovingBool: boolean + isRemoving: { [key: string]: boolean } + doRemoveTermUser: (userId: number) => void + doRemoveTermUsers: (userIds: number[], callBack: () => void) => void + doAddTermUser: ( + userId: number, + userHandle: string, + sucess: () => void, + fail: () => void, + ) => void + termInfo?: UserTerm +} + +/** + * Manage terms users redux state + * @param termsId terms id + * @param loadUsers load list of users function + * @param cancelLoadUser cancel load users + * @returns state data + */ +export function useManageTermsUsers( + termsId: string, + loadUser: (userId: UserIdType) => void, + cancelLoadUser: () => void, +): useManageTermsUsersProps { + const [isAdding, setIsAdding] = useState(false) + const [termInfo, setTermInfo] = useState() + const [state, dispatch] = useReducer(reducer, { + datas: [], + isLoading: false, + isRemoving: {}, + totalPages: 1, + }) + const isRemovingBool = useMemo( + () => _.some(state.isRemoving, value => value === true), + [state.isRemoving], + ) + const [isLoadingTerm, setIsLoadingTerm] = useState(false) + const isLoadingTermRef = useRef(false) + + /** + * Cancel load user when component is destroyed + */ + useEffect( + () => () => { + // clear queue of currently loading user handles after exit ui + cancelLoadUser() + }, + [cancelLoadUser], + ) + + /** + * Fetch term info + */ + const doFetchTerm = useCallback(() => { + if (!isLoadingTermRef.current && termsId) { + isLoadingTermRef.current = true + setIsLoadingTerm(isLoadingTermRef.current) + findTermsById(termsId) + .then(termInfoResult => { + setTermInfo(termInfoResult) + + isLoadingTermRef.current = false + setIsLoadingTerm(isLoadingTermRef.current) + }) + .catch(e => { + isLoadingTermRef.current = false + setIsLoadingTerm(isLoadingTermRef.current) + handleError(e) + }) + } + }, [termsId]) + + /** + * Fetch term info on init + */ + useEffect(() => { + doFetchTerm() + }, [doFetchTerm]) + + /** + * Handle backend call for pagination, filtering + */ + const { + page, + setPage, + setFilterCriteria, + reloadData, + }: useTableFilterBackendProps + = useTableFilterBackend( + (pagRequest, sortRequest, filterCriteria, success, fail) => { + if (!termsId) { + fail() + return + } + + dispatch({ + type: TermsActionType.FETCH_TERMS_USERS_INIT, + }) + const requestSuccess = (data: number[], totalPages: number): void => { + dispatch({ + payload: { + data: data.map(item => ({ + userId: item, + })), + totalPages, + }, + type: TermsActionType.FETCH_TERMS_USERS_DONE, + }) + success() + window.scrollTo({ left: 0, top: 0 }) + } + + const requestFail = (error: any): void => { + dispatch({ + type: TermsActionType.FETCH_TERMS_USERS_FAILED, + }) + handleError(error) + fail() + } + + let filter = `page=${pagRequest}&perPage=${TABLE_PAGINATION_ITEM_PER_PAGE}` + + if ( + filterCriteria?.userId + && filterCriteria?.userId.toString() + .trim() + ) { + filter += `&userId=${filterCriteria.userId}` + } + + if (filterCriteria?.signTermsFrom) { + filter += `&signedAtFrom=${filterCriteria.signTermsFrom.toISOString()}` + } + + if (filterCriteria?.signTermsTo) { + filter += `&signedAtTo=${filterCriteria.signTermsTo.toISOString()}` + } + + if (filterCriteria?.handle && filterCriteria?.handle.trim()) { + if ( + filterCriteria?.userId + && filterCriteria?.userId.toString() + .trim() + ) { + getProfile(filterCriteria?.handle) + .then(profileData => { + if ( + `${profileData.userId}` + !== filterCriteria?.userId + ) { + requestSuccess([], 0) + } else { + fetchAllTermsUsers(termsId, filter) + .then(data => { + requestSuccess( + data.data.result, + data.totalPages, + ) + }) + .catch(requestFail) + } + }) + .catch(error => { + dispatch({ + type: TermsActionType.FETCH_TERMS_USERS_FAILED, + }) + handleError(error) + fail() + }) + } else { + getProfile(filterCriteria?.handle) + .then(profileData => { + filter += `&userId=${profileData.userId}` + fetchAllTermsUsers(termsId, filter) + .then(data => { + requestSuccess( + data.data.result, + data.totalPages, + ) + }) + .catch(requestFail) + }) + .catch(requestFail) + } + } else { + fetchAllTermsUsers(termsId, filter) + .then(( + data: PaginatedResponse<{ + result: number[] + }>, + ) => requestSuccess(data.data.result, data.totalPages)) + .catch(requestFail) + } + }, + {}, + ) + + /** + * Remove term user + */ + const doRemoveTermUser = useCallback( + (userId: number) => { + dispatch({ + payload: userId, + type: TermsActionType.REMOVE_TERMS_USERS_INIT, + }) + removeTermUser(termsId, `${userId}`) + .then(() => { + toast.success('User removed successfully', { + toastId: 'Remove term user', + }) + + dispatch({ + payload: userId, + type: TermsActionType.REMOVE_TERMS_USERS_DONE, + }) + }) + .catch(e => { + dispatch({ + payload: userId, + type: TermsActionType.REMOVE_TERMS_USERS_FAILED, + }) + handleError(e) + }) + }, + [dispatch, termsId], + ) + + /** + * Remove list of term user + */ + const doRemoveTermUsers = useCallback( + (userIds: number[], callBack: () => void) => { + let hasErrors = false + _.forEach(userIds, userId => { + dispatch({ + payload: userId, + type: TermsActionType.REMOVE_TERMS_USERS_INIT, + }) + }) + Promise.all( + userIds.map(async userId => removeTermUser( + termsId, + `${userId}`, + ) + .catch(e => { + hasErrors = true + handleError(e) + })), + ) + .then(() => { + if (!hasErrors) { + toast.success( + `${ + userIds.length > 1 ? 'Users' : 'User' + } removed successfully`, + { + toastId: 'Remove term users', + }, + ) + callBack() + } + + _.forEach(userIds, userId => { + dispatch({ + payload: userId, + type: TermsActionType.REMOVE_TERMS_USERS_DONE, + }) + }) + }) + .catch(e => { + _.forEach(userIds, userId => { + dispatch({ + payload: userId, + type: TermsActionType.REMOVE_TERMS_USERS_FAILED, + }) + }) + handleError(e) + }) + }, + [dispatch, termsId], + ) + + /** + * Add term user + */ + const doAddTermUser = useCallback( + ( + userId: number, + userHandle: string, + sucess: () => void, + fail: () => void, + ) => { + setIsAdding(true) + addUserTerm(termsId, `${userId}`) + .then(() => { + toast.success( + `Terms Added Successfullly to user ${userHandle}`, + { + toastId: 'Add term user', + }, + ) + setIsAdding(false) + reloadData() + sucess() + }) + .catch(e => { + setIsAdding(false) + handleError(e) + fail() + }) + }, + [termsId, reloadData], + ) + + useEffect(() => { + _.forEach(state.datas, termUser => { + loadUser(termUser.userId) + }) + + // Check to reload table data after removing + if (state.totalPages > 1 && !isRemovingBool) { + if (page === state.totalPages) { + if (!state.datas.length) { + // move to new last page after remove item + setPage(state.totalPages - 1) + } + } else if (state.datas.length < TABLE_PAGINATION_ITEM_PER_PAGE) { + // reload data after removing success + reloadData() + } + } + }, [state.datas]) + + return { + datas: state.datas, + doAddTermUser, + doRemoveTermUser, + doRemoveTermUsers, + isAdding, + isLoading: state.isLoading, + isLoadingTerm, + isRemoving: state.isRemoving, + isRemovingBool, + page, + setFilterCriteria, + setPage, + termInfo, + totalPages: state.totalPages, + } +} diff --git a/src/apps/admin/src/lib/hooks/useTableFilterBackend.ts b/src/apps/admin/src/lib/hooks/useTableFilterBackend.ts index 0c1723d78..c7c55cb6e 100644 --- a/src/apps/admin/src/lib/hooks/useTableFilterBackend.ts +++ b/src/apps/admin/src/lib/hooks/useTableFilterBackend.ts @@ -18,6 +18,7 @@ export interface useTableFilterBackendProps { setSort: Dispatch> sort: Sort | undefined setFilterCriteria: (criteria: T | undefined) => void + reloadData: () => void } /** @@ -99,6 +100,7 @@ export function useTableFilterBackend( return { page, + reloadData: doSearchDatas, setFilterCriteria, setPage, setSort, diff --git a/src/apps/admin/src/lib/models/FormAddTerm.model.ts b/src/apps/admin/src/lib/models/FormAddTerm.model.ts new file mode 100644 index 000000000..edf345a57 --- /dev/null +++ b/src/apps/admin/src/lib/models/FormAddTerm.model.ts @@ -0,0 +1,11 @@ +/** + * Model for add term + */ +export interface FormAddTerm { + title: string + typeId: string + agreeabilityTypeId: string + docusignTemplateId?: string + url?: string + text?: string +} diff --git a/src/apps/admin/src/lib/models/FormAddTermUser.model.ts b/src/apps/admin/src/lib/models/FormAddTermUser.model.ts new file mode 100644 index 000000000..93079c0c4 --- /dev/null +++ b/src/apps/admin/src/lib/models/FormAddTermUser.model.ts @@ -0,0 +1,9 @@ +/** + * Model for add term user form + */ +export interface FormAddTermUser { + handle?: { + label: string + value: number + } | null +} diff --git a/src/apps/admin/src/lib/models/FormTermsUsersFilter.model.ts b/src/apps/admin/src/lib/models/FormTermsUsersFilter.model.ts new file mode 100644 index 000000000..d3b03eef3 --- /dev/null +++ b/src/apps/admin/src/lib/models/FormTermsUsersFilter.model.ts @@ -0,0 +1,9 @@ +/** + * Model for terms users filter form + */ +export interface FormTermsUsersFilter { + userId?: string + handle?: string + signTermsFrom?: Date | null + signTermsTo?: Date | null +} diff --git a/src/apps/admin/src/lib/models/MemberInfo.model.ts b/src/apps/admin/src/lib/models/MemberInfo.model.ts new file mode 100644 index 000000000..7349fa88f --- /dev/null +++ b/src/apps/admin/src/lib/models/MemberInfo.model.ts @@ -0,0 +1,7 @@ +/** + * Model for member info + */ +export interface MemberInfo { + handle: string + userId: number +} diff --git a/src/apps/admin/src/lib/models/TermAgreeabilityType.model.ts b/src/apps/admin/src/lib/models/TermAgreeabilityType.model.ts new file mode 100644 index 000000000..043fc3b63 --- /dev/null +++ b/src/apps/admin/src/lib/models/TermAgreeabilityType.model.ts @@ -0,0 +1,9 @@ +/** + * Model for term agreeability type + */ +export interface TermAgreeabilityType { + id: string + legacyId: number + name: string + description: string +} diff --git a/src/apps/admin/src/lib/models/TermType.model.ts b/src/apps/admin/src/lib/models/TermType.model.ts new file mode 100644 index 000000000..245c64484 --- /dev/null +++ b/src/apps/admin/src/lib/models/TermType.model.ts @@ -0,0 +1,7 @@ +/** + * Model for term type + */ +export interface TermType { + id: number + name: string +} diff --git a/src/apps/admin/src/lib/models/TermUserInfo.model.ts b/src/apps/admin/src/lib/models/TermUserInfo.model.ts new file mode 100644 index 000000000..7d52cc2f0 --- /dev/null +++ b/src/apps/admin/src/lib/models/TermUserInfo.model.ts @@ -0,0 +1,6 @@ +/** + * Term user info + */ +export interface TermUserInfo { + userId: number +} diff --git a/src/apps/admin/src/lib/models/UserTerm.model.ts b/src/apps/admin/src/lib/models/UserTerm.model.ts index 639ca9798..0f7fc5c62 100644 --- a/src/apps/admin/src/lib/models/UserTerm.model.ts +++ b/src/apps/admin/src/lib/models/UserTerm.model.ts @@ -3,5 +3,13 @@ */ export interface UserTerm { id: string + legacyId: number title: string + url: string + agreeabilityTypeId: string + typeId: number + agreeabilityType: string + type: string + docusignTemplateId?: string + text?: string } diff --git a/src/apps/admin/src/lib/models/index.ts b/src/apps/admin/src/lib/models/index.ts index d85a6e63a..926c74ba5 100644 --- a/src/apps/admin/src/lib/models/index.ts +++ b/src/apps/admin/src/lib/models/index.ts @@ -35,6 +35,12 @@ export * from './RequestBusAPI.model' export * from './MemberSubmission.model' export * from './SSOUserLogin.model' export * from './SSOLoginProvider.model' +export * from './FormAddTerm.model' +export * from './TermType.model' +export * from './TermAgreeabilityType.model' +export * from './FormTermsUsersFilter.model' +export * from './MemberInfo.model' +export * from './TermUserInfo.model' export * from './FormAddGroupMembers.type' export * from './TableFilterType.type' export * from './TableRolesFilter.type' diff --git a/src/apps/admin/src/lib/services/terms.service.ts b/src/apps/admin/src/lib/services/terms.service.ts index b0c2306ba..8d103adf0 100644 --- a/src/apps/admin/src/lib/services/terms.service.ts +++ b/src/apps/admin/src/lib/services/terms.service.ts @@ -5,11 +5,19 @@ import { EnvironmentConfig } from '~/config' import { PaginatedResponse, xhrDeleteAsync, + xhrGetAsync, xhrGetPaginatedAsync, xhrPostAsync, + xhrPutAsync, } from '~/libs/core' -import { ApiV5ResponseSuccess, UserTerm } from '../models' +import { + ApiV5ResponseSuccess, + FormAddTerm, + TermAgreeabilityType, + TermType, + UserTerm, +} from '../models' /** * Fetch all terms list. @@ -29,6 +37,94 @@ export const fetchAllTerms = async ( return result } +/** + * Fetch term by id. + * @param termsId the term id. + * @returns resolves to the term info. + */ +export const findTermsById = async (termsId: string): Promise => { + const result = await xhrGetAsync( + `${EnvironmentConfig.API.V5}/terms/${termsId}`, + ) + return result +} + +/** + * Fetch all terms types. + * @returns resolves to the terms types list. + */ +export const fetchAllTermsTypes = async (): Promise => { + const result = await xhrGetAsync( + `${EnvironmentConfig.API.V5}/terms/types`, + ) + return result +} + +/** + * Fetch all terms agreeability types. + * @returns resolves to the terms agreeability types list. + */ +export const fetchAllTermsAgreeabilityTypes = async (): Promise< + TermAgreeabilityType[] +> => { + const result = await xhrGetAsync( + `${EnvironmentConfig.API.V5}/terms/agreeability-types`, + ) + return result +} + +/** + * Create a term. + * @param data new term data. + * @returns resolves to success or failure calling api. + */ +export const createTerm = async ( + data: Partial, +): Promise => { + const result = await xhrPostAsync, UserTerm>( + `${EnvironmentConfig.API.V5}/terms`, + data, + ) + return result +} + +/** + * Edit a term. + * @param termId term id. + * @param data new term data. + * @returns resolves to success or failure calling api. + */ +export const editTerm = async ( + termId: string, + data: Partial, +): Promise => { + const result = await xhrPutAsync, UserTerm>( + `${EnvironmentConfig.API.V5}/terms/${termId}`, + data, + ) + return result +} + +/** + * Fetch all terms users list. + * @param termId the term id. + * @param filter the filter. + * @returns resolves to the terms users list. + */ +export const fetchAllTermsUsers = async ( + termId: string, + filter?: string, +): Promise< + PaginatedResponse<{ + result: number[] + }> +> => { + const result = await xhrGetPaginatedAsync<{ + result: number[] + }>(`${EnvironmentConfig.API.V5}/terms/${termId}/users?${filter ?? ''}`) + return result +} + /** * Add a term to the user. * @param termId the term id. @@ -65,3 +161,17 @@ export const removeTermUser = async ( ) return result } + +/** + * Remove the term. + * @param termId the term id. + * @returns resolves to success or failure calling api. + */ +export const removeTerm = async ( + termId: string, +): Promise => { + const result = await xhrDeleteAsync( + `${EnvironmentConfig.API.V5}/terms/${termId}`, + ) + return result +} diff --git a/src/apps/admin/src/lib/services/user.service.ts b/src/apps/admin/src/lib/services/user.service.ts index 4f1dcab54..e61116532 100644 --- a/src/apps/admin/src/lib/services/user.service.ts +++ b/src/apps/admin/src/lib/services/user.service.ts @@ -1,12 +1,19 @@ import _ from 'lodash' import { EnvironmentConfig } from '~/config' -import { xhrDeleteAsync, xhrGetAsync, xhrPatchAsync, xhrPostAsync, xhrPutAsync } from '~/libs/core' +import { + xhrDeleteAsync, + xhrGetAsync, + xhrPatchAsync, + xhrPostAsync, + xhrPutAsync, +} from '~/libs/core' import { adjustUserInfoResponse, adjustUserStatusHistoryResponse, ApiV3Response, + MemberInfo, SSOLoginProvider, SSOUserLogin, UserInfo, @@ -20,14 +27,14 @@ import { FormAddSSOLoginData } from '../models/FormAddSSOLoginData.model' */ export const getMemberSuggestionsByHandle = async ( handle: string, -): Promise> => { +): Promise> => { if (!handle) { return [] } type v3Response = { result: { content: T } } const data = await xhrGetAsync< - v3Response> + v3Response> >(`${EnvironmentConfig.API.V3}/members/_suggest/${handle}`) return data.result.content } @@ -38,13 +45,13 @@ export const getMemberSuggestionsByHandle = async ( */ export const getMembersByHandle = async ( handles: string[], -): Promise> => { +): Promise> => { let qs = '' handles.forEach(handle => { qs += `&handlesLower[]=${handle.toLowerCase()}` }) - return xhrGetAsync>( + return xhrGetAsync>( `${EnvironmentConfig.API.V5}/members?fields=userId,handle${qs}`, ) } @@ -85,6 +92,22 @@ export const searchUsers = async (options?: { return result.result.content.map(adjustUserInfoResponse) } +/** + * Get profile by handle. + * @param handle the user handle. + * @returns resolves to user info + */ +export const getProfile = async (handle: string): Promise => { + if (!handle) { + return Promise.reject(new Error('Handle must be specified.')) + } + + const result = await xhrGetAsync>( + `${EnvironmentConfig.API.V3}/members/${handle}`, + ) + return result.result.content +} + /** * Update user email. * @param userId user id. @@ -151,7 +174,9 @@ export const fetchAchievements = async ( * @param userId user id. * @returns resolves to user info */ -export const findUserById = async (userId: string | number): Promise => { +export const findUserById = async ( + userId: string | number, +): Promise => { const result = await xhrGetAsync>( `${EnvironmentConfig.API.V3}/users/${userId}`, ) @@ -163,7 +188,9 @@ export const findUserById = async (userId: string | number): Promise = * @param userId user id. * @returns resolves to sso user logins */ -export const fetchSSOUserLogins = async (userId: string | number): Promise => { +export const fetchSSOUserLogins = async ( + userId: string | number, +): Promise => { const result = await xhrGetAsync>( `${EnvironmentConfig.API.V3}/users/${userId}/SSOUserLogins`, ) diff --git a/src/apps/admin/src/lib/utils/number.ts b/src/apps/admin/src/lib/utils/number.ts index 7c8a70048..c653ad318 100644 --- a/src/apps/admin/src/lib/utils/number.ts +++ b/src/apps/admin/src/lib/utils/number.ts @@ -54,3 +54,24 @@ export function toFixed( return result } + +/** + * Calculate file size in units + * @param bytes file size in bytes + * @param units units + * @returns file size + */ +export function humanFileSize(inputBytes: number, units: string[]): string { + let bytes = inputBytes + if (Math.abs(bytes) < 1024) { + return `${bytes}${units[0]}` + } + + let u = 0 + do { + bytes /= 1024 + u += 1 + } while (Math.abs(bytes) >= 1024 && u < units.length) + + return `${bytes.toFixed(1)}${units[u]}` +} diff --git a/src/apps/admin/src/lib/utils/validation.ts b/src/apps/admin/src/lib/utils/validation.ts index 051761582..b8c13b9cb 100644 --- a/src/apps/admin/src/lib/utils/validation.ts +++ b/src/apps/admin/src/lib/utils/validation.ts @@ -1,9 +1,12 @@ import * as Yup from 'yup' import _ from 'lodash' +import { EnvironmentConfig } from '~/config' + import { FormAddGroup, FormAddGroupMembers, + FormAddTerm, FormBillingAccountsFilter, FormClientsFilter, FormEditBillingAccount, @@ -16,11 +19,16 @@ import { FormRoleMembersFilters, FormRolesFilter, FormSearchByKey, + FormTermsUsersFilter, FormUsersFilters, } from '../models' import { FormEditUserStatus } from '../models/FormEditUserStatus.model' import { FormAddRoleMembers } from '../models/FormAddRoleMembers.type' import { FormAddSSOLoginData } from '../models/FormAddSSOLoginData.model' +import { FormAddTermUser } from '../models/FormAddTermUser.model' + +const docusignTypeId + = EnvironmentConfig.ADMIN.AGREE_FOR_DOCUSIGN_TEMPLATE /** * validation schema for form filter users @@ -79,6 +87,25 @@ export const formClientsFilterSchema: Yup.ObjectSchema .optional(), }) +/** + * validation schema for form terms users filter + */ +export const formTermsUsersFilterSchema: Yup.ObjectSchema + = Yup.object({ + handle: Yup.string() + .trim() + .optional(), + signTermsFrom: Yup.date() + .nullable() + .optional(), + signTermsTo: Yup.date() + .nullable() + .optional(), + userId: Yup.string() + .trim() + .optional(), + }) + /** * validation schema for form new billing account resource */ @@ -289,6 +316,22 @@ export const formRolesFilterSchema: Yup.ObjectSchema .required('Role is required.'), }) +/** + * validation schema for form add term user + */ +export const formAddTermUserSchema: Yup.ObjectSchema + = Yup.object({ + handle: Yup.object() + .shape({ + label: Yup.string() + .required('Label is required.'), + value: Yup.number() + .typeError('Invalid number.') + .required('Value is required.'), + }) + .required('Handle is required.'), + }) + /** * validation schema for form add role members */ @@ -356,6 +399,38 @@ export const formAddGroupMembersSchema: Yup.ObjectSchema }), }) +/** + * validation schema for form add term + */ +export const formAddTermSchema: Yup.ObjectSchema + = Yup.object({ + agreeabilityTypeId: Yup.string() + .trim() + .required('Agreeability type is required.'), + docusignTemplateId: Yup.string() + .trim() + .when('agreeabilityTypeId', (agreeabilityTypeId, schema) => { + if (agreeabilityTypeId[0] === docusignTypeId) { + return schema.required('Docusign template id is required.') + } + + return schema + }), + text: Yup.string() + .trim() + .optional(), + title: Yup.string() + .trim() + .required('Title is required.'), + typeId: Yup.string() + .trim() + .required('Type is required.'), + url: Yup.string() + .trim() + .url('Invalid url.') + .optional(), + }) + /** * validation schema for form edit user email */ diff --git a/src/apps/admin/src/platform/terms/TermsAddPage/TermsAddPage.module.scss b/src/apps/admin/src/platform/terms/TermsAddPage/TermsAddPage.module.scss new file mode 100644 index 000000000..01f9304ec --- /dev/null +++ b/src/apps/admin/src/platform/terms/TermsAddPage/TermsAddPage.module.scss @@ -0,0 +1,4 @@ +.container { + display: flex; + flex-direction: column; +} diff --git a/src/apps/admin/src/platform/terms/TermsAddPage/TermsAddPage.tsx b/src/apps/admin/src/platform/terms/TermsAddPage/TermsAddPage.tsx new file mode 100644 index 000000000..ac464952d --- /dev/null +++ b/src/apps/admin/src/platform/terms/TermsAddPage/TermsAddPage.tsx @@ -0,0 +1,29 @@ +/** + * Terms Add Page. + */ +import { FC } from 'react' +import classNames from 'classnames' + +import { PageWrapper, TermsAddForm } from '../../../lib' +import { useAutoScrollTopWhenInit } from '../../../lib/hooks' + +import styles from './TermsAddPage.module.scss' + +interface Props { + className?: string +} + +export const TermsAddPage: FC = (props: Props) => { + useAutoScrollTopWhenInit() + + return ( + + + + ) +} + +export default TermsAddPage diff --git a/src/apps/admin/src/platform/terms/TermsAddPage/index.ts b/src/apps/admin/src/platform/terms/TermsAddPage/index.ts new file mode 100644 index 000000000..86394c178 --- /dev/null +++ b/src/apps/admin/src/platform/terms/TermsAddPage/index.ts @@ -0,0 +1 @@ +export { default as TermsAddPage } from './TermsAddPage' diff --git a/src/apps/admin/src/platform/terms/TermsEditPage/TermsEditPage.module.scss b/src/apps/admin/src/platform/terms/TermsEditPage/TermsEditPage.module.scss new file mode 100644 index 000000000..01f9304ec --- /dev/null +++ b/src/apps/admin/src/platform/terms/TermsEditPage/TermsEditPage.module.scss @@ -0,0 +1,4 @@ +.container { + display: flex; + flex-direction: column; +} diff --git a/src/apps/admin/src/platform/terms/TermsEditPage/TermsEditPage.tsx b/src/apps/admin/src/platform/terms/TermsEditPage/TermsEditPage.tsx new file mode 100644 index 000000000..e4f1d8b3e --- /dev/null +++ b/src/apps/admin/src/platform/terms/TermsEditPage/TermsEditPage.tsx @@ -0,0 +1,29 @@ +/** + * Terms Edit Page. + */ +import { FC } from 'react' +import classNames from 'classnames' + +import { PageWrapper, TermsAddForm } from '../../../lib' +import { useAutoScrollTopWhenInit } from '../../../lib/hooks' + +import styles from './TermsEditPage.module.scss' + +interface Props { + className?: string +} + +export const TermsEditPage: FC = (props: Props) => { + useAutoScrollTopWhenInit() + + return ( + + + + ) +} + +export default TermsEditPage diff --git a/src/apps/admin/src/platform/terms/TermsEditPage/index.ts b/src/apps/admin/src/platform/terms/TermsEditPage/index.ts new file mode 100644 index 000000000..be2a897e0 --- /dev/null +++ b/src/apps/admin/src/platform/terms/TermsEditPage/index.ts @@ -0,0 +1 @@ +export { default as TermsEditPage } from './TermsEditPage' diff --git a/src/apps/admin/src/platform/terms/TermsListPage/TermsListPage.module.scss b/src/apps/admin/src/platform/terms/TermsListPage/TermsListPage.module.scss new file mode 100644 index 000000000..01f9304ec --- /dev/null +++ b/src/apps/admin/src/platform/terms/TermsListPage/TermsListPage.module.scss @@ -0,0 +1,4 @@ +.container { + display: flex; + flex-direction: column; +} diff --git a/src/apps/admin/src/platform/terms/TermsListPage/TermsListPage.tsx b/src/apps/admin/src/platform/terms/TermsListPage/TermsListPage.tsx new file mode 100644 index 000000000..29e3d3211 --- /dev/null +++ b/src/apps/admin/src/platform/terms/TermsListPage/TermsListPage.tsx @@ -0,0 +1,84 @@ +/** + * Terms List Page. + */ +import { FC, useState } from 'react' +import classNames from 'classnames' + +import { colWidthType, LinkButton, PageDivider } from '~/libs/ui' +import { PlusIcon } from '@heroicons/react/solid' + +import { + PageWrapper, + TableLoading, + TableNoRecord, + TermsFilters, + TermsTable, +} from '../../../lib' +import { useAutoScrollTopWhenInit, useManageTerms, useManageTermsProps } from '../../../lib/hooks' + +import styles from './TermsListPage.module.scss' + +interface Props { + className?: string +} + +export const TermsListPage: FC = (props: Props) => { + useAutoScrollTopWhenInit() + const [colWidth, setColWidth] = useState({}) + /** + * Manage term list + */ + const { + isLoading, + datas, + totalPages, + page, + setPage, + setFilterCriteria, + }: useManageTermsProps = useManageTerms() + + return ( + + )} + > + + + {isLoading ? ( + + ) : ( + <> + {datas.length === 0 ? ( + + ) : ( +
+ +
+ )} + + )} +
+ ) +} + +export default TermsListPage diff --git a/src/apps/admin/src/platform/terms/TermsListPage/index.ts b/src/apps/admin/src/platform/terms/TermsListPage/index.ts new file mode 100644 index 000000000..d4357f038 --- /dev/null +++ b/src/apps/admin/src/platform/terms/TermsListPage/index.ts @@ -0,0 +1 @@ +export { default as TermsListPage } from './TermsListPage' diff --git a/src/apps/admin/src/platform/terms/TermsUsersPage/TermsUsersPage.module.scss b/src/apps/admin/src/platform/terms/TermsUsersPage/TermsUsersPage.module.scss new file mode 100644 index 000000000..f18cd98c2 --- /dev/null +++ b/src/apps/admin/src/platform/terms/TermsUsersPage/TermsUsersPage.module.scss @@ -0,0 +1,38 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; +} + +.removeSelectionButtonContainer { + padding: 20px 0 30px $sp-8; + + @include ltemd { + text-align: center; + padding-left: $sp-4; + } +} + +.blockTableContainer { + position: relative; +} + +.blockActionLoading { + position: absolute; + width: 64px; + display: flex; + align-items: center; + justify-content: center; + bottom: 50px; + height: 64px; + left: $sp-8; + + .spinner { + background: none; + } + + @include ltelg { + left: $sp-4; + } +} diff --git a/src/apps/admin/src/platform/terms/TermsUsersPage/TermsUsersPage.tsx b/src/apps/admin/src/platform/terms/TermsUsersPage/TermsUsersPage.tsx new file mode 100644 index 000000000..f9c10c373 --- /dev/null +++ b/src/apps/admin/src/platform/terms/TermsUsersPage/TermsUsersPage.tsx @@ -0,0 +1,196 @@ +/** + * Terms Users Page. + */ +import { FC, useContext, useMemo, useState } from 'react' +import { useParams } from 'react-router-dom' +import classNames from 'classnames' + +import { + Button, + colWidthType, + LinkButton, + LoadingSpinner, + PageDivider, +} from '~/libs/ui' +import { PlusIcon } from '@heroicons/react/solid' + +import { + AdminAppContext, + DialogAddTermUser, + PageWrapper, + TableLoading, + TableNoRecord, + TermsUsersFilters, + TermsUsersTable, +} from '../../../lib' +import { + useAutoScrollTopWhenInit, + useManageTermsUsers, + useManageTermsUsersProps, +} from '../../../lib/hooks' +import { AdminAppContextType } from '../../../lib/models' +import { + useTableSelection, + useTableSelectionProps, +} from '../../../lib/hooks/useTableSelection' + +import styles from './TermsUsersPage.module.scss' + +interface Props { + className?: string +} + +export const TermsUsersPage: FC = (props: Props) => { + const [showDialogAddUser, setShowDialogAddUser] = useState() + useAutoScrollTopWhenInit() + const { id = '' }: { id?: string } = useParams<{ + id?: string + }>() + const { loadUser, cancelLoadUser, usersMapping }: AdminAppContextType + = useContext(AdminAppContext) + const [colWidth, setColWidth] = useState({}) + + /** + * Hook for manage term users + */ + const { + isAdding, + isRemovingBool, + isRemoving, + isLoading: isLoadingUserTerms, + isLoadingTerm, + datas, + totalPages, + page, + setPage, + setFilterCriteria, + doAddTermUser, + doRemoveTermUser, + doRemoveTermUsers, + termInfo, + }: useManageTermsUsersProps = useManageTermsUsers( + id, + loadUser, + cancelLoadUser, + ) + const isLoading = isLoadingUserTerms || isLoadingTerm + + /** + * Get list of term user id for the selection + */ + const datasIds = useMemo(() => datas.map(item => item.userId), [datas]) + + const { + selectedDatas, + selectedDatasArray, + toggleSelect, + hasSelected, + forceSelect, + forceUnSelect, + unselectAll, + }: useTableSelectionProps = useTableSelection(datasIds) + + return ( + + +
+ + )} + + )} + + {showDialogAddUser && termInfo && ( + + )} + + ) +} + +export default TermsUsersPage diff --git a/src/apps/admin/src/platform/terms/TermsUsersPage/index.ts b/src/apps/admin/src/platform/terms/TermsUsersPage/index.ts new file mode 100644 index 000000000..1b856b6eb --- /dev/null +++ b/src/apps/admin/src/platform/terms/TermsUsersPage/index.ts @@ -0,0 +1 @@ +export { default as TermsUsersPage } from './TermsUsersPage' diff --git a/src/config/environments/default.env.ts b/src/config/environments/default.env.ts index 5ac83bb22..7c48c826f 100644 --- a/src/config/environments/default.env.ts +++ b/src/config/environments/default.env.ts @@ -77,6 +77,8 @@ export const USERFLOW_SURVEYS = { } export const ADMIN = { + AGREE_ELECTRONICALLY: '5b2798b2-ae82-4210-9b4d-5d6428125ccb', + AGREE_FOR_DOCUSIGN_TEMPLATE: '999a26ad-b334-453c-8425-165d4cf496d7', AV_SCAN_SCORER_REVIEW_TYPE_ID: '68c5a381-c8ab-48af-92a7-7a869a4ee6c3', CHALLENGE_URL: 'https://www.topcoder-dev.com/challenges', CONNECT_URL: 'https://connect.topcoder-dev.com', diff --git a/src/config/environments/global-config.model.ts b/src/config/environments/global-config.model.ts index d7de71112..0a813b77a 100644 --- a/src/config/environments/global-config.model.ts +++ b/src/config/environments/global-config.model.ts @@ -51,5 +51,7 @@ export interface GlobalConfig { ONLINE_REVIEW_URL: string CHALLENGE_URL: string AV_SCAN_SCORER_REVIEW_TYPE_ID: string + AGREE_ELECTRONICALLY: string + AGREE_FOR_DOCUSIGN_TEMPLATE: string } } diff --git a/src/config/environments/prod.env.ts b/src/config/environments/prod.env.ts index 69e9fab0a..f0938d0a3 100644 --- a/src/config/environments/prod.env.ts +++ b/src/config/environments/prod.env.ts @@ -7,6 +7,8 @@ export const VANILLA_FORUM = { } export const ADMIN = { + AGREE_ELECTRONICALLY: '2db6c920-4089-4755-9cd1-99b0df0af961', + AGREE_FOR_DOCUSIGN_TEMPLATE: '1363a7ab-fd3e-4d7c-abbb-2f7440b8b355', AV_SCAN_SCORER_REVIEW_TYPE_ID: '55bbb17d-aac2-45a6-89c3-a8d102863d05', CHALLENGE_URL: 'https://www.topcoder.com/challenges', CONNECT_URL: 'https://connect.topcoder.com', diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-date-picker/InputDatePicker.tsx b/src/libs/ui/lib/components/form/form-groups/form-input/input-date-picker/InputDatePicker.tsx index 2c4840898..5a481611c 100644 --- a/src/libs/ui/lib/components/form/form-groups/form-input/input-date-picker/InputDatePicker.tsx +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-date-picker/InputDatePicker.tsx @@ -14,6 +14,7 @@ import styles from './InputDatePicker.module.scss' interface InputDatePickerProps { date: Date | undefined | null onChange: (date: Date | null) => void + onBlur?: () => void readonly className?: string readonly dateFormat?: string | string[] readonly dirty?: boolean @@ -184,7 +185,10 @@ const InputDatePicker: FC = (props: InputDatePickerProps) popperPlacement='bottom' portalId='react-date-portal' onFocus={() => setStateHasFocus(true)} - onBlur={() => setStateHasFocus(false)} + onBlur={() => { + setStateHasFocus(false) + props.onBlur?.() + }} isClearable={props.isClearable} /> diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/InputSelectReact.tsx b/src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/InputSelectReact.tsx index 48546fa7f..50f706d56 100644 --- a/src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/InputSelectReact.tsx +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/InputSelectReact.tsx @@ -26,6 +26,7 @@ interface InputSelectReactProps { readonly classNameWrapper?: string readonly dirty?: boolean readonly disabled?: boolean + readonly isLoading?: boolean readonly error?: string readonly hideInlineErrors?: boolean readonly hint?: string @@ -156,6 +157,7 @@ const InputSelectReact: FC = props => { backspaceRemovesValue isDisabled={props.disabled} filterOption={props.filterOption} + isLoading={props.isLoading} /> ) diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-textarea/InputTextarea.module.scss b/src/libs/ui/lib/components/form/form-groups/form-input/input-textarea/InputTextarea.module.scss index 0b5e6eee4..b0ddbfd3f 100644 --- a/src/libs/ui/lib/components/form/form-groups/form-input/input-textarea/InputTextarea.module.scss +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-textarea/InputTextarea.module.scss @@ -10,7 +10,6 @@ outline: none; resize: vertical; margin-left: calc(-1 * $border); - overflow: hidden; padding: $border; &::placeholder { diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-textarea/InputTextarea.tsx b/src/libs/ui/lib/components/form/form-groups/form-input/input-textarea/InputTextarea.tsx index ccfa44f0f..25e2dee42 100644 --- a/src/libs/ui/lib/components/form/form-groups/form-input/input-textarea/InputTextarea.tsx +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-textarea/InputTextarea.tsx @@ -23,6 +23,7 @@ interface InputTextareaProps { readonly tabIndex?: number readonly value?: string | number readonly inputControl?: UseFormRegisterReturn + readonly classNameWrapper?: string } const InputTextarea: FC = (props: InputTextareaProps) => ( diff --git a/yarn.lock b/yarn.lock index 6fd04826d..b4e8f1d84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4765,6 +4765,13 @@ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.4.3.tgz#af975e367743fa91989cd666666aec31a8f50591" integrity sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q== +"@tinymce/tinymce-react@^6.2.1": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@tinymce/tinymce-react/-/tinymce-react-6.2.1.tgz#23e1d73b0a5b1f01c7d23f5b6c6bb71d7fc617c7" + integrity sha512-P/xWz3sNeJ2kXykxBkxM+4vEUYFlqWuJFifcJTmIwqHODJc17eZWvtNapzqGD+mUjXglf3VePu7ojRV1kdK22A== + dependencies: + prop-types "^15.6.2" + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -4899,6 +4906,20 @@ "@types/node" "*" "@types/responselike" "^1.0.0" +"@types/codemirror@5.60.15": + version "5.60.15" + resolved "https://registry.yarnpkg.com/@types/codemirror/-/codemirror-5.60.15.tgz#0f82be6f4126d1e59cf4c4830e56dcd49d3c3e8a" + integrity sha512-dTOvwEQ+ouKJ/rE9LT1Ue2hmP6H1mZv5+CCnNWu2qtiOe2LQa9lCprEY20HxiDmV/Bxh+dXjywmy5aKvoGjULA== + dependencies: + "@types/tern" "*" + +"@types/codemirror@^5.60.10": + version "5.60.16" + resolved "https://registry.yarnpkg.com/@types/codemirror/-/codemirror-5.60.16.tgz#1f462f9771113bd8e1c6130c666b17db8e1087c2" + integrity sha512-V/yHdamffSS075jit+fDxaOAmdP2liok8NSNJnAZfDJErzOheuygHZEhAJrfmk5TEyM32MhkZjwo/idX791yxw== + dependencies: + "@types/tern" "*" + "@types/connect-history-api-fallback@^1.3.5": version "1.3.5" resolved "https://registry.yarnpkg.com/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.3.5.tgz#d1f7a8a09d0ed5a57aee5ae9c18ab9b803205dae" @@ -5170,6 +5191,11 @@ resolved "https://registry.yarnpkg.com/@types/marked/-/marked-4.0.7.tgz#400a76809fd08c2bbd9e25f3be06ea38c8e0a1d3" integrity sha512-eEAhnz21CwvKVW+YvRvcTuFKNU9CV1qH+opcgVK3pIMI6YZzDm6gc8o2vHjldFk6MGKt5pueSB7IOpvpx5Qekw== +"@types/marked@^4.0.7": + version "4.3.2" + resolved "https://registry.yarnpkg.com/@types/marked/-/marked-4.3.2.tgz#e2e0ad02ebf5626bd215c5bae2aff6aff0ce9eac" + integrity sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w== + "@types/mdast@^3.0.0": version "3.0.11" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.11.tgz#dc130f7e7d9306124286f6d6cee40cf4d14a3dc0" @@ -5439,6 +5465,13 @@ resolved "https://registry.yarnpkg.com/@types/systemjs/-/systemjs-6.13.0.tgz#c46a6083488b095fc2e00270f28fb6fe9f420ec6" integrity sha512-T7P3qWZmtAVNUrEkWXlT8Hm8ND0w7rVmMZu+HYmS38mrNyAyxIdoZQ23ySmClhWR1oq0E2RhOSmuI3Cs2By6nQ== +"@types/tern@*": + version "0.23.9" + resolved "https://registry.yarnpkg.com/@types/tern/-/tern-0.23.9.tgz#6f6093a4a9af3e6bb8dde528e024924d196b367c" + integrity sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw== + dependencies: + "@types/estree" "*" + "@types/testing-library__jest-dom@^5.14.5", "@types/testing-library__jest-dom@^5.9.1": version "5.14.5" resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.5.tgz#d113709c90b3c75fdb127ec338dad7d5f86c974f" @@ -7337,6 +7370,18 @@ coa@^2.0.2: chalk "^2.4.1" q "^1.1.2" +codemirror-spell-checker@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/codemirror-spell-checker/-/codemirror-spell-checker-1.1.2.tgz#1c660f9089483ccb5113b9ba9ca19c3f4993371e" + integrity sha512-2Tl6n0v+GJRsC9K3MLCdLaMOmvWL0uukajNJseorZJsslaxZyZMgENocPU8R0DyoTAiKsyqiemSOZo7kjGV0LQ== + dependencies: + typo-js "*" + +codemirror@^5.65.15: + version "5.65.19" + resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.65.19.tgz#71016c701d6a4b6e1982b0f6e7186be65e49653d" + integrity sha512-+aFkvqhaAVr1gferNMuN8vkTSrWIFvzlMV9I2KBLCWS2WpZ2+UAkZjlMZmEuT+gcXTi6RrGQCkWq1/bDtGqhIA== + collect-v8-coverage@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59" @@ -8624,6 +8669,17 @@ duplexify@^3.5.0, duplexify@^3.6.0: readable-stream "^2.0.0" stream-shift "^1.0.0" +easymde@2.20.0: + version "2.20.0" + resolved "https://registry.yarnpkg.com/easymde/-/easymde-2.20.0.tgz#88b3161feab6e1900afa9c4dab3f1da352b0a26e" + integrity sha512-V1Z5f92TfR42Na852OWnIZMbM7zotWQYTddNaLYZFVKj7APBbyZ3FYJ27gBw2grMW3R6Qdv9J8n5Ij7XRSIgXQ== + dependencies: + "@types/codemirror" "^5.60.10" + "@types/marked" "^4.0.7" + codemirror "^5.65.15" + codemirror-spell-checker "1.1.2" + marked "^4.1.0" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -12910,6 +12966,11 @@ marked@4.1.1: resolved "https://registry.yarnpkg.com/marked/-/marked-4.1.1.tgz#2f709a4462abf65a283f2453dc1c42ab177d302e" integrity sha512-0cNMnTcUJPxbA6uWmCmjWz4NJRe/0Xfk2NhXCUHjew9qJzFN20krFnsUe7QynwqOwa5m1fZ4UDg0ycKFVC0ccw== +marked@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/marked/-/marked-4.3.0.tgz#796362821b019f734054582038b116481b456cf3" + integrity sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A== + matchmediaquery@^0.3.0: version "0.3.1" resolved "https://registry.yarnpkg.com/matchmediaquery/-/matchmediaquery-0.3.1.tgz#8247edc47e499ebb7c58f62a9ff9ccf5b815c6d7" @@ -17439,7 +17500,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -17453,6 +17514,13 @@ strip-ansi@^3.0.0: dependencies: ansi-regex "^2.0.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2" @@ -17977,6 +18045,11 @@ tinycolor2@^1.4.1: resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw== +tinymce@^7.9.1: + version "7.9.1" + resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-7.9.1.tgz#1b18bad9cb7a3b4b12e3e5a7f29fc7daad0713d7" + integrity sha512-zaOHwmiP1EqTeLRXAvVriDb00JYnfEjWGPdKEuac7MiZJ5aiDMZ4Unc98Gmajn+PBljOmO1GKV6G0KwWn3+k8A== + tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -18272,6 +18345,11 @@ typescript@^4.8.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78" integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg== +typo-js@*: + version "1.2.5" + resolved "https://registry.yarnpkg.com/typo-js/-/typo-js-1.2.5.tgz#0aa65e0be9b69036463a3827de8185b4144e3086" + integrity sha512-F45vFWdGX8xahIk/sOp79z2NJs8ETMYsmMChm9D5Hlx3+9j7VnCyQyvij5MOCrNY3NNe8noSyokRjQRfq+Bc7A== + ua-parser-js@^0.7.30: version "0.7.35" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.35.tgz#8bda4827be4f0b1dda91699a29499575a1f1d307" @@ -19291,7 +19369,7 @@ workbox-window@6.5.4: "@types/trusted-types" "^2.0.2" workbox-core "6.5.4" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -19309,6 +19387,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.0.1.tgz#2101e861777fec527d0ea90c57c6b03aac56a5b3"