From 9889cd4e294cbeef6b9b0bf9e0744d19846fd717 Mon Sep 17 00:00:00 2001 From: dat Date: Thu, 10 Jul 2025 10:47:52 +0700 Subject: [PATCH 1/3] Topcoder Admin App - Add Terms Management Final fix --- package.json | 2 + src/apps/admin/src/admin-app.routes.tsx | 33 ++ src/apps/admin/src/config/routes.config.ts | 1 + .../DialogAddTermUser.module.scss | 34 ++ .../DialogAddTermUser/DialogAddTermUser.tsx | 145 +++++ .../lib/components/DialogAddTermUser/index.ts | 1 + .../FieldHandleSelect/FieldHandleSelect.tsx | 3 +- .../FieldSingleSelectAsync.tsx | 3 +- .../TermsAddForm/TermsAddForm.module.scss | 64 +++ .../components/TermsAddForm/TermsAddForm.tsx | 393 ++++++++++++++ .../src/lib/components/TermsAddForm/index.ts | 1 + .../TermsFilters/TermsFilters.module.scss | 43 ++ .../components/TermsFilters/TermsFilters.tsx | 96 ++++ .../src/lib/components/TermsFilters/index.ts | 1 + .../TermsTable/TermsTable.module.scss | 31 ++ .../lib/components/TermsTable/TermsTable.tsx | 158 ++++++ .../src/lib/components/TermsTable/index.ts | 1 + .../TermsUsersFilters.module.scss | 38 ++ .../TermsUsersFilters/TermsUsersFilters.tsx | 163 ++++++ .../lib/components/TermsUsersFilters/index.ts | 1 + .../TermsUsersTable.module.scss | 21 + .../TermsUsersTable/TermsUsersTable.tsx | 213 ++++++++ .../lib/components/TermsUsersTable/index.ts | 1 + .../FieldHtmlEditor/Editor/Editor.module.scss | 181 +++++++ .../common/FieldHtmlEditor/Editor/Editor.tsx | 421 +++++++++++++++ .../common/FieldHtmlEditor/Editor/index.ts | 1 + .../FieldHtmlEditor.module.scss | 21 + .../FieldHtmlEditor/FieldHtmlEditor.tsx | 60 +++ .../common/FieldHtmlEditor/index.ts | 1 + .../FormAddWrapper/FormAddWrapper.module.scss | 5 + .../common/Layout/Layout.module.scss | 2 +- .../lib/components/common/Layout/Layout.tsx | 20 +- .../common/PageWrapper/PageWrapper.tsx | 2 + .../Tab/config/system-admin-tabs-config.ts | 6 + src/apps/admin/src/lib/components/index.ts | 6 + src/apps/admin/src/lib/hooks/index.ts | 4 + .../src/lib/hooks/useAutoScrollTopWhenInit.ts | 13 + .../admin/src/lib/hooks/useManageAddTerm.ts | 215 ++++++++ .../admin/src/lib/hooks/useManageTerms.ts | 153 ++++++ .../src/lib/hooks/useManageTermsUsers.ts | 508 ++++++++++++++++++ .../src/lib/hooks/useTableFilterBackend.ts | 2 + .../admin/src/lib/models/FormAddTerm.model.ts | 11 + .../src/lib/models/FormAddTermUser.model.ts | 9 + .../lib/models/FormTermsUsersFilter.model.ts | 9 + .../admin/src/lib/models/MemberInfo.model.ts | 7 + .../lib/models/TermAgreeabilityType.model.ts | 9 + .../admin/src/lib/models/TermType.model.ts | 7 + .../src/lib/models/TermUserInfo.model.ts | 6 + .../admin/src/lib/models/UserTerm.model.ts | 8 + src/apps/admin/src/lib/models/index.ts | 6 + .../admin/src/lib/services/terms.service.ts | 112 +++- .../admin/src/lib/services/user.service.ts | 41 +- src/apps/admin/src/lib/utils/number.ts | 21 + src/apps/admin/src/lib/utils/validation.ts | 75 +++ .../TermsAddPage/TermsAddPage.module.scss | 4 + .../terms/TermsAddPage/TermsAddPage.tsx | 29 + .../src/platform/terms/TermsAddPage/index.ts | 1 + .../TermsEditPage/TermsEditPage.module.scss | 4 + .../terms/TermsEditPage/TermsEditPage.tsx | 29 + .../src/platform/terms/TermsEditPage/index.ts | 1 + .../TermsListPage/TermsListPage.module.scss | 4 + .../terms/TermsListPage/TermsListPage.tsx | 84 +++ .../src/platform/terms/TermsListPage/index.ts | 1 + .../TermsUsersPage/TermsUsersPage.module.scss | 38 ++ .../terms/TermsUsersPage/TermsUsersPage.tsx | 196 +++++++ .../platform/terms/TermsUsersPage/index.ts | 1 + src/config/environments/default.env.ts | 2 + .../environments/global-config.model.ts | 2 + src/config/environments/prod.env.ts | 2 + .../input-date-picker/InputDatePicker.tsx | 6 +- .../input-select-react/InputSelectReact.tsx | 2 + .../input-textarea/InputTextarea.module.scss | 1 - .../input-textarea/InputTextarea.tsx | 1 + yarn.lock | 79 ++- 74 files changed, 3848 insertions(+), 28 deletions(-) create mode 100644 src/apps/admin/src/lib/components/DialogAddTermUser/DialogAddTermUser.module.scss create mode 100644 src/apps/admin/src/lib/components/DialogAddTermUser/DialogAddTermUser.tsx create mode 100644 src/apps/admin/src/lib/components/DialogAddTermUser/index.ts create mode 100644 src/apps/admin/src/lib/components/TermsAddForm/TermsAddForm.module.scss create mode 100644 src/apps/admin/src/lib/components/TermsAddForm/TermsAddForm.tsx create mode 100644 src/apps/admin/src/lib/components/TermsAddForm/index.ts create mode 100644 src/apps/admin/src/lib/components/TermsFilters/TermsFilters.module.scss create mode 100644 src/apps/admin/src/lib/components/TermsFilters/TermsFilters.tsx create mode 100644 src/apps/admin/src/lib/components/TermsFilters/index.ts create mode 100644 src/apps/admin/src/lib/components/TermsTable/TermsTable.module.scss create mode 100644 src/apps/admin/src/lib/components/TermsTable/TermsTable.tsx create mode 100644 src/apps/admin/src/lib/components/TermsTable/index.ts create mode 100644 src/apps/admin/src/lib/components/TermsUsersFilters/TermsUsersFilters.module.scss create mode 100644 src/apps/admin/src/lib/components/TermsUsersFilters/TermsUsersFilters.tsx create mode 100644 src/apps/admin/src/lib/components/TermsUsersFilters/index.ts create mode 100644 src/apps/admin/src/lib/components/TermsUsersTable/TermsUsersTable.module.scss create mode 100644 src/apps/admin/src/lib/components/TermsUsersTable/TermsUsersTable.tsx create mode 100644 src/apps/admin/src/lib/components/TermsUsersTable/index.ts create mode 100644 src/apps/admin/src/lib/components/common/FieldHtmlEditor/Editor/Editor.module.scss create mode 100644 src/apps/admin/src/lib/components/common/FieldHtmlEditor/Editor/Editor.tsx create mode 100644 src/apps/admin/src/lib/components/common/FieldHtmlEditor/Editor/index.ts create mode 100644 src/apps/admin/src/lib/components/common/FieldHtmlEditor/FieldHtmlEditor.module.scss create mode 100644 src/apps/admin/src/lib/components/common/FieldHtmlEditor/FieldHtmlEditor.tsx create mode 100644 src/apps/admin/src/lib/components/common/FieldHtmlEditor/index.ts create mode 100644 src/apps/admin/src/lib/hooks/useAutoScrollTopWhenInit.ts create mode 100644 src/apps/admin/src/lib/hooks/useManageAddTerm.ts create mode 100644 src/apps/admin/src/lib/hooks/useManageTerms.ts create mode 100644 src/apps/admin/src/lib/hooks/useManageTermsUsers.ts create mode 100644 src/apps/admin/src/lib/models/FormAddTerm.model.ts create mode 100644 src/apps/admin/src/lib/models/FormAddTermUser.model.ts create mode 100644 src/apps/admin/src/lib/models/FormTermsUsersFilter.model.ts create mode 100644 src/apps/admin/src/lib/models/MemberInfo.model.ts create mode 100644 src/apps/admin/src/lib/models/TermAgreeabilityType.model.ts create mode 100644 src/apps/admin/src/lib/models/TermType.model.ts create mode 100644 src/apps/admin/src/lib/models/TermUserInfo.model.ts create mode 100644 src/apps/admin/src/platform/terms/TermsAddPage/TermsAddPage.module.scss create mode 100644 src/apps/admin/src/platform/terms/TermsAddPage/TermsAddPage.tsx create mode 100644 src/apps/admin/src/platform/terms/TermsAddPage/index.ts create mode 100644 src/apps/admin/src/platform/terms/TermsEditPage/TermsEditPage.module.scss create mode 100644 src/apps/admin/src/platform/terms/TermsEditPage/TermsEditPage.tsx create mode 100644 src/apps/admin/src/platform/terms/TermsEditPage/index.ts create mode 100644 src/apps/admin/src/platform/terms/TermsListPage/TermsListPage.module.scss create mode 100644 src/apps/admin/src/platform/terms/TermsListPage/TermsListPage.tsx create mode 100644 src/apps/admin/src/platform/terms/TermsListPage/index.ts create mode 100644 src/apps/admin/src/platform/terms/TermsUsersPage/TermsUsersPage.module.scss create mode 100644 src/apps/admin/src/platform/terms/TermsUsersPage/TermsUsersPage.tsx create mode 100644 src/apps/admin/src/platform/terms/TermsUsersPage/index.ts diff --git a/package.json b/package.json index 4836bfad5..d924bb73e 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@storybook/react": "7.6.10", "@stripe/react-stripe-js": "1.13.0", "@stripe/stripe-js": "1.41.0", + "@types/codemirror": "5.60.15", "apexcharts": "^3.36.0", "axios": "^1.7.9", "browser-cookies": "^1.2.0", @@ -43,6 +44,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", 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..9f21caaf6 --- /dev/null +++ b/src/apps/admin/src/lib/components/TermsAddForm/TermsAddForm.module.scss @@ -0,0 +1,64 @@ +@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; + + 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..303bcc35c --- /dev/null +++ b/src/apps/admin/src/lib/components/TermsAddForm/TermsAddForm.tsx @@ -0,0 +1,393 @@ +/** + * 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/Editor/Editor.module.scss b/src/apps/admin/src/lib/components/common/FieldHtmlEditor/Editor/Editor.module.scss new file mode 100644 index 000000000..0a958e4ed --- /dev/null +++ b/src/apps/admin/src/lib/components/common/FieldHtmlEditor/Editor/Editor.module.scss @@ -0,0 +1,181 @@ +@import '@libs/ui/styles/typography'; +@import '@libs/ui/styles/includes'; + +$error-line-height: 14px; + +.container { + display: flex; + flex-direction: column; + height: 280px; + gap: 9px; + @include ltemd { + height: 280px; + gap: 0; + } + + &.disabled { + :global { + .CodeMirror, + .editor-toolbar { + background-color: $black-10; + } + .CodeMirror.CodeMirror-wrap { + border-left: 1px solid $black-10; + border-right: 1px solid $black-10; + border-bottom: 1px solid $black-10; + } + .editor-toolbar { + border-top: 1px solid $black-10; + border-left: 1px solid $black-10; + border-right: 1px solid $black-10; + } + } + } + + &.isError { + :global { + .CodeMirror.CodeMirror-wrap { + border-right: 1px solid $red-100; + border-left: 1px solid $red-100; + border-bottom: 1px solid $red-100; + } + .editor-toolbar { + border-top: 1px solid $red-100; + border-left: 1px solid $red-100; + border-right: 1px solid $red-100; + } + } + } + + :global { + .EasyMDEContainer { + height: 100px; + flex: 1; + display: flex; + flex-direction: column; + } + + .CodeMirror.CodeMirror-wrap { + @include font-roboto; + + min-height: 0; + flex: 1; + box-sizing: border-box; + height: auto; + border-right: 1px solid white; + border-left: 1px solid white; + border-bottom: 1px solid white; + border-top: 1px solid #d9d9d9; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + font-size: 14px; + color: $black-60; + } + + .CodeMirror-sizer, + .CodeMirror-scroll { + min-height: unset !important; + } + + .editor-toolbar { + opacity: 1; + border-radius: 0; + border-top: 1px solid white; + border-left: 1px solid white; + border-right: 1px solid white; + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 0 8px; + background-color: white; + @include ltemd { + gap: $sp-1; + padding: 0 $sp-1; + } + &::after, + &::before { + content: none; + } + + button.table { + width: auto; + } + + button { + margin: 8px 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + min-width: 24px; + color: black; + + @include ltemd { + margin: $sp-1 0; + height: 24px; + width: 24px; + } + } + + i.separator { + border-left: 1px solid #d9d9d9; + @include ltemd { + margin: 0 $sp-1; + } + } + } + + .editor-statusbar { + @include font-roboto; + + font-size: 14px; + line-height: 19px; + padding: $sp-4 0 0 0; + overflow: hidden; + display: flex; + + span { + min-width: 0; + } + + .upload-image { + @include font-roboto; + + margin-left: 0; + margin-right: auto; + display: flex; + font-size: 14px; + line-height: 20px; + } + + .countOfRemainingChars { + margin-left: 0; + min-width: 0; + display: flex; + } + + .message { + display: none; + } + } + } +} + +.error { + display: flex; + align-items: center; + color: $red-100; + // extend body ultra small and override it + font-size: 14px; + line-height: 19px; + line-height: $error-line-height; + margin-top: $sp-1; + + svg { + @include icon-md; + fill: $red-100; + margin-right: $sp-1; + } +} diff --git a/src/apps/admin/src/lib/components/common/FieldHtmlEditor/Editor/Editor.tsx b/src/apps/admin/src/lib/components/common/FieldHtmlEditor/Editor/Editor.tsx new file mode 100644 index 000000000..b54851059 --- /dev/null +++ b/src/apps/admin/src/lib/components/common/FieldHtmlEditor/Editor/Editor.tsx @@ -0,0 +1,421 @@ +/** + * Field Markdown Editor. + */ +import { FC, useCallback, useEffect, useRef } from 'react' +import _ from 'lodash' +import CodeMirror from 'codemirror' +import EasyMDE from 'easymde' +import classNames from 'classnames' +import 'easymde/dist/easymde.min.css' + +import { useOnComponentDidMount } from '~/apps/admin/src/lib/hooks' + +import styles from './Editor.module.scss' + +interface Props { + className?: string + placeholder?: string + initialValue?: string + onChange?: (value: string) => void + onBlur?: (event: any) => void + error?: string + showBorder?: boolean + disabled?: boolean +} +const errorMessages = { + fileTooLarge: + 'Uploading #image_name# was failed. The file is too big (#image_size#).\n' + + 'Maximum file size is #image_max_size#.', + importError: + 'Uploading #image_name# was failed. Something went wrong when uploading the file.', + noFileGiven: 'Select a file.', + typeNotAllowed: + 'Uploading #image_name# was failed. The file type (#image_type#) is not supported.', +} +const maxUploadSize = 40 * 1024 * 1024 +const imageExtensions = ['gif', 'png', 'jpeg', 'jpg', 'bmp', 'svg'] +const allowedImageExtensions = [ + ...imageExtensions, + ...imageExtensions.map(key => `image/${key}`), +] +const allowedOtherExtensions = [ + 'application/zip', + 'zip', + 'application/octet-stream', + 'application/x-zip-compressed', + 'multipart/x-zip', + 'text/plain', + 'txt', + 'mov', + 'video/mpeg', + 'mp4', + 'video/mp4', + 'webm', + 'video/webm', + 'doc', + 'docx', + 'pdf', + 'application/pdf', + 'csv', + 'text/csv', + 'htm', + 'html', + 'text/html', + 'js', + 'json', + 'application/json', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'xls', + 'xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt', + 'application/vnd.ms-powerpoint', + 'pptx', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', +] + +const stateStrategy = { + atom: (ret: any) => { + ret.quote = true + }, + comment: (ret: any) => { + ret.code = true + }, + em: (ret: any) => { + ret.italic = true + }, + link: (ret: any) => { + ret.link = true + }, + quote: (ret: any) => { + ret.quote = true + }, + strikethrough: (ret: any) => { + ret.strikethrough = true + }, + strong: (ret: any) => { + ret.bold = true + }, + tag: (ret: any) => { + ret.image = true + }, +} + +const toggleStrategy = { + bold: (start: any, end: any) => { + const startType = start.replace(/(\*\*|__)(?![\s\S]*(\*\*|__))/, '') + const endType = end.replace(/(\*\*|__)/, '') + return { endType, startType } + }, + italic: (start: any, end: any) => { + const startType = start.replace(/(\*|_)(?![\s\S]*(\*|_))/, '') + const endType = end.replace(/(\*|_)/, '') + return { endType, startType } + }, + strikethrough: (start: any, end: any) => { + const startType = start.replace(/(\*\*|~~)(?![\s\S]*(\*\*|~~))/, '') + const endType = end.replace(/(\*\*|~~)/, '') + return { endType, startType } + }, +} + +type CodeMirrorType = keyof typeof stateStrategy | 'variable-2' + +export const Editor: FC = (props: Props) => { + const elementRef = useRef(null) + const easyMDE = useRef(null) + + /** + * The state of CodeMirror at the given position. + */ + const getState = useCallback( + (cm: CodeMirror.Editor, posInput?: CodeMirror.Position | undefined) => { + const pos = posInput || cm.getCursor('start') + const stat = cm.getTokenAt(pos) + if (!stat.type) return {} + + const types = stat.type.split(' ') + + const ret: any = {} + + let data: CodeMirrorType + let text + for (let i = 0; i < types.length; i++) { + data = types[i] as CodeMirrorType + if (data === 'variable-2') { + text = cm.getLine(pos.line) + if (/^\s*\d+\.\s/.test(text)) { + ret['ordered-list'] = true + } else { + ret['unordered-list'] = true + } + } else if (data.match(/^header(-[1-6])?$/)) { + ret[data.replace('header', 'heading')] = true + } else if (data in stateStrategy) { + stateStrategy[data](ret) + } + } + + return ret + }, + [], + ) + + /** + * Handle toggle block + */ + const toggleBlock = useCallback( + (editor: any, type: string, startChars: any, endCharsInput?: any) => { + if ( + /editor-preview-active/.test( + editor.codemirror.getWrapperElement().lastChild.className, + ) + ) { + return + } + + const endChars + = typeof endCharsInput === 'undefined' + ? startChars + : endCharsInput + const cm = editor.codemirror + const stat = getState(cm) + + let text = '' + let start = startChars + let end = endChars + + const startPoint = cm.getCursor('start') + const endPoint = cm.getCursor('end') + + if (stat[type]) { + text = cm.getLine(startPoint.line) + start = text.slice(0, startPoint.ch) + end = text.slice(startPoint.ch) + toggleStrategy[type as keyof typeof toggleStrategy](start, end) + + cm.replaceRange( + start + end, + { + ch: 0, + line: startPoint.line, + }, + { + ch: 99999999999999, + line: startPoint.line, + }, + ) + + if (type === 'bold' || type === 'strikethrough') { + startPoint.ch -= 2 + if (startPoint !== endPoint) { + endPoint.ch -= 2 + } + } else if (type === 'italic') { + startPoint.ch -= 1 + if (startPoint !== endPoint) { + endPoint.ch -= 1 + } + } + } else { + text = cm.getSelection() + let trimText = text.trim() + let lastSpaces = '' + for (let i = trimText.length; i < text.length; i++) { + lastSpaces += text[i] + } + + if (type === 'bold') { + trimText = trimText.split('**') + .join('') + } else if (type === 'italic') { + trimText = trimText.split('*') + .join('') + } else if (type === 'strikethrough') { + trimText = trimText.split('~~') + .join('') + } + + cm.replaceSelection(start + trimText + end + lastSpaces) + + startPoint.ch += startChars.length + endPoint.ch = startPoint.ch + text.length + } + + cm.setSelection(startPoint, endPoint) + cm.focus() + }, + [getState], + ) + + /** + * Show hint after '@' + */ + const completeAfter = useCallback((cm: CodeMirror.Editor) => { + if (!cm.state.completionActive) { + if (cm.getCursor().ch === 0) { + cm.replaceSelection('@') + } else { + const from = { + ch: 0, + line: cm.getCursor().line, + } + const to = cm.getCursor() + const line = cm.getRange(from, to) + const lastIndexOf = line.lastIndexOf(' ') + const tokenIndex = lastIndexOf > -1 ? lastIndexOf + 1 : 0 + cm.replaceRange('@', { + ch: tokenIndex, + line: cm.getCursor().line, + }) + } + } + + return CodeMirror.Pass + }, []) + + useOnComponentDidMount(() => { + easyMDE.current = new EasyMDE({ + autofocus: false, + element: elementRef.current as HTMLElement, + errorCallback: _.noop, // A callback function used to define how to display an error message. + errorMessages, + forceSync: true, // true, force text changes made in EasyMDE to be immediately stored in original text area. + hideIcons: ['guide', 'heading', 'preview', 'side-by-side'], + imageAccept: [ + ...allowedImageExtensions, + ...allowedOtherExtensions, + ].join(', '), // A comma-separated list of mime-types and extensions + imageMaxSize: maxUploadSize, // Maximum image size in bytes + imageTexts: { + sbInit: '', + sbOnDragEnter: 'Drop file to upload it.', + sbOnDrop: 'Uploading file #images_names#...', + sbOnUploaded: 'Uploaded #image_name#', + sbProgress: 'Uploading #file_name#: #progress#%', + sizeUnits: ' B, KB, MB', + }, + imageUploadFunction: _.noop, + initialValue: props.initialValue, + insertTexts: { + file: ['[](', '#url#)'], + horizontalRule: ['', '\n\n-----\n\n'], + image: ['![](', '#url#)'], + link: ['[', '](#url#)'], + table: [ + '', + // eslint-disable-next-line max-len + '\n\n| Column 1 | Column 2 | Column 3 |\n|' + + '-------- | -------- | -------- |\n|' + + ' Text | Text | Text |\n\n', + ], + uploadedFile: ['[#name#](#url#)', ''], + uploadedImage: ['![#name#](#url#)', ''], + uploadingFile: ['[Uploading #name#]()', ''], + uploadingImage: ['![Uploading #name#]()', ''], + } as any, + placeholder: '', + shortcuts: { + toggleHeading1: '', + toggleHeading2: '', + toggleHeading3: '', + }, + status: [ + { + className: 'message', + defaultValue: el => { + el.innerHTML = '' + }, + onUpdate: el => { + el.innerHTML = '' + }, + }, + 'upload-image', + ], + toolbar: [ + { + action: function format(editor: any) { + toggleBlock( + editor, + 'bold', + editor.options.blockStyles.bold, + ) + }, + className: 'fa fa-bold', + name: 'bold', + title: 'Bold', + }, + { + action: function format(editor: any) { + toggleBlock( + editor, + 'italic', + editor.options.blockStyles.italic, + ) + }, + className: 'fa fa-italic', + name: 'italic', + title: 'Italic', + }, + 'strikethrough', + '|', + 'heading-1', + 'heading-2', + 'heading-3', + '|', + 'code', + 'quote', + '|', + 'unordered-list', + 'ordered-list', + 'clean-block', + '|', + { + action: function mentions(editor: EasyMDE) { + completeAfter(editor.codemirror) + }, + className: 'fa fa-at', + name: 'mentions', + title: 'Mention a Topcoder User', + }, + 'link', + 'image', + 'table', + 'horizontal-rule', + '|', + 'fullscreen', + '|', + 'guide', + ], + uploadImage: false, + }) + + easyMDE.current.codemirror.on('change', (cm: CodeMirror.Editor) => { + props.onChange?.(cm.getValue()) + }) + + easyMDE.current.codemirror.on('blur', (event: any) => { + props.onBlur?.(event) + }) + }) + + useEffect(() => { + easyMDE.current?.value(props.initialValue) + }, [props.initialValue]) + + return ( +
+