diff --git a/package.json b/package.json index 99c00588a..b0d8cb1fc 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,9 @@ "@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", + "amazon-s3-uri": "^0.1.1", "apexcharts": "^3.36.0", "axios": "^1.7.9", "browser-cookies": "^1.2.0", @@ -43,6 +46,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 +105,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 ec2648d75..bcfc329c4 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('./platform/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/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx b/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx index ab9a2abf1..bbc6a4c25 100644 --- a/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx +++ b/src/apps/admin/src/challenge-management/ManageSubmissionPage/ManageSubmissionPage.tsx @@ -1,13 +1,19 @@ /** * Manage Submission Page. */ -import { FC } from 'react' +import { FC, useMemo } from 'react' import { useParams } from 'react-router-dom' import classNames from 'classnames' import { LinkButton } from '~/libs/ui' import { + useDownloadSubmission, + useDownloadSubmissionProps, + useFetchChallenge, + useFetchChallengeProps, + useManageAVScan, + useManageAVScanProps, useManageBusEvent, useManageBusEventProps, useManageChallengeSubmissions, @@ -20,6 +26,7 @@ import { TableLoading, TableNoRecord, } from '../../lib' +import { checkIsMM } from '../../lib/utils' import styles from './ManageSubmissionPage.module.scss' @@ -35,7 +42,13 @@ export const ManageSubmissionPage: FC = (props: Props) => { = useManageBusEvent() const { - isLoading, + isLoading: isLoadingChallenge, + challengeInfo, + }: useFetchChallengeProps = useFetchChallenge(challengeId) + const isMM = useMemo(() => checkIsMM(challengeInfo), [challengeInfo]) + + const { + isLoading: isLoadingSubmission, submissions, isRemovingSubmission, isRemovingSubmissionBool, @@ -48,6 +61,19 @@ export const ManageSubmissionPage: FC = (props: Props) => { }: useManageChallengeSubmissionsProps = useManageChallengeSubmissions(challengeId) + const { + isLoading: isDownloadingSubmission, + isLoadingBool: isDownloadingSubmissionBool, + downloadSubmission, + }: useDownloadSubmissionProps = useDownloadSubmission() + const { + isLoading: isDoingAvScan, + isLoadingBool: isDoingAvScanBool, + doPostBusEvent: doPostBusEventAvScan, + }: useManageAVScanProps = useManageAVScan() + + const isLoading = isLoadingSubmission || isLoadingChallenge + return ( = (props: Props) => { ) : (
= (props: Props) => { doPostBusEvent={doPostBusEvent} showSubmissionHistory={showSubmissionHistory} setShowSubmissionHistory={setShowSubmissionHistory} + isMM={isMM} /> - {(isRemovingSubmissionBool + {(isDoingAvScanBool + || isDownloadingSubmissionBool + || isRemovingSubmissionBool || isRunningTestBool || isRemovingReviewSummationsBool) && ( diff --git a/src/apps/admin/src/config/busEvent.config.ts b/src/apps/admin/src/config/busEvent.config.ts index 464b198a0..85888b5dc 100644 --- a/src/apps/admin/src/config/busEvent.config.ts +++ b/src/apps/admin/src/config/busEvent.config.ts @@ -3,10 +3,14 @@ */ import { v4 as uuidv4 } from 'uuid' -import { RequestBusAPI } from '../lib/models' +import { + RequestBusAPI, + RequestBusAPIAVScan, + RequestBusAPIAVScanPayload, +} from '../lib/models' /** - * Create data for bus event + * Create data for data submission marathon match bus event * @param submissionId submission id * @param testType test type * @returns data for bus event @@ -27,3 +31,19 @@ export const CREATE_BUS_EVENT_DATA_SUBMISSION_MARATHON_MATCH = ( .toISOString(), topic: 'submission.notification.score', }) + +/** + * Create data for av rescan bus event + * @param payload av rescan payload + * @returns data for bus event + */ +export const CREATE_BUS_EVENT_AV_RESCAN = ( + payload: RequestBusAPIAVScanPayload, +): RequestBusAPIAVScan => ({ + 'mime-type': 'application/json', + originator: 'submission-processor', + payload, + timestamp: new Date() + .toISOString(), + topic: 'avscan.action.scan', +}) 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/ChallengeList/ChallengeList.tsx b/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx index 7e20829f9..de3fcb6af 100644 --- a/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx +++ b/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx @@ -21,7 +21,6 @@ import { import { useEventCallback } from '../../hooks' import { Challenge, ChallengeFilterCriteria, ChallengeType } from '../../models' import { Paging } from '../../models/challenge-management/Pagination' -import { checkIsMM } from '../../utils' import { MobileListView } from './MobileListView' import styles from './ChallengeList.module.scss' @@ -135,7 +134,6 @@ const Actions: FC<{ challenge: Challenge currentFilters: ChallengeFilterCriteria }> = props => { - const isMM = useMemo(() => checkIsMM(props.challenge), [props.challenge]) const [openDropdown, setOpenDropdown] = useState(false) const navigate = useNavigate() const goToManageUser = useEventCallback(() => { @@ -202,16 +200,14 @@ const Actions: FC<{ > Users - {isMM && ( -
  • - Submissions -
  • - )} +
  • + Submissions +
  • 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/SubmissionTable/ShowHistoryButton.tsx b/src/apps/admin/src/lib/components/SubmissionTable/ShowHistoryButton.tsx new file mode 100644 index 000000000..43e17b342 --- /dev/null +++ b/src/apps/admin/src/lib/components/SubmissionTable/ShowHistoryButton.tsx @@ -0,0 +1,31 @@ +/** + * Submission Table Actions For Non MM Challenge. + */ +import { Dispatch, FC, SetStateAction } from 'react' + +import { Button } from '~/libs/ui' + +import { IsRemovingType, Submission } from '../../models' + +interface Props { + data: Submission + showSubmissionHistory: IsRemovingType + setShowSubmissionHistory: Dispatch> +} + +export const ShowHistoryButton: FC = (props: Props) => ( + +) + +export default ShowHistoryButton diff --git a/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTable.module.scss b/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTable.module.scss index e6e7556f2..4b3979e46 100644 --- a/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTable.module.scss +++ b/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTable.module.scss @@ -13,6 +13,10 @@ .rowActions { display: flex; align-items: center; + + @include ltelg { + flex-wrap: wrap; + } } .desktopTable { diff --git a/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTable.tsx b/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTable.tsx index bdf462125..9cc50716c 100644 --- a/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTable.tsx +++ b/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTable.tsx @@ -6,13 +6,15 @@ import _ from 'lodash' import classNames from 'classnames' import { useWindowSize, WindowSize } from '~/libs/shared' -import { Button, ConfirmModal, Table, TableColumn } from '~/libs/ui' +import { ConfirmModal, Table, TableColumn } from '~/libs/ui' import { IsRemovingType, MobileTableColumn, Submission } from '../../models' import { TableMobile } from '../common/TableMobile' import { TableWrapper } from '../common/TableWrapper' +import ShowHistoryButton from './ShowHistoryButton' import SubmissionTableActions from './SubmissionTableActions' +import SubmissionTableActionsNonMM from './SubmissionTableActionsNonMM' import styles from './SubmissionTable.module.scss' interface Props { @@ -26,11 +28,22 @@ interface Props { doPostBusEvent: (submissionId: string, testType: string) => void showSubmissionHistory: IsRemovingType setShowSubmissionHistory: Dispatch> + isMM: boolean + isDownloading: IsRemovingType + downloadSubmission: (submissionId: string) => void + isDoingAvScan: IsRemovingType + doPostBusEventAvScan: (submission: Submission) => void } export const SubmissionTable: FC = (props: Props) => { const { width: screenWidth }: WindowSize = useWindowSize() - const isTablet = useMemo(() => screenWidth <= 1479, [screenWidth]) + const isTablet = useMemo(() => { + if (props.isMM) { + return screenWidth <= 1479 + } + + return screenWidth <= 900 + }, [screenWidth, props.isMM]) const [ showConfirmDeleteSubmissionDialog, setShowConfirmDeleteSubmissionDialog, @@ -39,122 +52,167 @@ export const SubmissionTable: FC = (props: Props) => { = useState() const columns = useMemo[]>( - () => [ - { - label: 'Submitter', - propertyName: 'createdBy', - type: 'text', - }, - { - className: 'blockCellWrap', - label: 'ID', - propertyName: 'id', - type: 'text', - }, - { - label: 'Submission date', - propertyName: 'submittedDateString', - type: 'text', - }, - { - label: 'Example score', - renderer: (data: Submission) => ( - - {data.exampleScore === undefined - ? 'N/A' - : data.exampleScore} - - ), - type: 'element', - }, - { - label: 'Provisional score', - renderer: (data: Submission) => ( - - {data.provisionalScore === undefined - ? 'N/A' - : data.provisionalScore} - - ), - type: 'element', - }, - { - label: 'Final score', - renderer: (data: Submission) => ( - - {data.finalScore === undefined - ? 'N/A' - : data.finalScore} - - ), - type: 'element', - }, - { - label: 'Provisional rank', - renderer: (data: Submission) => ( - - {data.provisionalRank === undefined - ? 'N/A' - : data.provisionalRank} - - ), - type: 'element', - }, - { - label: 'Final rank', - renderer: (data: Submission) => ( - - {data.finalRank === undefined ? 'N/A' : data.finalRank} - - ), - type: 'element', - }, - { - label: '', - renderer: (data: Submission) => ( -
    - - {!data.hideToggleHistory && ( - - )} -
    - ), - type: 'element', - }, - ], + isDownloading={props.isDownloading} + downloadSubmission={function downloadSubmission() { + props.downloadSubmission(data.id) + }} + data={data} + /> + {!data.hideToggleHistory && ( + + )} +
    + ), + type: 'element', + }, + ]), // eslint-disable-next-line react-hooks/exhaustive-deps [ + props.isMM, props.isRemovingSubmission, props.isRemovingReviewSummations, props.isRunningTest, props.showSubmissionHistory, + props.isDownloading, + props.downloadSubmission, + props.isDoingAvScan, + props.doPostBusEventAvScan, ], ) diff --git a/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTableActionsNonMM.module.scss b/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTableActionsNonMM.module.scss new file mode 100644 index 000000000..628fcf8a0 --- /dev/null +++ b/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTableActionsNonMM.module.scss @@ -0,0 +1,16 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + gap: 10px; + + @include ltelg { + flex-wrap: wrap; + } + + :global(button.btn-disabled.btn-style-primary) { + background-color: $turq-160 !important; + color: $tc-white !important; + opacity: 0.5; + } +} diff --git a/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTableActionsNonMM.tsx b/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTableActionsNonMM.tsx new file mode 100644 index 000000000..42ffc4f22 --- /dev/null +++ b/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTableActionsNonMM.tsx @@ -0,0 +1,45 @@ +/** + * Submission Table Actions For Non MM Challenge. + */ +import { FC } from 'react' + +import { Button } from '~/libs/ui' + +import { IsRemovingType, Submission } from '../../models' + +import styles from './SubmissionTableActionsNonMM.module.scss' + +interface Props { + data: Submission + isDownloading: IsRemovingType + downloadSubmission: () => void + isDoingAvScan: IsRemovingType + doPostBusEventAvScan: () => void +} + +export const SubmissionTableActionsNonMM: FC = (props: Props) => ( +
    + + {props.data.isTheLatestSubmission && ( + + )} +
    +) + +export default SubmissionTableActionsNonMM 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..74d6e6aa0 100644 --- a/src/apps/admin/src/lib/hooks/index.ts +++ b/src/apps/admin/src/lib/hooks/index.ts @@ -26,3 +26,10 @@ export * from './useSearchUserInfo' export * from './useManageBusEvent' export * from './useManageChallengeSubmissions' export * from './useManageUserSSOLogin' +export * from './useManageTerms' +export * from './useManageAddTerm' +export * from './useManageTermsUsers' +export * from './useAutoScrollTopWhenInit' +export * from './useFetchChallenge' +export * from './useDownloadSubmission' +export * from './useManageAVScan' 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/useDownloadSubmission.ts b/src/apps/admin/src/lib/hooks/useDownloadSubmission.ts new file mode 100644 index 000000000..e5b131b31 --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useDownloadSubmission.ts @@ -0,0 +1,62 @@ +/** + * Download submission + */ +import { useCallback, useMemo, useState } from 'react' +import { some } from 'lodash' + +import { downloadSubmissionFile } from '../services' +import { handleError } from '../utils' +import { IsRemovingType } from '../models' + +export interface useDownloadSubmissionProps { + isLoading: IsRemovingType + isLoadingBool: boolean + downloadSubmission: (submissionId: string) => void +} + +/** + * Download submission + * @returns download info + */ +export function useDownloadSubmission(): useDownloadSubmissionProps { + const [isLoading, setIsLoading] = useState({}) + const isLoadingBool = useMemo( + () => some(isLoading, value => value === true), + [isLoading], + ) + + const downloadSubmission = useCallback((submissionId: string) => { + setIsLoading(previous => ({ + ...previous, + [submissionId]: true, + })) + downloadSubmissionFile(submissionId) + .then((data: Blob) => { + setIsLoading(previous => ({ + ...previous, + [submissionId]: false, + })) + + const url = window.URL.createObjectURL(new Blob([data])) + const link = document.createElement('a') + link.href = url + link.setAttribute('download', `submission-${submissionId}.zip`) + document.body.appendChild(link) + link.click() + link.parentNode?.removeChild(link) + }) + .catch(e => { + setIsLoading(previous => ({ + ...previous, + [submissionId]: false, + })) + handleError(e) + }) + }, []) + + return { + downloadSubmission, + isLoading, + isLoadingBool, + } +} diff --git a/src/apps/admin/src/lib/hooks/useFetchChallenge.ts b/src/apps/admin/src/lib/hooks/useFetchChallenge.ts new file mode 100644 index 000000000..e5b9019a2 --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useFetchChallenge.ts @@ -0,0 +1,51 @@ +/** + * Manage fetch challenge info + */ +import { useEffect, useRef, useState } from 'react' + +import { Challenge } from '../models' +import { getChallengeById } from '../services' +import { handleError } from '../utils' + +export interface useFetchChallengeProps { + isLoading: boolean + challengeInfo?: Challenge +} + +/** + * Fetch challenge info + * @returns challenge info + */ +export function useFetchChallenge( + challengeId: string, +): useFetchChallengeProps { + const [isLoading, setIsLoading] = useState(false) + + const isLoadingRef = useRef(false) + const [challengeInfo, setChallengeInfo] = useState() + + useEffect(() => { + if (challengeId && !isLoadingRef.current) { + isLoadingRef.current = true + setIsLoading(isLoadingRef.current) + setChallengeInfo(undefined) + + getChallengeById(challengeId) + .then((data: Challenge) => { + isLoadingRef.current = false + setIsLoading(isLoadingRef.current) + setChallengeInfo(data) + }) + .catch(e => { + isLoadingRef.current = false + setIsLoading(isLoadingRef.current) + handleError(e) + }) + } + }, [challengeId]) + + return { + challengeInfo, + isLoading, + } +} diff --git a/src/apps/admin/src/lib/hooks/useManageAVScan.ts b/src/apps/admin/src/lib/hooks/useManageAVScan.ts new file mode 100644 index 000000000..455000165 --- /dev/null +++ b/src/apps/admin/src/lib/hooks/useManageAVScan.ts @@ -0,0 +1,71 @@ +/** + * Manage bus event + */ +import { useCallback, useMemo, useState } from 'react' +import { toast } from 'react-toastify' +import _ from 'lodash' + +import { + CREATE_BUS_EVENT_AV_RESCAN, +} from '../../config/busEvent.config' +import { createAvScanSubmissionPayload, reqToBusAPI } from '../services' +import { handleError } from '../utils' +import { IsRemovingType, Submission } from '../models' + +export interface useManageAVScanProps { + isLoading: IsRemovingType + isLoadingBool: boolean + doPostBusEvent: (submission: Submission) => void +} + +/** + * Manage bus event + */ +export function useManageAVScan(): useManageAVScanProps { + const [isLoading, setIsLoading] = useState({}) + const isLoadingBool = useMemo( + () => _.some(isLoading, value => value === true), + [isLoading], + ) + + const doPostBusEvent = useCallback((submission: Submission) => { + setIsLoading(previous => ({ + ...previous, + [submission.id]: true, + })) + + function cbError(e: Error): void { + setIsLoading(previous => ({ + ...previous, + [submission.id]: false, + })) + handleError(e) + } + + createAvScanSubmissionPayload(submission) + .then(payload => { + const data = CREATE_BUS_EVENT_AV_RESCAN(payload) + reqToBusAPI(data) + .then(() => { + setIsLoading(previous => ({ + ...previous, + [submission.id]: false, + })) + toast.success( + 'Sending request to av rescan successfully', + { + toastId: 'Av scan', + }, + ) + }) + .catch(cbError) + }) + .catch(cbError) + }, []) + + return { + doPostBusEvent, + isLoading, + isLoadingBool, + } +} 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 988ea4016..0c1459aed 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/CommonRequestBusAPI.type.ts b/src/apps/admin/src/lib/models/CommonRequestBusAPI.type.ts new file mode 100644 index 000000000..905bb7b70 --- /dev/null +++ b/src/apps/admin/src/lib/models/CommonRequestBusAPI.type.ts @@ -0,0 +1,7 @@ +import { RequestBusAPI } from './RequestBusAPI.model' +import { RequestBusAPIAVScan } from './RequestBusAPIAVScan.model' + +/** + * Common type for bus api request + */ +export type CommonRequestBusAPI = RequestBusAPI | RequestBusAPIAVScan 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/RequestBusAPIAVScan.model.ts b/src/apps/admin/src/lib/models/RequestBusAPIAVScan.model.ts new file mode 100644 index 000000000..c5a225e71 --- /dev/null +++ b/src/apps/admin/src/lib/models/RequestBusAPIAVScan.model.ts @@ -0,0 +1,21 @@ +/** + * Request to av scan bus api + */ +export interface RequestBusAPIAVScanPayload { + submissionId: string + url: string + fileName?: string + moveFile: boolean + cleanDestinationBucket: string + quarantineDestinationBucket: string + callbackOption: string + callbackKafkaTopic: string +} + +export interface RequestBusAPIAVScan { + topic: string + originator: string + timestamp: string + 'mime-type': string + payload: RequestBusAPIAVScanPayload +} 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/ValidateS3URIResult.model.ts b/src/apps/admin/src/lib/models/ValidateS3URIResult.model.ts new file mode 100644 index 000000000..29bfcfbd2 --- /dev/null +++ b/src/apps/admin/src/lib/models/ValidateS3URIResult.model.ts @@ -0,0 +1,8 @@ +/** + * Validate s3 url result + */ +export interface ValidateS3URIResult { + isValid: boolean + bucket?: string + key?: string +} diff --git a/src/apps/admin/src/lib/models/index.ts b/src/apps/admin/src/lib/models/index.ts index d85a6e63a..7ad54810d 100644 --- a/src/apps/admin/src/lib/models/index.ts +++ b/src/apps/admin/src/lib/models/index.ts @@ -32,11 +32,20 @@ export * from './FormGroupMembersFilters.model' export * from './RoleMemberInfo.model' export * from './Submission.model' export * from './RequestBusAPI.model' +export * from './RequestBusAPIAVScan.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 './ValidateS3URIResult.model' export * from './FormAddGroupMembers.type' export * from './TableFilterType.type' export * from './TableRolesFilter.type' export * from './AdminAppContextType.type' export * from './IsRemoving.type' +export * from './CommonRequestBusAPI.type' diff --git a/src/apps/admin/src/lib/services/bus-event.service.ts b/src/apps/admin/src/lib/services/bus-event.service.ts index 82a352944..082bdb110 100644 --- a/src/apps/admin/src/lib/services/bus-event.service.ts +++ b/src/apps/admin/src/lib/services/bus-event.service.ts @@ -4,15 +4,15 @@ import { EnvironmentConfig } from '~/config' import { xhrPostAsync } from '~/libs/core' -import { RequestBusAPI } from '../models' +import { CommonRequestBusAPI } from '../models' /** * Send post event to bus api * @param data bus event data * @returns resolve to empty string if success */ -export const reqToBusAPI = async (data: RequestBusAPI): Promise => { - const resultData = await xhrPostAsync( +export const reqToBusAPI = async (data: CommonRequestBusAPI): Promise => { + const resultData = await xhrPostAsync( `${EnvironmentConfig.API.V5}/bus/events`, data, ) diff --git a/src/apps/admin/src/lib/services/submissions.service.ts b/src/apps/admin/src/lib/services/submissions.service.ts index 9bec8b654..be09c2e87 100644 --- a/src/apps/admin/src/lib/services/submissions.service.ts +++ b/src/apps/admin/src/lib/services/submissions.service.ts @@ -2,14 +2,17 @@ * Submissions service */ import { EnvironmentConfig } from '~/config' -import { xhrDeleteAsync, xhrGetAsync } from '~/libs/core' +import { xhrDeleteAsync, xhrGetAsync, xhrGetBlobAsync } from '~/libs/core' import { adjustSubmissionsResponse, ApiV5ResponseSuccess, MemberSubmission, + RequestBusAPIAVScanPayload, Submission, + ValidateS3URIResult, } from '../models' +import { validateS3URI } from '../utils' /** * Gets all submissions of challenge @@ -42,3 +45,44 @@ export const removeSubmission = async ( ) return result } + +/** + * Download submission file + * @param submissionId submission id + * @returns resolves to the submission file + */ +export const downloadSubmissionFile = async ( + submissionId: string, +): Promise => { + const results = await xhrGetBlobAsync( + `${EnvironmentConfig.API.V5}/submissions/${submissionId}/download`, + ) + return results +} + +/** + * Create av scan submission payload + * @param submissionInfo submission info + * @returns resolves to the av scan submission payload + */ +export const createAvScanSubmissionPayload = async ( + submissionInfo: Submission, +): Promise => { + const url = submissionInfo.url + const { isValid, key: fileName }: ValidateS3URIResult = validateS3URI(url) + if (!isValid) { + throw new Error('Submission url is not a valid') + } + + return { + callbackKafkaTopic: EnvironmentConfig.ADMIN.SUBMISSION_SCAN_TOPIC, + callbackOption: 'kafka', + cleanDestinationBucket: EnvironmentConfig.ADMIN.AWS_CLEAN_BUCKET, + fileName, + moveFile: true, + quarantineDestinationBucket: + EnvironmentConfig.ADMIN.AWS_QUARANTINE_BUCKET, + submissionId: submissionInfo.id, + url, + } +} 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/challenge.ts b/src/apps/admin/src/lib/utils/challenge.ts index 13007f1be..e800ad1f1 100644 --- a/src/apps/admin/src/lib/utils/challenge.ts +++ b/src/apps/admin/src/lib/utils/challenge.ts @@ -11,7 +11,7 @@ import { Challenge, MemberSubmission } from '../models' * @param challenge challenge info * @returns true if challenge is mm */ -export function checkIsMM(challenge: Challenge): boolean { +export function checkIsMM(challenge?: Challenge): boolean { const tags = _.get(challenge, 'tags') || [] const isMMType = challenge ? challenge.type === 'Marathon Match' : false return tags.includes('Marathon Match') || isMMType 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/others.ts b/src/apps/admin/src/lib/utils/others.ts index 2d8cbfa1b..0cac22b99 100644 --- a/src/apps/admin/src/lib/utils/others.ts +++ b/src/apps/admin/src/lib/utils/others.ts @@ -1,6 +1,11 @@ /** * Util for other check */ +import AmazonS3URI from 'amazon-s3-uri' + +import { EnvironmentConfig } from '~/config' + +import { ValidateS3URIResult } from '../models' /** * Check if object is date @@ -19,3 +24,30 @@ export function checkIsDateObject(date: any): boolean { export function checkIsNumberObject(numberObject: any): boolean { return typeof numberObject === 'number' } + +/** + * Validate s3 url + * @param fileURL file url + * @returns resolve to validate result + */ +export function validateS3URI( + fileURL: string, +): ValidateS3URIResult { + try { + const { region, bucket, key }: AmazonS3URI = AmazonS3URI(fileURL) + if ( + region !== EnvironmentConfig.ADMIN.AWS_REGION + || bucket !== EnvironmentConfig.ADMIN.AWS_DMZ_BUCKET + ) { + return { isValid: false } + } + + return { + bucket: bucket ?? undefined, + isValid: true, + key: key ?? undefined, + } + } catch (error) {} + + return { isValid: false } +} 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/apps/copilots/src/models/CopilotRequest.ts b/src/apps/copilots/src/models/CopilotRequest.ts index f4b4bd381..cd8122d86 100644 --- a/src/apps/copilots/src/models/CopilotRequest.ts +++ b/src/apps/copilots/src/models/CopilotRequest.ts @@ -23,4 +23,7 @@ export interface CopilotRequest { tzRestrictions: 'yes' | 'no', createdAt: Date, opportunity?: CopilotOpportunity, + project?: { + name: string, + }, } diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/index.tsx b/src/apps/copilots/src/pages/copilot-opportunity-details/index.tsx index aae03070f..ac76eaa43 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/index.tsx +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/index.tsx @@ -291,12 +291,13 @@ const CopilotOpportunityDetails: FC<{}> = () => { ) } {activeTab === CopilotDetailsTabViews.details && } - {activeTab === CopilotDetailsTabViews.applications && isAdminOrPM && opportunity && ( + {activeTab === CopilotDetailsTabViews.applications && opportunity && ( )} diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/copilot-applications/CopilotApplications.tsx b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/copilot-applications/CopilotApplications.tsx index aef307c04..5887bd383 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/copilot-applications/CopilotApplications.tsx +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/copilot-applications/CopilotApplications.tsx @@ -67,7 +67,7 @@ const tableColumns: TableColumn[] = [ }, { label: 'Actions', - propertyName: '', + propertyName: 'actions', renderer: CopilotApplicationAction, type: 'element', }, @@ -78,6 +78,7 @@ const CopilotApplications: FC<{ members?: FormattedMembers[] opportunity: CopilotOpportunity onApplied: () => void + isAdminOrPM: boolean }> = props => { const getData = (): CopilotApplication[] => (props.copilotApplications ? props.copilotApplications.map(item => { const member = props.members && props.members.find(each => each.userId === item.userId) @@ -96,12 +97,18 @@ const CopilotApplications: FC<{ const tableData = useMemo(getData, [props.copilotApplications, props.members]) + const visibleColumns = props.isAdminOrPM + ? tableColumns + : tableColumns.filter(col => ![ + 'fulfilment', 'activeProjects', 'pastProjects', 'notes', 'actions', + ].includes(col.propertyName ?? '')) + return (
    { tableData.length > 0 ? (
    [] = [ label: 'Payment', propertyName: 'paymentType', renderer: (copilotOpportunity: CopilotOpportunity) => ( -
    +
    {copilotOpportunity.paymentType === 'standard' - ? copilotOpportunity.paymentType : copilotOpportunity.otherPaymentType} + ? copilotOpportunity.paymentType : copilotOpportunity.otherPaymentType.slice(0, 8)}
    ), type: 'element', diff --git a/src/apps/copilots/src/pages/copilot-opportunity-list/styles.module.scss b/src/apps/copilots/src/pages/copilot-opportunity-list/styles.module.scss index ed569a42f..76fd34f5f 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-list/styles.module.scss +++ b/src/apps/copilots/src/pages/copilot-opportunity-list/styles.module.scss @@ -39,3 +39,8 @@ .type { white-space: nowrap; } + +.payment { + white-space: nowrap; +} + diff --git a/src/apps/copilots/src/pages/copilot-request-form/index.tsx b/src/apps/copilots/src/pages/copilot-request-form/index.tsx index 7bb898494..648630d99 100644 --- a/src/apps/copilots/src/pages/copilot-request-form/index.tsx +++ b/src/apps/copilots/src/pages/copilot-request-form/index.tsx @@ -276,6 +276,11 @@ const CopilotRequestForm: FC<{}> = () => { key: 'otherPaymentType', message: 'Field cannot be left empty', }, + { + condition: formValues.otherPaymentType && formValues.otherPaymentType.trim().length > 8, + key: 'otherPaymentType', + message: 'Field only allows 8 characters', + }, ] fieldValidations.forEach( @@ -600,6 +605,7 @@ const CopilotRequestForm: FC<{}> = () => { onChange={bind(handleFormValueChange, this, 'otherPaymentType')} error={formErrors.otherPaymentType} tabIndex={0} + maxLength={8} /> )}
    diff --git a/src/apps/copilots/src/pages/copilot-requests/CopilotRequestsPage.module.scss b/src/apps/copilots/src/pages/copilot-requests/CopilotRequestsPage.module.scss index 8c5108caa..372c1eb15 100644 --- a/src/apps/copilots/src/pages/copilot-requests/CopilotRequestsPage.module.scss +++ b/src/apps/copilots/src/pages/copilot-requests/CopilotRequestsPage.module.scss @@ -1,5 +1,13 @@ @import '@libs/ui/styles/includes'; +.shortPage { + margin-bottom: 250px; +} + +.tableWrapper { + margin-bottom: 60px; +} + .actionButtons { display: flex; gap: $sp-1; @@ -7,6 +15,10 @@ padding: $sp-2 0; } +.title { + max-width: 200px; +} + @media (max-width: 767px) { .title { min-width: 200px; diff --git a/src/apps/copilots/src/pages/copilot-requests/index.tsx b/src/apps/copilots/src/pages/copilot-requests/index.tsx index 62c504b5c..a61fd2426 100644 --- a/src/apps/copilots/src/pages/copilot-requests/index.tsx +++ b/src/apps/copilots/src/pages/copilot-requests/index.tsx @@ -1,4 +1,4 @@ -import { FC, useCallback, useContext, useMemo } from 'react' +import { FC, useCallback, useContext, useMemo, useState } from 'react' import { find } from 'lodash' import { NavigateFunction, Params, useNavigate, useParams } from 'react-router-dom' import classNames from 'classnames' @@ -9,7 +9,7 @@ import { ContentLayout, IconCheck, IconSolid, - LoadingSpinner, + LoadingCircles, PageTitle, Table, TableColumn, @@ -18,11 +18,11 @@ import { } from '~/libs/ui' import { profileContext, ProfileContextData, UserRole } from '~/libs/core' import { EnvironmentConfig } from '~/config' +import { Sort } from '~/apps/admin/src/platform/gamification-admin/src/game-lib' import { ProjectTypeLabels } from '../../constants' import { approveCopilotRequest, CopilotRequestsResponse, useCopilotRequests } from '../../services/copilot-requests' import { CopilotRequest } from '../../models/CopilotRequest' -import { ProjectsResponse, useProjects } from '../../services/projects' import { copilotRoutesMap } from '../../copilots.routes' import { Project } from '../../models/Project' @@ -137,6 +137,10 @@ const CopilotTableActions: FC<{request: CopilotRequest}> = props => { const CopilotRequestsPage: FC = () => { const navigate: NavigateFunction = useNavigate() const routeParams: Params = useParams() + const [sort, setSort] = useState({ + direction: 'desc', + fieldName: 'createdAt', + }) const { profile }: ProfileContextData = useContext(profileContext) const isAdminOrPM: boolean = useMemo( @@ -144,18 +148,14 @@ const CopilotRequestsPage: FC = () => { [profile], ) - const { data: requests = [], isValidating: requestsLoading }: CopilotRequestsResponse = useCopilotRequests() - const projectIds = useMemo(() => ( - (new Set(requests.map(r => r.projectId)) - .values() as any) - .toArray() - ), [requests]) - - const { data: projects = [], isValidating: projectsLoading }: ProjectsResponse = useProjects(undefined, { - filter: { id: projectIds }, - isPaused: () => !projectIds?.length, - }) - const isLoading = projectsLoading || requestsLoading + const { + data: requests = [], + isValidating: requestsLoading, + hasMoreCopilotRequests, + setSize, + size, + page, + }: CopilotRequestsResponse = useCopilotRequests(sort) const viewRequestDetails = useMemo(() => ( routeParams.requestId && find(requests, { id: +routeParams.requestId }) as CopilotRequest @@ -165,10 +165,6 @@ const CopilotRequestsPage: FC = () => { navigate(copilotRoutesMap.CopilotRequests) }, [navigate]) - const projectsMap = useMemo(() => projects.reduce((all, c) => ( - Object.assign(all, { [c.id]: c }) - ), {} as {[key: string]: Project}), [projects]) - const handleLinkClick = useCallback((e: React.MouseEvent) => { e.stopPropagation() }, []) @@ -178,7 +174,6 @@ const CopilotRequestsPage: FC = () => { label: 'Project', propertyName: 'projectName', renderer: (copilotRequest: CopilotRequest) => { - const projectName = projectsMap[copilotRequest.projectId]?.name const projectLink = ` ${EnvironmentConfig.ADMIN.WORK_MANAGER_URL}/projects/${copilotRequest.projectId}/challenges ` @@ -190,7 +185,7 @@ const CopilotRequestsPage: FC = () => { rel='noreferrer' onClick={handleLinkClick} > - {projectName} + {copilotRequest.project?.name} ) }, @@ -207,7 +202,7 @@ const CopilotRequestsPage: FC = () => { }, { label: 'Type', - propertyName: 'type', + propertyName: 'projectType', type: 'text', }, { @@ -238,9 +233,17 @@ const CopilotRequestsPage: FC = () => { const tableData = useMemo(() => requests.map(request => ({ ...request, - projectName: projectsMap[request.projectId]?.name, - type: ProjectTypeLabels[request.projectType] ?? '', - })), [projectsMap, requests]) + projectName: request.project?.name, + projectType: ProjectTypeLabels[request.projectType] ?? '', + })), [requests]) + + function loadMore(): void { + setSize(size + 1) + } + + function onToggleSort(s: Sort): void { + setSort(s) + } // header button config const addNewRequestButton: ButtonProps = { @@ -263,18 +266,19 @@ const CopilotRequestsPage: FC = () => { buttonConfig={addNewRequestButton} > Copilot Requests - {isLoading ? ( - - ) : ( -
    - )} +
    + {requestsLoading && } {viewRequestDetails && ( )} diff --git a/src/apps/copilots/src/services/copilot-requests.ts b/src/apps/copilots/src/services/copilot-requests.ts index af45f6276..4987089b4 100644 --- a/src/apps/copilots/src/services/copilot-requests.ts +++ b/src/apps/copilots/src/services/copilot-requests.ts @@ -1,12 +1,16 @@ import useSWR, { SWRResponse } from 'swr' +import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite' import { EnvironmentConfig } from '~/config' import { xhrGetAsync, xhrPatchAsync, xhrPostAsync } from '~/libs/core' import { buildUrl } from '~/libs/shared/lib/utils/url' +import { Sort } from '~/apps/admin/src/platform/gamification-admin/src/game-lib' +import { getPaginatedAsync, PaginatedResponse } from '~/libs/core/lib/xhr/xhr-functions/xhr.functions' import { CopilotRequest } from '../models/CopilotRequest' const baseUrl = `${EnvironmentConfig.API.V5}/projects` +const PAGE_SIZE = 20 /** * Creates a CopilotRequest object by merging the provided data and its nested data, @@ -27,7 +31,14 @@ function copilotRequestFactory(data: any): CopilotRequest { } } -export type CopilotRequestsResponse = SWRResponse +export type CopilotRequestsResponse = { + data: CopilotRequest[]; + hasMoreCopilotRequests: boolean; + isValidating: boolean; + size: number; + setSize: (size: number) => void; + page: number; +} /** * Custom hook to fetch copilot requests for a given project. @@ -35,16 +46,46 @@ export type CopilotRequestsResponse = SWRResponse { - const url = buildUrl(`${baseUrl}${projectId ? `/${projectId}` : ''}/copilots/requests`) - - const fetcher = (urlp: string): Promise => xhrGetAsync(urlp) - .then((data: any) => data.map(copilotRequestFactory)) +export const useCopilotRequests = (sort: Sort, projectId?: string): CopilotRequestsResponse => { + + const getKey = (pageIndex: number, previousPageData: CopilotRequest[]): string | undefined => { + if (previousPageData && previousPageData.length < PAGE_SIZE) return undefined + const url = buildUrl(`${baseUrl}${projectId ? `/${projectId}` : ''}/copilots/requests`) + return ` + ${url}?page=${pageIndex + 1}&pageSize=${PAGE_SIZE}&sort=${sort.fieldName} ${sort.direction} + ` + } - return useSWR(url, fetcher, { - refreshInterval: 0, + const fetcher = ( + url: string, + ): Promise> => getPaginatedAsync(url) + .then((data: any) => ( + { + ...data, + data: data.data.map(copilotRequestFactory), + } + )) + + const { + isValidating, + data = [], + size, + setSize, + }: SWRInfiniteResponse> = useSWRInfinite(getKey, fetcher, { revalidateOnFocus: false, }) + const latestPage = data[data.length - 1] || {} + const copilotRequests = data.flatMap(page => page.data) + const hasMoreCopilotRequests = latestPage.page + 1 < latestPage.totalPages + + return { + data: copilotRequests, + hasMoreCopilotRequests, + isValidating, + page: latestPage.page, + setSize: (s: number) => { setSize(s) }, + size, + } } export type CopilotRequestResponse = SWRResponse diff --git a/src/apps/wallet-admin/src/lib/components/payment-method-table/PaymentMethodTable.tsx b/src/apps/wallet-admin/src/lib/components/payment-method-table/PaymentMethodTable.tsx index 59834b28e..c91fc01d7 100644 --- a/src/apps/wallet-admin/src/lib/components/payment-method-table/PaymentMethodTable.tsx +++ b/src/apps/wallet-admin/src/lib/components/payment-method-table/PaymentMethodTable.tsx @@ -29,6 +29,7 @@ const PaymentProviderTable: React.FC = (props: PaymentM + {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} diff --git a/src/apps/wallet-admin/src/lib/components/tax-forms-table/TaxFormTable.tsx b/src/apps/wallet-admin/src/lib/components/tax-forms-table/TaxFormTable.tsx index 149be504a..6df26da92 100644 --- a/src/apps/wallet-admin/src/lib/components/tax-forms-table/TaxFormTable.tsx +++ b/src/apps/wallet-admin/src/lib/components/tax-forms-table/TaxFormTable.tsx @@ -30,6 +30,7 @@ const TaxFormTable: React.FC = (props: TaxFormTableProps) => + {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} diff --git a/src/config/environments/default.env.ts b/src/config/environments/default.env.ts index 5ac83bb22..193b13975 100644 --- a/src/config/environments/default.env.ts +++ b/src/config/environments/default.env.ts @@ -77,11 +77,19 @@ 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', + AVSCAN_TOPIC: 'avscan.action.scan', + AWS_CLEAN_BUCKET: '', + AWS_DMZ_BUCKET: 'topcoder-dev-submissions', + AWS_QUARANTINE_BUCKET: '', + AWS_REGION: 'us-east-1', CHALLENGE_URL: 'https://www.topcoder-dev.com/challenges', CONNECT_URL: 'https://connect.topcoder-dev.com', DEFAULT_PAYMENT_TERMS: 1, DIRECT_URL: 'https://www.topcoder-dev.com/direct', ONLINE_REVIEW_URL: 'https://software.topcoder-dev.com/review', + SUBMISSION_SCAN_TOPIC: 'submission.scan.complete', WORK_MANAGER_URL: 'https://challenges.topcoder-dev.com', } diff --git a/src/config/environments/global-config.model.ts b/src/config/environments/global-config.model.ts index d7de71112..abaf3c391 100644 --- a/src/config/environments/global-config.model.ts +++ b/src/config/environments/global-config.model.ts @@ -51,5 +51,13 @@ 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 + AWS_REGION: string + AWS_DMZ_BUCKET: string + AWS_CLEAN_BUCKET: string + AWS_QUARANTINE_BUCKET: string + SUBMISSION_SCAN_TOPIC: string + AVSCAN_TOPIC: string } } diff --git a/src/config/environments/prod.env.ts b/src/config/environments/prod.env.ts index 69e9fab0a..5fe1b34bb 100644 --- a/src/config/environments/prod.env.ts +++ b/src/config/environments/prod.env.ts @@ -7,11 +7,19 @@ 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', + AVSCAN_TOPIC: 'avscan.action.scan', + AWS_CLEAN_BUCKET: '', + AWS_DMZ_BUCKET: 'topcoder-submissions', + AWS_QUARANTINE_BUCKET: '', + AWS_REGION: 'us-east-1', CHALLENGE_URL: 'https://www.topcoder.com/challenges', CONNECT_URL: 'https://connect.topcoder.com', DEFAULT_PAYMENT_TERMS: 1, DIRECT_URL: 'https://www.topcoder.com/direct', ONLINE_REVIEW_URL: 'https://software.topcoder.com/review', + SUBMISSION_SCAN_TOPIC: 'submission.scan.complete', WORK_MANAGER_URL: 'https://challenges.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 ce15c0cf3..729f36d3e 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 @@ -186,7 +187,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 0aea4daa5..e2b0e62ea 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 @@ -28,6 +28,7 @@ interface InputSelectReactProps { readonly classNameWrapper?: string readonly dirty?: boolean readonly disabled?: boolean + readonly isLoading?: boolean readonly error?: string readonly hideInlineErrors?: boolean readonly hint?: string @@ -168,6 +169,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-text/InputText.tsx b/src/libs/ui/lib/components/form/form-groups/form-input/input-text/InputText.tsx index 5fc1847a6..b7315367f 100644 --- a/src/libs/ui/lib/components/form/form-groups/form-input/input-text/InputText.tsx +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-text/InputText.tsx @@ -37,6 +37,7 @@ export interface InputTextProps { readonly forceUpdateValue?: boolean readonly inputControl?: UseFormRegisterReturn readonly isLoading?: boolean + readonly maxLength?: number } const InputText: FC = (props: InputTextProps) => { @@ -70,6 +71,7 @@ const InputText: FC = (props: InputTextProps) => { onBlur={props.inputControl ? props.inputControl.onBlur : props.onBlur} onChange={props.inputControl ? props.inputControl.onChange : props.onChange} name={props.inputControl ? props.inputControl.name : props.name} + maxLength={props.maxLength} /> ) 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 078aa5a13..90737f689 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 afe2bf405..b2349b56f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4777,6 +4777,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" @@ -4911,6 +4918,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" @@ -5182,6 +5203,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" @@ -5451,6 +5477,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" @@ -5992,6 +6025,11 @@ ajv@^8.9.0: json-schema-traverse "^1.0.0" require-from-string "^2.0.2" +amazon-s3-uri@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/amazon-s3-uri/-/amazon-s3-uri-0.1.1.tgz#37afdfa88352ee0c22ac12ea6417a7c725f2b90b" + integrity sha512-LklZtJ3lgTFdVpy/5ln0okxdgMdnRmFLRg9FGcJ7DeB5Ez5TCs1DHdmVovcPIxW9tQlA1+QLpGNg1Ig6hv768A== + ansi-align@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59" @@ -7349,6 +7387,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" @@ -8645,6 +8695,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" @@ -12963,6 +13024,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" @@ -18060,6 +18126,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" @@ -18355,6 +18426,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"
    CONNECTED PROVIDER PROVIDER ID STATUS
    FORM DATE FILED STATUS