diff --git a/.circleci/config.yml b/.circleci/config.yml index f77046979..5b8bcf54d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -227,9 +227,8 @@ workflows: only: - dev - mm-final-2025-reveal - - engagements - HOTFIX-PM-3269 - + - work-manager - deployQa: context: org-global requires: diff --git a/.env.development b/.env.development new file mode 100644 index 000000000..66244ad78 --- /dev/null +++ b/.env.development @@ -0,0 +1,5 @@ +REACT_APP_GROUPS_API_URL=https://api.topcoder-dev.com/v6/groups +REACT_APP_TERMS_API_URL=https://api.topcoder-dev.com/v5/terms +REACT_APP_RESOURCES_API_URL=https://api.topcoder-dev.com/v6/resources +REACT_APP_MEMBER_API_URL=https://api.topcoder-dev.com/v6/members +REACT_APP_RESOURCE_ROLES_API_URL=https://api.topcoder-dev.com/v6/resource-roles diff --git a/.env.production b/.env.production new file mode 100644 index 000000000..d2b109d06 --- /dev/null +++ b/.env.production @@ -0,0 +1,5 @@ +REACT_APP_GROUPS_API_URL=https://api.topcoder.com/v6/groups +REACT_APP_TERMS_API_URL=https://api.topcoder.com/v5/terms +REACT_APP_RESOURCES_API_URL=https://api.topcoder.com/v6/resources +REACT_APP_MEMBER_API_URL=https://api.topcoder.com/v6/members +REACT_APP_RESOURCE_ROLES_API_URL=https://api.topcoder.com/v6/resource-roles diff --git a/package.json b/package.json index 9bc448381..123a8f9aa 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "express": "^4.22.1", "express-fileupload": "^1.5.2", "express-interceptor": "^1.2.0", + "fflate": "^0.8.2", "filestack-js": "^3.44.2", "highcharts": "^10.3.3", "highcharts-react-official": "^3.2.3", 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 314c87953..f471f983f 100644 --- a/src/apps/accounts/src/settings/tabs/tools/devices/Devices.tsx +++ b/src/apps/accounts/src/settings/tabs/tools/devices/Devices.tsx @@ -24,6 +24,8 @@ import { WearableIcon, } from '~/apps/accounts/src/lib' +import { shouldUseUpdateTraitAction } from '../trait-action.utils' + import styles from './Devices.module.scss' interface DevicesProps { @@ -315,7 +317,9 @@ const Devices: FC = (props: DevicesProps) => { }, }] - const action = props.devicesTrait ? updateMemberTraitsAsync : createMemberTraitsAsync + const action = shouldUseUpdateTraitAction(props.devicesTrait, deviceTypesData) + ? updateMemberTraitsAsync + : createMemberTraitsAsync action( props.profile.handle, diff --git a/src/apps/accounts/src/settings/tabs/tools/service-provider/ServiceProvider.tsx b/src/apps/accounts/src/settings/tabs/tools/service-provider/ServiceProvider.tsx index 4de9e7c0d..8a8a51e6a 100644 --- a/src/apps/accounts/src/settings/tabs/tools/service-provider/ServiceProvider.tsx +++ b/src/apps/accounts/src/settings/tabs/tools/service-provider/ServiceProvider.tsx @@ -14,6 +14,8 @@ import { TelevisionServiceProviderIcon, } from '~/apps/accounts/src/lib' +import { shouldUseUpdateTraitAction } from '../trait-action.utils' + import { serviceProviderTypes } from './service-provider-types.config' import styles from './ServiceProvider.module.scss' @@ -216,7 +218,9 @@ const ServiceProvider: FC = (props: ServiceProviderProps) }, }] - const action = props.serviceProviderTrait ? updateMemberTraitsAsync : createMemberTraitsAsync + const action = shouldUseUpdateTraitAction(props.serviceProviderTrait, serviceProviderTypesData) + ? updateMemberTraitsAsync + : createMemberTraitsAsync action( props.profile.handle, diff --git a/src/apps/accounts/src/settings/tabs/tools/software/Software.tsx b/src/apps/accounts/src/settings/tabs/tools/software/Software.tsx index 340b48bfd..2e3d116c5 100644 --- a/src/apps/accounts/src/settings/tabs/tools/software/Software.tsx +++ b/src/apps/accounts/src/settings/tabs/tools/software/Software.tsx @@ -7,6 +7,8 @@ import { createMemberTraitsAsync, updateMemberTraitsAsync, UserProfile, UserTrai import { Button, Collapsible, ConfirmModal, IconOutline, InputSelect, InputText } from '~/libs/ui' import { SettingSection, SoftwareIcon } from '~/apps/accounts/src/lib' +import { shouldUseUpdateTraitAction } from '../trait-action.utils' + import { softwareTypes } from './software-types.config' import styles from './Software.module.scss' @@ -170,7 +172,9 @@ const Software: FC = (props: SoftwareProps) => { }, }] - const action = props.softwareTrait ? updateMemberTraitsAsync : createMemberTraitsAsync + const action = shouldUseUpdateTraitAction(props.softwareTrait, softwareTypesData) + ? updateMemberTraitsAsync + : createMemberTraitsAsync action( props.profile.handle, diff --git a/src/apps/accounts/src/settings/tabs/tools/subscriptions/Subscriptions.tsx b/src/apps/accounts/src/settings/tabs/tools/subscriptions/Subscriptions.tsx index 491018f9b..041b45da9 100644 --- a/src/apps/accounts/src/settings/tabs/tools/subscriptions/Subscriptions.tsx +++ b/src/apps/accounts/src/settings/tabs/tools/subscriptions/Subscriptions.tsx @@ -7,6 +7,8 @@ import { createMemberTraitsAsync, updateMemberTraitsAsync, UserProfile, UserTrai import { Button, Collapsible, ConfirmModal, IconOutline, InputText } from '~/libs/ui' import { SettingSection, SubscriptionsIcon } from '~/apps/accounts/src/lib' +import { shouldUseUpdateTraitAction } from '../trait-action.utils' + import styles from './Subscriptions.module.scss' interface SubscriptionsProps { @@ -150,7 +152,9 @@ const Subscriptions: FC = (props: SubscriptionsProps) => { setIsSaving(false) }) } else { - const action = props.subscriptionsTrait ? updateMemberTraitsAsync : createMemberTraitsAsync + const action = shouldUseUpdateTraitAction(props.subscriptionsTrait, subscriptionsTypesData) + ? updateMemberTraitsAsync + : createMemberTraitsAsync action( props.profile.handle, [{ diff --git a/src/apps/accounts/src/settings/tabs/tools/trait-action.utils.spec.ts b/src/apps/accounts/src/settings/tabs/tools/trait-action.utils.spec.ts new file mode 100644 index 000000000..8eafa870a --- /dev/null +++ b/src/apps/accounts/src/settings/tabs/tools/trait-action.utils.spec.ts @@ -0,0 +1,18 @@ +import { shouldUseUpdateTraitAction } from './trait-action.utils' + +describe('shouldUseUpdateTraitAction', () => { + it('returns true when the initial trait exists', () => { + expect(shouldUseUpdateTraitAction({ traitId: 'software' }, undefined)) + .toBe(true) + }) + + it('returns true when local traits exist even without the initial trait', () => { + expect(shouldUseUpdateTraitAction(undefined, [{ name: 'Chrome' }])) + .toBe(true) + }) + + it('returns false when both initial and local traits are missing', () => { + expect(shouldUseUpdateTraitAction(undefined, undefined)) + .toBe(false) + }) +}) diff --git a/src/apps/accounts/src/settings/tabs/tools/trait-action.utils.ts b/src/apps/accounts/src/settings/tabs/tools/trait-action.utils.ts new file mode 100644 index 000000000..f2e5e7420 --- /dev/null +++ b/src/apps/accounts/src/settings/tabs/tools/trait-action.utils.ts @@ -0,0 +1,13 @@ +import { UserTrait } from '~/libs/core' + +/** + * Determine whether tool traits should use update or create action. + * The initial trait prop can be stale while the user stays on the tab, + * so local list state is also considered to avoid duplicate create calls. + */ +export function shouldUseUpdateTraitAction( + initialTrait: UserTrait | undefined, + localTraitsData: UserTrait[] | undefined, +): boolean { + return Boolean(initialTrait || localTraitsData?.length) +} diff --git a/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.spec.ts b/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.spec.ts new file mode 100644 index 000000000..884b4bb7e --- /dev/null +++ b/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.spec.ts @@ -0,0 +1,30 @@ +import { + canOpenReviewUi, + getReviewUiChallengeUrl, +} from './reviewUiLink' + +describe('ChallengeList review UI helpers', () => { + describe('canOpenReviewUi', () => { + it('returns true when challenge has a uuid id', () => { + expect(canOpenReviewUi('challenge-uuid')) + .toBe(true) + }) + + it('returns false when challenge id is empty', () => { + expect(canOpenReviewUi('')) + .toBe(false) + }) + + it('returns false when challenge id is only whitespace', () => { + expect(canOpenReviewUi(' ')) + .toBe(false) + }) + }) + + describe('getReviewUiChallengeUrl', () => { + it('builds review ui url using challenge id path', () => { + expect(getReviewUiChallengeUrl('https://review.topcoder-dev.com', 'challenge-uuid')) + .toBe('https://review.topcoder-dev.com/challenge-uuid') + }) + }) +}) diff --git a/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx b/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx index 55df372c2..988a7f01d 100644 --- a/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx +++ b/src/apps/admin/src/lib/components/ChallengeList/ChallengeList.tsx @@ -24,6 +24,10 @@ import { Paging } from '../../models/challenge-management/Pagination' import { checkIsMM } from '../../utils/challenge' import { MobileListView } from './MobileListView' +import { + canOpenReviewUi, + getReviewUiChallengeUrl, +} from './reviewUiLink' import styles from './ChallengeList.module.scss' export interface ChallengeListProps { @@ -180,11 +184,12 @@ const Actions: FC<{ }, ) + const hasChallengeDetailsAccess = canOpenReviewUi(props.challenge.id) const hasProjectId - = 'projectId' in props.challenge - && props.challenge.projectId !== undefined - const hasLegacyId - = 'legacyId' in props.challenge && props.challenge.legacyId !== undefined + = typeof props.challenge.projectId === 'number' + && props.challenge.projectId > 0 + const hasWorkManagerAccess = hasProjectId && hasChallengeDetailsAccess + const hasReviewUiAccess = canOpenReviewUi(props.challenge.id) return (
@@ -234,17 +239,20 @@ const Actions: FC<{ classNames={{ menu: 'challenge-list-actions-dropdown-menu' }} > diff --git a/src/apps/admin/src/lib/components/ChallengeList/reviewUiLink.ts b/src/apps/admin/src/lib/components/ChallengeList/reviewUiLink.ts new file mode 100644 index 000000000..7a825ebfa --- /dev/null +++ b/src/apps/admin/src/lib/components/ChallengeList/reviewUiLink.ts @@ -0,0 +1,16 @@ +/** + * Returns whether the Review UI link can be opened for a challenge. + */ +export function canOpenReviewUi(challengeId?: string): boolean { + return Boolean(challengeId?.trim()) +} + +/** + * Builds the Review UI URL for a challenge id. + */ +export function getReviewUiChallengeUrl( + reviewUiBaseUrl: string, + challengeId: string, +): string { + return `${reviewUiBaseUrl}/${challengeId}` +} diff --git a/src/apps/admin/src/lib/components/DialogUserEmails/DialogUserEmails.module.scss b/src/apps/admin/src/lib/components/DialogUserEmails/DialogUserEmails.module.scss new file mode 100644 index 000000000..76bd5ca02 --- /dev/null +++ b/src/apps/admin/src/lib/components/DialogUserEmails/DialogUserEmails.module.scss @@ -0,0 +1,89 @@ +@import '@libs/ui/styles/includes'; + +.modal { + width: 1240px !important; + max-width: calc(100vw - 32px) !important; +} + +.container { + display: flex; + flex-direction: column; + gap: 20px; + + th:first-child { + padding-left: 16px !important; + } +} + +.actionButtons { + display: flex; + justify-content: flex-end; + gap: 6px; +} + +.tableCellNoWrap { + white-space: nowrap; + text-align: left !important; +} + +.statusCell { + text-align: center !important; + width: 90px; +} + +.emailStatus { + align-items: center; + display: inline-flex; + justify-content: center; + min-height: 24px; + min-width: 24px; + + svg { + width: 20px; + height: 20px; + } +} + +.emailStatusDelivered { + color: $green-120; +} + +.emailStatusFailed { + color: $red-110; +} + +.tableCell { + min-width: 220px; + white-space: break-spaces !important; + text-align: left !important; +} + +.loadingSpinnerContainer { + position: relative; + height: 100px; + + .spinner { + background: none; + } +} + +.noRecordFound { + padding: 16px 16px 32px; + text-align: center; +} + +.desktopTable { + overflow-x: auto; + overflow-y: visible; + + thead th { + position: sticky; + top: 0; + z-index: 2; + background: $tc-white; + } + + td { + vertical-align: middle; + } +} diff --git a/src/apps/admin/src/lib/components/DialogUserEmails/DialogUserEmails.tsx b/src/apps/admin/src/lib/components/DialogUserEmails/DialogUserEmails.tsx new file mode 100644 index 000000000..2412dc32d --- /dev/null +++ b/src/apps/admin/src/lib/components/DialogUserEmails/DialogUserEmails.tsx @@ -0,0 +1,187 @@ +/** + * Dialog with SendGrid emails sent to a member in the last 30 days. + */ +import { FC, useCallback, useEffect, useMemo, useState } from 'react' +import _ from 'lodash' +import classNames from 'classnames' +import moment from 'moment' + +import { + BaseModal, + Button, + IconOutline, + LoadingSpinner, + Table, + TableColumn, +} from '~/libs/ui' + +import { + MSG_NO_RECORD_FOUND, + TABLE_DATE_FORMAT, +} from '../../../config/index.config' +import { MemberSendgridEmail, UserInfo } from '../../models' +import { fetchMemberSendgridEmails } from '../../services' +import { handleError } from '../../utils' + +import styles from './DialogUserEmails.module.scss' + +interface Props { + className?: string + open: boolean + setOpen: (isOpen: boolean) => void + userInfo: UserInfo +} + +export const DialogUserEmails: FC = (props: Props) => { + const [isLoading, setIsLoading] = useState(false) + const [emails, setEmails] = useState([]) + + const handleClose = useCallback(() => { + props.setOpen(false) + }, [props.setOpen]) + + useEffect(() => { + if (!props.open) { + return undefined + } + + let active = true + setIsLoading(true) + fetchMemberSendgridEmails(props.userInfo.handle) + .then(result => { + if (active) { + setEmails(result) + } + }) + .catch(error => { + if (active) { + setEmails([]) + } + + handleError(error) + }) + .finally(() => { + if (active) { + setIsLoading(false) + } + }) + + return () => { + active = false + } + }, [props.open, props.userInfo.handle]) + + const columns = useMemo[]>( + () => [ + { + className: styles.tableCell, + columnId: 'subject', + label: 'Subject', + propertyName: 'subject', + type: 'text', + }, + { + className: styles.tableCellNoWrap, + columnId: 'toEmail', + label: 'To Email', + propertyName: 'toEmail', + type: 'text', + }, + { + className: styles.statusCell, + columnId: 'status', + label: 'Status', + propertyName: 'status', + renderer: (data: MemberSendgridEmail) => { + const status = data.status || '-' + const isDelivered = status.toLowerCase() === 'delivered' + + return ( + + {isDelivered + ? + : } + + ) + }, + type: 'element', + }, + { + className: styles.tableCellNoWrap, + columnId: 'timestamp', + label: 'Timestamp', + propertyName: 'timestamp', + renderer: (data: MemberSendgridEmail) => { + if (data.timestamp === '-') { + return
-
+ } + + const timestamp = moment(data.timestamp) + const timestampDisplay = timestamp.isValid() + ? timestamp + .local() + .format(TABLE_DATE_FORMAT) + : data.timestamp + + return
{timestampDisplay}
+ }, + type: 'element', + }, + ], + [], + ) + + return ( + +
+ {isLoading ? ( +
+ +
+ ) : ( + <> + {emails.length === 0 ? ( +

+ {MSG_NO_RECORD_FOUND} +

+ ) : ( + + )} + + )} +
+ +
+ + + ) +} + +export default DialogUserEmails diff --git a/src/apps/admin/src/lib/components/DialogUserEmails/index.ts b/src/apps/admin/src/lib/components/DialogUserEmails/index.ts new file mode 100644 index 000000000..51373a8c1 --- /dev/null +++ b/src/apps/admin/src/lib/components/DialogUserEmails/index.ts @@ -0,0 +1,2 @@ +export * from './DialogUserEmails' +export { default as DialogUserEmails } from './DialogUserEmails' diff --git a/src/apps/admin/src/lib/components/UsersTable/UsersTable.module.scss b/src/apps/admin/src/lib/components/UsersTable/UsersTable.module.scss index 0fd56a9bb..a57f11bcd 100644 --- a/src/apps/admin/src/lib/components/UsersTable/UsersTable.module.scss +++ b/src/apps/admin/src/lib/components/UsersTable/UsersTable.module.scss @@ -57,7 +57,7 @@ } .blockColumnAction { - width: 320px; + width: 430px; @include ltelg { width: 60px; diff --git a/src/apps/admin/src/lib/components/UsersTable/UsersTable.tsx b/src/apps/admin/src/lib/components/UsersTable/UsersTable.tsx index 665777b74..6c9a0bfc5 100644 --- a/src/apps/admin/src/lib/components/UsersTable/UsersTable.tsx +++ b/src/apps/admin/src/lib/components/UsersTable/UsersTable.tsx @@ -26,6 +26,7 @@ import { DialogEditUserGroups } from '../DialogEditUserGroups' import { DialogEditUserSSOLogin } from '../DialogEditUserSSOLogin' import { DialogEditUserTerms } from '../DialogEditUserTerms' import { DialogEditUserStatus } from '../DialogEditUserStatus' +import { DialogUserEmails } from '../DialogUserEmails' import { DialogUserStatusHistory } from '../DialogUserStatusHistory' import { DialogDeleteUser } from '../DialogDeleteUser' import { DropdownMenuButton } from '../common/DropdownMenuButton' @@ -111,6 +112,9 @@ export const UsersTable: FC = props => { const [showDialogStatusHistory, setShowDialogStatusHistory] = useState< UserInfo | undefined >() + const [showDialogUserEmails, setShowDialogUserEmails] = useState< + UserInfo | undefined + >() const [showDialogDeleteUser, setShowDialogDeleteUser] = useState< UserInfo | undefined >() @@ -323,6 +327,8 @@ export const UsersTable: FC = props => { setShowDialogEditUserTerms(data) } else if (item === 'SSO Logins') { setShowDialogEditSSOLogin(data) + } else if (item === 'View Emails') { + setShowDialogUserEmails(data) } else if (item === 'Deactivate') { setShowDialogEditUserStatus(data) } else if (item === 'Activate') { @@ -355,6 +361,7 @@ export const UsersTable: FC = props => { 'Groups', 'Terms', 'SSO Logins', + 'View Emails', ...(data.active ? ['Deactivate', 'Delete'] : ['Activate', 'Delete']), @@ -388,6 +395,13 @@ export const UsersTable: FC = props => { Edit +