diff --git a/package.json b/package.json index 4836bfad5..b0d8cb1fc 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "craco start --mode ${LOGICAL_ENV:-dev}", "start": "bash start.sh", - "build": "export CI=false && craco build --mode ${LOGICAL_ENV:-prod}", + "build": "rimraf ./build && export CI=false && craco build --mode ${LOGICAL_ENV:-prod}", "build:dev": "craco build --mode ${LOGICAL_ENV:-dev}", "demo": "npx http-server --port 443 -a 0.0.0.0 -S -C ./ssl/rootCA.crt -K ./ssl/rootCA.key -P https://local.topcoder-dev.com? --proxy-options.secure false ./build", "lint": "eslint -c ./src/.eslintrc.js 'src/**/*.{ts,tsx,js,jsx}'", @@ -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", @@ -189,6 +194,7 @@ "react-docgen-typescript": "^2.2.2", "react-hot-loader": "^4.3.3", "resolve-url-loader": "^5.0.0", + "rimraf": "^6.0.1", "sass-loader": "^13.3.3", "serve": "^14.0.1", "start-server-and-test": "^1.14.0", diff --git a/src/apps/accounts/src/lib/assets/security/dicelogo.png b/src/apps/accounts/src/lib/assets/security/dicelogo.png deleted file mode 100644 index 723b63b2e..000000000 Binary files a/src/apps/accounts/src/lib/assets/security/dicelogo.png and /dev/null differ diff --git a/src/apps/accounts/src/lib/assets/security/dicelogobig.png b/src/apps/accounts/src/lib/assets/security/dicelogobig.png deleted file mode 100644 index b18935081..000000000 Binary files a/src/apps/accounts/src/lib/assets/security/dicelogobig.png and /dev/null differ diff --git a/src/apps/accounts/src/lib/assets/security/dicelogosmall.png b/src/apps/accounts/src/lib/assets/security/dicelogosmall.png deleted file mode 100644 index c2be4e4a8..000000000 Binary files a/src/apps/accounts/src/lib/assets/security/dicelogosmall.png and /dev/null differ diff --git a/src/apps/accounts/src/lib/assets/security/index.ts b/src/apps/accounts/src/lib/assets/security/index.ts index 4559e27b4..efa8c4123 100644 --- a/src/apps/accounts/src/lib/assets/security/index.ts +++ b/src/apps/accounts/src/lib/assets/security/index.ts @@ -1,17 +1,9 @@ -import { ReactComponent as MFAImage } from './mfa.svg' import { ReactComponent as AppleStore } from './apple-store.svg' import credentialImage from './credential.png' -import diceIdLogo from './dicelogo.png' -import diceIdLogoBig from './dicelogobig.png' -import diceIdLogoSmall from './dicelogosmall.png' import googlePlay from './google-play.png' export { AppleStore, credentialImage, - diceIdLogo, - diceIdLogoBig, - diceIdLogoSmall, googlePlay, - MFAImage, } diff --git a/src/apps/accounts/src/lib/assets/security/mfa.svg b/src/apps/accounts/src/lib/assets/security/mfa.svg deleted file mode 100644 index 33b5ab0cc..000000000 --- a/src/apps/accounts/src/lib/assets/security/mfa.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/apps/accounts/src/settings/tabs/account/AccountTab.tsx b/src/apps/accounts/src/settings/tabs/account/AccountTab.tsx index b0551acda..39999ac33 100644 --- a/src/apps/accounts/src/settings/tabs/account/AccountTab.tsx +++ b/src/apps/accounts/src/settings/tabs/account/AccountTab.tsx @@ -3,7 +3,6 @@ import { FC } from 'react' import { UserProfile, UserTraits } from '~/libs/core' import { AccountRole } from './account-role' -import { SecuritySection } from './security' import { UserAndPassword } from './user-and-pass' import { MemberAddress } from './address' import styles from './AccountTab.module.scss' @@ -15,15 +14,13 @@ interface AccountTabProps { const AccountTab: FC = (props: AccountTabProps) => (
-

ACCOUNT INFORMATION & SECURITY

+

ACCOUNT INFORMATION

- -
) diff --git a/src/apps/accounts/src/settings/tabs/account/security/Security.module.scss b/src/apps/accounts/src/settings/tabs/account/security/Security.module.scss deleted file mode 100644 index d80b6953d..000000000 --- a/src/apps/accounts/src/settings/tabs/account/security/Security.module.scss +++ /dev/null @@ -1,43 +0,0 @@ -@import '@libs/ui/styles/includes'; - -.container { - margin: $sp-8 0; - - .content { - display: grid; - grid-template-columns: 1fr; - margin-bottom: 0; - - >p { - max-width: 380px; - } - - .imageWrap { - padding: $sp-2; - background-color: $black-5; - border-radius: 4px; - margin-right: $sp-2; - align-self: flex-start; - } - - .diceBtnWrap { - display: flex; - align-items: center; - color: #767676; - - @include ltelg { - margin-top: $sp-4; - } - - .diceIdButton { - align-self: center; - } - - svg { - width: 24px; - height: 24px; - margin-left: $sp-2; - } - } - } -} \ No newline at end of file diff --git a/src/apps/accounts/src/settings/tabs/account/security/Security.tsx b/src/apps/accounts/src/settings/tabs/account/security/Security.tsx deleted file mode 100644 index f521783bf..000000000 --- a/src/apps/accounts/src/settings/tabs/account/security/Security.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { Dispatch, FC, SetStateAction, useEffect, useState } from 'react' -import { toast } from 'react-toastify' -import { KeyedMutator } from 'swr' - -import { Button, Collapsible, FormToggleSwitch } from '~/libs/ui' -import { diceIdLogo, MFAImage, SettingSection, triggerSurvey } from '~/apps/accounts/src/lib' -import { MemberMFAStatus, updateMemberMFAStatusAsync, useMemberMFAStatus, UserProfile } from '~/libs/core' - -import { DiceSetupModal } from './dice-setup-modal' -import styles from './Security.module.scss' - -interface SecurityProps { - profile: UserProfile -} - -const Security: FC = (props: SecurityProps) => { - const [setupDiceModalOpen, setSetupDiceModalOpen]: [boolean, Dispatch>] = useState(false) - - const { data: mfaStatusData, mutate: mutateMFAData }: { - data: MemberMFAStatus | undefined - mutate: KeyedMutator, - } = useMemberMFAStatus(props.profile.userId) - - const [mfaEnabled, setMFAEnabled]: [boolean, Dispatch>] = useState(false) - const [diceEnabled, setDiceEnabled]: [boolean, Dispatch>] = useState(false) - - useEffect(() => { - if (mfaStatusData) { - setMFAEnabled(mfaStatusData.mfaEnabled) - setDiceEnabled(mfaStatusData.diceEnabled) - } - }, [mfaStatusData]) - - function handleUserMFAChange(): void { - updateMemberMFAStatusAsync(props.profile.userId, { - param: { - mfaEnabled: !mfaEnabled, - }, - }) - .then(response => { - setMFAEnabled(response.result.content.mfaEnabled) - setDiceEnabled(response.result.content.diceEnabled) - toast.success('Your Multi Factor Authentication (MFA) status was updated.') - triggerSurvey() - }) - .catch(() => { - toast.error('Something went wrong. Please try again later.') - }) - } - - function handleUserDiceChange(): void { - if (!diceEnabled) { - return - } - - updateMemberMFAStatusAsync(props.profile.userId, { - param: { - diceEnabled: !diceEnabled, - }, - }) - .then(response => { - setMFAEnabled(response.result.content.mfaEnabled) - setDiceEnabled(response.result.content.diceEnabled) - toast.success('Your DICE credential was disabled.') - triggerSurvey() - }) - .catch(() => { - toast.error('Something went wrong. Please try again later.') - }) - } - - function handleDiceModalStatus(): void { - setSetupDiceModalOpen(!setupDiceModalOpen) - } - - return ( - Security} - containerClass={styles.container} - contentClass={styles.content} - > - - - - )} - title='Multi Factor Authentication (MFA) Status' - // eslint-disable-next-line max-len - infoText='Status of MFA for your Topcoder account. If enabled, MFA will be enforced during the Topcoder login process.' - actionElement={( - - )} - /> - - - DiceID logo - - )} - title='DICE ID Authenticator App' - infoText='DICE ID authentication application.' - actionElement={( -
- { - diceEnabled ? ( - - ) : ( -
- )} - /> - - {setupDiceModalOpen && ( - - )} -
- ) -} - -export default Security diff --git a/src/apps/accounts/src/settings/tabs/account/security/dice-setup-modal/ConnectionHandler/index.tsx b/src/apps/accounts/src/settings/tabs/account/security/dice-setup-modal/ConnectionHandler/index.tsx deleted file mode 100644 index 07e3f7075..000000000 --- a/src/apps/accounts/src/settings/tabs/account/security/dice-setup-modal/ConnectionHandler/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { FC, useEffect } from 'react' - -import { DiceConnectionStatus, useDiceIdConnection } from '~/libs/core' - -interface ConnectionHandlerProps { - onChange: (newStatus: DiceConnectionStatus) => void; - userId: number; -} - -export const ConnectionHandler: FC = ( - props: ConnectionHandlerProps, -) => { - const diceConnection: DiceConnectionStatus | undefined = useDiceIdConnection(props.userId) - - useEffect(() => { - if (diceConnection) { - props.onChange(diceConnection) - } - }, [diceConnection, props]) - - return ( - <> - ) -} diff --git a/src/apps/accounts/src/settings/tabs/account/security/dice-setup-modal/DiceSetupModal.module.scss b/src/apps/accounts/src/settings/tabs/account/security/dice-setup-modal/DiceSetupModal.module.scss deleted file mode 100644 index 0fe6f3dec..000000000 --- a/src/apps/accounts/src/settings/tabs/account/security/dice-setup-modal/DiceSetupModal.module.scss +++ /dev/null @@ -1,68 +0,0 @@ -@import '@libs/ui/styles/includes'; - -.diceModal { - :global(.modal-body) { - align-items: flex-start; - } - - :global(.react-responsive-modal-closeButton) { - display: flex; - } - - .titleWrap { - display: flex; - align-items: center; - - img { - margin-right: $sp-4; - } - } - - .appSoresWrap { - display: flex; - justify-content: space-evenly; - margin: $sp-4 0; - width: 100%; - - .appStoreCard { - display: flex; - flex-direction: column; - } - } - - .qrCode { - margin: auto; - } - - .credentialImage { - height: 280px; - width: 400px; - align-self: center; - } - - .diceBigLogo { - margin: $sp-4 0; - } - - .errorWrap { - display: flex; - align-items: center; - - svg { - margin-right: $sp-2; - width: 28px; - height: 28px; - } - } - - .ctaButtons { - display: flex; - justify-content: space-between; - width: 100%; - flex: 1; - } - - .errorText { - color: #ef476f; - } -} \ No newline at end of file diff --git a/src/apps/accounts/src/settings/tabs/account/security/dice-setup-modal/DiceSetupModal.tsx b/src/apps/accounts/src/settings/tabs/account/security/dice-setup-modal/DiceSetupModal.tsx deleted file mode 100644 index 1b03bce41..000000000 --- a/src/apps/accounts/src/settings/tabs/account/security/dice-setup-modal/DiceSetupModal.tsx +++ /dev/null @@ -1,240 +0,0 @@ -/* eslint-disable complexity */ -import { Dispatch, FC, SetStateAction, useState } from 'react' -import { QRCodeSVG } from 'qrcode.react' -import { toast } from 'react-toastify' -import { KeyedMutator } from 'swr' - -import { BaseModal, Button } from '~/libs/ui' -import { - AppleStore, - credentialImage, - diceIdLogoBig, - diceIdLogoSmall, - googlePlay, -} from '~/apps/accounts/src/lib' -import { DiceConnectionStatus, UserProfile } from '~/libs/core' - -import { ConnectionHandler } from './ConnectionHandler' -import styles from './DiceSetupModal.module.scss' - -const GooglePlayLink: string = 'https://play.google.com/store/apps/details?id=com.diwallet1' -const AppleStoreLink: string = 'https://apps.apple.com/us/app/dice-id/id1624858853' -interface DiceSetupModalProps { - mutateMFAData: KeyedMutator - onClose: () => void - profile: UserProfile -} - -const DiceSetupModal: FC = (props: DiceSetupModalProps) => { - const [step, setStep]: [number, Dispatch>] = useState(1) - - const [diceConnectionUrl, setDiceConnectionUrl] = useState() - - function handleSecondaryButtonClick(): void { - switch (step) { - case 2: return setStep(step - 1) - default: return props.onClose() - } - } - - function handlePrimaryButtonClick(): void { - switch (step) { - case 1: - return setStep(step + 1) - default: return props.onClose() - } - } - - function handleDiceConnectionStatusChange(newStatus: DiceConnectionStatus): void { - if (newStatus.diceEnabled) { - setStep(4) - toast.success('Your credentials have been verified and you are all set for' - + ' MFA using your decentralized identity (DICE ID).') - props.mutateMFAData() - } else if (newStatus.accepted) { - setStep(3) - } else if (newStatus.connection) { - setDiceConnectionUrl(newStatus.connection) - setStep(2) - } - } - - return ( - - DICE ID Title Logo -

DICE ID AUTHENTICATOR SETUP

- - )} - size='body' - classNames={{ modal: styles.diceModal }} - buttons={( -
-
- )} - > - { - step < 4 && ( -

- Step - {' '} - {step} - {' '} - of 3 -

- ) - } - { - step === 1 && ( - <> -

First, please download the DICE ID App from the Google Play Store or the iOS App Store.

-
-
- - Google Play Store - - -
-
- - - - -
-
-

- After you have downloaded and installed the mobile app, - make sure to complete the configuration process. - When ready, click next below. -

- - ) - } - { - step === 2 && ( - <> - -

- Scan the following DICE ID QR Code in your DICE ID - mobile application. -

- {diceConnectionUrl ? ( - - ) : ( -

Loading...

- )} -

- Once the connection is established, the service will - offer you a Verifiable Credential. -
- Press the ACCEPT button in your DICE ID App. -
- If you DECLINE the invitation, please try again after 5 - minutes. -

- - ) - } - { - step === 3 && ( - <> - -

- You will receive the credential offer from Topcoder in the DICE ID wallet home page. -
- Your credentials should get automatically processed in few seconds. -
- If you have disabled the auto-accept feature, - please review the credential offer and manually accept it. -
-
- Example credential offer: -

- Example Credential Offer - - ) - } - { - step === 4 && ( - <> -

Setup completed!

-

- Hello - {' '} - {props.profile.handle} - , -
-
- Your credentials have been verified and you are all set - for MFA using your decentralized identity (DICE ID). -

- DICE ID Logo -

- For more information on DICE ID, please visit - {' '} - - https://www.diceid.com - -

-

Please click Finish below.

- - ) - } -
- ) -} - -export default DiceSetupModal diff --git a/src/apps/accounts/src/settings/tabs/account/security/dice-setup-modal/index.ts b/src/apps/accounts/src/settings/tabs/account/security/dice-setup-modal/index.ts deleted file mode 100644 index da7fd8d0c..000000000 --- a/src/apps/accounts/src/settings/tabs/account/security/dice-setup-modal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as DiceSetupModal } from './DiceSetupModal' diff --git a/src/apps/accounts/src/settings/tabs/account/security/index.ts b/src/apps/accounts/src/settings/tabs/account/security/index.ts deleted file mode 100644 index 998d0b21d..000000000 --- a/src/apps/accounts/src/settings/tabs/account/security/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as SecuritySection } from './Security' 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 5c5656ad0..ff74a19a7 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 f55cdce2f..d570d54a1 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 913d341cd..bf8e07773 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,7 +45,7 @@ export const getMemberSuggestionsByHandle = async ( */ export const getMembersByHandle = async ( handles: string[], -): Promise> => { +): Promise> => { let qs = '' handles.forEach(handle => { qs += `&handlesLower[]=${handle.toLowerCase()}` @@ -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/copilots.routes.tsx b/src/apps/copilots/src/copilots.routes.tsx index 1a3f9a9e4..ba479d965 100644 --- a/src/apps/copilots/src/copilots.routes.tsx +++ b/src/apps/copilots/src/copilots.routes.tsx @@ -35,6 +35,13 @@ export const childRoutes = [ rolesRequired: [UserRole.administrator, UserRole.projectManager] as UserRole[], route: '/requests/new', }, + { + authRequired: true, + element: , + id: 'CopilotRequestEditForm', + rolesRequired: [UserRole.administrator, UserRole.projectManager] as UserRole[], + route: '/requests/edit/:requestId', + }, { authRequired: true, element: , diff --git a/src/apps/copilots/src/models/CopilotApplication.ts b/src/apps/copilots/src/models/CopilotApplication.ts index a501980a2..b69d42864 100644 --- a/src/apps/copilots/src/models/CopilotApplication.ts +++ b/src/apps/copilots/src/models/CopilotApplication.ts @@ -4,6 +4,11 @@ export enum CopilotApplicationStatus { PENDING = 'pending', } +export interface ExistingMembership { + role: string, + id: number, +} + export interface CopilotApplication { id: number, notes?: string, @@ -13,4 +18,7 @@ export interface CopilotApplication { userId: number, status: CopilotApplicationStatus, opportunityStatus: string, + existingMembership?: ExistingMembership, + projectName: string, + onApplied: () => void, } diff --git a/src/apps/copilots/src/models/CopilotOpportunity.ts b/src/apps/copilots/src/models/CopilotOpportunity.ts index b53545377..da5040d32 100644 --- a/src/apps/copilots/src/models/CopilotOpportunity.ts +++ b/src/apps/copilots/src/models/CopilotOpportunity.ts @@ -15,6 +15,7 @@ export interface CopilotOpportunity { numHoursPerWeek: number, numWeeks: number, overview: string, + opportunityTitle: string, paymentType: string, otherPaymentType: string, requiresCommunication: 'yes' | 'no', @@ -22,5 +23,4 @@ export interface CopilotOpportunity { startDate: Date, tzRestrictions: 'yes' | 'no', createdAt: Date, - canApplyAsCopilot: boolean, } diff --git a/src/apps/copilots/src/models/CopilotRequest.ts b/src/apps/copilots/src/models/CopilotRequest.ts index e295bda9b..cd8122d86 100644 --- a/src/apps/copilots/src/models/CopilotRequest.ts +++ b/src/apps/copilots/src/models/CopilotRequest.ts @@ -13,6 +13,7 @@ export interface CopilotRequest { numHoursPerWeek: number, numWeeks: number, overview: string, + opportunityTitle: string, paymentType: string, otherPaymentType: string, requiresCommunication: 'yes' | 'no', @@ -22,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/apply-opportunity-modal/ApplyOpportunityModal.tsx b/src/apps/copilots/src/pages/copilot-opportunity-details/apply-opportunity-modal/ApplyOpportunityModal.tsx index 27acf2830..b52e92447 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/apply-opportunity-modal/ApplyOpportunityModal.tsx +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/apply-opportunity-modal/ApplyOpportunityModal.tsx @@ -50,7 +50,7 @@ const ApplyOpportunityModal: FC = props => { buttons={ !success ? ( <> -
    + ) : ( +
    + No Applications yet +
    ) } diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/copilot-applications/styles.module.scss b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/copilot-applications/styles.module.scss index d31ba3f75..43efc6329 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/copilot-applications/styles.module.scss +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/copilot-applications/styles.module.scss @@ -12,3 +12,22 @@ color: $teal-100; } } + +.noApplications { + margin-top: $sp-6; + display: flex; + justify-content: center; + align-items: center; + font-size: large; +} + +.notes { + text-align: left; + max-width: 200px; +} + +@media (max-width: 767px) { + .notes { + min-width: 200px; + } +} \ No newline at end of file diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/OpportunityDetails.tsx b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/OpportunityDetails.tsx index 8cd3afd44..b3e3e4013 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/OpportunityDetails.tsx +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/OpportunityDetails.tsx @@ -11,13 +11,15 @@ const OpportunityDetails: FC<{

    Required skills

    - {props.opportunity?.skills.map(item => item.name) - .join(',')} + {props.opportunity?.skills.map(item => ({item.name}))}

    Description

    -

    - {props.opportunity?.overview} -

    + {props.opportunity?.overview && ( +
    '), + }} + /> + )}

    Complexity

    diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/styles.module.scss b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/styles.module.scss index 8debc451e..aad91a31d 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/styles.module.scss +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/tabs/opportunity-details/styles.module.scss @@ -11,6 +11,13 @@ gap: 100px; } +@media (max-width: 767px) { + .content { + flex-direction: column; + gap: 15px; + } +} + .content > div:first-child { flex: 3; } diff --git a/src/apps/copilots/src/pages/copilot-opportunity-list/index.tsx b/src/apps/copilots/src/pages/copilot-opportunity-list/index.tsx index 9c9bf4d3e..290bed7e8 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-list/index.tsx +++ b/src/apps/copilots/src/pages/copilot-opportunity-list/index.tsx @@ -22,10 +22,10 @@ import styles from './styles.module.scss' const tableColumns: TableColumn[] = [ { label: 'Title', - propertyName: 'projectName', + propertyName: 'opportunityTitle', renderer: (copilotOpportunity: CopilotOpportunity) => (
    - {copilotOpportunity.projectName} + {copilotOpportunity.opportunityTitle}
    ), type: 'element', @@ -100,9 +100,16 @@ const tableColumns: TableColumn[] = [ type: 'number', }, { + isSortable: false, label: 'Payment', propertyName: 'paymentType', - type: 'text', + renderer: (copilotOpportunity: CopilotOpportunity) => ( +
    + {copilotOpportunity.paymentType === 'standard' + ? 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 fabdab748..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 @@ -7,6 +7,12 @@ gap: 8px; } +@media (max-width: 767px) { + .title { + min-width: 200px; + } +} + .skillPill { background-color: #d6d6d6; color: #333; @@ -33,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 8fec31503..648630d99 100644 --- a/src/apps/copilots/src/pages/copilot-request-form/index.tsx +++ b/src/apps/copilots/src/pages/copilot-request-form/index.tsx @@ -1,6 +1,7 @@ -import { FC, useContext, useMemo, useState } from 'react' +import { FC, useContext, useEffect, useMemo, useState } from 'react' import { bind, debounce, isEmpty } from 'lodash' import { toast } from 'react-toastify' +import { Params, useNavigate, useParams, useSearchParams } from 'react-router-dom' import classNames from 'classnames' import { profileContext, ProfileContextData } from '~/libs/core' @@ -8,19 +9,88 @@ import { Button, IconSolid, InputDatePicker, InputMultiselectOption, InputRadio, InputSelect, InputSelectReact, InputText, InputTextarea } from '~/libs/ui' import { InputSkillSelector } from '~/libs/shared' -import { getProjects } from '../../services/projects' +import { getProject, getProjects, ProjectsResponse, useProjects } from '../../services/projects' import { ProjectTypes, ProjectTypeValues } from '../../constants' -import { saveCopilotRequest } from '../../services/copilot-requests' +import { CopilotRequestResponse, saveCopilotRequest, useCopilotRequest } from '../../services/copilot-requests' +import { Project } from '../../models/Project' import styles from './styles.module.scss' + +const editableFields = [ + 'projectId', + 'opportunityTitle', + 'copilotUsername', + 'complexity', + 'requiresCommunication', + 'paymentType', + 'otherPaymentType', + 'projectType', + 'overview', + 'skills', + 'startDate', + 'numWeeks', + 'tzRestrictions', + 'numHoursPerWeek', +] + // eslint-disable-next-line const CopilotRequestForm: FC<{}> = () => { const { profile }: ProfileContextData = useContext(profileContext) + const navigate = useNavigate() + const routeParams: Params = useParams() + const [params] = useSearchParams() const [formValues, setFormValues] = useState({}) const [isFormChanged, setIsFormChanged] = useState(false) const [formErrors, setFormErrors] = useState({}) const [paymentType, setPaymentType] = useState('') + const [projectFromQuery, setProjectFromQuery] = useState() + const activeProjectStatuses = ['active', 'approved', 'draft', 'new'] + + const { data: copilotRequestData }: CopilotRequestResponse = useCopilotRequest(routeParams.requestId) + + useEffect(() => { + if (copilotRequestData) { + setFormValues(copilotRequestData) + } + }, [copilotRequestData]) + + const fetchProject = async (): Promise => { + const projectId = params.get('projectId') + + if (!projectId) { + return + } + + const project = await getProject(projectId as string) + + setFormValues((prevValues: any) => ({ + ...prevValues, + projectId: project.id, + })) + setIsFormChanged(true) + setProjectFromQuery(project) + } + + useEffect(() => { + fetchProject() + }, [params]) + + const { data: projects = [] }: ProjectsResponse = useProjects(undefined, { + filter: { id: copilotRequestData?.projectId }, + isPaused: () => !copilotRequestData?.projectId, + }) + + const projectOptions = useMemo(() => { + const projectsFromResponse = projects.map(p => ({ + label: p.name, + value: p.id, + })) + + return projectFromQuery + ? [...projectsFromResponse, { label: projectFromQuery.name, value: projectFromQuery.id }] + : projectsFromResponse + }, [projects, projectFromQuery]) const projectTypes = ProjectTypes ? ProjectTypes.map(project => ({ label: project, @@ -45,7 +115,13 @@ const CopilotRequestForm: FC<{}> = () => { label: string; value: string; }>> { - const response = await getProjects(inputValue) + const response = await getProjects(inputValue, { + filter: { + status: { + $in: [activeProjectStatuses], + }, + }, + }) return response.map(project => ({ label: project.name, value: project.id })) } @@ -61,6 +137,8 @@ const CopilotRequestForm: FC<{}> = () => { return updatedErrors }) + + setIsFormChanged(true) } function handleFormValueChange( @@ -77,7 +155,12 @@ const CopilotRequestForm: FC<{}> = () => { oldFormValues[key] = Array.isArray(value) ? [...value] : [] break default: - value = event.target.value + if (event.type === 'blur') { + value = event.target.value?.trim() + } else { + value = event.target.value + } + break } @@ -129,6 +212,11 @@ const CopilotRequestForm: FC<{}> = () => { const updatedFormErrors: { [key: string]: string } = {} const fieldValidations: { condition: boolean; key: string; message: string }[] = [ + { + condition: (formValues.opportunityTitle?.trim().length ?? 0) < 7, + key: 'opportunityTitle', + message: 'The title for the opportunity must be at least 7 characters', + }, { condition: !formValues.projectId, key: 'projectId', message: 'Project is required' }, { condition: !formValues.complexity, key: 'complexity', message: 'Selection is required' }, { @@ -139,7 +227,7 @@ const CopilotRequestForm: FC<{}> = () => { { condition: !formValues.paymentType, key: 'paymentType', message: 'Selection is required' }, { condition: !formValues.projectType, key: 'projectType', message: 'Selecting project type is required' }, { - condition: !formValues.overview || formValues.overview.length < 10, + condition: !formValues.overview || formValues.overview.trim().length < 10, key: 'overview', message: 'Project overview must be at least 10 characters', }, @@ -169,7 +257,7 @@ const CopilotRequestForm: FC<{}> = () => { message: 'Number of weeks should be a positive number', }, { - condition: !formValues.tzRestrictions, + condition: !formValues.tzRestrictions || formValues.tzRestrictions.trim().length === 0, key: 'tzRestrictions', message: 'Providing timezone restrictions is required. Type No if no restrictions', }, @@ -183,6 +271,16 @@ const CopilotRequestForm: FC<{}> = () => { key: 'numHoursPerWeek', message: 'Number of hours per week should be a positive number', }, + { + condition: formValues.otherPaymentType && formValues.otherPaymentType.trim().length === 0, + 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( @@ -196,11 +294,15 @@ const CopilotRequestForm: FC<{}> = () => { if (isEmpty(updatedFormErrors)) { const cleanedFormValues: any = Object.fromEntries( Object.entries(formValues) - .filter(([, value]) => value !== ''), // Excludes null and undefined + // Excludes null and undefined + .filter(([field, value]) => editableFields.includes(field) && value !== ''), ) - saveCopilotRequest(cleanedFormValues) + saveCopilotRequest({ ...cleanedFormValues, id: copilotRequestData?.id }) .then(() => { - toast.success('Copilot request sent successfully') + toast.success( + copilotRequestData ? 'Copilot request updated successfully' + : 'Copilot request sent successfully', + ) setFormValues({ complexity: '', numHoursPerWeek: '', @@ -217,6 +319,10 @@ const CopilotRequestForm: FC<{}> = () => { setIsFormChanged(false) setFormErrors({}) setPaymentType('') + // Added a small timeout for the toast to be visible properly to the users + setTimeout(() => { + navigate('/requests') + }, 1000) }) .catch(e => { toast.error(e.message) @@ -242,12 +348,19 @@ const CopilotRequestForm: FC<{}> = () => {

    Copilot Request

    +

    Hi, - {profile?.firstName} {' '} + {profile?.firstName} ! - This form is to request a copilot for your project. Please fill in the details below. + {' '} + { + copilotRequestData?.id + ? 'Use this form to update the copilot request for your project.' + : 'This form is to request a copilot for your project.' + + ' Please fill in the details below.' + }

    { !isEmpty(formErrors) && ( @@ -256,6 +369,22 @@ const CopilotRequestForm: FC<{}> = () => { Resolve the errors on the form before submitting

    )} + +

    Copilot Opportunity Title

    + +

    Select the project you want the copilot for

    = () => { label='Project' placeholder='Start typing the name of the project' dirty + isClearable error={formErrors.projectId} + options={projectOptions} />

    What type of project are you working on?

    @@ -324,6 +455,7 @@ const CopilotRequestForm: FC<{}> = () => { customRadius noCaps leftAlignText + textWrap /> {formErrors.complexity && (

    @@ -370,6 +502,11 @@ const CopilotRequestForm: FC<{}> = () => { error={formErrors.startDate} dirty minDate={new Date()} + maxDate={new Date(new Date() + .setFullYear(new Date() + .getFullYear() + 2))} + minYear={new Date()} + className={styles.datepicker} />

    How many weeks will you need the copilot for?

    = () => { onChange={bind(handleFormValueChange, this, 'otherPaymentType')} error={formErrors.otherPaymentType} tabIndex={0} + maxLength={8} /> )}
    @@ -480,7 +618,7 @@ const CopilotRequestForm: FC<{}> = () => {
    +
    +
    Title
    +
    {props.request.opportunityTitle}
    +
    +
    + {isEditable ? ( + + + + ) : ( + + )} +
    {props.request.status === 'approved' && (
    @@ -111,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( @@ -118,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 @@ -139,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() }, []) @@ -152,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 ` @@ -164,15 +185,24 @@ const CopilotRequestsPage: FC = () => { rel='noreferrer' onClick={handleLinkClick} > - {projectName} + {copilotRequest.project?.name} ) }, type: 'element', }, + { + className: styles.opportunityTitle, + label: 'Title', + propertyName: 'opportunityTitle', + renderer: (copilotRequest: CopilotRequest) => ( +
    {copilotRequest.opportunityTitle}
    + ), + type: 'element', + }, { label: 'Type', - propertyName: 'type', + propertyName: 'projectType', type: 'text', }, { @@ -203,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 = { @@ -228,18 +266,19 @@ const CopilotRequestsPage: FC = () => { buttonConfig={addNewRequestButton} > Copilot Requests - {isLoading ? ( - - ) : ( -
    - )} +
    + {requestsLoading && } {viewRequestDetails && ( )} diff --git a/src/apps/copilots/src/services/copilot-opportunities.ts b/src/apps/copilots/src/services/copilot-opportunities.ts index 61cebb6d7..07292b9c7 100644 --- a/src/apps/copilots/src/services/copilot-opportunities.ts +++ b/src/apps/copilots/src/services/copilot-opportunities.ts @@ -23,7 +23,7 @@ function copilotOpportunityFactory(data: any): CopilotOpportunity { return { ...data, ...data.data, - projectName: data.project.name, + projectName: data.project?.name, } } diff --git a/src/apps/copilots/src/services/copilot-requests.ts b/src/apps/copilots/src/services/copilot-requests.ts index be46ffa75..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, xhrPostAsync } from '~/libs/core' +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, @@ -23,10 +27,18 @@ function copilotRequestFactory(data: any): CopilotRequest { createdAt: new Date(data.createdAt), data: undefined, opportunity: data.copilotOpportunity?.[0], + startDate: new Date(data.data?.startDate), } } -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. @@ -34,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 @@ -54,8 +96,8 @@ export type CopilotRequestResponse = SWRResponse * @param {string} requestId - The unique identifier of the copilot request. * @returns {CopilotRequestResponse} - The response containing the copilot request data. */ -export const useCopilotRequest = (requestId: string): CopilotRequestResponse => { - const url = buildUrl(`${baseUrl}/copilots/requests/${requestId}`) +export const useCopilotRequest = (requestId?: string): CopilotRequestResponse => { + const url = requestId && buildUrl(`${baseUrl}/copilots/requests/${requestId}`) const fetcher = (urlp: string): Promise => xhrGetAsync(urlp) .then(copilotRequestFactory) @@ -74,12 +116,14 @@ export const useCopilotRequest = (requestId: string): CopilotRequestResponse => */ export const saveCopilotRequest = (request: CopilotRequest) : Promise => { - const url = `${baseUrl}/${request.projectId}/copilots/requests` + const url = request.id + ? `${baseUrl}/copilots/requests/${request.id}` : `${baseUrl}/${request.projectId}/copilots/requests` + const requestData = { - data: request, + data: { ...request, id: undefined }, } - return xhrPostAsync(url, requestData, {}) + return request.id ? xhrPatchAsync(url, requestData) : xhrPostAsync(url, requestData, {}) } /** diff --git a/src/apps/copilots/src/services/members.ts b/src/apps/copilots/src/services/members.ts index 5af2f7ba4..ba5c23d30 100644 --- a/src/apps/copilots/src/services/members.ts +++ b/src/apps/copilots/src/services/members.ts @@ -12,6 +12,7 @@ interface Member { COPILOT: { activeProjects: number, fulfillment: number, + projects: number, } }[] } @@ -19,6 +20,7 @@ interface Member { export interface FormattedMembers extends Member { copilotFulfillment: number, activeProjects: number, + pastProjects: number; } export type MembersResponse = SWRResponse @@ -40,11 +42,16 @@ export const getMembersByUserIds = async ( ) } -const membersFactory = (members: Member[]): FormattedMembers[] => members.map(member => ({ - ...member, - activeProjects: member.stats.find(item => item.COPILOT.activeProjects)?.COPILOT.activeProjects || 0, - copilotFulfillment: member.stats.find(item => item.COPILOT.fulfillment)?.COPILOT.fulfillment || 0, -})) +const membersFactory = (members: Member[]): FormattedMembers[] => members.map(member => { + const copilotStats = member.stats?.find(item => item.COPILOT)?.COPILOT ?? {} as Member['stats'][0]['COPILOT'] + + return { + ...member, + activeProjects: copilotStats.activeProjects || 0, + copilotFulfillment: copilotStats.fulfillment || 0, + pastProjects: copilotStats.projects || 0, + } +}) /** * Custom hook to fetch members by list of user ids diff --git a/src/apps/copilots/src/services/projects.ts b/src/apps/copilots/src/services/projects.ts index 45b26131e..2c31d88b7 100644 --- a/src/apps/copilots/src/services/projects.ts +++ b/src/apps/copilots/src/services/projects.ts @@ -11,6 +11,8 @@ const baseUrl = `${EnvironmentConfig.API.V5}/projects` export type ProjectsResponse = SWRResponse +const sleep = (ms: number): Promise<()=> void> => new Promise(resolve => { setTimeout(resolve, ms) }) + /** * Custom hook to fetch and manage projects data. * @@ -24,13 +26,26 @@ export const useProjects = (search?: string, config?: {isPaused?: () => boolean, const params = { name: search, ...config?.filter } const url = buildUrl(baseUrl, params) - const fetcher = (): Promise => { - if (config?.filter?.id && Array.isArray(config.filter.id)) { - const chunks = chunk(config.filter.id, 20) - return Promise.all( - chunks.map(page => xhrGetAsync(buildUrl(baseUrl, { ...params, id: page }))), - ) - .then(responses => responses.flat()) + const fetcher = async (): Promise => { + const ids = config?.filter?.id + + if (Array.isArray(ids)) { + const idChunks = chunk(ids, 20) + const allResults: Project[] = [] + + for (const chunkIds of idChunks) { + // eslint-disable-next-line no-await-in-loop + const response = await xhrGetAsync( + buildUrl(baseUrl, { ...params, id: chunkIds }), + ) + allResults.push(...response) + + // Rate limit: delay 200ms between calls + // eslint-disable-next-line no-await-in-loop + await sleep(200) + } + + return allResults } return xhrGetAsync(url) @@ -43,8 +58,13 @@ export const useProjects = (search?: string, config?: {isPaused?: () => boolean, }) } -export const getProjects = (search?: string, filter?: any): Promise => { - const params = { name: `"${search}"`, ...filter } +export const getProject = (projectId: string): Promise => { + const url = `${baseUrl}/${projectId}` + return xhrGetAsync(url) +} + +export const getProjects = (search?: string, config?: {filter: any}): Promise => { + const params = { name: search, ...config?.filter } const url = buildUrl(baseUrl, params) return xhrGetAsync(url) } diff --git a/src/apps/learn/src/certification-details/enroll-cta-btn/EnrollCtaBtn.tsx b/src/apps/learn/src/certification-details/enroll-cta-btn/EnrollCtaBtn.tsx index 6c29627f8..2c19e954f 100644 --- a/src/apps/learn/src/certification-details/enroll-cta-btn/EnrollCtaBtn.tsx +++ b/src/apps/learn/src/certification-details/enroll-cta-btn/EnrollCtaBtn.tsx @@ -1,12 +1,10 @@ -import { Dispatch, FC, SetStateAction, useCallback, useContext, useState } from 'react' +import { FC, useCallback, useContext } from 'react' import { NavigateFunction, useNavigate } from 'react-router-dom' import { Button } from '~/libs/ui' import { profileContext, ProfileContextData } from '~/libs/core' import { getAuthenticateAndEnrollRoute, getTCACertificationEnrollPath } from '../../learn.routes' -import { LearnConfig } from '../../config' -import { DiceModal } from '../../course-details/course-curriculum/dice-modal' interface EnrollCtaBtnProps { certification: string @@ -15,15 +13,9 @@ interface EnrollCtaBtnProps { const EnrollCtaBtn: FC = (props: EnrollCtaBtnProps) => { const navigate: NavigateFunction = useNavigate() const { initialized: profileReady, profile }: ProfileContextData = useContext(profileContext) - const [isDiceModalOpen, setIsDiceModalOpen]: [boolean, Dispatch>] - = useState(false) const isLoggedIn: boolean = profileReady && !!profile - function onDiceModalClose(): void { - setIsDiceModalOpen(false) - } - /** * Handle user click on start course/resume/login button */ @@ -37,15 +29,8 @@ const EnrollCtaBtn: FC = (props: EnrollCtaBtnProps) => { return } - // if the user is wipro and s/he hasn't set up DICE, - // let the user know - if (LearnConfig.REQUIRE_DICE_ID && profile?.isWipro && !profile.diceEnabled) { - setIsDiceModalOpen(true) - return - } - navigate(getTCACertificationEnrollPath(props.certification)) - }, [isLoggedIn, profile?.isWipro, profile?.diceEnabled, props, navigate]) + }, [isLoggedIn, props, navigate]) return ( <> @@ -55,11 +40,6 @@ const EnrollCtaBtn: FC = (props: EnrollCtaBtnProps) => { label={isLoggedIn ? 'Enroll Now' : 'Log in to enroll'} onClick={handleEnrollClick} /> - - ) } diff --git a/src/apps/learn/src/config/learn-config.model.ts b/src/apps/learn/src/config/learn-config.model.ts index 876fd8363..7a68f6a33 100644 --- a/src/apps/learn/src/config/learn-config.model.ts +++ b/src/apps/learn/src/config/learn-config.model.ts @@ -7,5 +7,4 @@ export interface LearnConfigModel { value: string, } CLIENT: string - REQUIRE_DICE_ID: boolean | undefined } diff --git a/src/apps/learn/src/config/learn.default.config.ts b/src/apps/learn/src/config/learn.default.config.ts index 7cb9bbd26..799fd2b3f 100644 --- a/src/apps/learn/src/config/learn.default.config.ts +++ b/src/apps/learn/src/config/learn.default.config.ts @@ -1,5 +1,4 @@ import { EnvironmentConfig } from '~/config' -import { getReactEnv } from '~/config/environments/react-env' import { LearnConfigModel } from './learn-config.model' @@ -14,5 +13,4 @@ export const LearnConfigDefault: LearnConfigModel = { value: 'certificate-container', }, CLIENT: 'https://fcc.topcoder-dev.com:4431', - REQUIRE_DICE_ID: `${getReactEnv('REQUIRE_DICE_ID', '')}` === 'true', } diff --git a/src/apps/learn/src/course-details/course-curriculum/CourseCurriculum.tsx b/src/apps/learn/src/course-details/course-curriculum/CourseCurriculum.tsx index ecc87c9e2..abb253e52 100644 --- a/src/apps/learn/src/course-details/course-curriculum/CourseCurriculum.tsx +++ b/src/apps/learn/src/course-details/course-curriculum/CourseCurriculum.tsx @@ -22,11 +22,9 @@ import { getLessonPathFromCurrentLesson, LEARN_PATHS, } from '../../learn.routes' -import { LearnConfig } from '../../config' import { CurriculumSummary } from './curriculum-summary' import { TcAcademyPolicyModal } from './tc-academy-policy-modal' -import { DiceModal } from './dice-modal' import styles from './CourseCurriculum.module.scss' interface CourseCurriculumProps { @@ -47,8 +45,6 @@ const CourseCurriculum: FC = (props: CourseCurriculumProp const [isTcAcademyPolicyModal, setIsTcAcademyPolicyModal]: [boolean, Dispatch>] = useState(false) - const [isDiceModalOpen, setIsDiceModalOpen]: [boolean, Dispatch>] - = useState(false) const status: string = props.progress?.status ?? UserCertificationProgressStatus.inititialized const completedPercentage: number = (props.progress?.courseProgressPercentage ?? 0) / 100 @@ -98,17 +94,6 @@ const CourseCurriculum: FC = (props: CourseCurriculumProp return } - // if the user is wipro and s/he hasn't set up DICE, - // let the user know - if ( - LearnConfig.REQUIRE_DICE_ID - && props.profile?.isWipro - && !props.profile.diceEnabled - ) { - setIsDiceModalOpen(true) - return - } - // Check if user accepted policy and resume(or start) the course if (props.progress?.academicHonestyPolicyAcceptedAt) { handleStartCourse() @@ -188,10 +173,6 @@ const CourseCurriculum: FC = (props: CourseCurriculumProp setIsTcAcademyPolicyModal(false) } - function onDiceModalClose(): void { - setIsDiceModalOpen(false) - } - return ( <>
    @@ -232,11 +213,6 @@ const CourseCurriculum: FC = (props: CourseCurriculumProp onClose={onAcademicHonestyModalClose} onConfirm={handlePolicyAccept} /> - - ) } diff --git a/src/apps/learn/src/course-details/course-curriculum/dice-modal/DiceModal.module.scss b/src/apps/learn/src/course-details/course-curriculum/dice-modal/DiceModal.module.scss deleted file mode 100644 index 974999840..000000000 --- a/src/apps/learn/src/course-details/course-curriculum/dice-modal/DiceModal.module.scss +++ /dev/null @@ -1,13 +0,0 @@ -@import '@libs/ui/styles/includes'; - -.diceModal { - - p { - margin-bottom: $sp-4; - - &.buttonContainer { - display: flex; - justify-content: center; - } - } -} diff --git a/src/apps/learn/src/course-details/course-curriculum/dice-modal/DiceModal.tsx b/src/apps/learn/src/course-details/course-curriculum/dice-modal/DiceModal.tsx deleted file mode 100644 index edf2f89d2..000000000 --- a/src/apps/learn/src/course-details/course-curriculum/dice-modal/DiceModal.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Dispatch, FC, SetStateAction, useEffect, useState } from 'react' - -import { EnvironmentConfig } from '~/config' -import { BaseModal, Button } from '~/libs/ui' - -import styles from './DiceModal.module.scss' - -const preventDefault = (ev: any): void => { - ev?.preventDefault?.() -} - -interface DiceModalProps { - isOpen: boolean - onClose: () => void -} - -const DiceModal: FC = (props: DiceModalProps) => { - - const [isOpen, setIsOpen]: [boolean, Dispatch>] - = useState(false) - - useEffect(() => { - setIsOpen(props.isOpen) - }, [props.isOpen]) - - return ( - -
    - -

    - Wipro requires employees to enable Multifactor Authentication - with DICE ID in order to take Topcoder Academy courses. -

    -

    - Please go to Account Settings to configure your account. -

    -

    - -

    - -
    - ) -} - -export default DiceModal diff --git a/src/apps/learn/src/course-details/course-curriculum/dice-modal/index.ts b/src/apps/learn/src/course-details/course-curriculum/dice-modal/index.ts deleted file mode 100644 index b74a55774..000000000 --- a/src/apps/learn/src/course-details/course-curriculum/dice-modal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as DiceModal } from './DiceModal' diff --git a/src/apps/learn/src/free-code-camp/FreeCodeCamp.tsx b/src/apps/learn/src/free-code-camp/FreeCodeCamp.tsx index 819cad557..db164a3e5 100644 --- a/src/apps/learn/src/free-code-camp/FreeCodeCamp.tsx +++ b/src/apps/learn/src/free-code-camp/FreeCodeCamp.tsx @@ -50,7 +50,6 @@ import { getCoursePath, getLessonPathFromModule, } from '../learn.routes' -import { LearnConfig } from '../config' import { CoursePageContextValue, useCoursePageContext } from '../course-page-wrapper' import { useCheckAndMarkCourseCompleted } from './hooks/use-mark-course-completed' @@ -410,7 +409,7 @@ const FreeCodeCamp: FC<{}> = () => { /** * Check if the user accepted the academic honesty policy - * and either is not a wipro user or the wipro user has dice enabled. + * and either is not a wipro user. * if not, redirect user to course details page to accept the policy */ useLayoutEffect(() => { @@ -421,11 +420,9 @@ const FreeCodeCamp: FC<{}> = () => { } // if the user is logged in, - // and the user is a either not wipro user or is a wipro user with dice enabled, // and if the user has accepted the academic honesty policy, // the user is permitted to take the course, so there's nothing to do. if (isLoggedIn - && (!LearnConfig.REQUIRE_DICE_ID || !profile?.isWipro || !!profile?.diceEnabled) && !!certificateProgress?.academicHonestyPolicyAcceptedAt) { return } diff --git a/src/apps/talent-search/src/routes/search-results-page/SearchResultsPage.tsx b/src/apps/talent-search/src/routes/search-results-page/SearchResultsPage.tsx index dd7a315e6..501f15b86 100644 --- a/src/apps/talent-search/src/routes/search-results-page/SearchResultsPage.tsx +++ b/src/apps/talent-search/src/routes/search-results-page/SearchResultsPage.tsx @@ -1,4 +1,4 @@ -import { FC, useCallback, useState } from 'react' +import { FC, useCallback, useEffect, useState } from 'react' import classNames from 'classnames' import { Button, ContentLayout, LinkButton, LoadingCircles } from '~/libs/ui' @@ -17,6 +17,9 @@ import styles from './SearchResultsPage.module.scss' const SearchResultsPage: FC = () => { const [showSkillsModal, setShowSkillsModal] = useState(false) + const [currentPage, setCurrentPage] = useState(1) + const itemsPerPage = 10 + const [skills, setSkills] = useUrlQuerySearchParms('q') const { loading, @@ -25,6 +28,27 @@ const SearchResultsPage: FC = () => { hasNext, total, }: InfiniteTalentMatchesResposne = useInfiniteTalentMatches(skills) + const paginatedMatches = matches.slice(0, currentPage * itemsPerPage) + + useEffect(() => { + const handleScroll: () => void = () => { + const scrollY = window.scrollY + const visibleHeight = window.innerHeight + const fullHeight = document.body.scrollHeight + const footerElem = document.getElementById('footer-nav-el') + const footerHeight = (footerElem && footerElem.offsetHeight) || 650 + if (scrollY + visibleHeight >= fullHeight - (footerHeight + 100)) { + // Scroll near bottom + setCurrentPage(prev => { + const maxPages = Math.ceil(matches.length / itemsPerPage) + return prev < maxPages ? prev + 1 : prev + }) + } + } + + window.addEventListener('scroll', handleScroll) + return () => window.removeEventListener('scroll', handleScroll) + }, [matches]) const toggleSkillsModal = useCallback(() => setShowSkillsModal(s => !s), []) @@ -100,7 +124,7 @@ const SearchResultsPage: FC = () => { )}
    - {matches.map(member => ( + {paginatedMatches.map(member => ( - - - \ No newline at end of file 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 825ca636a..3ed1a388d 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 @@ -30,6 +30,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 d384988d7..d6db81256 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 @@ -31,6 +31,7 @@ const TaxFormTable: React.FC = (props: TaxFormTableProps) => + {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} diff --git a/src/apps/wallet/src/lib/assets/security/dicelogo.png b/src/apps/wallet/src/lib/assets/security/dicelogo.png deleted file mode 100644 index 723b63b2e..000000000 Binary files a/src/apps/wallet/src/lib/assets/security/dicelogo.png and /dev/null differ diff --git a/src/apps/wallet/src/lib/assets/security/dicelogobig.png b/src/apps/wallet/src/lib/assets/security/dicelogobig.png deleted file mode 100644 index b18935081..000000000 Binary files a/src/apps/wallet/src/lib/assets/security/dicelogobig.png and /dev/null differ diff --git a/src/apps/wallet/src/lib/assets/security/dicelogosmall.png b/src/apps/wallet/src/lib/assets/security/dicelogosmall.png deleted file mode 100644 index c2be4e4a8..000000000 Binary files a/src/apps/wallet/src/lib/assets/security/dicelogosmall.png and /dev/null differ diff --git a/src/apps/wallet/src/lib/assets/security/index.ts b/src/apps/wallet/src/lib/assets/security/index.ts index 4559e27b4..efa8c4123 100644 --- a/src/apps/wallet/src/lib/assets/security/index.ts +++ b/src/apps/wallet/src/lib/assets/security/index.ts @@ -1,17 +1,9 @@ -import { ReactComponent as MFAImage } from './mfa.svg' import { ReactComponent as AppleStore } from './apple-store.svg' import credentialImage from './credential.png' -import diceIdLogo from './dicelogo.png' -import diceIdLogoBig from './dicelogobig.png' -import diceIdLogoSmall from './dicelogosmall.png' import googlePlay from './google-play.png' export { AppleStore, credentialImage, - diceIdLogo, - diceIdLogoBig, - diceIdLogoSmall, googlePlay, - MFAImage, } diff --git a/src/apps/wallet/src/lib/assets/security/mfa.svg b/src/apps/wallet/src/lib/assets/security/mfa.svg deleted file mode 100644 index 33b5ab0cc..000000000 --- a/src/apps/wallet/src/lib/assets/security/mfa.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/config/environments/default.env.ts b/src/config/environments/default.env.ts index 8db092aba..403668d77 100644 --- a/src/config/environments/default.env.ts +++ b/src/config/environments/default.env.ts @@ -78,11 +78,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 a2a518bb6..8550fb496 100644 --- a/src/config/environments/global-config.model.ts +++ b/src/config/environments/global-config.model.ts @@ -52,5 +52,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/core/lib/auth/user-functions/index.ts b/src/libs/core/lib/auth/user-functions/index.ts index 7a5ad1a47..d406614d2 100644 --- a/src/libs/core/lib/auth/user-functions/index.ts +++ b/src/libs/core/lib/auth/user-functions/index.ts @@ -1,4 +1,3 @@ export { - getDiceStatusAsync as userGetDiceStatusAsync, updatePasswordAsync as userUpdatePasswordAsync, } from './user.functions' diff --git a/src/libs/core/lib/auth/user-functions/user-store/index.ts b/src/libs/core/lib/auth/user-functions/user-store/index.ts index 5e88d6a17..28b205023 100644 --- a/src/libs/core/lib/auth/user-functions/user-store/index.ts +++ b/src/libs/core/lib/auth/user-functions/user-store/index.ts @@ -1,5 +1,4 @@ export { - getMfaStatusAsync as userStoreGetMfaStatusAsync, patchAsync as userStorePatchAsync, } from './user-xhr.store' export { type UserPatchRequest } from './user-xhr.store' diff --git a/src/libs/core/lib/auth/user-functions/user-store/user-xhr.store.ts b/src/libs/core/lib/auth/user-functions/user-store/user-xhr.store.ts index e133b8385..c644c3017 100644 --- a/src/libs/core/lib/auth/user-functions/user-store/user-xhr.store.ts +++ b/src/libs/core/lib/auth/user-functions/user-store/user-xhr.store.ts @@ -1,17 +1,8 @@ -import { xhrGetAsync, xhrPatchAsync } from '../../../xhr' +import { xhrPatchAsync } from '../../../xhr' import { AuthUser } from '../../authentication-functions/auth-user.model' import { user as userEndpoint } from './user-endpoint.config' -export interface MfaStatusResult { - result: { - content: { - diceEnabled: boolean - mfaEnabled: boolean - } - } -} - export interface UserPatchRequest { param: { credential: { @@ -21,10 +12,6 @@ export interface UserPatchRequest { } } -export async function getMfaStatusAsync(userId: number): Promise { - return xhrGetAsync(`${userEndpoint(userId)}/2fa`) -} - export async function patchAsync(userId: number, request: UserPatchRequest): Promise { const url: string = userEndpoint(userId) return xhrPatchAsync(url, request) diff --git a/src/libs/core/lib/auth/user-functions/user.functions.ts b/src/libs/core/lib/auth/user-functions/user.functions.ts index 613e7100c..2ba611493 100644 --- a/src/libs/core/lib/auth/user-functions/user.functions.ts +++ b/src/libs/core/lib/auth/user-functions/user.functions.ts @@ -1,10 +1,4 @@ -import { UserPatchRequest, userStoreGetMfaStatusAsync, userStorePatchAsync } from './user-store' -import { MfaStatusResult } from './user-store/user-xhr.store' - -export async function getDiceStatusAsync(userId: number): Promise { - const result: MfaStatusResult = await userStoreGetMfaStatusAsync(userId) - return !!result.result.content.mfaEnabled && !!result.result.content.diceEnabled -} +import { UserPatchRequest, userStorePatchAsync } from './user-store' export async function updatePasswordAsync(userId: number, currentPassword: string, password: string): Promise { const request: UserPatchRequest = { diff --git a/src/libs/core/lib/profile/data-providers/index.ts b/src/libs/core/lib/profile/data-providers/index.ts index 41e6581cc..b4511c660 100644 --- a/src/libs/core/lib/profile/data-providers/index.ts +++ b/src/libs/core/lib/profile/data-providers/index.ts @@ -5,8 +5,6 @@ export * from './useUserCompletedCertifications' export * from './useStatsHistory' export * from './useStatsDistribution' export * from './useMemberEmailPreferences' -export * from './useMemberMFAStatus' -export * from './useDiceIdConnection' export * from './useMemberTraits' export * from './useMemberDevicesLookup' export * from './useCountryLookup' diff --git a/src/libs/core/lib/profile/data-providers/useDiceIdConnection.ts b/src/libs/core/lib/profile/data-providers/useDiceIdConnection.ts deleted file mode 100644 index d86e3c30c..000000000 --- a/src/libs/core/lib/profile/data-providers/useDiceIdConnection.ts +++ /dev/null @@ -1,18 +0,0 @@ -import useSWR, { SWRConfiguration, SWRResponse } from 'swr' - -import { diceIDURL } from '~/libs/core' - -export interface DiceConnectionStatus { - accepted: boolean - connection: string | null - diceEnabled: boolean -} - -export function useDiceIdConnection(userId: number): DiceConnectionStatus | undefined { - const options: SWRConfiguration = { refreshInterval: 5000 } // pooling interval 5s - - const { data }: SWRResponse - = useSWR(`${diceIDURL(userId)}/diceConnection`, options) - - return data ? data.result.content : undefined -} diff --git a/src/libs/core/lib/profile/data-providers/useMemberMFAStatus.ts b/src/libs/core/lib/profile/data-providers/useMemberMFAStatus.ts deleted file mode 100644 index 844b5a496..000000000 --- a/src/libs/core/lib/profile/data-providers/useMemberMFAStatus.ts +++ /dev/null @@ -1,28 +0,0 @@ -import useSWR, { KeyedMutator, SWRResponse } from 'swr' - -import { memberModifyMfaURL } from '~/libs/core' - -export interface MemberMFAStatus { - createdAt: Date - createdBy: number - diceEnabled: boolean - id: number - mfaEnabled: boolean - modifiedAt: Date - modifiedBy: number - userId: number -} - -export interface UseMemberMFAStatusAPI { - data: MemberMFAStatus | undefined - mutate: KeyedMutator -} - -export function useMemberMFAStatus(userId: number): UseMemberMFAStatusAPI { - const { data, mutate }: SWRResponse = useSWR(memberModifyMfaURL(userId)) - - return { - data: data ? data.result.content : undefined, - mutate, - } -} diff --git a/src/libs/core/lib/profile/index.ts b/src/libs/core/lib/profile/index.ts index 87b18783d..df5249fd8 100644 --- a/src/libs/core/lib/profile/index.ts +++ b/src/libs/core/lib/profile/index.ts @@ -11,7 +11,6 @@ export * from './user-badge.model' export * from './modify-user-role.model' export * from './user-email-preference.model' export * from './modify-user-email-preferences.model' -export * from './modify-user-mfa.model' export * from './user-traits.model' export * from './modify-tracks.request' export * from './modify-user-profile.model' diff --git a/src/libs/core/lib/profile/modify-user-mfa.model.ts b/src/libs/core/lib/profile/modify-user-mfa.model.ts deleted file mode 100644 index 1fda94156..000000000 --- a/src/libs/core/lib/profile/modify-user-mfa.model.ts +++ /dev/null @@ -1,17 +0,0 @@ -export interface ModifyUserMFARequest { - param: { - mfaEnabled?: boolean - diceEnabled?: boolean - } -} - -export interface ModifyUserMFAResponse { - id: string - result: { - content: { - mfaEnabled: boolean - diceEnabled: boolean - }, - success: boolean - } -} diff --git a/src/libs/core/lib/profile/profile-functions/index.ts b/src/libs/core/lib/profile/profile-functions/index.ts index fd4a249c6..ed8ec1d6c 100644 --- a/src/libs/core/lib/profile/profile-functions/index.ts +++ b/src/libs/core/lib/profile/profile-functions/index.ts @@ -8,7 +8,6 @@ export { editNameAsync as profileEditNameAsync, updatePrimaryMemberRoleAsync, updateMemberEmailPreferencesAsync, - updateMemberMFAStatusAsync, updateMemberPasswordAsync, updateMemberTraitsAsync, createMemberTraitsAsync, diff --git a/src/libs/core/lib/profile/profile-functions/profile-factory/profile.factory.ts b/src/libs/core/lib/profile/profile-functions/profile-factory/profile.factory.ts index 23e62c088..a17be2d18 100644 --- a/src/libs/core/lib/profile/profile-functions/profile-factory/profile.factory.ts +++ b/src/libs/core/lib/profile/profile-functions/profile-factory/profile.factory.ts @@ -3,7 +3,7 @@ import { UserProfile } from '../../user-profile.model' import { UserRole } from './user-role.enum' -export function create(profile: UserProfile, token?: TokenModel, hasDiceEnabled?: boolean): UserProfile { +export function create(profile: UserProfile, token?: TokenModel): UserProfile { // Currently, the "Self-Service Customer" role is being set when a user is created // during the self-service workflow. There are no other roles being set to distinguish @@ -16,7 +16,6 @@ export function create(profile: UserProfile, token?: TokenModel, hasDiceEnabled? profile.isMember = !profile.isCustomer profile.isWipro = profile.email?.endsWith('@wipro.com') - profile.diceEnabled = !!hasDiceEnabled // store roles for custom capability checks profile.roles = token?.roles || [] diff --git a/src/libs/core/lib/profile/profile-functions/profile-store/index.ts b/src/libs/core/lib/profile/profile-functions/profile-store/index.ts index 39e376a86..f74524d04 100644 --- a/src/libs/core/lib/profile/profile-functions/profile-store/index.ts +++ b/src/libs/core/lib/profile/profile-functions/profile-store/index.ts @@ -12,7 +12,5 @@ export { learnBaseURL, memberStatsDistroURL, memberEmailPreferencesURL, - memberModifyMfaURL, - diceIDURL, userSkillsUrl, } from './profile-endpoint.config' diff --git a/src/libs/core/lib/profile/profile-functions/profile-store/profile-endpoint.config.ts b/src/libs/core/lib/profile/profile-functions/profile-store/profile-endpoint.config.ts index e418f131e..78de45d52 100644 --- a/src/libs/core/lib/profile/profile-functions/profile-store/profile-endpoint.config.ts +++ b/src/libs/core/lib/profile/profile-functions/profile-store/profile-endpoint.config.ts @@ -39,14 +39,6 @@ export function memberEmailPreferencesURL(): string { return `https://community-app.${EnvironmentConfig.TC_DOMAIN}/api/mailchimp/28bfd3c062/members` } -export function memberModifyMfaURL(userId: number): string { - return `${EnvironmentConfig.API.V3}/users/${userId}/2fa` -} - -export function diceIDURL(userId: number): string { - return `${EnvironmentConfig.API.V3}/users/${userId}` -} - export function userSkillsUrl(userIdOrAction: string): string { return `${EnvironmentConfig.API.V5}/standardized-skills/user-skills/${userIdOrAction}` } diff --git a/src/libs/core/lib/profile/profile-functions/profile-store/profile-xhr.store.ts b/src/libs/core/lib/profile/profile-functions/profile-store/profile-xhr.store.ts index 84966a017..3272b716b 100644 --- a/src/libs/core/lib/profile/profile-functions/profile-store/profile-xhr.store.ts +++ b/src/libs/core/lib/profile/profile-functions/profile-store/profile-xhr.store.ts @@ -3,7 +3,6 @@ import { CountryLookup } from '../../country-lookup.model' import { EditNameRequest } from '../../edit-name-request.model' import { ModifyTracksRequest } from '../../modify-tracks.request' import { ModifyMemberEmailPreferencesRequest } from '../../modify-user-email-preferences.model' -import { ModifyUserMFARequest, ModifyUserMFAResponse } from '../../modify-user-mfa.model' import { UpdateProfileRequest, UserPhotoUpdateResponse } from '../../modify-user-profile.model' import { ModifyUserPropertyRequest, ModifyUserPropertyResponse } from '../../modify-user-role.model' import { UserEmailPreferences } from '../../user-email-preference.model' @@ -15,7 +14,6 @@ import { UserVerify } from '../../user-verify.model' import { countryLookupURL, memberEmailPreferencesURL, - memberModifyMfaURL, memberModifyURL, profile as profileUrl, verify as verifyUrl, @@ -63,13 +61,6 @@ export async function updateMemberEmailPreferences( ) } -export async function updateMemberMFA(userId: number, payload: ModifyUserMFARequest): Promise { - return xhrPatchAsync( - memberModifyMfaURL(userId), - payload, - ) -} - export async function updateMemberPassword( userId: number, currentPassword: string, diff --git a/src/libs/core/lib/profile/profile-functions/profile.functions.ts b/src/libs/core/lib/profile/profile-functions/profile.functions.ts index 1f1e482bd..581723f3a 100644 --- a/src/libs/core/lib/profile/profile-functions/profile.functions.ts +++ b/src/libs/core/lib/profile/profile-functions/profile.functions.ts @@ -1,9 +1,8 @@ -import { tokenGetAsync, TokenModel, userGetDiceStatusAsync } from '../../auth' +import { tokenGetAsync, TokenModel } from '../../auth' import { CountryLookup } from '../country-lookup.model' import { EditNameRequest } from '../edit-name-request.model' import { ModifyTracksRequest } from '../modify-tracks.request' import { ModifyMemberEmailPreferencesRequest } from '../modify-user-email-preferences.model' -import { ModifyUserMFARequest, ModifyUserMFAResponse } from '../modify-user-mfa.model' import { UpdateProfileRequest, UserPhotoUpdateResponse } from '../modify-user-profile.model' import { ModifyUserPropertyResponse } from '../modify-user-role.model' import { UserEmailPreferences } from '../user-email-preference.model' @@ -20,7 +19,6 @@ import { getCountryLookup, modifyTracks, updateMemberEmailPreferences, - updateMemberMFA, updateMemberPassword, updateMemberPhoto, updateMemberProfile, @@ -40,13 +38,10 @@ export async function getLoggedInAsync(handle?: string): Promise = profileStoreGet(safeHandle) - const dicePromise: Promise = userGetDiceStatusAsync(token.userId) - - const [profileResult, diceEnabled]: [UserProfile, boolean] = await Promise.all([profilePromise, dicePromise]) + const profileResult: UserProfile = await profileStoreGet(safeHandle) // make the changes we need based on the token - const output: UserProfile = profileFactoryCreate(profileResult, token, diceEnabled) + const output: UserProfile = profileFactoryCreate(profileResult, token) return output } @@ -107,13 +102,6 @@ export async function updateMemberEmailPreferencesAsync( return updateMemberEmailPreferences(email, emailPreferences) } -export async function updateMemberMFAStatusAsync( - userId: number, - payload: ModifyUserMFARequest, -): Promise { - return updateMemberMFA(userId, payload) -} - export async function updateMemberPasswordAsync( userId: number, currentPassword: string, diff --git a/src/libs/core/lib/profile/user-profile.model.ts b/src/libs/core/lib/profile/user-profile.model.ts index 03d863bfd..1e263d2d0 100644 --- a/src/libs/core/lib/profile/user-profile.model.ts +++ b/src/libs/core/lib/profile/user-profile.model.ts @@ -20,7 +20,6 @@ export interface UserProfile { competitionCountryCode: string createdAt: number description: string - diceEnabled: boolean email: string firstName: string handle: string diff --git a/src/libs/shared/lib/components/profile-picture/ProfilePicture.tsx b/src/libs/shared/lib/components/profile-picture/ProfilePicture.tsx index e7b713dc1..059be6399 100644 --- a/src/libs/shared/lib/components/profile-picture/ProfilePicture.tsx +++ b/src/libs/shared/lib/components/profile-picture/ProfilePicture.tsx @@ -29,8 +29,8 @@ const ProfilePicture: FC = props => { > {!loaded && ( - {props.member.firstName.slice(0, 1)} - {props.member.lastName.slice(0, 1)} + {props.member?.firstName?.slice(0, 1)} + {props.member?.lastName?.slice(0, 1)} )} {props.member.photoURL && !error && ( diff --git a/src/libs/ui/lib/components/content-layout/ContentLayout.module.scss b/src/libs/ui/lib/components/content-layout/ContentLayout.module.scss index 7cc8a3ac9..628902a35 100644 --- a/src/libs/ui/lib/components/content-layout/ContentLayout.module.scss +++ b/src/libs/ui/lib/components/content-layout/ContentLayout.module.scss @@ -36,6 +36,12 @@ color: $black-100; } } + + @media (max-width: 767px) { + .page-header { + flex-direction: column; + } + } } } } diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-date-picker/InputDatePicker.tsx b/src/libs/ui/lib/components/form/form-groups/form-input/input-date-picker/InputDatePicker.tsx index 2c4840898..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 @@ -26,6 +27,7 @@ interface InputDatePickerProps { readonly maxTime?: Date | undefined readonly minDate?: Date | null | undefined readonly minTime?: Date | undefined + readonly minYear?: Date | null |undefined readonly placeholder?: string readonly showMonthPicker?: boolean readonly showYearPicker?: boolean @@ -76,7 +78,8 @@ const InputDatePicker: FC = (props: InputDatePickerProps) const datePickerRef = useRef>(null) const years = useMemo(() => { const maxYear = getYear(props.maxDate ? props.maxDate : new Date()) + 1 - return range(1979, maxYear, 1) + const minYear = getYear(props.minYear ? props.minYear : 1979) + return range(minYear, maxYear, 1) }, [props.maxDate]) const [stateHasFocus, setStateHasFocus] = useState(false) @@ -184,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.module.scss b/src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/InputSelectReact.module.scss index 3fe20ea51..761a49e51 100644 --- a/src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/InputSelectReact.module.scss +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-select-react/InputSelectReact.module.scss @@ -77,7 +77,7 @@ &:global(__single-value) { @extend .body-small; - color: $black-60; + color: $black-100; white-space: break-spaces; word-break: break-all; text-align: left; 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 224f73928..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 @@ -48,6 +49,7 @@ interface InputSelectReactProps { readonly async?: boolean readonly loadOptions?: (inputValue: string, callback: (option: any) => void) => void readonly filterOption?: (option: InputSelectOption, value: string) => boolean + readonly isClearable?: boolean } /** @@ -105,11 +107,12 @@ const InputSelectReact: FC = props => { // throw the proper event type to the form handler (needs name & form element on target) function handleSelect(option: unknown): void { + const selectedOption = option as InputSelectOption | null props.onChange({ target: { form: findParentFrom(wrapRef.current as HTMLDivElement), name: props.name, - value: (option as InputSelectOption).value, + value: selectedOption?.value || '', }, } as ChangeEvent) } @@ -162,9 +165,11 @@ const InputSelectReact: FC = props => { formatCreateLabel={props.createLabel} onCreateOption={props.onCreateOption} onBlur={handleBlur} + isClearable={props.isClearable} backspaceRemovesValue isDisabled={props.disabled} filterOption={props.filterOption} + isLoading={props.isLoading} /> ) diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-select/InputSelect.module.scss b/src/libs/ui/lib/components/form/form-groups/form-input/input-select/InputSelect.module.scss index d3673be51..16505e78b 100644 --- a/src/libs/ui/lib/components/form/form-groups/form-input/input-select/InputSelect.module.scss +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-select/InputSelect.module.scss @@ -5,7 +5,7 @@ align-items: center; margin-top: $sp-1; cursor: pointer; - color: $black-60; + color: $black-100; &-icon { margin-left: auto; diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-text/InputText.module.scss b/src/libs/ui/lib/components/form/form-groups/form-input/input-text/InputText.module.scss index 555e29b8e..2ef89273c 100644 --- a/src/libs/ui/lib/components/form/form-groups/form-input/input-text/InputText.module.scss +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-text/InputText.module.scss @@ -3,7 +3,7 @@ .form-input-text { @extend .body-small; - color: $black-60; + color: $black-100; box-sizing: border-box; border: 0; width: 100%; 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 0b5e6eee4..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 @@ -4,13 +4,12 @@ .form-input-textarea { @include font-roboto; @extend .body-small; - color: $black-60; + color: $black-100; box-sizing: border-box; border: none; 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/src/libs/ui/lib/components/table/table-functions/table.functions.ts b/src/libs/ui/lib/components/table/table-functions/table.functions.ts index 10fd8bbde..736b58eff 100644 --- a/src/libs/ui/lib/components/table/table-functions/table.functions.ts +++ b/src/libs/ui/lib/components/table/table-functions/table.functions.ts @@ -65,6 +65,11 @@ export function getSorted( .sort((a: T, b: T) => { const aField: string = a[sort.fieldName] const bField: string = b[sort.fieldName] + + // Handle undefined/null values safely + if (aField === undefined && bField === undefined) return 0 + if (aField === undefined) return 1 + if (bField === undefined) return -1 return sort.direction === 'asc' ? aField.localeCompare(bField) : bField.localeCompare(aField) diff --git a/src/libs/ui/lib/components/tabs-navbar/TabsNavbar.tsx b/src/libs/ui/lib/components/tabs-navbar/TabsNavbar.tsx index 0b2d8f019..9d0ea8c76 100644 --- a/src/libs/ui/lib/components/tabs-navbar/TabsNavbar.tsx +++ b/src/libs/ui/lib/components/tabs-navbar/TabsNavbar.tsx @@ -125,7 +125,7 @@ const TabsNavbar: FC = (props: TabsNavbarProps) => { handleActivateTab={handleActivateTab} handleActivateChildTab={handleActivateChildTab} /> - + {props.tabs.length > 1 && }
    diff --git a/yarn.lock b/yarn.lock index 6fd04826d..b2349b56f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2529,6 +2529,18 @@ resolved "https://registry.yarnpkg.com/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8" integrity sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw== +"@isaacs/balanced-match@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz#3081dadbc3460661b751e7591d7faea5df39dd29" + integrity sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ== + +"@isaacs/brace-expansion@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz#4b3dabab7d8e75a429414a96bd67bf4c1d13e0f3" + integrity sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA== + dependencies: + "@isaacs/balanced-match" "^4.0.1" + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -4765,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" @@ -4899,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" @@ -5170,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" @@ -5439,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" @@ -5980,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" @@ -7337,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" @@ -7722,6 +7784,15 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + crypto-js@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" @@ -8624,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" @@ -9995,6 +10077,14 @@ foreground-child@^3.1.0: cross-spawn "^7.0.0" signal-exit "^4.0.1" +foreground-child@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + fork-ts-checker-webpack-plugin@^6.5.0: version "6.5.2" resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz#4f67183f2f9eb8ba7df7177ce3cf3e75cdafb340" @@ -10314,6 +10404,18 @@ glob@^10.0.0: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" +glob@^11.0.0: + version "11.0.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-11.0.3.tgz#9d8087e6d72ddb3c4707b1d2778f80ea3eaefcd6" + integrity sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA== + dependencies: + foreground-child "^3.3.1" + jackspeak "^4.1.1" + minimatch "^10.0.3" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^2.0.0" + glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.0: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" @@ -11410,6 +11512,13 @@ jackspeak@^3.1.2: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" +jackspeak@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.1.1.tgz#96876030f450502047fc7e8c7fcf8ce8124e43ae" + integrity sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ== + dependencies: + "@isaacs/cliui" "^8.0.2" + jake@^10.8.5: version "10.8.5" resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.5.tgz#f2183d2c59382cb274226034543b9c03b8164c46" @@ -12814,6 +12923,11 @@ lru-cache@^10.2.0: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== +lru-cache@^11.0.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.1.0.tgz#afafb060607108132dbc1cf8ae661afb69486117" + integrity sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -12910,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" @@ -13542,6 +13661,13 @@ minimatch@3.1.2, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch dependencies: brace-expansion "^1.1.7" +minimatch@^10.0.3: + version "10.0.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.0.3.tgz#cf7a0314a16c4d9ab73a7730a0e8e3c3502d47aa" + integrity sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw== + dependencies: + "@isaacs/brace-expansion" "^5.0.0" + minimatch@^5.0.1: version "5.1.1" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.1.tgz#6c9dffcf9927ff2a31e74b5af11adf8b9604b022" @@ -14261,6 +14387,14 @@ path-scurry@^1.11.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" +path-scurry@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.0.tgz#9f052289f23ad8bf9397a2a0425e7b8615c58580" + integrity sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg== + dependencies: + lru-cache "^11.0.0" + minipass "^7.1.2" + path-to-regexp@0.1.12: version "0.1.12" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz#d5e1a12e478a976d432ef3c58d534b9923164bb7" @@ -16543,6 +16677,14 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" +rimraf@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-6.0.1.tgz#ffb8ad8844dd60332ab15f52bc104bc3ed71ea4e" + integrity sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A== + dependencies: + glob "^11.0.0" + package-json-from-dist "^1.0.0" + rimraf@~2.6.2: version "2.6.3" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" @@ -17439,7 +17581,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -17453,6 +17595,13 @@ strip-ansi@^3.0.0: dependencies: ansi-regex "^2.0.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2" @@ -17977,6 +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" @@ -18272,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" @@ -19291,7 +19450,7 @@ workbox-window@6.5.4: "@types/trusted-types" "^2.0.2" workbox-core "6.5.4" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -19309,6 +19468,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.0.1.tgz#2101e861777fec527d0ea90c57c6b03aac56a5b3"
    CONNECTED PROVIDER PROVIDER ID STATUS
    FORM DATE FILED STATUS