diff --git a/.circleci/config.yml b/.circleci/config.yml index 68adf3aa3..b23ba76b9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -26,10 +26,11 @@ install_dependency: &install_dependency install_deploysuite: &install_deploysuite name: Installation of install_deploysuite. command: | - git clone --branch v1.4.13 https://github.com/topcoder-platform/tc-deploy-scripts ../buildscript + git clone --branch v1.4.17 https://github.com/topcoder-platform/tc-deploy-scripts ../buildscript cp ./../buildscript/master_deploy.sh . cp ./../buildscript/buildenv.sh . cp ./../buildscript/awsconfiguration.sh . + cp ./../buildscript/psvar-processor.sh . restore_cache_settings_for_build: &restore_cache_settings_for_build key: connect-node-modules-{{ checksum "yarn.lock" }} @@ -48,14 +49,16 @@ running_yarn_eslint: &running_yarn_eslint running_yarn_build: &running_yarn_build name: Running Yarn Build command: | - source buildenvvar + # source buildenvvar + source buildvar_env yarn install yarn build running_yarn_sb_build: &running_yarn_sb_build name: Running Yarn Storybook Build command: | - source buildenvvar + # source buildenvvar + source buildvar_env yarn sb:build workspace_persist: &workspace_persist @@ -70,7 +73,9 @@ build_configuration_fetch: &build_configuration_fetch name: "configuring environment" command: | ./awsconfiguration.sh $DEPLOY_ENV - ./buildenv.sh -e $DEPLOY_ENV -b ${LOGICAL_ENV}-${APPNAME}-buildvar + # ./buildenv.sh -e $DEPLOY_ENV -b ${LOGICAL_ENV}-${APPNAME}-buildvar + ./psvar-processor.sh -t appenv -p /config/${APPNAME}/buildvar + source buildvar_env aws s3 cp s3://tc-platform-${LOGICAL_ENV}/securitymanager/${LOGICAL_ENV}-platform-ui.env ./.environments/.env.${LOGICAL_ENV}.local lint_steps: &lint_steps # Initialization. @@ -98,8 +103,10 @@ deploy_steps: &deploy_steps command: | ./awsconfiguration.sh $DEPLOY_ENV source awsenvconf - ./buildenv.sh -e $DEPLOY_ENV -b ${LOGICAL_ENV}-${APPNAME}-deployvar - source buildenvvar + # ./buildenv.sh -e $DEPLOY_ENV -b ${LOGICAL_ENV}-${APPNAME}-deployvar + # source buildenvvar + ./psvar-processor.sh -t appenv -p /config/${APPNAME}/deployvar + source deployvar_env ./master_deploy.sh -d CFRONT -e $DEPLOY_ENV -c $ENABLE_CACHE jobs: @@ -221,9 +228,10 @@ workflows: - dev - LVT-256 - CORE-635 + - feat/review - feat/system-admin - - pm-1365_1 - - PM-959_tc-finance-integration + - feat/v6 + - pm-2074_1 - deployQa: context: org-global @@ -242,4 +250,4 @@ workflows: filters: &filters-prod branches: only: - - master \ No newline at end of file + - master diff --git a/.gitignore b/.gitignore index 272d0ee0d..ada8e1f55 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules /.pnp .pnp.js +.yarn # testing /coverage diff --git a/package.json b/package.json index 03bbab422..a9ef6921e 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "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}'", + "lint": "eslint --quiet -c ./src/.eslintrc.js 'src/**/*.{ts,tsx,js,jsx}'", "lint:fix": "yarn lint --fix", "test": "craco test --watchAll", "test:no-watch": "craco test --watchAll=false --passWithNoTests", @@ -20,6 +20,7 @@ }, "dependencies": { "@datadog/browser-logs": "^4.21.2", + "@hello-pangea/dnd": "^18.0.1", "@heroicons/react": "^1.0.6", "@hookform/resolvers": "^4.1.2", "@popperjs/core": "^2.11.8", @@ -50,6 +51,7 @@ "express": "^4.21.2", "express-fileupload": "^1.4.0", "express-interceptor": "^1.2.0", + "filestack-js": "^3.42.0", "highcharts": "^10.3.3", "highcharts-react-official": "^3.2.0", "highlight.js": "^11.6.0", @@ -96,9 +98,12 @@ "redux-promise": "^0.6.0", "redux-promise-middleware": "^6.1.3", "redux-thunk": "^2.4.1", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", "remark-breaks": "^3.0.2", "remark-frontmatter": "^4.0.1", "remark-gfm": "^3.0.1", + "remark-parse": "^11.0.0", "remove": "^0.1.5", "sanitize-html": "^2.12.1", "sass": "^1.79.0", diff --git a/src/.eslintrc.js b/src/.eslintrc.js index 3a4ea5127..d90237e75 100644 --- a/src/.eslintrc.js +++ b/src/.eslintrc.js @@ -1,3 +1,8 @@ +const path = require('path'); + +const tsconfigPath = path.resolve(__dirname, '../tsconfig.json'); +const tsconfigRoot = path.resolve(__dirname, '..'); + module.exports = { root: true, overrides: [ @@ -25,8 +30,8 @@ module.exports = { parser: '@typescript-eslint/parser', parserOptions: { useJSXTextNode: true, - project: './tsconfig.json', - tsconfigRootDir: '.', + project: tsconfigPath, + tsconfigRootDir: tsconfigRoot, tsx: true, jsx: true, sourceType: 'module', @@ -40,7 +45,19 @@ module.exports = { ], settings: { 'import/resolver': { - typescript: {}, + typescript: { + project: tsconfigPath, + }, + node: { + extensions: [ + '.js', + '.jsx', + '.ts', + '.tsx', + '.d.ts', + '.json', + ], + }, }, }, rules: { diff --git a/src/apps/accounts/src/config/constants.ts b/src/apps/accounts/src/config/constants.ts index 2a9a036cd..799cd4366 100644 --- a/src/apps/accounts/src/config/constants.ts +++ b/src/apps/accounts/src/config/constants.ts @@ -1,3 +1,4 @@ -import { EnvironmentConfig } from '~/config' +// (removed) CES Survey/Userflow integrations -export const CES_SURVEY_ID = EnvironmentConfig.USERFLOW_SURVEYS.ACCOUNT_SETTINGS +// Mark this file as a module for TS isolatedModules +export {} diff --git a/src/apps/accounts/src/lib/index.ts b/src/apps/accounts/src/lib/index.ts index 81de86514..a435df51a 100644 --- a/src/apps/accounts/src/lib/index.ts +++ b/src/apps/accounts/src/lib/index.ts @@ -1,4 +1,3 @@ export * from './accounts-swr' export * from './components' export * from './assets' -export * from './userflow-survey' diff --git a/src/apps/accounts/src/lib/userflow-survey.ts b/src/apps/accounts/src/lib/userflow-survey.ts deleted file mode 100644 index b86e2d62e..000000000 --- a/src/apps/accounts/src/lib/userflow-survey.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { TcUniNavFn } from 'universal-navigation' - -import { CES_SURVEY_ID } from '../config' - -declare let tcUniNav: TcUniNavFn - -export function triggerSurvey(): void { - tcUniNav('triggerFlow', CES_SURVEY_ID, {}) -} diff --git a/src/apps/accounts/src/settings/tabs/account/account-role/AccountRole.tsx b/src/apps/accounts/src/settings/tabs/account/account-role/AccountRole.tsx index 90b2f9b1e..0eac482d3 100644 --- a/src/apps/accounts/src/settings/tabs/account/account-role/AccountRole.tsx +++ b/src/apps/accounts/src/settings/tabs/account/account-role/AccountRole.tsx @@ -2,7 +2,6 @@ import { Dispatch, FC, SetStateAction, useState } from 'react' import { BaseModal, Button, Collapsible } from '~/libs/ui' import { authUrlLogout, updatePrimaryMemberRoleAsync, UserProfile } from '~/libs/core' -import { triggerSurvey } from '~/apps/accounts/src/lib' import styles from './AccountRole.module.scss' @@ -36,7 +35,6 @@ const AccountRole: FC = (props: AccountRoleProps) => { .then(() => { setMemberRole(newRole) setIsRoleChangeConfirmed(true) - triggerSurvey() }) .finally(() => { setIsUpdating(false) diff --git a/src/apps/accounts/src/settings/tabs/account/address/MemberAddress.tsx b/src/apps/accounts/src/settings/tabs/account/address/MemberAddress.tsx index 86a03a005..f99f43989 100644 --- a/src/apps/accounts/src/settings/tabs/account/address/MemberAddress.tsx +++ b/src/apps/accounts/src/settings/tabs/account/address/MemberAddress.tsx @@ -13,7 +13,6 @@ import { useCountryLookup, UserProfile, } from '~/libs/core' -import { triggerSurvey } from '~/apps/accounts/src/lib' import styles from './MemberAddress.module.scss' @@ -88,7 +87,6 @@ const MemberAddress: FC = (props: MemberAddressProps) => { .then(() => { toast.success('Your account has been updated.', { position: toast.POSITION.BOTTOM_RIGHT }) setFormErrors({}) - triggerSurvey() }) .catch(() => { toast.error('Something went wrong. Please try again.', { position: toast.POSITION.BOTTOM_RIGHT }) diff --git a/src/apps/accounts/src/settings/tabs/account/user-and-pass/UserAndPassword.tsx b/src/apps/accounts/src/settings/tabs/account/user-and-pass/UserAndPassword.tsx index 08237cb34..fa3f9aab7 100644 --- a/src/apps/accounts/src/settings/tabs/account/user-and-pass/UserAndPassword.tsx +++ b/src/apps/accounts/src/settings/tabs/account/user-and-pass/UserAndPassword.tsx @@ -15,9 +15,10 @@ import { useMemberTraits, UserProfile, UserTrait, + UserTraitIds, UserTraits, } from '~/libs/core' -import { SettingSection, triggerSurvey } from '~/apps/accounts/src/lib' +import { SettingSection } from '~/apps/accounts/src/lib' import { UserAndPassFromConfig } from './user-and-pass.form.config' import styles from './UserAndPassword.module.scss' @@ -76,13 +77,13 @@ const UserAndPassword: FC = (props: UserAndPasswordProps) data: [{ userConsent: !userConsent, }], + traitId: UserTraitIds.personalization, }, }]) .then(() => { setUserConsent(!userConsent) mutateTraits() toast.success('User consent updated successfully.') - triggerSurvey() }) .catch(() => { toast.error('Failed to update user consent.') diff --git a/src/apps/accounts/src/settings/tabs/tcandyou/communities/Communities.tsx b/src/apps/accounts/src/settings/tabs/tcandyou/communities/Communities.tsx index 28cf80e93..b26c02fa7 100644 --- a/src/apps/accounts/src/settings/tabs/tcandyou/communities/Communities.tsx +++ b/src/apps/accounts/src/settings/tabs/tcandyou/communities/Communities.tsx @@ -5,7 +5,6 @@ import { toast } from 'react-toastify' import { updateOrCreateMemberTraitsAsync, useMemberTraits, UserProfile, UserTraits } from '~/libs/core' import { Button, Collapsible, FormToggleSwitch } from '~/libs/ui' -import { triggerSurvey } from '~/apps/accounts/src/lib' import { communitiesConfig } from './communities-config' import styles from './Communities.module.scss' @@ -49,7 +48,6 @@ const Communities: FC = (props: CommunitiesProps) => { setMemberCommunities(updatedCommunities) mutateTraits() toast.success('Communities updated successfully.') - triggerSurvey() }) .catch(() => { toast.error('Failed to update user Communities.') diff --git a/src/apps/accounts/src/settings/tabs/tcandyou/tracks/Tracks.tsx b/src/apps/accounts/src/settings/tabs/tcandyou/tracks/Tracks.tsx index 99982750f..8a155982c 100644 --- a/src/apps/accounts/src/settings/tabs/tcandyou/tracks/Tracks.tsx +++ b/src/apps/accounts/src/settings/tabs/tcandyou/tracks/Tracks.tsx @@ -9,7 +9,6 @@ import { DesignTrackIcon, DevelopmentTrackIcon, SettingSection, - triggerSurvey, } from '~/apps/accounts/src/lib' import styles from './Tracks.module.scss' @@ -21,6 +20,7 @@ interface TracksProps { const Tracks: FC = (props: TracksProps) => { const [memberTracks, setMemberTracks]: [TC_TRACKS[], Dispatch] = useState(props.profile.tracks || []) + const [isUpdating, setIsUpdating] = useState(false) const memberProfileContext: ProfileContextData = useContext(profileContext) @@ -29,6 +29,11 @@ const Tracks: FC = (props: TracksProps) => { }, [props.profile]) function handleTracksChange(type: TC_TRACKS): void { + if (isUpdating) { + return + } + + setIsUpdating(true) const hasTrack: boolean = memberTracks.includes(type) let updatedTracks: TC_TRACKS[] @@ -54,11 +59,13 @@ const Tracks: FC = (props: TracksProps) => { } as any, }) toast.success('Your profile has been updated.') - triggerSurvey() }) .catch(() => { toast.error('Failed to update your profile.') }) + .finally(() => { + setIsUpdating(false) + }) } return ( @@ -83,6 +90,7 @@ const Tracks: FC = (props: TracksProps) => { name='designTrack' onChange={bind(handleTracksChange, this, 'DESIGN')} value={!!memberTracks.includes('DESIGN')} + disabled={isUpdating} /> )} /> @@ -98,6 +106,7 @@ const Tracks: FC = (props: TracksProps) => { name='devTrack' onChange={bind(handleTracksChange, this, 'DEVELOP')} value={!!memberTracks.includes('DEVELOP')} + disabled={isUpdating} /> )} /> @@ -113,6 +122,7 @@ const Tracks: FC = (props: TracksProps) => { name='dsTrack' onChange={bind(handleTracksChange, this, 'DATA_SCIENCE')} value={!!memberTracks.includes('DATA_SCIENCE')} + disabled={isUpdating} /> )} /> diff --git a/src/apps/accounts/src/settings/tabs/tools/devices/Devices.tsx b/src/apps/accounts/src/settings/tabs/tools/devices/Devices.tsx index 14e8dfc29..314c87953 100644 --- a/src/apps/accounts/src/settings/tabs/tools/devices/Devices.tsx +++ b/src/apps/accounts/src/settings/tabs/tools/devices/Devices.tsx @@ -5,11 +5,12 @@ import { toast } from 'react-toastify' import classNames from 'classnames' import { + createMemberTraitsAsync, updateMemberTraitsAsync, - updateOrCreateMemberTraitsAsync, useMemberDevicesLookup, UserProfile, UserTrait, + UserTraitIds, } from '~/libs/core' import { Button, Collapsible, ConfirmModal, IconOutline, InputSelect } from '~/libs/ui' import { @@ -20,7 +21,6 @@ import { SettingSection, SmartphoneIcon, TabletIcon, - triggerSurvey, WearableIcon, } from '~/apps/accounts/src/lib' @@ -100,6 +100,9 @@ const Devices: FC = (props: DevicesProps) => { ] = useState() + const [isSaving, setIsSaving] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + useEffect(() => { setDeviceTypesData(props.devicesTrait?.traits.data) }, [props.devicesTrait]) @@ -151,6 +154,10 @@ const Devices: FC = (props: DevicesProps) => { } function onRemoveItemConfirm(): void { + if (isDeleting) { + return + } + const updatedDeviceTypesData: UserTrait[] = reject(deviceTypesData, (trait: UserTrait) => ( trait.model === itemToRemove?.model && trait.deviceType === itemToRemove?.deviceType @@ -159,6 +166,7 @@ const Devices: FC = (props: DevicesProps) => { resetForm() + setIsDeleting(true) updateMemberTraitsAsync( props.profile.handle, [{ @@ -166,19 +174,20 @@ const Devices: FC = (props: DevicesProps) => { traitId: 'device', traits: { data: updatedDeviceTypesData, + traitId: UserTraitIds.device, }, }], ) .then(() => { toast.success('Device deleted successfully') setDeviceTypesData(updatedDeviceTypesData) - triggerSurvey() }) .catch(() => { toast.error('Error deleting Device') }) .finally(() => { toggleRemoveConfirmation() + setIsDeleting(false) }) } @@ -209,6 +218,10 @@ const Devices: FC = (props: DevicesProps) => { } function handleFormAction(): void { + if (isSaving) { + return + } + const updatedFormErrors: { [key: string]: string } = {} const deviceUpdate: UserTrait = { deviceType: selectedDeviceType, @@ -247,6 +260,7 @@ const Devices: FC = (props: DevicesProps) => { } if (isEmpty(updatedFormErrors)) { + setIsSaving(true) // call the API to update the trait based on action type if (isEditMode) { const updatedDeviceTypesData: UserTrait[] = reject( @@ -269,6 +283,7 @@ const Devices: FC = (props: DevicesProps) => { ...updatedDeviceTypesData || [], deviceUpdate, ], + traitId: UserTraitIds.device, }, }], ) @@ -278,7 +293,6 @@ const Devices: FC = (props: DevicesProps) => { ...updatedDeviceTypesData || [], deviceUpdate, ]) - triggerSurvey() }) .catch(() => { toast.error('Error updating Device') @@ -286,20 +300,26 @@ const Devices: FC = (props: DevicesProps) => { .finally(() => { resetForm() setIsEditMode(false) + setIsSaving(false) }) } else { - updateOrCreateMemberTraitsAsync( + const request = [{ + categoryName: 'Device', + traitId: 'device', + traits: { + data: [ + ...deviceTypesData || [], + deviceUpdate, + ], + traitId: UserTraitIds.device, + }, + }] + + const action = props.devicesTrait ? updateMemberTraitsAsync : createMemberTraitsAsync + + action( props.profile.handle, - [{ - categoryName: 'Device', - traitId: 'device', - traits: { - data: [ - ...deviceTypesData || [], - deviceUpdate, - ], - }, - }], + request, ) .then(() => { toast.success('Device added successfully') @@ -314,6 +334,7 @@ const Devices: FC = (props: DevicesProps) => { .finally(() => { resetForm() setIsEditMode(false) + setIsSaving(false) }) } } @@ -369,6 +390,7 @@ const Devices: FC = (props: DevicesProps) => { onClose={toggleRemoveConfirmation} onConfirm={onRemoveItemConfirm} open={removeConfirmationOpen} + isLoading={isDeleting} >
Are you sure you want to delete @@ -445,6 +467,7 @@ const Devices: FC = (props: DevicesProps) => { link label={`${isEditMode ? 'Edit' : 'Add'} Device to your List`} onClick={handleFormAction} + disabled={isSaving} /> {isEditMode && (
)} title={trait.name} - infoText={trait.softwareType} + infoText={softwareTypes.find(t => t.value === trait.softwareType)?.label || trait.softwareType} actionElement={(
Back @@ -68,7 +122,7 @@ export const BillingAccountResourcesPage: FC = (props: Props) => { {isRemovingBool && ( @@ -83,6 +137,46 @@ export const BillingAccountResourcesPage: FC = (props: Props) => { )} + + {/* Remove confirmation */} + + Are you sure you want to remove  + {pendingRemoveItem?.name} +  from this billing account? + + + {/* Add Resource Modal */} + +
+ +
+ + +
+ {isAdding && ( +
+ +
+ )} +
+
) } diff --git a/src/apps/admin/src/billing-account/ClientEditPage/ClientEditPage.tsx b/src/apps/admin/src/billing-account/ClientEditPage/ClientEditPage.tsx index 25b2e58d3..b446338eb 100644 --- a/src/apps/admin/src/billing-account/ClientEditPage/ClientEditPage.tsx +++ b/src/apps/admin/src/billing-account/ClientEditPage/ClientEditPage.tsx @@ -69,7 +69,7 @@ export const ClientEditPage: FC = (props: Props) => { endDate: undefined, name: '', startDate: undefined, - status: 'Active', + status: 'ACTIVE', }, mode: 'all', resolver: yupResolver(formEditClientSchema), diff --git a/src/apps/admin/src/billing-account/ClientsPage/ClientsPage.tsx b/src/apps/admin/src/billing-account/ClientsPage/ClientsPage.tsx index ceb44fc8f..3bb9b0b02 100644 --- a/src/apps/admin/src/billing-account/ClientsPage/ClientsPage.tsx +++ b/src/apps/admin/src/billing-account/ClientsPage/ClientsPage.tsx @@ -1,17 +1,18 @@ /** * Billing account clients page. */ -import { FC, useState } from 'react' +import { FC, useCallback, useState } from 'react' import classNames from 'classnames' -import { colWidthType, LinkButton, LoadingSpinner, PageDivider, PageTitle } from '~/libs/ui' import { PlusIcon } from '@heroicons/react/solid' +import { Button, colWidthType, LoadingSpinner, PageDivider, PageTitle } from '~/libs/ui' import { MSG_NO_RECORD_FOUND } from '../../config/index.config' import { useManageClients, useManageClientsProps } from '../../lib/hooks' import { PageContent, PageHeader } from '../../lib' import { ClientsFilter } from '../../lib/components/ClientsFilter' import { ClientsTable } from '../../lib/components/ClientsTable' +import { DialogAddClient } from '../../lib/components/DialogAddClient' import styles from './ClientsPage.module.scss' @@ -23,6 +24,7 @@ const pageTitle = 'Clients' export const ClientsPage: FC = (props: Props) => { const [colWidth, setColWidth] = useState({}) + const [showAddDialog, setShowAddDialog] = useState(false) const { isLoading, datas, @@ -32,23 +34,32 @@ export const ClientsPage: FC = (props: Props) => { sort, setSort, setFilterCriteria, + reloadData, }: useManageClientsProps = useManageClients({ endDateString: 'endDate', startDateString: 'startDate', }) + const handleOpenAddDialog = useCallback(() => { + setShowAddDialog(true) + }, []) + + const handleAdded = useCallback(() => { + reloadData?.() + }, [reloadData]) + return (
{pageTitle}

{pageTitle}

-
@@ -85,6 +96,11 @@ export const ClientsPage: FC = (props: Props) => { )} +
) } diff --git a/src/apps/admin/src/challenge-management/ChallengeManagementPage/ChallengeManagementPage.tsx b/src/apps/admin/src/challenge-management/ChallengeManagementPage/ChallengeManagementPage.tsx index 6476abb03..747456180 100644 --- a/src/apps/admin/src/challenge-management/ChallengeManagementPage/ChallengeManagementPage.tsx +++ b/src/apps/admin/src/challenge-management/ChallengeManagementPage/ChallengeManagementPage.tsx @@ -52,7 +52,7 @@ const defaultFilter: ChallengeFilterCriteria = { * Challenge Management page. */ export const ChallengeManagementPage: FC = () => { - const pageTitle = 'v5 Challenge Management' + const pageTitle = 'Challenge Management' const [filterCriteria, setFilterCriteria]: [ ChallengeFilterCriteria, Dispatch>, diff --git a/src/apps/admin/src/challenge-management/ManageMarathonMatchPage/ManageMarathonMatchPage.module.scss b/src/apps/admin/src/challenge-management/ManageMarathonMatchPage/ManageMarathonMatchPage.module.scss new file mode 100644 index 000000000..68aa52d6c --- /dev/null +++ b/src/apps/admin/src/challenge-management/ManageMarathonMatchPage/ManageMarathonMatchPage.module.scss @@ -0,0 +1,31 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + gap: $sp-6; +} + +.headerButtons { + display: flex; + flex-wrap: wrap; + gap: $sp-3; + justify-content: flex-end; +} + +.tableSection { + display: flex; + flex-direction: column; + gap: $sp-3; +} + +.sectionHeading { + font-size: 1.25rem; + font-weight: 600; + margin: 0; +} + +.errorMessage { + color: $red-110; + font-weight: 500; +} diff --git a/src/apps/admin/src/challenge-management/ManageMarathonMatchPage/ManageMarathonMatchPage.tsx b/src/apps/admin/src/challenge-management/ManageMarathonMatchPage/ManageMarathonMatchPage.tsx new file mode 100644 index 000000000..3bdd14a96 --- /dev/null +++ b/src/apps/admin/src/challenge-management/ManageMarathonMatchPage/ManageMarathonMatchPage.tsx @@ -0,0 +1,450 @@ +import { + FC, + useCallback, + useMemo, + useState, +} from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { toast } from 'react-toastify' +import _ from 'lodash' +import classNames from 'classnames' + +import { Button, LinkButton, ProgressBar } from '~/libs/ui' + +import { + ActionLoading, + PageWrapper, + TableLoading, + TableNoRecord, +} from '../../lib' +import { ConfirmModal } from '../../lib/components' +import { + useDownloadSubmission, + useDownloadSubmissionProps, + useManageBusEvent, + useManageBusEventProps, + useManageMarathonMatch, + useManageMarathonMatchProps, +} from '../../lib/hooks' +import { IsRemovingType } from '../../lib/models' +import { removeReviewSummations } from '../../lib/services/reviews.service' +import { removeSubmission } from '../../lib/services/submissions.service' +import { closeMarathonMatch } from '../../lib/services/challenges.service' +import { handleError } from '../../lib/utils' + +import { MarathonMatchScoreTable } from './MarathonMatchScoreTable' +import styles from './ManageMarathonMatchPage.module.scss' + +interface Props { + className?: string +} + +export const ManageMarathonMatchPage: FC = (props: Props) => { + const { challengeId = '' }: { challengeId?: string } + = useParams<{ challengeId: string }>() + const navigate = useNavigate() + + const { + error, + finalScoresData, + isLoading, + provisionalScores, + submissions, + }: useManageMarathonMatchProps = useManageMarathonMatch(challengeId) + + const { + doPostBusEvent, + isRunningTest, + isRunningTestBool, + }: useManageBusEventProps = useManageBusEvent() + + const { + downloadSubmission, + isLoading: isDownloadingSubmission, + isLoadingBool: isDownloadingSubmissionBool, + }: useDownloadSubmissionProps = useDownloadSubmission() + + const [isRemovingSubmission, setIsRemovingSubmission] + = useState({}) + const isRemovingSubmissionBool = useMemo( + () => _.some(isRemovingSubmission, value => value === true), + [isRemovingSubmission], + ) + + const [isRemovingReviewSummations, setIsRemovingReviewSummations] + = useState({}) + const isRemovingReviewSummationsBool = useMemo( + () => _.some(isRemovingReviewSummations, value => value === true), + [isRemovingReviewSummations], + ) + + const [showRunAllTestsConfirm, setShowRunAllTestsConfirm] + = useState(false) + const [showCloseConfirm, setShowCloseConfirm] + = useState(false) + const [showCloseError, setShowCloseError] + = useState(false) + const [isRunningAllTests, setIsRunningAllTests] + = useState(false) + const [runAllTestsProgress, setRunAllTestsProgress] = useState<{ current: number; total: number }>({ + current: 0, + total: 0, + }) + const [isClosingChallenge, setIsClosingChallenge] + = useState(false) + + const doRemoveSubmission = useCallback( + (submissionId: string) => { + if (!submissionId) { + return + } + + setIsRemovingSubmission(prev => ({ + ...prev, + [submissionId]: true, + })) + + removeSubmission(submissionId) + .then(() => { + toast.success('Submission removed successfully', { + toastId: 'Remove submission', + }) + }) + .catch(handleError) + .finally(() => { + setIsRemovingSubmission(prev => ({ + ...prev, + [submissionId]: false, + })) + }) + }, + [], + ) + + const doRemoveReviewSummations = useCallback( + (reviewSummationId?: string) => { + if (!reviewSummationId) { + return + } + + setIsRemovingReviewSummations(prev => ({ + ...prev, + [reviewSummationId]: true, + })) + + removeReviewSummations([reviewSummationId]) + .then(() => { + toast.success('Review summation removed successfully', { + toastId: 'Remove review summation', + }) + }) + .catch(handleError) + .finally(() => { + setIsRemovingReviewSummations(prev => ({ + ...prev, + [reviewSummationId]: false, + })) + }) + }, + [], + ) + + const handleRunAllSystemTests = useCallback(() => { + setShowRunAllTestsConfirm(true) + }, []) + + const dismissRunAllTestsConfirm = useCallback(() => { + setShowRunAllTestsConfirm(false) + }, []) + + const noop = useCallback(() => undefined, []) + + const handleConfirmRunAllTests = useCallback(async () => { + const submissionIds = submissions + .map(submission => submission.id) + .filter(Boolean) + + setShowRunAllTestsConfirm(false) + if (!submissionIds.length) { + toast.info('No submissions available for system tests', { + toastId: 'Run all system tests', + }) + return + } + + setIsRunningAllTests(true) + setRunAllTestsProgress({ current: 0, total: submissionIds.length }) + + let hasError = false + + for (let index = 0; index < submissionIds.length; index += 1) { + const submissionId = submissionIds[index] + + try { + // Ensure sequential updates for progress tracking + // eslint-disable-next-line no-await-in-loop + await doPostBusEvent(submissionId, 'system', { silent: true }) + } catch (err) { + hasError = true + handleError(err) + } finally { + setRunAllTestsProgress({ + current: index + 1, + total: submissionIds.length, + }) + } + } + + setIsRunningAllTests(false) + + if (hasError) { + toast.error('Some system tests failed to queue. Please review the errors and retry as needed.', { + toastId: 'Run all system tests error', + }) + return + } + + toast.success('All system tests have been queued successfully', { + toastId: 'Run all system tests', + }) + }, [doPostBusEvent, submissions]) + + const handleCloseChallenge = useCallback(() => { + const hasIncompleteFinalScores = finalScoresData.some(item => !item.reviewSummation) + + if (hasIncompleteFinalScores) { + setShowCloseError(true) + return + } + + setShowCloseConfirm(true) + }, [finalScoresData]) + + const dismissCloseConfirmModal = useCallback(() => { + setShowCloseConfirm(false) + }, []) + + const dismissCloseErrorModal = useCallback(() => { + setShowCloseError(false) + }, []) + + const handleConfirmCloseChallenge = useCallback(async () => { + setIsClosingChallenge(true) + setShowCloseConfirm(false) + + try { + await closeMarathonMatch(challengeId) + toast.success('Challenge closed successfully', { + toastId: 'Close marathon match challenge', + }) + // TODO: Confirm the appropriate post-close destination once the navigation flow is finalized. + navigate('../..') + } catch (err) { + handleError(err) + } finally { + setIsClosingChallenge(false) + } + }, [challengeId, navigate]) + + const isActionInProgress = useMemo( + () => ( + isDownloadingSubmissionBool + || isRemovingSubmissionBool + || isRunningTestBool + || isRemovingReviewSummationsBool + || isRunningAllTests + || isClosingChallenge + ), + [ + isDownloadingSubmissionBool, + isRemovingReviewSummationsBool, + isRemovingSubmissionBool, + isRunningTestBool, + isClosingChallenge, + isRunningAllTests, + ], + ) + + const renderScoresSection = ( + heading: string, + content: JSX.Element, + hasRecords: boolean, + ): JSX.Element => ( +
+

{heading}

+ {hasRecords ? content : } +
+ ) + + let pageContent: JSX.Element | undefined + + if (isLoading) { + pageContent = + } else if (error) { + pageContent = ( +
+ {error.message || 'Unable to load marathon match data.'} +
+ ) + } else { + pageContent = ( + <> + {renderScoresSection( + 'Provisional Scores', + ( + + ), + provisionalScores.length > 0, + )} + + {renderScoresSection( + 'Final Scores', + ( + + ), + finalScoresData.length > 0, + )} + + ) + } + + return ( + + + + + Back + + + )} + > + {pageContent} + + {isActionInProgress && !isRunningAllTests && } + + {showRunAllTestsConfirm && ( + +

+ Are you sure you want to run system tests for this challenge? + {' '} + This will overwrite any existing system tests run for these submissions. +

+

+ {submissions.length} + {' '} + submission(s) will be tested. +

+
+ )} + + {isRunningAllTests && ( + +

+ Queueing system tests: + {' '} + {runAllTestsProgress.current} + {' '} + of + {' '} + {runAllTestsProgress.total} +

+ 0 + ? runAllTestsProgress.current / runAllTestsProgress.total + : 0} + /> +
+ )} + + {showCloseConfirm && ( + + Are you sure you want to close this challenge? + {' '} + This will finalize the results and determine winners based on final scores. + + )} + + {showCloseError && ( + +

Final system tests are not complete. Please wait until all tests have finished and try again.

+
+ +
+
+ )} +
+ ) +} + +export default ManageMarathonMatchPage diff --git a/src/apps/admin/src/challenge-management/ManageMarathonMatchPage/MarathonMatchScoreTable.module.scss b/src/apps/admin/src/challenge-management/ManageMarathonMatchPage/MarathonMatchScoreTable.module.scss new file mode 100644 index 000000000..863e968f2 --- /dev/null +++ b/src/apps/admin/src/challenge-management/ManageMarathonMatchPage/MarathonMatchScoreTable.module.scss @@ -0,0 +1,46 @@ +@import '@libs/ui/styles/includes'; + +.container { + width: 100%; + overflow-x: auto; +} + +.table { + width: 100%; +} + +.downloadLink { + background: none; + border: none; + color: $link-blue; + cursor: pointer; + font: inherit; + padding: 0; + text-align: left; + text-decoration: underline; + + &:hover:not(:disabled) { + color: $link-blue-dark; + text-decoration: none; + } + + &:focus-visible { + outline: 2px solid $blue-120; + outline-offset: 2px; + } + + &:disabled { + color: $black-60; + cursor: default; + text-decoration: none; + } +} + +.isDownloading { + color: $black-60; + cursor: default; +} + +.noScore { + color: $black-60; +} diff --git a/src/apps/admin/src/challenge-management/ManageMarathonMatchPage/MarathonMatchScoreTable.tsx b/src/apps/admin/src/challenge-management/ManageMarathonMatchPage/MarathonMatchScoreTable.tsx new file mode 100644 index 000000000..6c25d3a99 --- /dev/null +++ b/src/apps/admin/src/challenge-management/ManageMarathonMatchPage/MarathonMatchScoreTable.tsx @@ -0,0 +1,333 @@ +import { + FC, + useMemo, + useState, +} from 'react' +import classNames from 'classnames' +import moment from 'moment' + +import { EnvironmentConfig } from '~/config' +import { getRatingColor } from '~/libs/core' +import { Table, TableColumn } from '~/libs/ui' + +import { TABLE_DATE_FORMAT } from '../../config/index.config' +import { ConfirmModal } from '../../lib/components' +import { TableWrapper } from '../../lib/components/common/TableWrapper' +import { + IsRemovingType, + Submission, + SubmissionReviewSummation, +} from '../../lib/models' +import type { DoPostBusEvent } from '../../lib/hooks' + +import { MarathonMatchScoreTableActions } from './MarathonMatchScoreTableActions' +import styles from './MarathonMatchScoreTable.module.scss' + +function normalizeDateValue( + value?: Date | string | number | null, +): number | undefined { + if (!value && value !== 0) { + return undefined + } + + const timestamp = typeof value === 'number' + ? value + : new Date(value) + .getTime() + + return Number.isNaN(timestamp) ? undefined : timestamp +} + +type FinalScoresRow = { + submission: Submission + reviewSummation?: SubmissionReviewSummation +} + +type TableData = SubmissionReviewSummation[] | FinalScoresRow[] + +interface NormalizedScoreRow { + aggregateScore?: number + createdAt?: number + maxRating?: number + memberCreatedBy?: string + memberHandle?: string + reviewSummationId?: string + submissionId: string +} + +interface Props { + className?: string + data: TableData + isFinalScores?: boolean + testType: 'provisional' | 'system' + isRunningTest: IsRemovingType + doPostBusEvent: DoPostBusEvent + isRemovingSubmission: IsRemovingType + doRemoveSubmission: (submissionId: string) => void + doRemoveReviewSummations?: (reviewSummationId: string) => void + isRemovingReviewSummations?: IsRemovingType + isDownloadingSubmission: IsRemovingType + downloadSubmission: (submissionId: string) => void +} + +export const MarathonMatchScoreTable: FC = props => { + const className = props.className + const tableData = props.data + const isFinalScores = props.isFinalScores + const downloadSubmission = props.downloadSubmission + const isDownloadingSubmission = props.isDownloadingSubmission + const doPostBusEvent = props.doPostBusEvent + const isRemovingSubmission = props.isRemovingSubmission + const isRunningTest = props.isRunningTest + const testType = props.testType + const doRemoveSubmission = props.doRemoveSubmission + const doRemoveReviewSummations = props.doRemoveReviewSummations + const isRemovingReviewSummations = props.isRemovingReviewSummations + + const [showConfirmDeleteDialog, setShowConfirmDeleteDialog] + = useState() + const [ + showConfirmDeleteReviewSummationDialog, + setShowConfirmDeleteReviewSummationDialog, + ] = useState() + + const normalizedData = useMemo(() => { + if (isFinalScores) { + const finalRows = tableData as FinalScoresRow[] + + return finalRows.map(item => { + const createdAt = normalizeDateValue( + item.submission.createdAt + ?? item.submission.submittedDate + ?? item.submission.updatedAt + ?? undefined, + ) + + return { + aggregateScore: item.reviewSummation?.aggregateScore, + createdAt, + maxRating: typeof item.submission.submitterMaxRating === 'number' + ? item.submission.submitterMaxRating + : undefined, + memberCreatedBy: item.submission.createdBy ?? undefined, + memberHandle: item.submission.submitterHandle + ?? item.submission.createdBy + ?? undefined, + reviewSummationId: item.reviewSummation?.id, + submissionId: item.submission.id, + } + }) + } + + const provisionalRows = tableData as SubmissionReviewSummation[] + + return provisionalRows.map(item => ({ + aggregateScore: item.aggregateScore, + createdAt: normalizeDateValue(item.createdAt ?? undefined), + maxRating: typeof item.submitterMaxRating === 'number' + ? item.submitterMaxRating + : undefined, + memberCreatedBy: item.submitterHandle ?? undefined, + memberHandle: item.submitterHandle ?? undefined, + reviewSummationId: item.id, + submissionId: item.submissionId, + })) + }, [isFinalScores, tableData]) + + const columns = useMemo[]>( + () => ([ + { + label: 'Member', + propertyName: 'memberHandle', + renderer: data => { + const handle = data.memberHandle ?? undefined + const rating = typeof data.maxRating === 'number' + ? data.maxRating + : undefined + const href = handle + ? `${EnvironmentConfig.URLS.USER_PROFILE}/${encodeURIComponent(handle)}` + : undefined + const color = getRatingColor(rating) + + if (handle && href) { + return ( + + {handle} + + ) + } + + return ( + + {data.memberCreatedBy ?? '--'} + + ) + }, + type: 'element', + }, + { + label: 'Submission ID', + propertyName: 'submissionId', + renderer: data => { + const isDownloading = Boolean( + isDownloadingSubmission[data.submissionId], + ) + + function handleDownload(): void { + downloadSubmission(data.submissionId) + } + + return ( + + ) + }, + type: 'element', + }, + { + defaultSortDirection: 'desc', + label: 'Score', + propertyName: 'aggregateScore', + renderer: data => { + if (typeof data.aggregateScore === 'number') { + return ( + {data.aggregateScore} + ) + } + + return N/A + }, + type: 'numberElement', + }, + { + defaultSortDirection: 'desc', + isDefaultSort: true, + label: 'Created', + propertyName: 'createdAt', + renderer: data => { + if (!data.createdAt) { + return -- + } + + return ( + + {moment(data.createdAt) + .local() + .format(TABLE_DATE_FORMAT)} + + ) + }, + type: 'date', + }, + { + label: '', + renderer: data => ( + + ), + type: 'element', + }, + ]), + [ + doPostBusEvent, + downloadSubmission, + isDownloadingSubmission, + isRemovingSubmission, + isRunningTest, + doRemoveReviewSummations, + isRemovingReviewSummations, + testType, + ], + ) + + function handleCloseConfirmModal(): void { + setShowConfirmDeleteDialog(undefined) + } + + function handleConfirmDelete(): void { + if (!showConfirmDeleteDialog) { + return + } + + doRemoveSubmission(showConfirmDeleteDialog) + setShowConfirmDeleteDialog(undefined) + } + + function handleCloseConfirmReviewSummationModal(): void { + setShowConfirmDeleteReviewSummationDialog(undefined) + } + + function handleConfirmDeleteReviewSummation(): void { + if (!showConfirmDeleteReviewSummationDialog || !doRemoveReviewSummations) { + return + } + + doRemoveReviewSummations(showConfirmDeleteReviewSummationDialog) + setShowConfirmDeleteReviewSummationDialog(undefined) + } + + return ( + + + + {showConfirmDeleteDialog && ( + +
Are you sure you want to delete this submission?
+
+ )} + + {showConfirmDeleteReviewSummationDialog && ( + +
+ Are you sure you want to delete this review summation? +
+
+ )} + + ) +} + +export default MarathonMatchScoreTable diff --git a/src/apps/admin/src/challenge-management/ManageMarathonMatchPage/MarathonMatchScoreTableActions.tsx b/src/apps/admin/src/challenge-management/ManageMarathonMatchPage/MarathonMatchScoreTableActions.tsx new file mode 100644 index 000000000..617310f1d --- /dev/null +++ b/src/apps/admin/src/challenge-management/ManageMarathonMatchPage/MarathonMatchScoreTableActions.tsx @@ -0,0 +1,144 @@ +import { + Dispatch, + FC, + SetStateAction, + useState, +} from 'react' +import classNames from 'classnames' + +import { ChevronDownIcon } from '@heroicons/react/solid' +import { Button } from '~/libs/ui' + +import { DropdownMenu } from '../../lib/components/common/DropdownMenu' +import { useEventCallback } from '../../lib/hooks' +import type { DoPostBusEvent } from '../../lib/hooks' +import { IsRemovingType } from '../../lib/models' + +interface Props { + submissionId: string + reviewSummationId?: string + testType: 'provisional' | 'system' + isRunningTest: IsRemovingType + doPostBusEvent: DoPostBusEvent + isRemovingSubmission: IsRemovingType + setShowConfirmDeleteDialog: Dispatch> + doRemoveReviewSummations?: (reviewSummationId: string) => void + isRemovingReviewSummations?: IsRemovingType + setShowConfirmDeleteReviewSummationDialog?: Dispatch> +} + +export const MarathonMatchScoreTableActions: FC = (props: Props) => { + const [openDropdown, setOpenDropdown] = useState(false) + + const manageDropdownMenuTrigger = useEventCallback( + (triggerProps: { + open: boolean + setOpen: Dispatch> + }) => { + function handleToggle(): void { + triggerProps.setOpen(!triggerProps.open) + } + + return ( + + ) + }, + ) + + const testLabel = props.testType === 'system' + ? 'System' + : 'Provisional' + + const testStatusKey = `${props.submissionId}_${props.testType}` + + function handleRerunTest(): void { + if (props.isRunningTest[testStatusKey]) { + return + } + + setOpenDropdown(false) + props.doPostBusEvent(props.submissionId, props.testType) + .catch(() => undefined) + } + + function handleDeleteSubmission(): void { + if (props.isRemovingSubmission[props.submissionId]) { + return + } + + props.setShowConfirmDeleteDialog(props.submissionId) + setOpenDropdown(false) + } + + function handleDeleteReviewSummation(): void { + const reviewSummationId = props.reviewSummationId + if ( + !reviewSummationId + || !props.doRemoveReviewSummations + || !props.setShowConfirmDeleteReviewSummationDialog + ) { + return + } + + if (props.isRemovingReviewSummations?.[reviewSummationId]) { + return + } + + props.setShowConfirmDeleteReviewSummationDialog(reviewSummationId) + setOpenDropdown(false) + } + + return ( + +
    +
  • + Re-run + {' '} + {testLabel} + {' '} + Test +
  • +
  • + Delete Submission +
  • + {props.reviewSummationId + && props.doRemoveReviewSummations + && props.setShowConfirmDeleteReviewSummationDialog && ( +
  • + Delete Review Summation +
  • + )} +
+
+ ) +} + +export default MarathonMatchScoreTableActions diff --git a/src/apps/admin/src/challenge-management/ManageMarathonMatchPage/index.ts b/src/apps/admin/src/challenge-management/ManageMarathonMatchPage/index.ts new file mode 100644 index 000000000..ccc79628b --- /dev/null +++ b/src/apps/admin/src/challenge-management/ManageMarathonMatchPage/index.ts @@ -0,0 +1,2 @@ +export { default as ManageMarathonMatchPage } from './ManageMarathonMatchPage' +export * from './ManageMarathonMatchPage' diff --git a/src/apps/admin/src/challenge-management/ManageUserPage/ManageUserPage.tsx b/src/apps/admin/src/challenge-management/ManageUserPage/ManageUserPage.tsx index dffe348c3..26f376312 100644 --- a/src/apps/admin/src/challenge-management/ManageUserPage/ManageUserPage.tsx +++ b/src/apps/admin/src/challenge-management/ManageUserPage/ManageUserPage.tsx @@ -41,7 +41,7 @@ import { getChallengeResources, } from '../../lib/services' import { createChallengeQueryString, handleError } from '../../lib/utils' -import { useEventCallback } from '../../lib/hooks' +import { useEventCallback, useFetchChallenge } from '../../lib/hooks' import { rootRoute } from '../../config/routes.config' import styles from './ManageUserPage.module.scss' @@ -69,10 +69,14 @@ const BackToChallengeListButton: FC = () => { * Manage Users page. */ export const ManageUserPage: FC = () => { - const pageTitle = 'Manage Users' const { challengeId = '' }: { challengeId?: string } = useParams<{ challengeId: string }>() + const { challengeInfo }: ReturnType + = useFetchChallenge(challengeId) + const pageTitle = challengeInfo?.name + ? `Manage users for ${challengeInfo.name}` + : 'Manage Users' const [filterCriteria, setFilterCriteria]: [ ChallengeResourceFilterCriteria, Dispatch>, diff --git a/src/apps/admin/src/config/index.config.ts b/src/apps/admin/src/config/index.config.ts index efa294770..97ffc5c48 100644 --- a/src/apps/admin/src/config/index.config.ts +++ b/src/apps/admin/src/config/index.config.ts @@ -12,12 +12,12 @@ export const USER_STATUS_SELECT_OPTIONS: InputSelectOption[] = [ ] export const BILLING_ACCOUNT_STATUS_FILTER_OPTIONS: InputSelectOption[] = [ { label: 'Select status', value: '' }, - { label: 'Active', value: '1' }, - { label: 'Inactive', value: '0' }, + { label: 'Active', value: 'ACTIVE' }, + { label: 'Inactive', value: 'INACTIVE' }, ] export const BILLING_ACCOUNT_STATUS_EDIT_OPTIONS: InputSelectOption[] = [ - { label: 'Active', value: 'Active' }, - { label: 'Inactive', value: 'Inactive' }, + { label: 'Active', value: 'ACTIVE' }, + { label: 'Inactive', value: 'INACTIVE' }, ] export const BILLING_ACCOUNT_RESOURCE_STATUS_EDIT_OPTIONS: InputSelectOption[] = [ diff --git a/src/apps/admin/src/config/routes.config.ts b/src/apps/admin/src/config/routes.config.ts index 7fa82046f..031002120 100644 --- a/src/apps/admin/src/config/routes.config.ts +++ b/src/apps/admin/src/config/routes.config.ts @@ -16,3 +16,4 @@ export const permissionManagementRouteId = 'permission-management' export const gamificationAdminRouteId = 'gamification-admin' export const termsRouteId = 'terms' export const platformRouteId = 'platform' +export const paymentsRouteId = 'payments' diff --git a/src/apps/admin/src/lib/components/BillingAccountResourcesTable/BillingAccountResourcesTable.tsx b/src/apps/admin/src/lib/components/BillingAccountResourcesTable/BillingAccountResourcesTable.tsx index 69eee47f7..cf75cfdaf 100644 --- a/src/apps/admin/src/lib/components/BillingAccountResourcesTable/BillingAccountResourcesTable.tsx +++ b/src/apps/admin/src/lib/components/BillingAccountResourcesTable/BillingAccountResourcesTable.tsx @@ -53,7 +53,7 @@ export const BillingAccountResourcesTable: FC = (props: Props) => { renderer: (data: BillingAccountResource) => ( + + + {isAdding && ( +
+ +
+ )} + + + ) +} + +export default DialogAddClient diff --git a/src/apps/admin/src/lib/components/DialogAddClient/index.ts b/src/apps/admin/src/lib/components/DialogAddClient/index.ts new file mode 100644 index 000000000..92c33fc96 --- /dev/null +++ b/src/apps/admin/src/lib/components/DialogAddClient/index.ts @@ -0,0 +1 @@ +export { default as DialogAddClient } from './DialogAddClient' diff --git a/src/apps/admin/src/lib/components/DialogAddTermUser/DialogAddTermUser.tsx b/src/apps/admin/src/lib/components/DialogAddTermUser/DialogAddTermUser.tsx index 986ef9bab..2f00b9d9e 100644 --- a/src/apps/admin/src/lib/components/DialogAddTermUser/DialogAddTermUser.tsx +++ b/src/apps/admin/src/lib/components/DialogAddTermUser/DialogAddTermUser.tsx @@ -37,7 +37,14 @@ interface Props { } export const DialogAddTermUser: FC = (props: Props) => { - const handleClose = useEventCallback(() => props.setOpen(false)) + const className = props.className + const open = props.open + const setOpen = props.setOpen + const termInfo = props.termInfo + const isAdding = props.isAdding + const doAddTermUser = props.doAddTermUser + + const handleClose = useEventCallback(() => setOpen(false)) const { handleSubmit, control, @@ -56,11 +63,11 @@ export const DialogAddTermUser: FC = (props: Props) => { */ const onSubmit = useCallback( (data: FormAddTermUser) => { - props.doAddTermUser( + doAddTermUser( data.handle?.value ?? 0, data.handle?.label ?? '', () => { - props.setOpen(false) + setOpen(false) }, () => { reset({ @@ -70,22 +77,22 @@ export const DialogAddTermUser: FC = (props: Props) => { }, ) }, - [props.doAddTermUser, reset], + [doAddTermUser, reset, setOpen], ) return (
@@ -105,7 +112,7 @@ export const DialogAddTermUser: FC = (props: Props) => { onChange={controlProps.field.onChange} onBlur={controlProps.field.onBlur} classNameWrapper={styles.inputField} - disabled={props.isAdding} + disabled={isAdding} dirty error={_.get(errors, 'handle.message')} /> @@ -118,7 +125,7 @@ export const DialogAddTermUser: FC = (props: Props) => { secondary size='lg' onClick={handleClose} - disabled={props.isAdding} + disabled={isAdding} > Close @@ -126,13 +133,13 @@ export const DialogAddTermUser: FC = (props: Props) => { type='submit' primary size='lg' - disabled={props.isAdding || !isValid || !isDirty} + disabled={isAdding || !isValid || !isDirty} > Sign Terms
- {props.isAdding && ( + {isAdding && (
diff --git a/src/apps/admin/src/lib/components/FieldClientSelect/FieldClientSelect.tsx b/src/apps/admin/src/lib/components/FieldClientSelect/FieldClientSelect.tsx index 6375a8487..0659e28a8 100644 --- a/src/apps/admin/src/lib/components/FieldClientSelect/FieldClientSelect.tsx +++ b/src/apps/admin/src/lib/components/FieldClientSelect/FieldClientSelect.tsx @@ -7,7 +7,8 @@ import { ClientInfo, SelectOption } from '../../models' import { FieldSingleSelectAsync } from '../FieldSingleSelectAsync' import { searchClients } from '../../services' -async function autoCompleteDatas(queryTerm: string): Promise { +async function autoCompleteDatas(rawQueryTerm: string): Promise { + const queryTerm = rawQueryTerm.trim() if (!queryTerm) { return Promise.resolve([]) } @@ -15,6 +16,7 @@ async function autoCompleteDatas(queryTerm: string): Promise { const result = await searchClients( { name: queryTerm, + status: 'ACTIVE', }, { limit: 10, @@ -38,6 +40,9 @@ const fetchDatas = ( })), ) }) + .catch(() => { + callback([]) + }) } interface Props { diff --git a/src/apps/admin/src/lib/components/FieldSingleSelectAsync/FieldSingleSelectAsync.tsx b/src/apps/admin/src/lib/components/FieldSingleSelectAsync/FieldSingleSelectAsync.tsx index 3c9d90abf..6525280b6 100644 --- a/src/apps/admin/src/lib/components/FieldSingleSelectAsync/FieldSingleSelectAsync.tsx +++ b/src/apps/admin/src/lib/components/FieldSingleSelectAsync/FieldSingleSelectAsync.tsx @@ -79,6 +79,8 @@ export const FieldSingleSelectAsync: FC = (props: Props) => { components={asyncSelectComponents} className={classNames(props.className, styles.select)} placeholder={props.placeholder ?? 'Enter'} + cacheOptions + defaultOptions={[]} menuPortalTarget={document.body} classNames={{ container: () => styles.select, diff --git a/src/apps/admin/src/lib/components/ReviewSummaryList/MobileListView/MobileListView.module.scss b/src/apps/admin/src/lib/components/ReviewSummaryList/MobileListView/MobileListView.module.scss index 47dc4e423..00ef1724d 100644 --- a/src/apps/admin/src/lib/components/ReviewSummaryList/MobileListView/MobileListView.module.scss +++ b/src/apps/admin/src/lib/components/ReviewSummaryList/MobileListView/MobileListView.module.scss @@ -42,11 +42,12 @@ .row2, .row3, - .row4 { + .row4, + .row5 { grid-template-columns: 1fr auto; } - .row5 { + .row6 { justify-content: flex-end; } } diff --git a/src/apps/admin/src/lib/components/ReviewSummaryList/MobileListView/MobileListView.tsx b/src/apps/admin/src/lib/components/ReviewSummaryList/MobileListView/MobileListView.tsx index a828fc033..120476a80 100644 --- a/src/apps/admin/src/lib/components/ReviewSummaryList/MobileListView/MobileListView.tsx +++ b/src/apps/admin/src/lib/components/ReviewSummaryList/MobileListView/MobileListView.tsx @@ -64,25 +64,27 @@ const MobileListView: FC> = props => { )) return ( -
+
{/* Title */ propertyElements[0]} - {/* Status */ propertyElements[2]}
- {/* Legacy ID */ propertyElements[1]} + {/* Review Start Date */ propertyElements[1]}
- {/* propertyElementLabels[5] */} - {/* Open Review Opp' */ propertyElements[3]} + {propertyElementLabels[2]} + {/* Open Review Opp */ propertyElements[2]}
- {propertyElementLabels[4]} - {/* Review Applications */ propertyElements[4]} + {propertyElementLabels[3]} + {/* Review Applications */ propertyElements[3]}
- {/* Action */ propertyElements[5]} + {/* Action */ propertyElements[4]}
diff --git a/src/apps/admin/src/lib/components/ReviewSummaryList/ReviewSummaryList.module.scss b/src/apps/admin/src/lib/components/ReviewSummaryList/ReviewSummaryList.module.scss index c94b23fd5..643ef9228 100644 --- a/src/apps/admin/src/lib/components/ReviewSummaryList/ReviewSummaryList.module.scss +++ b/src/apps/admin/src/lib/components/ReviewSummaryList/ReviewSummaryList.module.scss @@ -9,15 +9,23 @@ } } +.challengeTitleColumn { + text-align: left; + + :global(.TableCell_blockCell) { + justify-content: flex-start; + } +} + .challengeTitleText, .challengeTitleLink { - min-width: 200px; + min-width: 120px; padding: 0; justify-content: flex-start; border-radius: 0; color: $body-color; line-height: 16px; - white-space: break-spaces; + white-space: normal; } .challengeTitleLink { @@ -37,6 +45,9 @@ } .desktopTable { + th { + white-space: normal; + } td { vertical-align: middle; } diff --git a/src/apps/admin/src/lib/components/ReviewSummaryList/ReviewSummaryList.tsx b/src/apps/admin/src/lib/components/ReviewSummaryList/ReviewSummaryList.tsx index f95ee26fa..80a0e4607 100644 --- a/src/apps/admin/src/lib/components/ReviewSummaryList/ReviewSummaryList.tsx +++ b/src/apps/admin/src/lib/components/ReviewSummaryList/ReviewSummaryList.tsx @@ -1,15 +1,16 @@ import { FC, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' +import { format } from 'date-fns' +import { Sort } from '~/apps/admin/src/platform/gamification-admin/src/game-lib' import { EnvironmentConfig } from '~/config' import { useWindowSize, WindowSize } from '~/libs/shared' import { Button, colWidthType, LinkButton, Table, type TableColumn } from '~/libs/ui' -import { Sort } from '~/apps/admin/src/platform/gamification-admin/src/game-lib' -import { Pagination } from '../common/Pagination' import { useEventCallback } from '../../hooks' import { ReviewFilterCriteria, ReviewSummary } from '../../models' import { Paging } from '../../models/challenge-management/Pagination' +import { Pagination } from '../common/Pagination' import { MobileListView } from './MobileListView' import styles from './ReviewSummaryList.module.scss' @@ -19,7 +20,7 @@ export interface ReviewListProps { paging: Paging currentFilters: ReviewFilterCriteria onPageChange: (page: number) => void - onToggleSort: (sort: Sort) => void + onToggleSort: (sort: Sort | undefined) => void } const Actions: FC<{ @@ -27,15 +28,25 @@ const Actions: FC<{ currentFilters: ReviewFilterCriteria }> = props => { const navigate = useNavigate() + const targetId = props.review.challengeId || props.review.legacyChallengeId + const goToManageReviewer = useEventCallback(() => { - navigate(`${props.review.legacyChallengeId}/manage-reviewer`, { + if (!targetId) { + return + } + + navigate(`${targetId}/manage-reviewer`, { state: { previousReviewSummaryListFilter: props.currentFilters }, }) }) return (
-
@@ -49,13 +60,23 @@ const ChallengeTitle: FC<{ window.location.href = `${EnvironmentConfig.ADMIN.CHALLENGE_URL}/${props.review.legacyChallengeId}` }) + const fullTitle = props.review.challengeName || '' + const maxLen = 60 + const shortTitle = fullTitle.length > maxLen + ? `${fullTitle.slice(0, maxLen)}…` + : fullTitle + return props.review.legacyChallengeId ? ( - - {props.review.challengeName} + + {shortTitle} ) : ( - - {props.review.challengeName} + + {shortTitle} ) } @@ -71,6 +92,7 @@ const ReviewSummaryList: FC = props => { // type: 'text', // }, { + className: styles.challengeTitleColumn, columnId: 'challengeName', label: 'Challenge Title', propertyName: 'challengeName', @@ -79,45 +101,41 @@ const ReviewSummaryList: FC = props => { ), type: 'element', }, - { - columnId: 'legacyChallengeId', - label: 'Legacy ID', - propertyName: 'legacyChallengeId', - type: 'text', - }, + // { // label: 'Current phase', // propertyName: '', // type: 'text', // }, - { - columnId: 'challengeStatus', - label: 'Status', - propertyName: 'challengeStatus', - type: 'text', - }, + // Status column removed to prevent table overflow // I think this column is important, and it exits in `admin-app` // but resp does not have it, so I just comment it here - // { - // label: 'Submission End Date', - // propertyName: 'submissionEndDate', - // renderer: (review: ReviewSummary) => ( - // // eslint-disable-next-line jsx-a11y/anchor-is-valid - //
- // {review.submissionEndDate} - // {/* {format( - // new Date(review.submissionEndDate), - // 'MMM dd, yyyy HH:mm' - // )} */} - //
- // ), - // type: 'element', - // }, + { + label: 'Review Start Date', + propertyName: 'submissionEndDate', + renderer: (review: ReviewSummary) => ( + // eslint-disable-next-line jsx-a11y/anchor-is-valid +
+ {review.submissionEndDate + ? format( + new Date(review.submissionEndDate), + 'MMM dd, yyyy HH:mm', + ) : 'N/A'} +
+ ), + type: 'element', + }, { columnId: 'OpenReviewOpp', label: 'Open Review Opp', renderer: (review: ReviewSummary) => ( -
{review.numberOfReviewerSpots - review.numberOfApprovedApplications}
+
+ {Math.max( + review.numberOfReviewerSpots + - review.numberOfApprovedApplications, + 0, + )} +
), type: 'element', }, diff --git a/src/apps/admin/src/lib/components/ReviewerList/ReviewerList.tsx b/src/apps/admin/src/lib/components/ReviewerList/ReviewerList.tsx index 4960400ae..da0356381 100644 --- a/src/apps/admin/src/lib/components/ReviewerList/ReviewerList.tsx +++ b/src/apps/admin/src/lib/components/ReviewerList/ReviewerList.tsx @@ -27,7 +27,7 @@ export interface ReviewerListProps { approvingReviewerId: number onPageChange: (page: number) => void onApproveApplication: (reviewer: Reviewer) => void - onToggleSort: (sort: Sort) => void + onToggleSort: (sort: Sort | undefined) => void } const ApproveButton: FC<{ @@ -93,10 +93,14 @@ const ReviewerMail: FC<{ window.open(`mailto:${props.reviewer.emailAddress}`, '_blank') }) - return ( + const email = props.reviewer.emailAddress?.trim() + + return email ? ( - {props.reviewer.emailAddress} + {email} + ) : ( + — ) } diff --git a/src/apps/admin/src/lib/components/RoleMembersTable/RoleMembersTable.tsx b/src/apps/admin/src/lib/components/RoleMembersTable/RoleMembersTable.tsx index a3fd832b6..a52cd3ab0 100644 --- a/src/apps/admin/src/lib/components/RoleMembersTable/RoleMembersTable.tsx +++ b/src/apps/admin/src/lib/components/RoleMembersTable/RoleMembersTable.tsx @@ -24,19 +24,22 @@ interface Props { isRemoving: { [key: string]: boolean } doRemoveRoleMember: (roleMember: RoleMemberInfo) => void doRemoveRoleMembers: (roleMemberIds: string[], callback: () => void) => void + page: number + totalPages: number + onPageChange: (page: number) => void } export const RoleMembersTable: FC = (props: Props) => { const [colWidth, setColWidth] = useState({}) const { - page, - setPage, - totalPages, results, setSort, sort, }: useTableFilterLocalProps = useTableFilterLocal( props.datas ?? [], + undefined, + undefined, + true, ) const datasIds = useMemo(() => results.map(item => item.id), [results]) const { @@ -210,9 +213,9 @@ export const RoleMembersTable: FC = (props: Props) => {
) diff --git a/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTable.tsx b/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTable.tsx index 9cc50716c..5217a13b4 100644 --- a/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTable.tsx +++ b/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTable.tsx @@ -7,8 +7,11 @@ import classNames from 'classnames' import { useWindowSize, WindowSize } from '~/libs/shared' import { ConfirmModal, Table, TableColumn } from '~/libs/ui' +import { EnvironmentConfig } from '~/config' +import { getRatingColor } from '~/libs/core' import { IsRemovingType, MobileTableColumn, Submission } from '../../models' +import type { DoPostBusEvent } from '../../hooks' import { TableMobile } from '../common/TableMobile' import { TableWrapper } from '../common/TableWrapper' @@ -25,7 +28,7 @@ interface Props { isRemovingReviewSummations: IsRemovingType doRemoveReviewSummations: (item: Submission) => void isRunningTest: IsRemovingType - doPostBusEvent: (submissionId: string, testType: string) => void + doPostBusEvent: DoPostBusEvent showSubmissionHistory: IsRemovingType setShowSubmissionHistory: Dispatch> isMM: boolean @@ -56,8 +59,29 @@ export const SubmissionTable: FC = (props: Props) => { ? [ { label: 'Submitter', - propertyName: 'createdBy', - type: 'text', + renderer: (data: Submission) => { + const handle = data.submitterHandle || data.createdBy + const rating = data.submitterMaxRating ?? undefined + const href = handle + ? `${EnvironmentConfig.URLS.USER_PROFILE}/${encodeURIComponent(handle)}` + : undefined + const color = getRatingColor( + typeof rating === 'number' ? rating : undefined, + ) + return handle && href ? ( + + {handle} + + ) : ( + {data.createdBy} + ) + }, + type: 'element', }, { className: 'blockCellWrap', @@ -172,8 +196,29 @@ export const SubmissionTable: FC = (props: Props) => { }, { label: 'Submitter handle', - propertyName: 'createdBy', - type: 'text', + renderer: (data: Submission) => { + const handle = data.submitterHandle || data.createdBy + const rating = data.submitterMaxRating ?? undefined + const href = handle + ? `${EnvironmentConfig.URLS.USER_PROFILE}/${encodeURIComponent(handle)}` + : undefined + const color = getRatingColor( + typeof rating === 'number' ? rating : undefined, + ) + return handle && href ? ( + + {handle} + + ) : ( + {data.createdBy} + ) + }, + type: 'element', }, { label: '', diff --git a/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTableActions.tsx b/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTableActions.tsx index d46eef312..71fea7aa4 100644 --- a/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTableActions.tsx +++ b/src/apps/admin/src/lib/components/SubmissionTable/SubmissionTableActions.tsx @@ -9,6 +9,7 @@ import { Button } from '~/libs/ui' import { DropdownMenu } from '../common/DropdownMenu' import { useEventCallback } from '../../hooks' +import type { DoPostBusEvent } from '../../hooks' import { IsRemovingType, Submission } from '../../models' interface Props { @@ -16,7 +17,7 @@ interface Props { isRunningTest: IsRemovingType isRemovingSubmission: IsRemovingType isRemovingReviewSummations: IsRemovingType - doPostBusEvent: (submissionId: string, testType: string) => void + doPostBusEvent: DoPostBusEvent setShowConfirmDeleteSubmissionDialog: Dispatch< SetStateAction > @@ -62,6 +63,7 @@ export const SubmissionTableActions: FC = (props: Props) => { onClick={function onClick() { setOpenDropdown(false) props.doPostBusEvent(props.data.id, 'system') + .catch(() => undefined) }} > Run System Test @@ -75,6 +77,7 @@ export const SubmissionTableActions: FC = (props: Props) => { onClick={function onClick() { setOpenDropdown(false) props.doPostBusEvent(props.data.id, 'provisional') + .catch(() => undefined) }} > Run Provisional Test diff --git a/src/apps/admin/src/lib/components/UsersFilters/UsersFilters.tsx b/src/apps/admin/src/lib/components/UsersFilters/UsersFilters.tsx index 1f6817e30..332a67a97 100644 --- a/src/apps/admin/src/lib/components/UsersFilters/UsersFilters.tsx +++ b/src/apps/admin/src/lib/components/UsersFilters/UsersFilters.tsx @@ -147,7 +147,7 @@ export const UsersFilters: FC = props => { Tips:
- Wildcard(*) is available for partial matching. (e.g. - ChrisB*, chris*@appirio.com) + ChrisB*, chris*@wipro.com)
- Maximum number of searched results is 500.

diff --git a/src/apps/admin/src/lib/components/UsersTable/UsersTable.tsx b/src/apps/admin/src/lib/components/UsersTable/UsersTable.tsx index aa65b2dee..8b481359f 100644 --- a/src/apps/admin/src/lib/components/UsersTable/UsersTable.tsx +++ b/src/apps/admin/src/lib/components/UsersTable/UsersTable.tsx @@ -1,11 +1,12 @@ /** * Users table. */ -import { FC, useMemo, useState } from 'react' +import { FC, useEffect, useMemo, useState } from 'react' import _ from 'lodash' import classNames from 'classnames' import moment from 'moment' +import { EnvironmentConfig } from '~/config' import { useWindowSize, WindowSize } from '~/libs/shared' import { Button, @@ -26,19 +27,21 @@ import { DialogEditUserTerms } from '../DialogEditUserTerms' import { DialogEditUserStatus } from '../DialogEditUserStatus' import { DialogUserStatusHistory } from '../DialogUserStatusHistory' import { DropdownMenuButton } from '../common/DropdownMenuButton' -import { useOnComponentDidMount, useTableFilterLocal, useTableFilterLocalProps } from '../../hooks' +import { useTableFilterLocal, useTableFilterLocalProps } from '../../hooks' import { TABLE_DATE_FORMAT } from '../../../config/index.config' import { SSOLoginProvider, UserInfo } from '../../models' +import { fetchSSOLoginProviders } from '../../services' import { Pagination } from '../common/Pagination' import { ReactComponent as RectangleListRegularIcon } from '../../assets/i/rectangle-list-regular-icon.svg' -import { fetchSSOLoginProviders } from '../../services' -import { handleError } from '../../utils' import styles from './UsersTable.module.scss' interface Props { className?: string allUsers: UserInfo[] + page: number + totalPages: number + onPageChange: (page: number) => void updatingStatus: { [key: string]: boolean } doUpdateStatus: ( userInfo: UserInfo, @@ -51,6 +54,24 @@ interface Props { export const UsersTable: FC = props => { const [colWidth, setColWidth] = useState({}) const [ssoLoginProviders, setSsoLoginProviders] = useState([]) + // initial fallback values from environment, until remote loads + useEffect(() => { + if (EnvironmentConfig.ADMIN_SSO_LOGIN_PROVIDERS?.length) { + setSsoLoginProviders(EnvironmentConfig.ADMIN_SSO_LOGIN_PROVIDERS.map(p => ({ ...p }))) + } + }, []) + // Fetch providers from identity-api on mount + useEffect(() => { + fetchSSOLoginProviders() + .then((list: SSOLoginProvider[]) => { + if (Array.isArray(list) && list.length) { + setSsoLoginProviders(list) + } + }) + .catch(() => { + // ignore and keep fallback from env + }) + }, []) const [showDialogEditUserEmail, setShowDialogEditUserEmail] = useState< UserInfo | undefined >() @@ -88,14 +109,11 @@ export const UsersTable: FC = props => { const isTablet = useMemo(() => screenWidth <= 984, [screenWidth]) const isMobile = useMemo(() => screenWidth <= 745, [screenWidth]) - const { - page, - setPage, - totalPages, - results, - setSort, - }: useTableFilterLocalProps = useTableFilterLocal( + const { results, setSort }: useTableFilterLocalProps = useTableFilterLocal( props.allUsers ?? [], + undefined, + undefined, + true, ) const columns = useMemo[]>( () => [ @@ -205,27 +223,32 @@ export const UsersTable: FC = props => { isExpand: true, label: 'Activation Code', propertyName: 'activationCode', - renderer: (data: UserInfo) => ( -
- {!!data.credential.activationCode && ( - {data.credential.activationCode} - )} + renderer: (data: UserInfo) => { + const activationCode = data.credential?.activationCode ?? '' + const activationLink = data.activationLink ?? '' + + return ( +
+ {!!activationCode && ( + {activationCode} + )} -
- +
+ - + +
-
- ), + ) + }, type: 'element', }, ...(isMobile @@ -285,8 +308,11 @@ export const UsersTable: FC = props => { } else if (item === 'Deactivate') { setShowDialogEditUserStatus(data) } else if (item === 'Activate') { + const isEmailVerified + = data.emailVerified ?? data.emailActive ?? false + let confirmation = `Are you sure you want to activate user '${data.handle}'?` - if (!data.emailActive) { + if (!isEmailVerified) { confirmation += "\nEmail address is also verified by the operation. Please confirm it's valid." } @@ -370,16 +396,6 @@ export const UsersTable: FC = props => { [isTablet, isMobile], ) - useOnComponentDidMount(() => { - fetchSSOLoginProviders() - .then(result => { - setSsoLoginProviders(result) - }) - .catch(e => { - handleError(e) - }) - }) - return (
= props => { /> {props.allUsers.length > 0 && ( )} diff --git a/src/apps/admin/src/lib/components/common/ActionLoading/ActionLoading.module.scss b/src/apps/admin/src/lib/components/common/ActionLoading/ActionLoading.module.scss index f50f5891a..91fd976ec 100644 --- a/src/apps/admin/src/lib/components/common/ActionLoading/ActionLoading.module.scss +++ b/src/apps/admin/src/lib/components/common/ActionLoading/ActionLoading.module.scss @@ -6,7 +6,7 @@ display: flex; align-items: center; justify-content: center; - bottom: 0; + bottom: -20px; height: 64px; left: $sp-8; diff --git a/src/apps/admin/src/lib/components/common/Pagination/Pagination.module.scss b/src/apps/admin/src/lib/components/common/Pagination/Pagination.module.scss index 013031b4a..dd4098a1f 100644 --- a/src/apps/admin/src/lib/components/common/Pagination/Pagination.module.scss +++ b/src/apps/admin/src/lib/components/common/Pagination/Pagination.module.scss @@ -6,18 +6,49 @@ justify-content: flex-end; align-items: center; padding: 16px 0; - gap: $sp-4; + gap: $sp-2; + + .pageNumbers button, + .previous, + .disabled, + .first, + .last, + .next { + box-shadow: none; + border: 1px solid #E9ECEF; + border-radius: 4px; + color: #0A0A0A; + font-weight: 400; + font-size: 14px; + } + + .previous, + .first, + .last, + .next { + padding: 7px 11px; + + &:disabled { + background-color: #E9ECEF !important; + } + } + .pageNumbers { display: flex; justify-content: center; align-items: center; - gap: $sp-1; + gap: $sp-2; + + button { + padding: 3px 12px; + } button.active { color: $black-60; pointer-events: none; - box-shadow: inset 0 0 0 2px #{$black-60}; + background-color: $teal-160; + color: white; } } @@ -35,4 +66,8 @@ @media (max-width: #{$mobile-max}) { justify-content: center; } + + :global(.btn-style-secondary) { + box-shadow: none !important; + } } diff --git a/src/apps/admin/src/lib/components/common/Pagination/Pagination.tsx b/src/apps/admin/src/lib/components/common/Pagination/Pagination.tsx index 477b8e50d..80f7c15c6 100644 --- a/src/apps/admin/src/lib/components/common/Pagination/Pagination.tsx +++ b/src/apps/admin/src/lib/components/common/Pagination/Pagination.tsx @@ -74,7 +74,6 @@ const Pagination: FC = (props: PaginationProps) => { size='md' icon={IconOutline.ChevronDoubleLeftIcon} iconToLeft - label='FIRST' disabled={props.page === 1 || props.disabled} className={styles.first} /> @@ -84,7 +83,6 @@ const Pagination: FC = (props: PaginationProps) => { size='md' icon={IconOutline.ChevronLeftIcon} iconToLeft - label='PREVIOUS' disabled={props.page === 1 || props.disabled} className={styles.previous} /> @@ -93,7 +91,6 @@ const Pagination: FC = (props: PaginationProps) => { + + + + {isSearching && ( +
+ +
+ )} + + {!isSearching && searched && ( +
+ {results.length === 0 ? ( +

No challenges found

+ ) : ( +
+ {results.map(ch => ( +
+
{ch.name}
+
+ ID: + {ch.id} + {' '} + • Status: + {formatChallengeStatusLabel(ch.status)} +
+
+ ))} +
+ )} +
+ )} + + {selectedChallenge && ( +
+
+

+ Payments for: + {selectedChallenge.name} +

+ +
+ {isLoadingPayments ? ( +
+ +
+ ) : ( +
+
+ + + + + + + + + + + + + + {payments.map(p => { + const first = p.details?.[0] + const handle = winnerHandleMap[p.winnerId] || p.handle || p.winnerId + return ( + + + + + + + + + + + ) + })} + +
WinnerTypeCategoryTitleDescriptionAmountStatusCurrency
{handle}{p.type}{p.category ? toReadableCategory(p.category) : ''}{p.title || ''}{p.description || ''}{first ? first.totalAmount : ''}{first ? first.status : ''}{first ? first.currency : ''}
+ {payments.length === 0 &&

No payments found

} + + )} + + )} + + + +
+ + + + + + +
+ + +
+ {isSubmitting && ( +
+ +
+ )} +
+
+ + ) +} + +export default PaymentsPage diff --git a/src/apps/admin/src/permission-management/PermissionRoleMembersPage/PermissionRoleMembersPage.tsx b/src/apps/admin/src/permission-management/PermissionRoleMembersPage/PermissionRoleMembersPage.tsx index b2e8fba48..618678544 100644 --- a/src/apps/admin/src/permission-management/PermissionRoleMembersPage/PermissionRoleMembersPage.tsx +++ b/src/apps/admin/src/permission-management/PermissionRoleMembersPage/PermissionRoleMembersPage.tsx @@ -28,6 +28,9 @@ export const PermissionRoleMembersPage: FC = (props: Props) => { isLoading, roleInfo, roleMembers, + page, + totalPages, + onPageChange, doFilterRoleMembers, isFiltering, isRemoving, @@ -36,11 +39,13 @@ export const PermissionRoleMembersPage: FC = (props: Props) => { doRemoveRoleMembers, }: useManagePermissionRoleMembersProps = useManagePermissionRoleMembers(roleId) + const pageTitleWithRole = roleInfo?.roleName ? `${pageTitle}: ${roleInfo.roleName}` : pageTitle + return (
- {pageTitle} + {pageTitleWithRole} -

{pageTitle}

+

{pageTitleWithRole}

= (props: Props) => {
) : ( <> - {roleMembers.length === 0 ? ( + {roleInfo?.roleName === 'Topcoder Talent' ? (

- No members + This role has too many members to display

+ ) : roleMembers.length === 0 ? ( +

No members

) : (
diff --git a/src/apps/admin/src/platform-management/PlatformManagement.tsx b/src/apps/admin/src/platform-management/PlatformManagement.tsx index e662b61c8..88cfa73c6 100644 --- a/src/apps/admin/src/platform-management/PlatformManagement.tsx +++ b/src/apps/admin/src/platform-management/PlatformManagement.tsx @@ -26,7 +26,7 @@ function useChildRoutes(): Array | undefined { () => adminRoutes[0].children ?.find(r => r.id === platformRouteId) ?.children?.map(getRouteElement), - [], // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: getRouteElement + [getRouteElement], ) return childRoutes } diff --git a/src/apps/admin/src/platform/gamification-admin/src/game-lib/member-autocomplete/input-handle-functions.ts b/src/apps/admin/src/platform/gamification-admin/src/game-lib/member-autocomplete/input-handle-functions.ts index 7fdccce72..c3f9fc8d7 100644 --- a/src/apps/admin/src/platform/gamification-admin/src/game-lib/member-autocomplete/input-handle-functions.ts +++ b/src/apps/admin/src/platform/gamification-admin/src/game-lib/member-autocomplete/input-handle-functions.ts @@ -19,5 +19,5 @@ export async function membersAutocompete(term: string): Promise = (props: AwardedMembersTabP const pageHandler: InfinitePageHandler = useGetGameBadgeAssigneesPage(props.badge, sort) - function onSortClick(newSort: Sort): void { + function onSortClick(newSort?: Sort): void { + if (!newSort) { + return + } + setSort({ ...newSort }) } diff --git a/src/apps/admin/src/platform/gamification-admin/src/pages/badge-listing/BadgeListingPage.tsx b/src/apps/admin/src/platform/gamification-admin/src/pages/badge-listing/BadgeListingPage.tsx index 315f5c306..28d76f8c4 100644 --- a/src/apps/admin/src/platform/gamification-admin/src/pages/badge-listing/BadgeListingPage.tsx +++ b/src/apps/admin/src/platform/gamification-admin/src/pages/badge-listing/BadgeListingPage.tsx @@ -33,7 +33,11 @@ const BadgeListingPage: FC = (props: Props) => { const pageHandler: InfinitePageHandler = useGetGameBadgesPage(sort) const navigate: NavigateFunction = useNavigate() - function onSortClick(newSort: Sort): void { + function onSortClick(newSort?: Sort): void { + if (!newSort) { + return + } + setSort({ ...newSort }) } diff --git a/src/apps/admin/src/review-management/ManageReviewerPage/ManageReviewerPage.tsx b/src/apps/admin/src/review-management/ManageReviewerPage/ManageReviewerPage.tsx index 1ab05a46c..d2f422de0 100644 --- a/src/apps/admin/src/review-management/ManageReviewerPage/ManageReviewerPage.tsx +++ b/src/apps/admin/src/review-management/ManageReviewerPage/ManageReviewerPage.tsx @@ -33,8 +33,8 @@ import { import { approveApplication, getChallengeByLegacyId, - getChallengeReviewers, getChallengeReviewOpportunities, + getReviewOpportunityApplications, rejectPending, } from '../../lib/services' import { handleError } from '../../lib/utils' @@ -63,6 +63,7 @@ export const ManageReviewerPage: FC = () => { challengeId: string }>() const [challengeUuid, setChallengeUuid] = useState('') + const challengeIdentifier = challengeUuid || challengeId const [filterCriteria, setFilterCriteria]: [ ReviewFilterCriteria, Dispatch> @@ -86,17 +87,18 @@ export const ManageReviewerPage: FC = () => { searched, totalReviewers: totalUsers, openReviews, - }: ReturnType = useSearch({ challengeId, filterCriteria }) + reviewOpportunityId, + }: ReturnType = useSearch({ challengeId: challengeIdentifier, filterCriteria }) const { reject: doReject, rejecting, - }: ReturnType = useReject({ challengeId }) + }: ReturnType = useReject({ opportunityId: reviewOpportunityId }) const [openRejectPendingConfirmDialog, setOpenRejectPendingConfirmDialog] = useState(false) const { approve: doApprove, userId }: ReturnType - = useApprove({ challengeId }) + = useApprove() const search = useEventCallback((): void => { doSearch() @@ -124,31 +126,69 @@ export const ManageReviewerPage: FC = () => { const reject = useEventCallback((): void => { doReject() - .then(() => { - newSearch() + .then(wasRejected => { + if (wasRejected) { + newSearch() + } }) }) const approve = useEventCallback((reviewer: Reviewer): void => { doApprove(reviewer) - .then(() => { - newSearch() + .then(wasApproved => { + if (wasApproved) { + newSearch() + } }) }) // Init useEffect(() => { - search() - }, [challengeId]) // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: search + if (challengeIdentifier) { + search() + } + }, [challengeIdentifier]) // eslint-disable-line react-hooks/exhaustive-deps -- missing dependency: search - // Gets the challenge details by legacyId + // Resolve challenge identifier for navigation targets useEffect(() => { - getChallengeByLegacyId(+challengeId) - .then(challenge => { - setChallengeUuid(challenge.id) - }) - // eslint-disable-next-line react-hooks/exhaustive-deps -- missing dependency: setChallengeUuid - }, [challengeId, getChallengeByLegacyId]) + let isMounted = true + + const resolve = async (): Promise => { + if (!challengeId) { + if (isMounted) { + setChallengeUuid('') + } + + return + } + + if (/^[0-9]+$/.test(challengeId)) { + try { + const challenge = await getChallengeByLegacyId(+challengeId) + if (isMounted) { + setChallengeUuid(challenge.id) + } + } catch (error) { + handleError(error) + if (isMounted) { + setChallengeUuid('') + } + } + + return + } + + if (isMounted) { + setChallengeUuid(challengeId) + } + } + + resolve() + + return () => { + isMounted = false + } + }, [challengeId]) // eslint-disable-line react-hooks/exhaustive-deps -- stable service reference // Page change const [pageChangeEvent, setPageChangeEvent] = useState(false) @@ -181,12 +221,17 @@ export const ManageReviewerPage: FC = () => { setPageChangeEvent(true) }) - const handleSortChange = useEventCallback((sort: Sort) => { + const handleSortChange = useEventCallback((sort?: Sort) => { + const sortToApply = sort ?? { + direction: filterCriteria.order, + fieldName: filterCriteria.sortBy, + } + setFilterCriteria({ ...filterCriteria, - order: sort.direction, + order: sortToApply.direction, page: 1, - sortBy: sort.fieldName, + sortBy: sortToApply.fieldName, }) setSortChangeEvent(true) }) @@ -201,7 +246,7 @@ export const ManageReviewerPage: FC = () => { primary onClick={handleRejectPendingConfirmDialog} size='lg' - to={`${rootRoute}/challenge-management/${challengeUuid}/manage-user`} + to={`${rootRoute}/challenge-management/${challengeUuid || challengeId}/manage-user`} > User Management @@ -210,7 +255,7 @@ export const ManageReviewerPage: FC = () => { variant='danger' onClick={handleRejectPendingConfirmDialog} size='lg' - disabled={rejecting} + disabled={rejecting || !reviewOpportunityId} > {' '} @@ -272,6 +317,8 @@ type SearchState = { totalReviewers: number openReviews: number allReviewers: Reviewer[] + reviewOpportunityId: string + activeChallengeId: string } type SearchReducerAction = @@ -286,6 +333,8 @@ type SearchReducerAction = totalReviewers: number openReviews: number allReviewers: Reviewer[] + reviewOpportunityId: string + challengeId: string } } @@ -300,6 +349,7 @@ const searchReducer = ( allReviewers: [], isLoading: true, openReviews: 0, + reviewOpportunityId: '', searched: false, totalReviewers: 0, } @@ -308,9 +358,11 @@ const searchReducer = ( case SearchActionType.SEARCH_DONE: { return { ...previousState, + activeChallengeId: action.payload.challengeId, allReviewers: action.payload.allReviewers, isLoading: false, openReviews: action.payload.openReviews, + reviewOpportunityId: action.payload.reviewOpportunityId, searched: true, totalReviewers: action.payload.totalReviewers, } @@ -353,26 +405,31 @@ function useSearch({ searching: boolean totalReviewers: number openReviews: number + reviewOpportunityId: string } { const [state, dispatch] = useReducer(searchReducer, { + activeChallengeId: '', allReviewers: [], isLoading: false, openReviews: 0, + reviewOpportunityId: '', searched: false, totalReviewers: 0, }) const sortData = useEventCallback(async (data?: Reviewer[]) => { const toSortData = data || state.allReviewers - let sortedList = [] + let sortedList: Reviewer[] if (filterCriteria.sortBy === 'applicationDate') { sortedList = sortBy( toSortData, item => new Date(item.applicationDate), ) - } else { + } else if (filterCriteria.sortBy) { sortedList = sortBy(toSortData, filterCriteria.sortBy) + } else { + sortedList = [...toSortData] } if (filterCriteria.order === 'desc') { @@ -386,7 +443,9 @@ function useSearch({ dispatch({ payload: { allReviewers: sortedList, + challengeId: state.activeChallengeId, openReviews: state.openReviews, + reviewOpportunityId: state.reviewOpportunityId, totalReviewers: sortedList.length, }, type: SearchActionType.SEARCH_DONE, @@ -395,34 +454,102 @@ function useSearch({ return getPageData(sortedList, filterCriteria) }) - const search = useEventCallback(async (newSearch?: boolean): Promise => { - // If api search has done, just get page data from last api response - if (state.searched && !newSearch) { + const resolveChallengeIdentifier = useEventCallback(async (): Promise => { + if (!challengeId) { + return '' + } + + if (/^[0-9]+$/.test(challengeId)) { + const challenge = await getChallengeByLegacyId(+challengeId) + return challenge.id + } + + return challengeId + }) + + const fetchOpportunityAndReviewers = useEventCallback( + async (resolvedId: string): Promise<{ + openPositions: number + opportunityId: string + reviewers: Reviewer[] + }> => { + const opportunity = await getChallengeReviewOpportunities(resolvedId) + + if (!opportunity) { + return { + openPositions: 0, + opportunityId: '', + reviewers: [], + } + } + + const reviewers = opportunity.id + ? await getReviewOpportunityApplications(opportunity.id) + : [] + + return { + openPositions: opportunity.openPositions ?? 0, + opportunityId: opportunity.id ?? '', + reviewers, + } + }, + ) + + const search = useEventCallback(async (forceApiFetch = false): Promise => { + let resolvedId = '' + + try { + resolvedId = await resolveChallengeIdentifier() + } catch (error) { + dispatch({ type: SearchActionType.SEARCH_FAILED }) + handleError(error) + return [] + } + + if (!resolvedId) { + dispatch({ + payload: { + allReviewers: [], + challengeId: '', + openReviews: 0, + reviewOpportunityId: '', + totalReviewers: 0, + }, + type: SearchActionType.SEARCH_DONE, + }) + return [] + } + + if ( + state.searched + && !forceApiFetch + && state.activeChallengeId === resolvedId + ) { return getPageData(state.allReviewers, filterCriteria) } dispatch({ type: SearchActionType.SEARCH_INIT }) + try { - const data = await getChallengeReviewers(challengeId) - const reviewOpportunity = await getChallengeReviewOpportunities( - challengeId, - ) + const result = await fetchOpportunityAndReviewers(resolvedId) dispatch({ payload: { - allReviewers: data, - openReviews: reviewOpportunity?.openPositions || 0, - totalReviewers: data.length, + allReviewers: result.reviewers, + challengeId: resolvedId, + openReviews: result.openPositions, + reviewOpportunityId: result.opportunityId, + totalReviewers: result.reviewers.length, }, type: SearchActionType.SEARCH_DONE, }) - return getPageData(data, filterCriteria) + + return getPageData(result.reviewers, filterCriteria) } catch (error) { dispatch({ type: SearchActionType.SEARCH_FAILED }) handleError(error) return [] } - }) const newSearch = useEventCallback(async (): Promise => search(true)) @@ -430,6 +557,7 @@ function useSearch({ return { newSearch, openReviews: state.openReviews, + reviewOpportunityId: state.reviewOpportunityId, search, searched: state.searched, searching: state.isLoading, @@ -494,7 +622,7 @@ const rejectReducer = ( } } -function useReject({ challengeId }: { challengeId: string }): { +function useReject({ opportunityId }: { opportunityId: string }): { reject: () => Promise rejected: boolean rejecting: boolean @@ -505,10 +633,14 @@ function useReject({ challengeId }: { challengeId: string }): { }) const reject = useEventCallback(async (): Promise => { + if (!opportunityId) { + return false + } + dispatch({ type: RejectActionType.REJECT_INIT }) try { - await rejectPending(challengeId) + await rejectPending(opportunityId) dispatch({ type: RejectActionType.REJECT_DONE }) return true } catch (error) { @@ -553,6 +685,12 @@ type ApproveState = { userId: number } +type UseApproveResult = { + approve: (reviewer: Reviewer) => Promise; + approving: boolean; + userId: number; +} + const approveReducer = ( previousState: ApproveState, action: ApproveActionType, @@ -588,11 +726,7 @@ const approveReducer = ( } } -function useApprove({ challengeId }: { challengeId: string }): { - approve: (reviewer: Reviewer) => Promise - approving: boolean - userId: number -} { +function useApprove(): UseApproveResult { const [state, dispatch] = useReducer(approveReducer, { isApproving: false, userId: 0, @@ -600,17 +734,17 @@ function useApprove({ challengeId }: { challengeId: string }): { const approve = useEventCallback( async (reviewer: Reviewer): Promise => { + if (!reviewer.applicationId) { + return false + } + dispatch({ payload: { userId: reviewer.userId }, type: ApproveActionType.APPROVE_INIT, }) try { - await approveApplication(challengeId, { - applicationRoleId: reviewer.applicationRoleId, - reviewAuctionId: reviewer.reviewAuctionId, - userId: reviewer.userId, - }) + await approveApplication(reviewer.applicationId) dispatch({ type: ApproveActionType.APPROVE_DONE }) return true } catch (error) { diff --git a/src/apps/admin/src/review-management/ReviewManagementPage/ReviewManagementPage.tsx b/src/apps/admin/src/review-management/ReviewManagementPage/ReviewManagementPage.tsx index 70ee17d9b..e0f77b4f7 100644 --- a/src/apps/admin/src/review-management/ReviewManagementPage/ReviewManagementPage.tsx +++ b/src/apps/admin/src/review-management/ReviewManagementPage/ReviewManagementPage.tsx @@ -8,7 +8,6 @@ import { useRef, useState, } from 'react' -import { sortBy } from 'lodash' import { LoadingSpinner, PageTitle } from '~/libs/ui' import { Sort } from '~/apps/admin/src/platform/gamification-admin/src/game-lib' @@ -53,6 +52,7 @@ export const ReviewManagementPage: FC = () => { searching, searched, totalReviews, + totalPages, }: ReturnType = useSearch({ filterCriteria }) const search = useEventCallback(() => { @@ -107,12 +107,17 @@ export const ReviewManagementPage: FC = () => { setPageChangeEvent(true) }) - const handleSortChange = useEventCallback((sort: Sort) => { + const handleSortChange = useEventCallback((sort?: Sort) => { + const sortToApply = sort ?? { + direction: filterCriteria.order, + fieldName: filterCriteria.sortBy, + } + setFilterCriteria({ ...filterCriteria, - order: sort.direction, + order: sortToApply.direction, page: 1, - sortBy: sort.fieldName, + sortBy: sortToApply.fieldName, }) setSortChangeEvent(true) }) @@ -137,9 +142,13 @@ export const ReviewManagementPage: FC = () => { reviews={reviews} paging={{ page: filterCriteria.page, - totalPages: Math.ceil( - totalReviews / filterCriteria.perPage, - ), + totalPages: totalPages + || (totalReviews > 0 + ? Math.ceil( + totalReviews + / filterCriteria.perPage, + ) + : 0), }} currentFilters={filterCriteria} onPageChange={handlePageChange} @@ -160,6 +169,7 @@ type SearchState = { searched: boolean totalReviews: number allReviews: ReviewSummary[] + totalPages: number } const SearchActionType = { @@ -179,6 +189,7 @@ type SearchReducerAction = payload: { totalReviews: number allReviews: ReviewSummary[] + totalPages: number } } @@ -193,6 +204,7 @@ const reducer = ( allReviews: [], isLoading: true, searched: false, + totalPages: 0, totalReviews: 0, } } @@ -203,6 +215,7 @@ const reducer = ( allReviews: action.payload.allReviews, isLoading: false, searched: true, + totalPages: action.payload.totalPages, totalReviews: action.payload.totalReviews, } } @@ -212,6 +225,7 @@ const reducer = ( ...previousState, allReviews: [], isLoading: false, + totalPages: 0, totalReviews: 0, } } @@ -222,14 +236,14 @@ const reducer = ( } } +type GetReviewOpportunitiesResponse = Awaited< + ReturnType +> + function getPageData( reviews: ReviewSummary[], - filterCriteria: ReviewFilterCriteria, ): ReviewSummary[] { - const total = reviews.length - const startIndex = (filterCriteria.page - 1) * filterCriteria.perPage - const endIndex = Math.min(startIndex + filterCriteria.perPage, total) - return reviews.slice(startIndex, endIndex) + return reviews } function useSearch({ @@ -242,78 +256,52 @@ function useSearch({ searched: boolean searching: boolean totalReviews: number + totalPages: number } { const [state, dispatch] = useReducer(reducer, { allReviews: [], isLoading: false, searched: false, + totalPages: 0, totalReviews: 0, }) - const sortData = useEventCallback(async (data?: ReviewSummary[]) => { - const toSortData = data || state.allReviews - let sortedList = [] - if (filterCriteria.sortBy === 'submissionEndDate') { - sortedList = sortBy( - toSortData, - item => new Date(item.submissionEndDate), - ) - } else { - sortedList = sortBy(toSortData, filterCriteria.sortBy) - } - - if (filterCriteria.order === 'desc') { - sortedList = sortedList.reverse() - } - - if (data) { - return sortedList - } - - dispatch({ - payload: { - allReviews: sortedList, - totalReviews: sortedList.length, - }, - type: SearchActionType.SEARCH_DONE, - }) - - return getPageData(sortedList, filterCriteria) - }) - - const search = useEventCallback(async () => { - // If api search has done, just get page data from last api response - if (state.searched) { - return getPageData(state.allReviews, filterCriteria) - } - + const fetchReviews = useEventCallback(async () => { dispatch({ type: SearchActionType.SEARCH_INIT }) try { - const data: ReviewSummary[] = await getReviewOpportunities( + const response: GetReviewOpportunitiesResponse = await getReviewOpportunities( filterCriteria, ) - const total = data.length + const { content, metadata }: GetReviewOpportunitiesResponse = response - // const sortedList = await sortData(data) + const total = metadata?.total ?? content.length + const totalPages = metadata?.totalPages + ?? (total > 0 + ? Math.ceil(total / filterCriteria.perPage) + : 0) dispatch({ - payload: { allReviews: data, totalReviews: total }, + payload: { + allReviews: content, + totalPages, + totalReviews: total, + }, type: SearchActionType.SEARCH_DONE, }) - return getPageData(data, filterCriteria) + return getPageData(content) } catch (error) { dispatch({ type: SearchActionType.SEARCH_FAILED }) handleError(error) return [] } - }) return { - search, + search: fetchReviews, searched: state.searched, searching: state.isLoading, - sortData, + sortData: fetchReviews, + totalPages: state.totalPages, totalReviews: state.totalReviews, } } diff --git a/src/apps/admin/src/user-management/UserManagementPage/UserManagementPage.tsx b/src/apps/admin/src/user-management/UserManagementPage/UserManagementPage.tsx index dc6d202c6..005e2abca 100644 --- a/src/apps/admin/src/user-management/UserManagementPage/UserManagementPage.tsx +++ b/src/apps/admin/src/user-management/UserManagementPage/UserManagementPage.tsx @@ -28,6 +28,9 @@ export const UserManagementPage: FC = (props: Props) => { isLoading, updatingStatus, doUpdateStatus, + page, + totalPages, + onPageChange, }: useManageUsersProps = useManageUsers() return ( @@ -58,6 +61,9 @@ export const UserManagementPage: FC = (props: Props) => { ) : ( 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 b52e92447..24473fefa 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 @@ -10,7 +10,6 @@ import styles from './styles.module.scss' interface ApplyOpportunityModalProps { onClose: () => void copilotOpportunityId: number - projectName: string onApplied: () => void } @@ -66,10 +65,10 @@ const ApplyOpportunityModal: FC = props => { We truly value your interest and effort. Your application will be reviewed promptly.` : `We're excited to see your interest in joining our team as a copilot - for the "${props.projectName}" project! Before we proceed, we want to - ensure that you have carefully reviewed the project requirements and + for this opportunity! Before we proceed, we want to + ensure that you have carefully reviewed the opportunity requirements and are committed to meeting them. Please write below the reason(s) - why you believe you're a good fit for this project + why you believe you're a good fit for this opportunity (e.g., previous experience, availability, etc.).` }
diff --git a/src/apps/copilots/src/pages/copilot-opportunity-details/index.tsx b/src/apps/copilots/src/pages/copilot-opportunity-details/index.tsx index d07f5aa23..36dbe627a 100644 --- a/src/apps/copilots/src/pages/copilot-opportunity-details/index.tsx +++ b/src/apps/copilots/src/pages/copilot-opportunity-details/index.tsx @@ -303,7 +303,6 @@ const CopilotOpportunityDetails: FC<{}> = () => { ) 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 648630d99..87f9f0c0a 100644 --- a/src/apps/copilots/src/pages/copilot-request-form/index.tsx +++ b/src/apps/copilots/src/pages/copilot-request-form/index.tsx @@ -13,6 +13,7 @@ import { getProject, getProjects, ProjectsResponse, useProjects } from '../../se import { ProjectTypes, ProjectTypeValues } from '../../constants' import { CopilotRequestResponse, saveCopilotRequest, useCopilotRequest } from '../../services/copilot-requests' import { Project } from '../../models/Project' +import { rootRoute } from '../../copilots.routes' import styles from './styles.module.scss' @@ -321,7 +322,7 @@ const CopilotRequestForm: FC<{}> = () => { setPaymentType('') // Added a small timeout for the toast to be visible properly to the users setTimeout(() => { - navigate('/requests') + navigate(`${rootRoute}/requests`) }, 1000) }) .catch(e => { diff --git a/src/apps/copilots/src/pages/copilot-requests/index.tsx b/src/apps/copilots/src/pages/copilot-requests/index.tsx index a61fd2426..876018d37 100644 --- a/src/apps/copilots/src/pages/copilot-requests/index.tsx +++ b/src/apps/copilots/src/pages/copilot-requests/index.tsx @@ -216,7 +216,6 @@ const CopilotRequestsPage: FC = () => { type: 'text', }, { - defaultSortDirection: 'desc', isDefaultSort: true, label: 'Created At', propertyName: 'createdAt', @@ -241,7 +240,11 @@ const CopilotRequestsPage: FC = () => { setSize(size + 1) } - function onToggleSort(s: Sort): void { + function onToggleSort(s?: Sort): void { + if (!s) { + return + } + setSort(s) } diff --git a/src/apps/copilots/src/services/members.ts b/src/apps/copilots/src/services/members.ts index 3ab23b867..ba5c23d30 100644 --- a/src/apps/copilots/src/services/members.ts +++ b/src/apps/copilots/src/services/members.ts @@ -38,7 +38,7 @@ export const getMembersByUserIds = async ( }) return xhrGetAsync>( - `${EnvironmentConfig.API.V5}/members?${qs}`, + `${EnvironmentConfig.API.V6}/members?${qs}`, ) } @@ -64,7 +64,7 @@ export const useMembers = (userIds: number[]): MembersResponse => { userIds.forEach(userId => { qs += `&userIds[]=${userId}` }) - const url = `${EnvironmentConfig.API.V5}/members?${qs}` + const url = `${EnvironmentConfig.API.V6}/members?${qs}` const fetcher = (urlp: string): Promise => xhrGetAsync(urlp) .then(data => membersFactory(data)) diff --git a/src/apps/learn/src/free-code-camp/FreeCodeCamp.tsx b/src/apps/learn/src/free-code-camp/FreeCodeCamp.tsx index db164a3e5..6bdc05099 100644 --- a/src/apps/learn/src/free-code-camp/FreeCodeCamp.tsx +++ b/src/apps/learn/src/free-code-camp/FreeCodeCamp.tsx @@ -352,6 +352,7 @@ const FreeCodeCamp: FC<{}> = () => { providerParam, certificateProgress, profile?.handle, + courseData, setCertificateProgress, ) diff --git a/src/apps/learn/src/free-code-camp/hooks/use-mark-course-completed.tsx b/src/apps/learn/src/free-code-camp/hooks/use-mark-course-completed.tsx index bebaf85b9..65b4efc5e 100644 --- a/src/apps/learn/src/free-code-camp/hooks/use-mark-course-completed.tsx +++ b/src/apps/learn/src/free-code-camp/hooks/use-mark-course-completed.tsx @@ -3,19 +3,31 @@ import { MutableRefObject, useEffect, useRef } from 'react' import { NavigateFunction, useLocation, useNavigate } from 'react-router-dom' import { + LearnCourse, LearnUserCertificationProgress, userCertificationProgressCompleteCourseAsync, UserCertificationProgressStatus, } from '../../lib' import { getCertificationCompletedPath } from '../../learn.routes' +import { + verifyMemberSkills as verifyMemberSkillsApi, +} from '../../lib/data-providers/member-skills-provider/member-skills.service' export const useCheckAndMarkCourseCompleted: ( isLoggedIn: boolean, providerName: string, certificateProgress?: LearnUserCertificationProgress, userHandle?: string, - setCertificateProgress?: (progess: LearnUserCertificationProgress) => void -) => void = (isLoggedIn, providerName, certificateProgress, userHandle, setCertificateProgress = noop) => { + courseData?: LearnCourse, + setCertificateProgress?: (progess: LearnUserCertificationProgress) => void, +) => void = ( + isLoggedIn, + providerName, + certificateProgress, + userHandle, + courseData, + setCertificateProgress = noop, +) => { const navigate: NavigateFunction = useNavigate() const location: any = useLocation() const isUpdating: MutableRefObject = useRef(false) @@ -44,7 +56,19 @@ export const useCheckAndMarkCourseCompleted: ( providerName, ) .then(setCertificateProgress) - .then(() => { + .then(async () => { + // Verify course skills in member-api-v6 + const skillIds: string[] = (courseData?.skills || []).map(s => s.id) + if (userHandle && skillIds.length) { + try { + await verifyMemberSkillsApi(userHandle, skillIds) + } catch (e) { + // do not block navigation on error + // eslint-disable-next-line no-console + console.warn('Failed to verify member skills:', e) + } + } + const completedPath: string = getCertificationCompletedPath( providerName, certificateProgress.certification, diff --git a/src/apps/learn/src/lib/data-providers/member-skills-provider/member-skills.service.ts b/src/apps/learn/src/lib/data-providers/member-skills-provider/member-skills.service.ts new file mode 100644 index 000000000..5e989eb3b --- /dev/null +++ b/src/apps/learn/src/lib/data-providers/member-skills-provider/member-skills.service.ts @@ -0,0 +1,13 @@ +import { EnvironmentConfig } from '~/config' +import { xhrPostAsync } from '~/libs/core' + +/** + * Verify a set of skills for a member in member-api-v6. + * Ensures association exists and sets level to 'verified'. + */ +export async function verifyMemberSkills(handle: string, skillIds: string[]): Promise { + if (!handle || !skillIds?.length) return + + const url = `${EnvironmentConfig.API.V6}/members/${handle}/skills/verify` + await xhrPostAsync(url, { skillIds }) +} diff --git a/src/apps/onboarding/src/components/modal-add-education/index.tsx b/src/apps/onboarding/src/components/modal-add-education/index.tsx index 0e870f8d4..0a3d809c8 100644 --- a/src/apps/onboarding/src/components/modal-add-education/index.tsx +++ b/src/apps/onboarding/src/components/modal-add-education/index.tsx @@ -1,5 +1,5 @@ import { FC, FocusEvent, useEffect, useState } from 'react' -import { getYear, setYear } from 'date-fns' +import { getYear } from 'date-fns' import _ from 'lodash' import classNames from 'classnames' @@ -28,7 +28,7 @@ const ModalAddEducation: FC = (props: ModalAddEducationP const [educationInfo, setEducationInfo] = useState(emptyEducationInfo()) const [formErrors, setFormErrors] = useState({ collegeName: undefined, - endDate: undefined, + endYear: undefined, major: undefined, }) @@ -42,8 +42,8 @@ const ModalAddEducation: FC = (props: ModalAddEducationP errorTmp.major = 'Required' } - if (!educationInfo.endDate) { - errorTmp.endDate = 'Required' + if (!educationInfo.endYear) { + errorTmp.endYear = 'Required' } setFormErrors(errorTmp) @@ -123,19 +123,16 @@ const ModalAddEducation: FC = (props: ModalAddEducationP diff --git a/src/apps/onboarding/src/components/modal-add-work/index.tsx b/src/apps/onboarding/src/components/modal-add-work/index.tsx index 1cc4c1e48..dea6de722 100644 --- a/src/apps/onboarding/src/components/modal-add-work/index.tsx +++ b/src/apps/onboarding/src/components/modal-add-work/index.tsx @@ -4,7 +4,7 @@ import classNames from 'classnames' import moment from 'moment' import { Button, IconSolid, InputDatePicker, InputSelect, InputText, Tooltip } from '~/libs/ui' -import { INDUSTRIES_OPTIONS } from '~/libs/shared' +import { getIndustryOptionLabel, getIndustryOptionValue, INDUSTRIES_OPTIONS } from '~/libs/shared' import FormInputCheckbox from '../form-input-checkbox' import OnboardingBaseModal from '../onboarding-base-modal' @@ -23,14 +23,14 @@ interface ModalAddWorkProps { const industryOptions: any = _.sortBy(INDUSTRIES_OPTIONS) .map(v => ({ - label: v, - value: v, + label: getIndustryOptionLabel(v), + value: getIndustryOptionValue(v), })) const ModalAddWork: FC = (props: ModalAddWorkProps) => { const [workInfo, setWorkInfo] = useState(emptyWorkInfo()) const [formErrors, setFormErrors] = useState({ - company: undefined, + companyName: undefined, endDate: undefined, position: undefined, startDate: undefined, @@ -46,8 +46,8 @@ const ModalAddWork: FC = (props: ModalAddWorkProps) => { const validateField: any = () => { const errorTmp: any = {} - if (!workInfo.company) { - errorTmp.company = 'Required' + if (!workInfo.companyName) { + errorTmp.companyName = 'Required' } if (!workInfo.position) { @@ -121,20 +121,20 @@ const ModalAddWork: FC = (props: ModalAddWorkProps) => {
diff --git a/src/apps/onboarding/src/config/index.ts b/src/apps/onboarding/src/config/index.ts index bbf6909a1..fc49aac88 100644 --- a/src/apps/onboarding/src/config/index.ts +++ b/src/apps/onboarding/src/config/index.ts @@ -7,6 +7,7 @@ export const ACTIONS = { SET_EDUCATIONS: 'SET_EDUCATIONS', SET_LOADING_MEMBER_INFO: 'SET_LOADING_MEMBER_INFO', SET_LOADING_MEMBER_TRAITS: 'SET_LOADING_MEMBER_TRAITS', + SET_ONBOARDING_CHECKLIST: 'SET_ONBOARDING_CHECKLIST', SET_OPEN_FOR_WORK: 'SET_OPEN_FOR_WORK', SET_PERSONALIZATIONS: 'SET_PERSONALIZATIONS', SET_WORKS: 'SET_WORKS', diff --git a/src/apps/onboarding/src/models/EducationInfo.ts b/src/apps/onboarding/src/models/EducationInfo.ts index 9c7047042..df956f350 100644 --- a/src/apps/onboarding/src/models/EducationInfo.ts +++ b/src/apps/onboarding/src/models/EducationInfo.ts @@ -2,14 +2,16 @@ export default interface EducationInfo { collegeName: string major: string dateDescription: string - endDate?: Date - id: number + endYear?: string + id: number, + traitId: string, } export const emptyEducationInfo: () => EducationInfo = () => ({ collegeName: '', dateDescription: '', - endDate: undefined, + endYear: undefined, id: 0, major: '', + traitId: '', }) diff --git a/src/apps/onboarding/src/models/OnboardingChecklistInfo.ts b/src/apps/onboarding/src/models/OnboardingChecklistInfo.ts new file mode 100644 index 000000000..ebc900293 --- /dev/null +++ b/src/apps/onboarding/src/models/OnboardingChecklistInfo.ts @@ -0,0 +1,9 @@ +export default interface OnboardingChecklistInfo { + bio: boolean; + country: boolean; + education: boolean; + language: boolean; + skills: boolean; + work: boolean; + profile_picture: boolean; +} diff --git a/src/apps/onboarding/src/models/WorkInfo.ts b/src/apps/onboarding/src/models/WorkInfo.ts index 12c4193f7..102ea7dce 100644 --- a/src/apps/onboarding/src/models/WorkInfo.ts +++ b/src/apps/onboarding/src/models/WorkInfo.ts @@ -1,5 +1,5 @@ export default interface WorkInfo { - company?: string + companyName?: string position?: string industry?: string startDate?: Date diff --git a/src/apps/onboarding/src/pages/educations/index.tsx b/src/apps/onboarding/src/pages/educations/index.tsx index d7a662a9f..6b084c842 100644 --- a/src/apps/onboarding/src/pages/educations/index.tsx +++ b/src/apps/onboarding/src/pages/educations/index.tsx @@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom' import { connect } from 'react-redux' import _ from 'lodash' import classNames from 'classnames' -import moment from 'moment' import { Button, IconOutline, PageDivider } from '~/libs/ui' @@ -57,12 +56,10 @@ export const PageEducationsContent: FC<{ }, [educations]) const displayEducations = useMemo(() => (educations || []).map(educationItem => { - const endDate: Date | undefined = educationItem.endDate - const endDateString: string = endDate ? moment(endDate) - .format('YYYY') : '' + const endYear: string = educationItem.endYear as string return { ...educationItem, - dateDescription: endDateString || '', + dateDescription: endYear || '', } }), [educations]) diff --git a/src/apps/onboarding/src/pages/personalization/index.tsx b/src/apps/onboarding/src/pages/personalization/index.tsx index 2902fec98..af2392067 100644 --- a/src/apps/onboarding/src/pages/personalization/index.tsx +++ b/src/apps/onboarding/src/pages/personalization/index.tsx @@ -17,22 +17,30 @@ import { } from '../../hooks/useAutoSavePersonalization' import { createMemberPersonalizations, + createOnboardingChecklist, setMemberPhotoUrl, updateMemberDescription, + updateMemberOnboardingChecklist, updateMemberPersonalizations, updateMemberPhotoUrl, } from '../../redux/actions/member' +import EducationInfo from '../../models/EducationInfo' import FieldAvatar from '../../components/FieldAvatar' import InputTextAutoSave from '../../components/InputTextAutoSave' import InputTextareaAutoSave from '../../components/InputTextareaAutoSave' import MemberInfo from '../../models/MemberInfo' +import OnboardingChecklistInfo from '../../models/OnboardingChecklistInfo' import PersonalizationInfo from '../../models/PersonalizationInfo' +import WorkInfo from '../../models/WorkInfo' import styles from './styles.module.scss' interface PagePersonalizationContentReduxProps { memberInfo?: MemberInfo, reduxPersonalizations: PersonalizationInfo[] | undefined + reduxEducations: EducationInfo[] | undefined + reduxWorks: WorkInfo[] | undefined + reduxOnboardingChecklist: OnboardingChecklistInfo | undefined loadingMemberTraits: boolean loadingMemberInfo: boolean } @@ -43,6 +51,9 @@ interface PagePersonalizationContentProps extends PagePersonalizationContentRedu setMemberPhotoUrl: (photoUrl: string) => void updateMemberPhotoUrl: (photoUrl: string) => void updateMemberDescription: (photoUrl: string) => void + updateMemberOnboardingChecklist: (onboardingChecklist: OnboardingChecklistInfo) => Promise; + createOnboardingChecklist: (onboardingChecklist: OnboardingChecklistInfo) => Promise; + } const PagePersonalizationContent: FC = props => { @@ -167,10 +178,28 @@ const PagePersonalizationContent: FC = props => primary iconToLeft disabled={!!shouldNavigateTo.current} - onClick={function onClick() { + onClick={async function onClick() { + if (!props.reduxOnboardingChecklist) { + await props.createOnboardingChecklist({ + ...(props.reduxOnboardingChecklist || {}), + bio: !!props.reduxPersonalizations?.length, + education: !!props.reduxEducations?.length, + skills: !!props.memberInfo?.skills.length, + work: !!props.reduxWorks?.length, + } as OnboardingChecklistInfo) + } else { + await props.updateMemberOnboardingChecklist({ + ...(props.reduxOnboardingChecklist || {}), + bio: !!props.reduxPersonalizations?.length, + education: !!props.reduxEducations?.length, + skills: !!props.memberInfo?.skills.length, + work: !!props.reduxWorks?.length, + }) + } + nextPage( `${EnvironmentConfig.USER_PROFILE_URL}/${props.memberInfo?.handle}` - + '?edit-mode=onboardingCompleted', + + '?edit-mode=onboardingCompleted', ) }} > @@ -188,20 +217,28 @@ const mapStateToProps: (state: any) => PagePersonalizationContentReduxProps loadingMemberInfo, personalizations, memberInfo, + educations, + works, + onboardingChecklist, }: any = state.member return { loadingMemberInfo, loadingMemberTraits, memberInfo, + reduxEducations: educations, + reduxOnboardingChecklist: onboardingChecklist, reduxPersonalizations: personalizations, + reduxWorks: works, } } const mapDispatchToProps: any = { createMemberPersonalizations, + createOnboardingChecklist, setMemberPhotoUrl, updateMemberDescription, + updateMemberOnboardingChecklist, updateMemberPersonalizations, updateMemberPhotoUrl, } diff --git a/src/apps/onboarding/src/pages/works/index.tsx b/src/apps/onboarding/src/pages/works/index.tsx index cebb2f64c..4f8ebeee7 100644 --- a/src/apps/onboarding/src/pages/works/index.tsx +++ b/src/apps/onboarding/src/pages/works/index.tsx @@ -73,7 +73,7 @@ export const PageWorksContent: FC<{ ...(startDateString ? [startDateString] : []), ...(endDateString ? [endDateString] : []), ].join('-'), - description: workItem.company, + description: workItem.companyName, } }), [works]) diff --git a/src/apps/onboarding/src/redux/actions/member.ts b/src/apps/onboarding/src/redux/actions/member.ts index 1f3b506d5..f80485f9a 100644 --- a/src/apps/onboarding/src/redux/actions/member.ts +++ b/src/apps/onboarding/src/redux/actions/member.ts @@ -14,6 +14,7 @@ import ConnectInfo from '../../models/ConnectInfo' import EducationInfo from '../../models/EducationInfo' import MemberAddress from '../../models/MemberAddress' import MemberInfo from '../../models/MemberInfo' +import OnboardingChecklistInfo from '../../models/OnboardingChecklistInfo' import PersonalizationInfo from '../../models/PersonalizationInfo' import WorkInfo from '../../models/WorkInfo' @@ -42,6 +43,14 @@ export const updatePersonalizations: (personalizations: PersonalizationInfo[]) = type: ACTIONS.MEMBER.SET_PERSONALIZATIONS, }) +export const updateOnboardingChecklist: (onboardingChecklist: OnboardingChecklistInfo) => { + payload: OnboardingChecklistInfo + type: string +} = (onboardingChecklist: OnboardingChecklistInfo) => ({ + payload: onboardingChecklist, + type: ACTIONS.MEMBER.SET_ONBOARDING_CHECKLIST, +}) + export const updateConnectInfo: any = (connectInfo: ConnectInfo) => ({ payload: { ...connectInfo, @@ -117,10 +126,10 @@ export const fetchMemberTraits: any = () => async (dispatch: any) => { if (workExpValue) { // workExpValue is array of works. fill it to state const works: WorkInfo[] = workExpValue.map((j: any, index: number) => { - const startDate: Date | undefined = dateTimeToDate(j.timePeriodFrom) - const endDate: Date | undefined = dateTimeToDate(j.timePeriodTo) + const startDate: Date | undefined = dateTimeToDate(j.startDate) + const endDate: Date | undefined = dateTimeToDate(j.endDate) return ({ - company: j.company, + companyName: j.companyName, currentlyWorking: j.working, endDate, id: index + 1, @@ -142,10 +151,11 @@ export const fetchMemberTraits: any = () => async (dispatch: any) => { const startDate: Date | undefined = dateTimeToDate(e.timePeriodFrom) const endDate: Date | undefined = dateTimeToDate(e.timePeriodTo) return ({ - collegeName: e.schoolCollegeName, + collegeName: e.collegeName, endDate, + endYear: e.endYear, id: index + 1, - major: e.major, + major: e.degree, startDate, }) }) @@ -160,13 +170,26 @@ export const fetchMemberTraits: any = () => async (dispatch: any) => { const personalizations: PersonalizationInfo[] = personalizationExpValue.map((e: any) => _.omitBy({ ...e, availableForGigs: e.availableForGigs, - profileSelfTitle: e.profileSelfTitle, + profileSelfTitle: e.personalization?.[0]?.profileSelfTitle || e.profileSelfTitle, referAs: e.referAs, shortBio: e.shortBio, }, _.isUndefined)) dispatch(updatePersonalizations(personalizations)) } + const onboardingChecklistExp: any = memberTraits.find( + (t: any) => t.traitId === UserTraitIds.onboardingChecklist, + ) + const onboardingChecklistExpValue: any = onboardingChecklistExp?.traits?.data + + if (onboardingChecklistExpValue) { + const profileCompletenessChecklist = onboardingChecklistExpValue + .find((item: any) => item.listItemType === 'profile_completed') + if (profileCompletenessChecklist) { + dispatch(updateOnboardingChecklist(profileCompletenessChecklist.metadata)) + } + } + const connectInfoExp: any = memberTraits.find( (t: any) => t.traitId === UserTraitIds.connectInfo, ) @@ -192,11 +215,13 @@ const createWorksPayloadData: any = (works: WorkInfo[]) => { currentlyWorking, }: any = work return { - company, + companyName: company || '', + // eslint-disable-next-line unicorn/no-null + endDate: endDate ? endDate.toISOString() : null, industry, position, - timePeriodFrom: startDate ? startDate.toISOString() : '', - timePeriodTo: endDate ? endDate.toISOString() : '', + // eslint-disable-next-line unicorn/no-null + startDate: startDate ? startDate.toISOString() : null, working: currentlyWorking, } }) @@ -206,11 +231,45 @@ const createWorksPayloadData: any = (works: WorkInfo[]) => { traitId: UserTraitIds.work, traits: { data, + traitId: UserTraitIds.work, }, } return [payload] } +const createOnboardingChecklistData: any = (onboardingChecklist: OnboardingChecklistInfo) => { + const isProfileInComplete = Object.values(onboardingChecklist) + .includes(false) + const payload: any = { + categoryName: UserTraitCategoryNames.onboardingChecklist, + traitId: UserTraitIds.onboardingChecklist, + traits: { + data: [{ + date: new Date() + .toISOString(), + listItemType: 'profile_completed', + message: isProfileInComplete ? 'Profile is incomplete' : 'Completed', + metadata: onboardingChecklist, + status: isProfileInComplete ? 'pending_at_user' : 'completed', + }], + traitId: UserTraitIds.onboardingChecklist, + }, + } + return [payload] +} + +export const createOnboardingChecklist: any = ( + onboardingChecklist: OnboardingChecklistInfo, +) => async (dispatch: any) => { + try { + const tokenInfo: TokenModel = await getAsyncToken() + + await createMemberTraits(tokenInfo.handle || '', createOnboardingChecklistData(onboardingChecklist)) + dispatch(updateOnboardingChecklist(onboardingChecklist)) + } catch (error) { + } +} + export const updateMemberWorks: any = (works: WorkInfo[]) => async (dispatch: any) => { try { const tokenInfo: TokenModel = await getAsyncToken() @@ -242,14 +301,12 @@ const createEducationsPayloadData: any = (educations: EducationInfo[]) => { const { collegeName, major, - startDate, - endDate, + endYear, }: any = education return { - major, - schoolCollegeName: collegeName, - timePeriodFrom: startDate ? startDate.toISOString() : '', - timePeriodTo: endDate ? endDate.toISOString() : '', + collegeName, + degree: major, + endYear: parseInt(endYear, 10), } }) @@ -258,6 +315,7 @@ const createEducationsPayloadData: any = (educations: EducationInfo[]) => { traitId: UserTraitIds.education, traits: { data, + traitId: UserTraitIds.education, }, } return [payload] @@ -266,7 +324,6 @@ const createEducationsPayloadData: any = (educations: EducationInfo[]) => { export const updateMemberEducations: any = (educations: EducationInfo[]) => async (dispatch: any) => { try { const tokenInfo: TokenModel = await getAsyncToken() - await updateMemberTraits(tokenInfo.handle || '', createEducationsPayloadData(educations)) dispatch(updateEducations(educations)) } catch (error) { @@ -311,6 +368,7 @@ const createPersonalizationsPayloadData: any = (personalizations: Personalizatio traitId: UserTraitIds.personalization, traits: { data, + traitId: UserTraitIds.personalization, }, } return [payload] @@ -326,6 +384,18 @@ export const updateMemberPersonalizations: any = (personalizations: Personalizat } } +export const updateMemberOnboardingChecklist: any = ( + onboardingChecklistInfo: OnboardingChecklistInfo, +) => async (dispatch: any) => { + try { + const tokenInfo: TokenModel = await getAsyncToken() + + await updateMemberTraits(tokenInfo.handle || '', createOnboardingChecklistData(onboardingChecklistInfo)) + dispatch(updateOnboardingChecklist(onboardingChecklistInfo)) + } catch (error) { + } +} + export const createMemberPersonalizations: any = (personalizations: PersonalizationInfo[]) => async (dispatch: any) => { let isCreatedSuccess = false try { diff --git a/src/apps/onboarding/src/redux/reducers/member.ts b/src/apps/onboarding/src/redux/reducers/member.ts index 66885bccf..e13cb2327 100644 --- a/src/apps/onboarding/src/redux/reducers/member.ts +++ b/src/apps/onboarding/src/redux/reducers/member.ts @@ -8,6 +8,7 @@ import ConnectInfo from '../../models/ConnectInfo' import EducationInfo from '../../models/EducationInfo' import MemberAddress from '../../models/MemberAddress' import MemberInfo from '../../models/MemberInfo' +import OnboardingChecklistInfo from '../../models/OnboardingChecklistInfo' import PersonalizationInfo from '../../models/PersonalizationInfo' import WorkInfo from '../../models/WorkInfo' @@ -20,6 +21,7 @@ const initialState: { connectInfo?: ConnectInfo loadingMemberTraits?: boolean loadingMemberInfo?: boolean + onboardingChecklist?: OnboardingChecklistInfo } = { } @@ -54,6 +56,11 @@ const memberReducer: any = ( ...state, personalizations: action.payload, } + case ACTIONS.MEMBER.SET_ONBOARDING_CHECKLIST: + return { + ...state, + onboardingChecklist: action.payload, + } case ACTIONS.MEMBER.SET_ADDRESS: return { ...state, diff --git a/src/apps/platform/src/components/app-footer/AppFooter.tsx b/src/apps/platform/src/components/app-footer/AppFooter.tsx index 1d5055c8a..dc1020f1d 100644 --- a/src/apps/platform/src/components/app-footer/AppFooter.tsx +++ b/src/apps/platform/src/components/app-footer/AppFooter.tsx @@ -1,7 +1,6 @@ import { FC, MutableRefObject, useEffect, useRef } from 'react' -import type { TcUniNavFn } from 'universal-navigation' -declare let tcUniNav: TcUniNavFn +import { getTcUniNav } from '../../utils' const APP_FOOTER_EL_ID: string = 'footer-nav-el' @@ -22,7 +21,7 @@ const AppFooter: FC<{}> = () => { return } - tcUniNav( + getTcUniNav()?.( 'init', APP_FOOTER_EL_ID, { diff --git a/src/apps/platform/src/components/app-header/AppHeader.tsx b/src/apps/platform/src/components/app-header/AppHeader.tsx index 48d81e331..6381cf483 100644 --- a/src/apps/platform/src/components/app-header/AppHeader.tsx +++ b/src/apps/platform/src/components/app-header/AppHeader.tsx @@ -11,7 +11,7 @@ import { useState, } from 'react' import { NavigateFunction, useNavigate } from 'react-router-dom' -import type { AuthUser as NavAuthUser, TcUniNavFn } from 'universal-navigation' +import type { AuthUser as NavAuthUser } from 'universal-navigation' import classNames from 'classnames' import { EnvironmentConfig, PageSubheaderPortalId } from '~/config' @@ -25,9 +25,10 @@ import { } from '~/libs/core' import { ConfigContextValue, useConfigContext } from '~/libs/shared' +import { getTcUniNav } from '../../utils' + import UniNavSnippet from './universal-nav-snippet' -declare let tcUniNav: TcUniNavFn UniNavSnippet(EnvironmentConfig.URLS.UNIVERSAL_NAV) interface NavigationRequest { @@ -86,7 +87,7 @@ const AppHeader: FC<{}> = () => { headerInit.current = true - tcUniNav( + getTcUniNav()?.( 'init', navElementId, { @@ -116,7 +117,7 @@ const AppHeader: FC<{}> = () => { // update uni-nav's tool details useEffect(() => { - tcUniNav( + getTcUniNav()?.( 'update', navElementId, { @@ -139,7 +140,7 @@ const AppHeader: FC<{}> = () => { return } - tcUniNav( + getTcUniNav()?.( 'update', navElementId, { diff --git a/src/apps/platform/src/platform.routes.tsx b/src/apps/platform/src/platform.routes.tsx index cfe671bc5..e32c8b239 100644 --- a/src/apps/platform/src/platform.routes.tsx +++ b/src/apps/platform/src/platform.routes.tsx @@ -10,6 +10,7 @@ import { walletRoutes } from '~/apps/wallet' import { walletAdminRoutes } from '~/apps/wallet-admin' import { copilotsRoutes } from '~/apps/copilots' import { adminRoutes } from '~/apps/admin' +import { reviewRoutes } from '~/apps/review' const Home: LazyLoadedComponent = lazyLoad( () => import('./routes/home'), @@ -37,6 +38,7 @@ export const platformRoutes: Array = [ ...walletRoutes, ...walletAdminRoutes, ...accountsRoutes, + ...reviewRoutes, ...homeRoutes, ...adminRoutes, ] diff --git a/src/apps/platform/src/utils/index.ts b/src/apps/platform/src/utils/index.ts new file mode 100644 index 000000000..6710c8dec --- /dev/null +++ b/src/apps/platform/src/utils/index.ts @@ -0,0 +1 @@ +export * from './other' diff --git a/src/apps/platform/src/utils/other.ts b/src/apps/platform/src/utils/other.ts new file mode 100644 index 000000000..14abc189c --- /dev/null +++ b/src/apps/platform/src/utils/other.ts @@ -0,0 +1,15 @@ +import { TcUniNavFn } from 'universal-navigation' + +declare let tcUniNav: TcUniNavFn + +/** + * Get tcUniNav + * @returns tcUniNav + */ +export function getTcUniNav(): TcUniNavFn | undefined { + if (typeof tcUniNav === 'undefined') { + return undefined + } + + return tcUniNav +} diff --git a/src/apps/profiles/src/config/constants.ts b/src/apps/profiles/src/config/constants.ts index b8bd645a6..ec7f7dce4 100644 --- a/src/apps/profiles/src/config/constants.ts +++ b/src/apps/profiles/src/config/constants.ts @@ -1,4 +1,4 @@ -import { EnvironmentConfig } from '~/config' +// import { EnvironmentConfig } from '~/config' export enum TRACKS_PROFILE_MAP { DEVELOP = 'Developer', @@ -23,6 +23,6 @@ export enum profileEditModes { onboardingCompleted = 'onboardingCompleted', } -export const CES_SURVEY_ID = EnvironmentConfig.USERFLOW_SURVEYS.PROFILES +// (removed) CES Survey/Userflow integrations export const MAX_PRINCIPAL_SKILLS_COUNT = 10 diff --git a/src/apps/profiles/src/lib/index.ts b/src/apps/profiles/src/lib/index.ts index dfd26ed29..6be858b03 100644 --- a/src/apps/profiles/src/lib/index.ts +++ b/src/apps/profiles/src/lib/index.ts @@ -1,3 +1,2 @@ export * from './helpers' -export * from './userflow-survey' export { ReactComponent as WinnerIcon } from './assets/winner-icon.svg' diff --git a/src/apps/profiles/src/lib/userflow-survey.ts b/src/apps/profiles/src/lib/userflow-survey.ts deleted file mode 100644 index b86e2d62e..000000000 --- a/src/apps/profiles/src/lib/userflow-survey.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { TcUniNavFn } from 'universal-navigation' - -import { CES_SURVEY_ID } from '../config' - -declare let tcUniNav: TcUniNavFn - -export function triggerSurvey(): void { - tcUniNav('triggerFlow', CES_SURVEY_ID, {}) -} diff --git a/src/apps/profiles/src/member-profile/MemberProfilePage.tsx b/src/apps/profiles/src/member-profile/MemberProfilePage.tsx index 648c928b2..b8054c8ac 100644 --- a/src/apps/profiles/src/member-profile/MemberProfilePage.tsx +++ b/src/apps/profiles/src/member-profile/MemberProfilePage.tsx @@ -6,7 +6,7 @@ import { profileContext, ProfileContextData, profileGetPublicAsync, UserProfile import { TALENT_SEARCH_PATHS } from '~/apps/talent-search' import { LoadingSpinner } from '~/libs/ui' -import { notifyUniNavi, triggerSurvey } from '../lib' +import { notifyUniNavi } from '../lib' import { ProfilePageLayout } from './page-layout' import { MemberProfileContextValue, useMemberProfileContext } from './MemberProfile.context' @@ -50,7 +50,6 @@ const MemberProfilePage: FC<{}> = () => { setProfile({ ...userProfile } as UserProfile) if (userProfile) { notifyUniNavi(userProfile) - triggerSurvey() } }) ), []) diff --git a/src/apps/profiles/src/member-profile/education-and-certifications/EducationAndCertifications.tsx b/src/apps/profiles/src/member-profile/education-and-certifications/EducationAndCertifications.tsx index 0c7f35983..3836048b6 100644 --- a/src/apps/profiles/src/member-profile/education-and-certifications/EducationAndCertifications.tsx +++ b/src/apps/profiles/src/member-profile/education-and-certifications/EducationAndCertifications.tsx @@ -14,7 +14,6 @@ import { import { EDIT_MODE_QUERY_PARAM, profileEditModes } from '../../config' import { AddButton, EditMemberPropertyBtn, EmptySection } from '../../components' import { MemberTCAInfo } from '../tca-info' -import { triggerSurvey } from '../../lib' import { ModifyEducationModal } from './ModifyEducationModal' import { EducationCard } from './EducationCard' @@ -72,7 +71,6 @@ const EducationAndCertifications: FC = (props: setIsEditMode(false) mutateTraits() props.refreshProfile(props.profile.handle) - triggerSurvey() }, 1000) } @@ -92,7 +90,7 @@ const EducationAndCertifications: FC = (props: (memberEducation?.length as number) > 0 && ( memberEducation?.map((education: UserTrait) => ( )) diff --git a/src/apps/profiles/src/member-profile/education-and-certifications/EducationCard/EducationCard.tsx b/src/apps/profiles/src/member-profile/education-and-certifications/EducationCard/EducationCard.tsx index 7c262e7f4..b32c452c7 100644 --- a/src/apps/profiles/src/member-profile/education-and-certifications/EducationCard/EducationCard.tsx +++ b/src/apps/profiles/src/member-profile/education-and-certifications/EducationCard/EducationCard.tsx @@ -1,6 +1,5 @@ import { FC } from 'react' import classNames from 'classnames' -import moment from 'moment' import { UserTrait } from '~/libs/core' @@ -16,18 +15,17 @@ const EducationCard: FC = (props: EducationCardProps) => (

- {props.education.major} + {props.education.degree}

- {props.education.schoolCollegeName} + {props.education.collegeName}

{ - props.education.timePeriodFrom || props.education.timePeriodTo ? ( + props.education.endYear ? (

- {props.education.timePeriodTo ? moment(props.education.timePeriodTo) - .format('YYYY') : ''} + {props.education.endYear}

) : undefined diff --git a/src/apps/profiles/src/member-profile/education-and-certifications/ModifyEducationModal/ModifyEducationModal.tsx b/src/apps/profiles/src/member-profile/education-and-certifications/ModifyEducationModal/ModifyEducationModal.tsx index 50863cb1c..0556519c4 100644 --- a/src/apps/profiles/src/member-profile/education-and-certifications/ModifyEducationModal/ModifyEducationModal.tsx +++ b/src/apps/profiles/src/member-profile/education-and-certifications/ModifyEducationModal/ModifyEducationModal.tsx @@ -39,10 +39,10 @@ const ModifyEducationModal: FC = (props: ModifyEducat = useState(props.education?.length === 0 || false) const [formValues, setFormValues]: [ - { [key: string]: string | boolean | Date | undefined }, - Dispatch> + { [key: string]: string | boolean | Date | number | undefined }, + Dispatch> ] - = useState<{ [key: string]: string | boolean | Date | undefined }>({}) + = useState<{ [key: string]: string | boolean | Date | number | undefined }>({}) const [formErrors, setFormErrors]: [ { [key: string]: string }, @@ -77,6 +77,7 @@ const ModifyEducationModal: FC = (props: ModifyEducat traitId: UserTraitIds.education, traits: { data: memberEducation || [], + traitId: UserTraitIds.education, }, }, props.education) .then(() => { @@ -90,16 +91,10 @@ const ModifyEducationModal: FC = (props: ModifyEducat } function handleFormValueChange(key: string, event: React.ChangeEvent): void { - let value: string | boolean | Date | undefined - - switch (key) { - case 'endDate': - value = new Date(event.target.value) - break - default: - value = event.target.value - break - } + + const value = key === 'endYear' + ? Number(event.target.value) + : event.target.value setFormValues({ ...formValues, @@ -118,25 +113,24 @@ const ModifyEducationModal: FC = (props: ModifyEducat function handleFormAction(): void { setFormErrors({}) - if (!trim(formValues.schoolCollegeName as string)) { + if (!trim(formValues.collegeName as string)) { setFormErrors({ - schoolCollegeName: 'School is required', + collegeName: 'School is required', }) return } - if (!trim(formValues.major as string)) { + if (!trim(formValues.degree as string)) { setFormErrors({ - major: 'Degree is required', + degree: 'Degree is required', }) return } const updatedEducation: UserTrait = { - graduated: formValues.graduated, - major: formValues.major, - schoolCollegeName: formValues.schoolCollegeName, - timePeriodTo: formValues.endDate ? (formValues.endDate as Date).toISOString() : undefined, + collegeName: formValues.collegeName, + degree: formValues.degree, + endYear: formValues.endYear, } if (editedItemIndex !== undefined && memberEducation) { @@ -160,10 +154,9 @@ const ModifyEducationModal: FC = (props: ModifyEducat setEditedItemIndex(indx) setFormValues({ - endDate: education.timePeriodTo ? new Date(education.timePeriodTo) : undefined, - graduated: education.graduated, - major: education.major, - schoolCollegeName: education.schoolCollegeName, + collegeName: education.collegeName, + degree: education.degree, + endYear: education.endYear, }) } @@ -226,7 +219,7 @@ const ModifyEducationModal: FC = (props: ModifyEducat memberEducation?.map((education: UserTrait, indx: number) => (
@@ -257,32 +250,32 @@ const ModifyEducationModal: FC = (props: ModifyEducat = (props: MemberLanguagesProps) setIsEditMode(false) mutateTraits() notifyUniNavi(props.profile) - triggerSurvey() }, 1000) } diff --git a/src/apps/profiles/src/member-profile/links/MemberLinks.tsx b/src/apps/profiles/src/member-profile/links/MemberLinks.tsx index 4631b9678..9a3bcc0ac 100644 --- a/src/apps/profiles/src/member-profile/links/MemberLinks.tsx +++ b/src/apps/profiles/src/member-profile/links/MemberLinks.tsx @@ -11,7 +11,7 @@ import { import { AddButton, EditMemberPropertyBtn } from '../../components' import { EDIT_MODE_QUERY_PARAM, profileEditModes } from '../../config' -import { notifyUniNavi, triggerSurvey } from '../../lib' +import { notifyUniNavi } from '../../lib' import { ModifyMemberLinksModal } from './ModifyMemberLinksModal' import { ReactComponent as GitHubLinkIcon } from './assets/github-link-icon.svg' @@ -82,7 +82,6 @@ const MemberLinks: FC = (props: MemberLinksProps) => { setIsEditMode(false) mutateTraits() notifyUniNavi(props.profile) - triggerSurvey() }, 1000) } diff --git a/src/apps/profiles/src/member-profile/local-info/MemberLocalInfo.tsx b/src/apps/profiles/src/member-profile/local-info/MemberLocalInfo.tsx index d0e31e017..0d14d9be8 100644 --- a/src/apps/profiles/src/member-profile/local-info/MemberLocalInfo.tsx +++ b/src/apps/profiles/src/member-profile/local-info/MemberLocalInfo.tsx @@ -60,11 +60,27 @@ const MemberLocalInfo: FC = (props: MemberLocalInfoProps) }, 1000) } + const locationDisplay: string = useMemo(() => { + if (city && memberCountry) { + return `${city}, ${memberCountry}` + } + + if (city) { + return city + } + + if (memberCountry) { + return memberCountry + } + + return 'Unknown location' + }, [city, memberCountry]) + return (
- {`${!!city ? `${city}, ` : ''}${memberCountry}`} + {locationDisplay} { canEdit && ( = (props: OpenForGigsProps) => { setTimeout(() => { setIsEditMode(false) props.refreshProfile(props.profile.handle) - triggerSurvey() }, 1000) } diff --git a/src/apps/profiles/src/member-profile/work-expirence/ModifyWorkExpirenceModal/ModifyWorkExpirenceModal.tsx b/src/apps/profiles/src/member-profile/work-expirence/ModifyWorkExpirenceModal/ModifyWorkExpirenceModal.tsx index 85658540e..d0c3bd367 100644 --- a/src/apps/profiles/src/member-profile/work-expirence/ModifyWorkExpirenceModal/ModifyWorkExpirenceModal.tsx +++ b/src/apps/profiles/src/member-profile/work-expirence/ModifyWorkExpirenceModal/ModifyWorkExpirenceModal.tsx @@ -11,7 +11,7 @@ import { UserTraitCategoryNames, UserTraitIds, } from '~/libs/core' -import { INDUSTRIES_OPTIONS } from '~/libs/shared' +import { getIndustryOptionLabel, getIndustryOptionValue, INDUSTRIES_OPTIONS } from '~/libs/shared' import { WorkExpirenceCard } from '../WorkExpirenceCard' @@ -58,8 +58,8 @@ const ModifyWorkExpirenceModal: FC = (props: Modi const industryOptions: any = sortBy(INDUSTRIES_OPTIONS) .map(v => ({ - label: v, - value: v, + label: getIndustryOptionLabel(v), + value: getIndustryOptionValue(v), })) function handleModifyWorkExpirenceSave(): void { @@ -76,6 +76,7 @@ const ModifyWorkExpirenceModal: FC = (props: Modi traitId: UserTraitIds.work, traits: { data: workExpirence || [], + traitId: UserTraitIds.work, }, }, props.workExpirence) .then(() => { diff --git a/src/apps/profiles/src/member-profile/work-expirence/WorkExpirence.tsx b/src/apps/profiles/src/member-profile/work-expirence/WorkExpirence.tsx index 296dcfa21..39d647df4 100644 --- a/src/apps/profiles/src/member-profile/work-expirence/WorkExpirence.tsx +++ b/src/apps/profiles/src/member-profile/work-expirence/WorkExpirence.tsx @@ -4,7 +4,6 @@ import { useSearchParams } from 'react-router-dom' import { MemberTraitsAPI, useMemberTraits, UserProfile, UserTrait, UserTraitIds } from '~/libs/core' import { EDIT_MODE_QUERY_PARAM, profileEditModes } from '../../config' -import { triggerSurvey } from '../../lib' import { AddButton, EditMemberPropertyBtn, EmptySection } from '../../components' import { ModifyWorkExpirenceModal } from './ModifyWorkExpirenceModal' @@ -52,7 +51,6 @@ const WorkExpirence: FC = (props: WorkExpirenceProps) => { setIsEditMode(false) mutateTraits() props.refreshProfile(props.profile.handle) - triggerSurvey() }, 1000) } diff --git a/src/apps/profiles/src/member-profile/work-expirence/WorkExpirenceCard/WorkExpirenceCard.tsx b/src/apps/profiles/src/member-profile/work-expirence/WorkExpirenceCard/WorkExpirenceCard.tsx index 1f6ff04f0..99f1f6b60 100644 --- a/src/apps/profiles/src/member-profile/work-expirence/WorkExpirenceCard/WorkExpirenceCard.tsx +++ b/src/apps/profiles/src/member-profile/work-expirence/WorkExpirenceCard/WorkExpirenceCard.tsx @@ -3,6 +3,7 @@ import classNames from 'classnames' import moment from 'moment' import { UserTrait } from '~/libs/core' +import { getIndustryOptionLabel } from '~/libs/shared' import styles from './WorkExpirenceCard.module.scss' @@ -17,7 +18,7 @@ const WorkExpirenceCard: FC = (props: WorkExpirenceCardP

{props.work.position} - {props.work.industry ? `, ${props.work.industry}` : undefined} + {props.work.industry ? `, ${getIndustryOptionLabel(props.work.industry)}` : undefined}

{props.work.company} diff --git a/src/apps/review/README.md b/src/apps/review/README.md new file mode 100644 index 000000000..de43e93f1 --- /dev/null +++ b/src/apps/review/README.md @@ -0,0 +1,23 @@ +# Instructions For Running The Review Locally + +### Build and run: + +- Run this script to start the app, you may have to type the admin password for the `sudo` command: + +```bash +nvm use +export NVM_DIR=~/.nvm +yarn install +sudo yarn start +``` + +- If you have any problem when running the above script, please check `README.md` in the root of the project for more info. +- After running successfully, please open `https://local.topcoder-dev.com/review` in the browser to start the admin app + +### Configuration: + +- Configuration files are under src/apps/review/src/config + +### Mock data: + +- Mock data files are under src/apps/review/src/mock-datas diff --git a/src/apps/review/index.ts b/src/apps/review/index.ts new file mode 100644 index 000000000..6f39cd49b --- /dev/null +++ b/src/apps/review/index.ts @@ -0,0 +1 @@ +export * from './src' diff --git a/src/apps/review/src/ReviewApp.tsx b/src/apps/review/src/ReviewApp.tsx new file mode 100644 index 000000000..056fce715 --- /dev/null +++ b/src/apps/review/src/ReviewApp.tsx @@ -0,0 +1,36 @@ +/** + * The review app. + */ +import { FC, useContext, useEffect, useMemo } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import { routerContext, RouterContextData } from '~/libs/core' + +import { Layout, ReviewAppContextProvider, SWRConfigProvider } from './lib' +import { toolTitle } from './review-app.routes' +import './lib/styles/index.scss' + +const ReviewApp: FC = () => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + const childRoutes = useMemo(() => getChildRoutes(toolTitle), [getChildRoutes]) + + useEffect(() => { + document.body.classList.add('review-app') + return () => { + document.body.classList.remove('review-app') + } + }, []) + + return ( + + + + + {childRoutes} + + + + ) +} + +export default ReviewApp diff --git a/src/apps/review/src/config/index.config.ts b/src/apps/review/src/config/index.config.ts new file mode 100644 index 000000000..3d9c8eff9 --- /dev/null +++ b/src/apps/review/src/config/index.config.ts @@ -0,0 +1,81 @@ +/** + * Common config for ui. + */ + +import { SelectOption } from '../lib/models' + +export const DESIGN = 'Design' +export const TRACK_CHALLENGE = 'Challenge' +export const CODE = 'Code' +export const BUG_HUNT = 'Bug Hunt' +export const TEST_SUITE = 'Test Suite' +export const COPILOT_OPPORTUNITY = 'Copilot Opportunity' +export const MARATHON_MATCH = 'Marathon Match' +export const FIRST2FINISH = 'First2Finish' +export const OTHER = 'Other' + +export const CHALLENGE_TYPE_SELECT_ALL_OPTION: SelectOption = { + label: 'All', + value: '', +} + +export const CHALLENGE_TYPE_SELECT_OPTIONS: SelectOption[] = [ + CHALLENGE_TYPE_SELECT_ALL_OPTION, + ...[ + DESIGN, + CODE, + BUG_HUNT, + TEST_SUITE, + COPILOT_OPPORTUNITY, + MARATHON_MATCH, + FIRST2FINISH, + OTHER, + ].map(item => ({ label: item, value: item })), +] +export const QUESTION_YES_NO_OPTIONS: SelectOption[] = ['Yes', 'No'].map( + item => ({ label: item, value: item }), +) +export const QUESTION_RESPONSE_OPTIONS: SelectOption[] = [ + { + label: 'Comment', + value: 'COMMENT', + }, + { + label: 'Required', + value: 'REQUIRED', + }, + { + label: 'Recommended', + value: 'RECOMMENDED', + }, +] +export const QUESTION_RESPONSE_TYPE_MAPPING_DISPLAY: { [key: string]: string } += { + COMMENT: 'Comment', + RECOMMENDED: 'Recommended', + REQUIRED: 'Required', +} +export const TABLE_DATE_FORMAT = 'MMM DD YYYY, HH:mm A' +export const TABLE_PAGINATION_ITEM_PER_PAGE = 100 +export const THRESHOLD_SHORT_TIME = 2 * 60 * 60 * 1000 // in miliseconds + +export const ORDINAL_SUFFIX = new Map([[1, '1st'], [2, '2nd'], [3, '3rd']]) + +export const REVIEWER = 'Reviewer' +export const SUBMITTER = 'Submitter' +export const COPILOT = 'Copilot' +export const ADMIN = 'Admin' +export const MANAGER = 'Manager' + +export const MOCKHANDLE = 'stevenfrog' +export const REVIEWCOUNT = 3 + +export const ITERATIVE_REVIEW = 'Iterative Review' +export const APPROVAL = 'Approval' +export const WINNERS = 'Winners' + +export const TAB = 'tab' +export const FINISHTAB = [WINNERS] +export const WITHOUT_APPEAL = [DESIGN, FIRST2FINISH] + +export const NO_RESOURCE_ID = 'noResource' diff --git a/src/apps/review/src/config/routes.config.ts b/src/apps/review/src/config/routes.config.ts new file mode 100644 index 000000000..2eae58a2c --- /dev/null +++ b/src/apps/review/src/config/routes.config.ts @@ -0,0 +1,17 @@ +/** + * Common config for routes in review app. + */ +import { AppSubdomain, EnvironmentConfig } from '~/config' + +export const rootRoute: string + = EnvironmentConfig.SUBDOMAIN === AppSubdomain.review + ? '' + : `/${AppSubdomain.review}` + +export const activeReviewAssignmentsRouteId = 'active-challenges' +export const openOpportunitiesRouteId = 'open-opportunities' +export const pastReviewAssignmentsRouteId = 'past-challenges' +export const challengeDetailRouteId = ':challengeId' +export const pastChallengeDetailContainerRouteId = 'past-challenge-details' +export const scorecardRouteId = 'scorecard' +export const viewScorecardRouteId = ':scorecardId' diff --git a/src/apps/review/src/declarations.d.ts b/src/apps/review/src/declarations.d.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/apps/review/src/index.ts b/src/apps/review/src/index.ts new file mode 100644 index 000000000..1070aef1f --- /dev/null +++ b/src/apps/review/src/index.ts @@ -0,0 +1,2 @@ +export { reviewRoutes } from './review-app.routes' +export { rootRoute as reviewRootRoute } from './config/routes.config' diff --git a/src/apps/review/src/lib/assets/icons/arrow-left.svg b/src/apps/review/src/lib/assets/icons/arrow-left.svg new file mode 100644 index 000000000..b6fe3376c --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/arrow-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/chevron-down.svg b/src/apps/review/src/lib/assets/icons/chevron-down.svg new file mode 100644 index 000000000..82b096f96 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/editor/bold.ts b/src/apps/review/src/lib/assets/icons/editor/bold.ts new file mode 100644 index 000000000..412e6b6a1 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/bold.ts @@ -0,0 +1,5 @@ +/* eslint-disable max-len */ +export const IconBold += ` + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/code.ts b/src/apps/review/src/lib/assets/icons/editor/code.ts new file mode 100644 index 000000000..58a2dfee4 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/code.ts @@ -0,0 +1,5 @@ +/* eslint-disable max-len */ +export const IconCode = ` + + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/heading-1.ts b/src/apps/review/src/lib/assets/icons/editor/heading-1.ts new file mode 100644 index 000000000..60f069328 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/heading-1.ts @@ -0,0 +1,5 @@ +/* eslint-disable max-len */ +export const IconHeading1 = ` + + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/heading-2.ts b/src/apps/review/src/lib/assets/icons/editor/heading-2.ts new file mode 100644 index 000000000..fb7b4a87e --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/heading-2.ts @@ -0,0 +1,4 @@ +/* eslint-disable max-len */ +export const IconHeading2 = ` + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/heading-3.ts b/src/apps/review/src/lib/assets/icons/editor/heading-3.ts new file mode 100644 index 000000000..e3760e891 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/heading-3.ts @@ -0,0 +1,4 @@ +/* eslint-disable max-len */ +export const IconHeading3 = ` + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/image.ts b/src/apps/review/src/lib/assets/icons/editor/image.ts new file mode 100644 index 000000000..2e1be4f9b --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/image.ts @@ -0,0 +1,5 @@ +/* eslint-disable max-len */ +export const IconImage = ` + + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/italic.ts b/src/apps/review/src/lib/assets/icons/editor/italic.ts new file mode 100644 index 000000000..9ac50459c --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/italic.ts @@ -0,0 +1,4 @@ +/* eslint-disable max-len */ +export const IconItalic = ` + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/link.ts b/src/apps/review/src/lib/assets/icons/editor/link.ts new file mode 100644 index 000000000..548763d30 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/link.ts @@ -0,0 +1,4 @@ +/* eslint-disable max-len */ +export const IconLink = ` + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/mentions.ts b/src/apps/review/src/lib/assets/icons/editor/mentions.ts new file mode 100644 index 000000000..7d96251cf --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/mentions.ts @@ -0,0 +1,6 @@ +/* eslint-disable max-len */ +export const IconMentions += ` + + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/ordered-list.ts b/src/apps/review/src/lib/assets/icons/editor/ordered-list.ts new file mode 100644 index 000000000..8acb5c59d --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/ordered-list.ts @@ -0,0 +1,4 @@ +/* eslint-disable max-len */ +export const IconOrderedList = ` + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/quote.ts b/src/apps/review/src/lib/assets/icons/editor/quote.ts new file mode 100644 index 000000000..b392d284f --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/quote.ts @@ -0,0 +1,5 @@ +/* eslint-disable max-len */ +export const IconQuote = ` + + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/strikethrough.ts b/src/apps/review/src/lib/assets/icons/editor/strikethrough.ts new file mode 100644 index 000000000..9289d4a55 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/strikethrough.ts @@ -0,0 +1,4 @@ +/* eslint-disable max-len */ +export const IconStrikethrough = ` + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/table.ts b/src/apps/review/src/lib/assets/icons/editor/table.ts new file mode 100644 index 000000000..544a5a0a4 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/table.ts @@ -0,0 +1,5 @@ +/* eslint-disable max-len */ +export const IconTable = ` + + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/unordered-list.ts b/src/apps/review/src/lib/assets/icons/editor/unordered-list.ts new file mode 100644 index 000000000..030f5bfc0 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/unordered-list.ts @@ -0,0 +1,5 @@ +/* eslint-disable max-len */ +export const IconUnorderedList = ` + + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/upload-file.ts b/src/apps/review/src/lib/assets/icons/editor/upload-file.ts new file mode 100644 index 000000000..d1f68fe29 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/upload-file.ts @@ -0,0 +1,4 @@ +/* eslint-disable max-len */ +export const IconUploadFile = ` + +` diff --git a/src/apps/review/src/lib/assets/icons/external-link.svg b/src/apps/review/src/lib/assets/icons/external-link.svg new file mode 100644 index 000000000..cc724f815 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/external-link.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-1st.svg b/src/apps/review/src/lib/assets/icons/icon-1st.svg new file mode 100644 index 000000000..0280de22d --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-1st.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-2nd.svg b/src/apps/review/src/lib/assets/icons/icon-2nd.svg new file mode 100644 index 000000000..499794d19 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-2nd.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-3rd.svg b/src/apps/review/src/lib/assets/icons/icon-3rd.svg new file mode 100644 index 000000000..693ef3604 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-3rd.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-appeal.svg b/src/apps/review/src/lib/assets/icons/icon-appeal.svg new file mode 100644 index 000000000..6023a177b --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-appeal.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-dollar.svg b/src/apps/review/src/lib/assets/icons/icon-dollar.svg new file mode 100644 index 000000000..ac96c7b15 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-dollar.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-error.svg b/src/apps/review/src/lib/assets/icons/icon-error.svg new file mode 100644 index 000000000..28209fad0 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-error.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-event.svg b/src/apps/review/src/lib/assets/icons/icon-event.svg new file mode 100644 index 000000000..0bf91afc4 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-event.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-file.svg b/src/apps/review/src/lib/assets/icons/icon-file.svg new file mode 100644 index 000000000..1c807aa56 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-file.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-handle.svg b/src/apps/review/src/lib/assets/icons/icon-handle.svg new file mode 100644 index 000000000..6bbc6dcd4 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-handle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-reopen.svg b/src/apps/review/src/lib/assets/icons/icon-reopen.svg new file mode 100644 index 000000000..c0edf1ae6 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-reopen.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-review.svg b/src/apps/review/src/lib/assets/icons/icon-review.svg new file mode 100644 index 000000000..301855daf --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-review.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-submission.svg b/src/apps/review/src/lib/assets/icons/icon-submission.svg new file mode 100644 index 000000000..4b96fe2b4 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-submission.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-timer.svg b/src/apps/review/src/lib/assets/icons/icon-timer.svg new file mode 100644 index 000000000..6406d581f --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-timer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-upload.svg b/src/apps/review/src/lib/assets/icons/icon-upload.svg new file mode 100644 index 000000000..e6f694f54 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-upload.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/icon-warning.svg b/src/apps/review/src/lib/assets/icons/icon-warning.svg new file mode 100644 index 000000000..f18f367c5 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/icon-warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/index.ts b/src/apps/review/src/lib/assets/icons/index.ts new file mode 100644 index 000000000..067315b11 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/index.ts @@ -0,0 +1,22 @@ +import { ReactComponent as IconArrowLeft } from './arrow-left.svg' +import { ReactComponent as IconExternalLink } from './external-link.svg' +import { ReactComponent as IconChevronDown } from './selector.svg' +import { ReactComponent as IconError } from './icon-error.svg' + +export * from './editor/bold' +export * from './editor/code' +export * from './editor/heading-1' +export * from './editor/heading-2' +export * from './editor/heading-3' +export * from './editor/image' +export * from './editor/italic' +export * from './editor/link' +export * from './editor/mentions' +export * from './editor/ordered-list' +export * from './editor/quote' +export * from './editor/strikethrough' +export * from './editor/table' +export * from './editor/unordered-list' +export * from './editor/upload-file' + +export { IconArrowLeft, IconExternalLink, IconChevronDown, IconError } diff --git a/src/apps/review/src/lib/assets/icons/selector.svg b/src/apps/review/src/lib/assets/icons/selector.svg new file mode 100644 index 000000000..724fed51a --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/selector.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/apps/review/src/lib/components/Appeal/Appeal.module.scss b/src/apps/review/src/lib/components/Appeal/Appeal.module.scss new file mode 100644 index 000000000..21ea7a3f5 --- /dev/null +++ b/src/apps/review/src/lib/components/Appeal/Appeal.module.scss @@ -0,0 +1,75 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + gap: 32px; + align-items: flex-start; + padding: $sp-2 $sp-6 0 0; + @include ltemd { + padding: $sp-2 $sp-3 $sp-4 0; + } + :global(.borderButton), + :global(.filledButton) { + font-size: 14px; + } + :global(.filledButton) { + font-size: 16px; + } +} + +.blockAppealComment { + background-color: var(--Appeal); +} + +.blockAppealResponse { + background-color: var(--TableColumn); +} + +.blockAppealResponse, +.blockAppealComment { + padding: $sp-4; + display: flex; + flex-direction: column; + gap: $sp-2; + width: 100%; +} + +.markdownEditor { + width: 100%; +} + +.textTitle { + color: var(--FontColor); + font-family: "Nunito Sans", sans-serif; + font-size: 14px; + font-weight: 700; + line-height: 20px; +} + +.blockBtns { + display: flex; + gap: 10px; + padding-top: 8px; + padding-bottom: 16px; + flex-wrap: wrap; +} + +.blockForm { + background-color: var(--TableColumn); + display: flex; + flex-direction: column; + padding: $sp-6; + gap: $sp-4; + width: 100%; + @include ltemd { + padding: $sp-4; + } + label { + color: var(--FontColor); + font-family: "Nunito Sans", sans-serif; + font-size: 16px; + font-weight: 700; + line-height: 20px; + } +} diff --git a/src/apps/review/src/lib/components/Appeal/Appeal.tsx b/src/apps/review/src/lib/components/Appeal/Appeal.tsx new file mode 100644 index 000000000..b86a67582 --- /dev/null +++ b/src/apps/review/src/lib/components/Appeal/Appeal.tsx @@ -0,0 +1,190 @@ +/** + * AppealComment. + */ +import { FC, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { + Controller, + ControllerRenderProps, + useForm, + UseFormReturn, +} from 'react-hook-form' +import { get } from 'lodash' +import classNames from 'classnames' + +import { yupResolver } from '@hookform/resolvers/yup' + +import { MarkdownReview } from '../MarkdownReview' +import { FieldMarkdownEditor } from '../FieldMarkdownEditor' +import { AppealInfo, ChallengeDetailContextModel, FormAppealResponse } from '../../models' +import { formAppealResponseSchema, isAppealsPhase } from '../../utils' +import { ReviewItemComment } from '../../models/ReviewItemComment.model' +import { ChallengeDetailContext } from '../../contexts' + +import styles from './Appeal.module.scss' + +interface Props { + className?: string + appealInfo?: AppealInfo + commentItem: ReviewItemComment + isSavingAppeal: boolean + addAppeal: ( + content: string, + commentItem: ReviewItemComment, + success: () => void, + ) => void + doDeleteAppeal: ( + appealInfo: AppealInfo | undefined, + success: () => void, + ) => void +} + +export const AppealComment: FC = (props: Props) => { + const className = props.className + const appealInfo = props.appealInfo + const commentItem = props.commentItem + const isSavingAppeal = props.isSavingAppeal + const addAppeal = props.addAppeal + const doDeleteAppeal = props.doDeleteAppeal + const [appealContent, setAppealContent] = useState('') + const [showResponseForm, setShowResponseForm] = useState(false) + + const { challengeInfo }: ChallengeDetailContextModel = useContext( + ChallengeDetailContext, + ) + const canAddAppeal = useMemo(() => isAppealsPhase(challengeInfo), [challengeInfo]) + + const { + handleSubmit, + control, + formState: { errors }, + }: UseFormReturn = useForm({ + defaultValues: { + response: '', + }, + mode: 'all', + resolver: yupResolver(formAppealResponseSchema), + }) + + const onSubmit = useCallback((data: FormAppealResponse) => { + addAppeal(data.response, commentItem, () => { + setAppealContent(data.response) + setShowResponseForm(false) + }) + }, [addAppeal, commentItem]) + + useEffect(() => { + if (appealInfo) { + setAppealContent(appealInfo.content) + } + }, [appealInfo]) + + const appealResponseContent = appealInfo?.appealResponse?.content + + return ( +

+ {appealContent && !showResponseForm && ( +
+ Appeal Comment + + {canAddAppeal && ( +
+ + +
+ )} +
+ )} + + {!appealContent && !showResponseForm && canAddAppeal && ( + + )} + + {!showResponseForm && appealResponseContent && ( +
+ Appeal Response + +
+ )} + + {showResponseForm && ( + + + + }) { + return ( + + ) + }} + /> +
+ + +
+ + )} +
+ ) +} + +export default AppealComment diff --git a/src/apps/review/src/lib/components/Appeal/index.ts b/src/apps/review/src/lib/components/Appeal/index.ts new file mode 100644 index 000000000..49c0fd636 --- /dev/null +++ b/src/apps/review/src/lib/components/Appeal/index.ts @@ -0,0 +1 @@ +export { default as Appeal } from './Appeal' diff --git a/src/apps/review/src/lib/components/AppealComment/AppealComment.module.scss b/src/apps/review/src/lib/components/AppealComment/AppealComment.module.scss new file mode 100644 index 000000000..dfbf9fb75 --- /dev/null +++ b/src/apps/review/src/lib/components/AppealComment/AppealComment.module.scss @@ -0,0 +1,82 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + gap: 24px; + align-items: flex-start; + padding: 0 $sp-6 0 0; + @include ltemd { + padding: 0 $sp-3 $sp-4 0; + } + :global(.borderButton), + :global(.filledButton) { + font-size: 14px; + } + &:has(.blockForm) { + gap: 16px; + } +} + +.blockAppealComment { + background-color: var(--Appeal); +} + +.blockAppealResponse { + background-color: var(--TableColumn); +} + +.blockAppealResponse, +.blockAppealComment { + padding: $sp-4; + display: flex; + flex-direction: column; + gap: $sp-2; + width: 100%; +} + +.markdownEditor { + width: 100%; +} + +.textTitle { + color: var(--FontColor); + font-family: "Nunito Sans", sans-serif; + font-size: 14px; + font-weight: 700; + line-height: 20px; +} + +.blockBtns { + display: flex; + gap: 10px; + padding-top: 8px; + padding-bottom: 16px; + flex-wrap: wrap; +} + +.blockForm { + background-color: var(--TableColumn); + display: flex; + flex-direction: column; + padding: $sp-6; + gap: $sp-4; + width: 100%; + @include ltemd { + padding: $sp-4; + } + label { + color: var(--FontColor); + font-family: "Nunito Sans", sans-serif; + font-size: 16px; + font-weight: 700; + line-height: 20px; + } +} + +.blockReaponseAppealHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} \ No newline at end of file diff --git a/src/apps/review/src/lib/components/AppealComment/AppealComment.tsx b/src/apps/review/src/lib/components/AppealComment/AppealComment.tsx new file mode 100644 index 000000000..ff02f379e --- /dev/null +++ b/src/apps/review/src/lib/components/AppealComment/AppealComment.tsx @@ -0,0 +1,246 @@ +/** + * AppealComment. + */ +import { FC, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { + Controller, + ControllerRenderProps, + useForm, + UseFormReturn, +} from 'react-hook-form' +import { bind, get } from 'lodash' +import Select, { SingleValue } from 'react-select' +import classNames from 'classnames' + +import { yupResolver } from '@hookform/resolvers/yup' + +import { MarkdownReview } from '../MarkdownReview' +import { FieldMarkdownEditor } from '../FieldMarkdownEditor' +import { + AppealInfo, + ChallengeDetailContextModel, + FormAppealResponse, + ReviewItemInfo, + ScorecardQuestion, + SelectOption, +} from '../../models' +import { formAppealResponseSchema, isAppealsResponsePhase } from '../../utils' +import { + QUESTION_YES_NO_OPTIONS, +} from '../../../config/index.config' +import { ChallengeDetailContext } from '../../contexts' + +import styles from './AppealComment.module.scss' + +interface Props { + className?: string + data: AppealInfo + scorecardQuestion: ScorecardQuestion + isSavingAppealResponse: boolean + reviewItem: ReviewItemInfo + appealInfo?: AppealInfo + addAppealResponse: ( + content: string, + updatedResponse: string, + appeal: AppealInfo, + reviewItem: ReviewItemInfo, + success: () => void, + ) => void + canRespondToAppeal?: boolean +} + +export const AppealComment: FC = (props: Props) => { + const className = props.className + const data = props.data + const scorecardQuestion = props.scorecardQuestion + const isSavingAppealResponse = props.isSavingAppealResponse + const reviewItem = props.reviewItem + const appealInfo = props.appealInfo + const addAppealResponse = props.addAppealResponse + const hasRespondPermission = props.canRespondToAppeal ?? false + const [appealResponse, setAppealResponse] = useState('') + const [showResponseForm, setShowResponseForm] = useState(false) + + const { challengeInfo }: ChallengeDetailContextModel = useContext( + ChallengeDetailContext, + ) + const canAddAppealResponse = useMemo( + () => hasRespondPermission && isAppealsResponsePhase(challengeInfo), + [challengeInfo, hasRespondPermission], + ) + + const [updatedResponse, setUpdatedResponse] = useState< + SingleValue<{ + label: string + value: string + }> + >() + + const { + handleSubmit, + control, + formState: { errors }, + }: UseFormReturn = useForm({ + defaultValues: { + response: '', + }, + mode: 'all', + resolver: yupResolver(formAppealResponseSchema), + }) + + const onSubmit = useCallback((formData: FormAppealResponse) => { + if (appealInfo) { + addAppealResponse( + formData.response, + updatedResponse?.value ?? '', + appealInfo, + reviewItem, + () => { + setAppealResponse(formData.response) + setShowResponseForm(false) + }, + ) + } + }, [addAppealResponse, appealInfo, reviewItem, updatedResponse]) + + const responseOptions = useMemo(() => { + if (scorecardQuestion.type === 'SCALE') { + const length + = scorecardQuestion.scaleMax + - scorecardQuestion.scaleMin + + 1 + return Array.from( + new Array(length), + (x, i) => `${i + scorecardQuestion.scaleMin}`, + ) + .map(item => ({ + label: item, + value: item, + })) + } + + if (scorecardQuestion.type === 'YES_NO') { + return QUESTION_YES_NO_OPTIONS + } + + return [] + }, [scorecardQuestion]) + + useEffect(() => { + setAppealResponse(data.appealResponse?.content ?? '') + }, [data.appealResponse?.content]) + + return ( +
+
+ Appeal Comment + +
+ {!showResponseForm && appealResponse && ( +
+ Appeal Response + + {canAddAppealResponse && ( +
+ +
+ )} +
+ )} + + {!showResponseForm && !appealResponse && canAddAppealResponse && ( + + )} + + {showResponseForm && ( +
+
+ + +