diff --git a/.circleci/config.yml b/.circleci/config.yml index 5f5f3e3c9..717a8915d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -226,10 +226,6 @@ workflows: branches: only: - dev - - mm-final-2025-reveal - - engagements - - HOTFIX-PM-3269 - - PM-4281 - deployQa: context: org-global diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml index 7b9fa4839..9cbcf5209 100644 --- a/.github/workflows/trivy.yaml +++ b/.github/workflows/trivy.yaml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@v4 - name: Run Trivy scanner in repo mode - uses: aquasecurity/trivy-action@0.33.1 + uses: aquasecurity/trivy-action@0.35.0 with: scan-type: "fs" ignore-unfixed: true diff --git a/src/apps/admin/src/AdminHomeRedirect.tsx b/src/apps/admin/src/AdminHomeRedirect.tsx new file mode 100644 index 000000000..a96869288 --- /dev/null +++ b/src/apps/admin/src/AdminHomeRedirect.tsx @@ -0,0 +1,22 @@ +import { FC } from 'react' +import { Navigate } from 'react-router-dom' + +import { reportsRootRoute } from '~/apps/reports' +import { ProfileContextData, useProfileContext } from '~/libs/core' + +import { manageChallengeRouteId } from './config/routes.config' +import { isAdministrator } from './lib/utils' + +/** + * Redirects authenticated admin-app users to the first route they can access. + */ +const AdminHomeRedirect: FC = () => { + const { profile }: ProfileContextData = useProfileContext() + const defaultRoute: string = isAdministrator(profile?.roles) + ? manageChallengeRouteId + : reportsRootRoute + + return +} + +export default AdminHomeRedirect diff --git a/src/apps/admin/src/admin-app.routes.tsx b/src/apps/admin/src/admin-app.routes.tsx index 606e40794..de637b87f 100644 --- a/src/apps/admin/src/admin-app.routes.tsx +++ b/src/apps/admin/src/admin-app.routes.tsx @@ -4,8 +4,6 @@ import { lazyLoad, LazyLoadedComponent, PlatformRoute, - Rewrite, - UserRole, } from '~/libs/core' import { @@ -17,12 +15,13 @@ import { paymentsRouteId, permissionManagementRouteId, platformRouteId, - reportsRouteId, rootRoute, termsRouteId, userManagementRouteId, } from './config/routes.config' +import { administratorOnlyRoles, adminReportsAccessRoles } from './lib/utils' import { platformSkillRouteId } from './platform/routes.config' +import AdminHomeRedirect from './AdminHomeRedirect' const AdminApp: LazyLoadedComponent = lazyLoad(() => import('./AdminApp')) @@ -173,10 +172,6 @@ const PaymentsPage: LazyLoadedComponent = lazyLoad( () => import('./payments/PaymentsPage'), 'PaymentsPage', ) -const ReportsPage: LazyLoadedComponent = lazyLoad( - () => import('./reports/ReportsPage'), - 'ReportsPage', -) export const toolTitle: string = ToolTitle.admin @@ -186,7 +181,7 @@ export const adminRoutes: ReadonlyArray = [ authRequired: true, children: [ { - element: , + element: , route: '', }, // Challenge Management Module @@ -220,12 +215,14 @@ export const adminRoutes: ReadonlyArray = [ ], element: , id: manageChallengeRouteId, + rolesRequired: administratorOnlyRoles, route: manageChallengeRouteId, }, // User Management Module { element: , id: userManagementRouteId, + rolesRequired: administratorOnlyRoles, route: userManagementRouteId, }, // Reviewer Management Module @@ -244,6 +241,7 @@ export const adminRoutes: ReadonlyArray = [ ], element: , id: manageReviewRouteId, + rolesRequired: administratorOnlyRoles, route: manageReviewRouteId, }, // Billing Account Module @@ -297,6 +295,7 @@ export const adminRoutes: ReadonlyArray = [ ], element: , id: billingAccountRouteId, + rolesRequired: administratorOnlyRoles, route: billingAccountRouteId, }, // Permission Management Module @@ -335,6 +334,7 @@ export const adminRoutes: ReadonlyArray = [ ], element: , id: permissionManagementRouteId, + rolesRequired: administratorOnlyRoles, route: permissionManagementRouteId, }, @@ -408,25 +408,21 @@ export const adminRoutes: ReadonlyArray = [ ], element: , id: platformRouteId, + rolesRequired: administratorOnlyRoles, route: platformRouteId, }, // Payments Module { element: , id: paymentsRouteId, + rolesRequired: administratorOnlyRoles, route: paymentsRouteId, }, - // Reports Module - { - element: , - id: reportsRouteId, - route: reportsRouteId, - }, ], domain: AppSubdomain.admin, element: , id: toolTitle, - rolesRequired: [UserRole.administrator], + rolesRequired: adminReportsAccessRoles, route: rootRoute, title: toolTitle, }, diff --git a/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.module.scss b/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.module.scss index a94e0ce92..ce85b57b4 100644 --- a/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.module.scss +++ b/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.module.scss @@ -19,6 +19,21 @@ gap: $sp-3; } +.exportDescription { + margin: 0; + color: #555; +} + +.exportActions { + display: flex; + flex-wrap: wrap; + gap: $sp-3; +} + +.exportButton { + min-width: 220px; +} + .sectionTitle { margin: 0; font-size: 20px; diff --git a/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.tsx b/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.tsx index e5f5c2c17..c6243edfe 100644 --- a/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.tsx +++ b/src/apps/admin/src/challenge-management/ChallengeDetailsPage/ChallengeDetailsPage.tsx @@ -1,6 +1,7 @@ import { ChangeEvent, FC, + MouseEvent, useEffect, useMemo, useState, @@ -32,6 +33,10 @@ import { getResourceRoles, updateChallengeById, } from '../../lib/services' +import { + downloadBlobFile, + downloadReportAsCsv, +} from '../../lib/services/reports.service' import { createChallengeQueryString, handleError } from '../../lib/utils' import styles from './ChallengeDetailsPage.module.scss' @@ -62,6 +67,22 @@ type RouteState = { type WinnerUpdate = Pick +type ChallengeExportReportKey = + | 'registered-users' + | 'submitters' + | 'valid-submitters' + | 'winners' + +const CHALLENGE_EXPORT_REPORTS: Array<{ + key: ChallengeExportReportKey + label: string +}> = [ + { key: 'registered-users', label: 'Registered Users' }, + { key: 'submitters', label: 'Submitters' }, + { key: 'valid-submitters', label: 'Valid Submitters' }, + { key: 'winners', label: 'Winners' }, +] + function formatStatusLabel(rawStatus: string): string { const normalized = rawStatus .trim() @@ -188,6 +209,8 @@ export const ChallengeDetailsPage: FC = () => { const [isLoading, setIsLoading] = useState(false) const [isSavingStatus, setIsSavingStatus] = useState(false) const [isSavingWinners, setIsSavingWinners] = useState(false) + const [downloadingReportKey, setDownloadingReportKey] + = useState() const [isLoadingSubmitters, setIsLoadingSubmitters] = useState(false) const [submitterOptions, setSubmitterOptions] = useState([ { label: 'Select submitter', value: '' }, @@ -328,6 +351,7 @@ export const ChallengeDetailsPage: FC = () => { }, [routeState.previousChallengeListFilter]) const pageTitle = challengeInfo?.name || 'Challenge Details' + const isMarathonMatch = challengeInfo?.type?.name === 'Marathon Match' const currentWinnerHandleByUserId = useMemo( () => Object.fromEntries( (challengeInfo?.winners ?? []).map(winner => [`${winner.userId}`, winner.handle]), @@ -414,6 +438,37 @@ export const ChallengeDetailsPage: FC = () => { } }) + const handleExportReport = useEventCallback(async (reportKey: ChallengeExportReportKey) => { + if (!challengeId) { + return + } + + setDownloadingReportKey(reportKey) + + try { + const path = `/challenges/${encodeURIComponent(challengeId)}/${reportKey}` + const blob = await downloadReportAsCsv(path) + const fileName = `challenge-${reportKey}_${challengeId}.csv` + + downloadBlobFile(blob, fileName) + } catch (error) { + handleError(error) + } finally { + setDownloadingReportKey(undefined) + } + }) + + const handleExportButtonClick = useEventCallback( + (event: MouseEvent) => { + const reportKey = event.currentTarget.value as ChallengeExportReportKey + if (!reportKey) { + return + } + + handleExportReport(reportKey) + }, + ) + return ( { )} {!isLoading && challengeInfo && ( <> +
+

Exports

+

+ Download challenge detail reports as CSV. + {isMarathonMatch && ( + ' Marathon Match submission-based exports include provisional ' + + 'score and final rank.' + )} +

+
+ {CHALLENGE_EXPORT_REPORTS.map(report => ( + + ))} +
+
+

Status

diff --git a/src/apps/admin/src/config/routes.config.ts b/src/apps/admin/src/config/routes.config.ts index b1b523086..a2ebf2790 100644 --- a/src/apps/admin/src/config/routes.config.ts +++ b/src/apps/admin/src/config/routes.config.ts @@ -18,4 +18,3 @@ export const termsRouteId = 'terms' export const defaultReviewersRouteId = 'default-reviewers' export const platformRouteId = 'platform' export const paymentsRouteId = 'payments' -export const reportsRouteId = 'reports' diff --git a/src/apps/admin/src/lib/components/UsersFilters/UsersFilters.tsx b/src/apps/admin/src/lib/components/UsersFilters/UsersFilters.tsx index 332a67a97..53e3b7302 100644 --- a/src/apps/admin/src/lib/components/UsersFilters/UsersFilters.tsx +++ b/src/apps/admin/src/lib/components/UsersFilters/UsersFilters.tsx @@ -24,10 +24,33 @@ interface Props { const defaultValues: FormUsersFilters = { email: '', handle: '', + ssoUserId: '', status: undefined, userId: '', } +function appendFilterValue(filter: string, key: string, value?: string | boolean): string { + if (value === undefined || value === null || value === '') { + return filter + } + + const nextFilter = filter.length > 0 ? `${filter}&` : filter + + return `${nextFilter}${key}=${value}` +} + +function getActiveFilterValue(status?: string): boolean | undefined { + if (status === 'active') { + return true + } + + if (status === 'inactive') { + return false + } + + return undefined +} + export const UsersFilters: FC = props => { const { register, @@ -42,40 +65,26 @@ export const UsersFilters: FC = props => { }) const onSubmit = useCallback( (data: FormUsersFilters) => { - const { handle, email, status, userId }: FormUsersFilters = data - const active - = status === 'active' - ? true - : status === 'inactive' - ? false - : undefined - let filter = '' - let like = false - - if (handle) { - like = handle.indexOf('*') >= 0 - filter += `handle=${handle}` - } + const { + handle, + email, + ssoUserId, + status, + userId, + }: FormUsersFilters = data + const active = getActiveFilterValue(status) + const like = [handle, email, ssoUserId].some( + value => value?.includes('*') === true, + ) - if (email) { - like = email.indexOf('*') >= 0 - if (filter.length > 0) filter += '&' - filter += `email=${email}` - } - - if (active !== null && active !== undefined) { - if (filter.length > 0) filter += '&' - filter += `active=${active}` - } - - if (like) { - filter += `&like=${like}` - } + let filter = '' - if (userId) { - if (filter.length > 0) filter += '&' - filter += `id=${userId}` - } + filter = appendFilterValue(filter, 'handle', handle) + filter = appendFilterValue(filter, 'email', email) + filter = appendFilterValue(filter, 'ssoUserId', ssoUserId) + filter = appendFilterValue(filter, 'active', active) + filter = appendFilterValue(filter, 'like', like ? String(like) : undefined) + filter = appendFilterValue(filter, 'id', userId) props.onFindMembers?.(filter) }, @@ -111,6 +120,16 @@ export const UsersFilters: FC = props => { inputControl={register('email')} dirty /> + = props => {

Tips:
- - Wildcard(*) is available for partial matching. (e.g. - ChrisB*, chris*@wipro.com) + - Wildcard(*) is available for partial matching on Handle, Email, and SSO + UserID. (e.g. ChrisB*, j*@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 665777b74..4316e4de7 100644 --- a/src/apps/admin/src/lib/components/UsersTable/UsersTable.tsx +++ b/src/apps/admin/src/lib/components/UsersTable/UsersTable.tsx @@ -6,6 +6,7 @@ import _ from 'lodash' import classNames from 'classnames' import moment from 'moment' +import { Sort } from '~/apps/admin/src/platform/gamification-admin/src/game-lib' import { EnvironmentConfig } from '~/config' import { useWindowSize, WindowSize } from '~/libs/shared' import { @@ -29,7 +30,6 @@ import { DialogEditUserStatus } from '../DialogEditUserStatus' import { DialogUserStatusHistory } from '../DialogUserStatusHistory' import { DialogDeleteUser } from '../DialogDeleteUser' import { DropdownMenuButton } from '../common/DropdownMenuButton' -import { useTableFilterLocal, useTableFilterLocalProps } from '../../hooks' import { TABLE_DATE_FORMAT } from '../../../config/index.config' import { SSOLoginProvider, UserInfo } from '../../models' import { fetchSSOLoginProviders } from '../../services' @@ -38,12 +38,125 @@ import { ReactComponent as RectangleListRegularIcon } from '../../assets/i/recta import styles from './UsersTable.module.scss' +const userSortCollator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', +}) + +/** + * Resolves the value rendered by a sortable user column. + * @param data User row to inspect. + * @param fieldName Column field name requested by the table. + * @returns Comparable scalar value for the requested column. + */ +function getUserSortValue( + data: UserInfo, + fieldName: string, +): boolean | Date | number | string | undefined { + switch (fieldName) { + case 'activationCode': + return data.credential?.activationCode + case 'firstName': + return `${data.firstName ?? ''} ${data.lastName ?? ''}`.trim() + default: + return (data as unknown as Record)[fieldName] + } +} + +/** + * Checks whether a sortable value should be treated as empty. + * @param value Candidate sort value. + * @returns True when the value should remain at the bottom of the table. + */ +function isEmptySortValue( + value: boolean | Date | number | string | undefined, +): boolean { + return value === undefined || value === null || value === '' +} + +/** + * Compares two non-empty user sort values. + * @param leftValue Left-hand sort value. + * @param rightValue Right-hand sort value. + * @param directionMultiplier Asc/desc multiplier. + * @returns Standard Array.sort comparison result. + */ +function compareUserSortValues( + leftValue: boolean | Date | number | string, + rightValue: boolean | Date | number | string, + directionMultiplier: number, +): number { + if (leftValue instanceof Date && rightValue instanceof Date) { + return directionMultiplier * (leftValue.getTime() - rightValue.getTime()) + } + + if (typeof leftValue === 'boolean' && typeof rightValue === 'boolean') { + return directionMultiplier * (Number(leftValue) - Number(rightValue)) + } + + if (typeof leftValue === 'number' && typeof rightValue === 'number') { + return directionMultiplier * (leftValue - rightValue) + } + + return directionMultiplier * userSortCollator.compare( + String(leftValue), + String(rightValue), + ) +} + +/** + * Sorts one page of users while keeping empty values at the end. + * @param users Current page of users. + * @param sort Active sort requested by the table. + * @returns Sorted copy of the current page. + */ +function sortUsers( + users: UserInfo[], + sort: Sort | undefined, +): UserInfo[] { + if (!sort?.fieldName) { + return users + } + + const directionMultiplier = sort.direction === 'asc' ? 1 : -1 + + return [...users].sort((left, right) => { + const leftValue = getUserSortValue(left, sort.fieldName) + const rightValue = getUserSortValue(right, sort.fieldName) + const leftEmpty = isEmptySortValue(leftValue) + const rightEmpty = isEmptySortValue(rightValue) + + if (leftEmpty && rightEmpty) { + return 0 + } + + if (leftEmpty) { + return 1 + } + + if (rightEmpty) { + return -1 + } + + const normalizedLeftValue = leftValue as boolean | Date | number | string + const normalizedRightValue = rightValue as boolean | Date | number | string + + return compareUserSortValues( + normalizedLeftValue, + normalizedRightValue, + directionMultiplier, + ) + }) +} + interface Props { className?: string allUsers: UserInfo[] page: number + sort: Sort | undefined totalPages: number onPageChange: (page: number) => void + onSortChange: (sort: Sort | undefined) => void updatingStatus: { [key: string]: boolean } deletingUsers: { [key: string]: boolean } doUpdateStatus: ( @@ -123,11 +236,10 @@ export const UsersTable: FC = props => { const isTablet = useMemo(() => screenWidth <= 984, [screenWidth]) const isMobile = useMemo(() => screenWidth <= 745, [screenWidth]) - const { results, setSort }: useTableFilterLocalProps = useTableFilterLocal( - props.allUsers ?? [], - undefined, - undefined, - true, + + const results = useMemo( + () => sortUsers(props.allUsers ?? [], props.sort), + [props.allUsers, props.sort], ) const columns = useMemo[]>( () => [ @@ -153,6 +265,13 @@ export const UsersTable: FC = props => { propertyName: 'email', type: 'text', } as TableColumn, + { + columnId: 'ssoUserId', + isExpand: true, + label: 'SSO UserID', + propertyName: 'ssoUserId', + type: 'text', + } as TableColumn, ] : [ { @@ -161,6 +280,12 @@ export const UsersTable: FC = props => { propertyName: 'email', type: 'text', } as TableColumn, + { + columnId: 'ssoUserId', + label: 'SSO UserID', + propertyName: 'ssoUserId', + type: 'text', + } as TableColumn, ]), { columnId: 'firstName', @@ -433,9 +558,10 @@ export const UsersTable: FC = props => { { const navigate: NavigateFunction = useNavigate() + const { profile }: ProfileContextData = useProfileContext() const { pathname }: { pathname: string } = useLocation() - const activeTabPathName: string = useMemo(() => getTabIdFromPathName(pathname), [pathname]) + const tabs = useMemo(() => getSystemAdminTabs(profile?.roles), [profile?.roles]) + const activeTabPathName: string = useMemo( + () => getTabIdFromPathName(pathname, tabs), + [pathname, tabs], + ) const [activeTab, setActiveTab]: [string, Dispatch>] = useState(activeTabPathName) @@ -26,17 +32,21 @@ const SystemAdminTabs: FC = () => { // If url is changed by navigator on different tabs, we need set activeTab useEffect(() => { - const pathTabId = getTabIdFromPathName(pathname) + const pathTabId = getTabIdFromPathName(pathname, tabs) if (pathTabId !== activeTab) { setActiveTab(pathTabId) } - }, [pathname]) // eslint-disable-line react-hooks/exhaustive-deps + }, [activeTab, pathname, tabs]) + + if (!tabs.length) { + return <> + } return (
diff --git a/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts b/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts index 55c448402..1154c6cbf 100644 --- a/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts +++ b/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts @@ -1,6 +1,7 @@ import _ from 'lodash' import { TabsNavItem } from '~/libs/ui' +import { isAdministrator } from '~/apps/admin/src/lib/utils' import { billingAccountRouteId, defaultReviewersRouteId, @@ -10,7 +11,6 @@ import { paymentsRouteId, permissionManagementRouteId, platformRouteId, - reportsRouteId, termsRouteId, userManagementRouteId, } from '~/apps/admin/src/config/routes.config' @@ -83,22 +83,32 @@ export const SystemAdminTabsConfig: TabsNavItem[] = [ id: paymentsRouteId, title: 'Payments', }, - { - id: reportsRouteId, - title: 'Reports', - }, ] -export function getTabIdFromPathName(pathname: string): string { - const matchItem = _.find(SystemAdminTabsConfig, item => pathname.includes(`/${item.id}`)) +/** + * Returns the visible system-admin tabs for the current user. + */ +export function getSystemAdminTabs(roles?: string[]): TabsNavItem[] { + if (isAdministrator(roles)) { + return SystemAdminTabsConfig + } + + return [] +} + +/** + * Resolves the active tab id for the current location and visible tab set. + */ +export function getTabIdFromPathName(pathname: string, tabs: TabsNavItem[] = SystemAdminTabsConfig): string { + const matchItem = _.find(tabs, item => pathname.includes(`/${item.id}`)) if (matchItem) { return matchItem.id } - if (pathname.includes(`/${manageReviewRouteId}`)) { + if (tabs.some(item => item.id === manageReviewRouteId) && pathname.includes(`/${manageReviewRouteId}`)) { return manageReviewRouteId } - return manageChallengeRouteId + return (tabs[0]?.id as string) || manageChallengeRouteId } diff --git a/src/apps/admin/src/lib/hooks/useManageUsers.ts b/src/apps/admin/src/lib/hooks/useManageUsers.ts index e7d01963f..5498d02f1 100644 --- a/src/apps/admin/src/lib/hooks/useManageUsers.ts +++ b/src/apps/admin/src/lib/hooks/useManageUsers.ts @@ -1,10 +1,12 @@ /** * Manage users redux state */ -import { useCallback, useReducer, useRef } from 'react' +import { useCallback, useReducer, useRef, useState } from 'react' import { toast } from 'react-toastify' import _ from 'lodash' +import { Sort } from '~/apps/admin/src/platform/gamification-admin/src/game-lib' + import { UserInfo } from '../models' import { deleteUser, searchUsersPaginated, updateUserStatus } from '../services' import { TABLE_PAGINATION_ITEM_PER_PAGE } from '../../config/index.config' @@ -177,7 +179,9 @@ export interface useManageUsersProps { doSearchUsers: (filter: string) => void page: number totalPages: number + sort: Sort | undefined onPageChange: (page: number) => void + onSortChange: (sort: Sort | undefined) => void updatingStatus: { [key: string]: boolean } deletingUsers: { [key: string]: boolean } doUpdateStatus: ( @@ -207,23 +211,37 @@ export function useManageUsers(): useManageUsersProps { updatingStatus: {}, users: [], }) + const [sort, setSort] = useState() const filterRef = useRef('') + const hasSearchedRef = useRef(false) + const sortRef = useRef() - const doSearchUsers = useCallback( - (filter: string) => { + /** + * Fetches one page of users with the current filter and optional server-side sort. + * @param filter Active legacy filter string. + * @param page Page number to request. + * @param sortToApply Current table sort, if any. + * @returns Resolves when the request completes and state is updated. + */ + const fetchUsers = useCallback( + (filter: string, page: number, sortToApply?: Sort) => { dispatch({ type: UsersActionType.FETCH_USERS_INIT, }) - filterRef.current = filter || '' - searchUsersPaginated({ - filter: filterRef.current, + + const offset = (page - 1) * TABLE_PAGINATION_ITEM_PER_PAGE + + return searchUsersPaginated({ + filter, limit: TABLE_PAGINATION_ITEM_PER_PAGE, - offset: 0, + offset, + sortBy: sortToApply?.fieldName, + sortOrder: sortToApply?.direction, }) .then(result => { dispatch({ payload: { - page: result.page || 1, + page: result.page || page, total: result.total || 0, totalPages: result.totalPages || 0, users: result.data, @@ -241,37 +259,35 @@ export function useManageUsers(): useManageUsersProps { [dispatch], ) + const doSearchUsers = useCallback( + (filter: string) => { + hasSearchedRef.current = true + filterRef.current = filter || '' + fetchUsers(filterRef.current, 1, sortRef.current) + }, + [fetchUsers], + ) + const onPageChange = useCallback( (page: number) => { - if (page < 1) return - dispatch({ - type: UsersActionType.FETCH_USERS_INIT, - }) - const offset = (page - 1) * TABLE_PAGINATION_ITEM_PER_PAGE - searchUsersPaginated({ - filter: filterRef.current, - limit: TABLE_PAGINATION_ITEM_PER_PAGE, - offset, - }) - .then(result => { - dispatch({ - payload: { - page: result.page || page, - total: result.total || 0, - totalPages: result.totalPages || 0, - users: result.data, - }, - type: UsersActionType.FETCH_USERS_DONE, - }) - }) - .catch(e => { - dispatch({ - type: UsersActionType.FETCH_USERS_FAILED, - }) - handleError(e) - }) + if (page < 1 || !hasSearchedRef.current) return + fetchUsers(filterRef.current, page, sortRef.current) }, - [dispatch], + [fetchUsers], + ) + + const onSortChange = useCallback( + (nextSort: Sort | undefined) => { + sortRef.current = nextSort + setSort(nextSort) + + if (!hasSearchedRef.current) { + return + } + + fetchUsers(filterRef.current, 1, nextSort) + }, + [fetchUsers], ) const doUpdateStatus = useCallback( @@ -357,7 +373,9 @@ export function useManageUsers(): useManageUsersProps { doUpdateStatus, isLoading: state.isLoading, onPageChange, + onSortChange, page: state.page, + sort, totalPages: state.totalPages, updatingStatus: state.updatingStatus, users: state.users, diff --git a/src/apps/admin/src/lib/models/FormUsersFilters.model.ts b/src/apps/admin/src/lib/models/FormUsersFilters.model.ts index fbaa6a699..f5f35184c 100644 --- a/src/apps/admin/src/lib/models/FormUsersFilters.model.ts +++ b/src/apps/admin/src/lib/models/FormUsersFilters.model.ts @@ -4,6 +4,7 @@ export interface FormUsersFilters { handle?: string email?: string + ssoUserId?: string userId?: string status?: string } diff --git a/src/apps/admin/src/lib/models/UserInfo.model.ts b/src/apps/admin/src/lib/models/UserInfo.model.ts index 1fe9e5aca..254adf9ad 100644 --- a/src/apps/admin/src/lib/models/UserInfo.model.ts +++ b/src/apps/admin/src/lib/models/UserInfo.model.ts @@ -15,6 +15,7 @@ export interface UserInfo { firstName: string lastName: string email: string + ssoUserId?: string status: 'A' | 'U' | '4' | '5' | '6' statusDesc: string activationLink: string diff --git a/src/apps/admin/src/lib/services/groups.service.ts b/src/apps/admin/src/lib/services/groups.service.ts index 9d67160d1..0bb2ac5f4 100644 --- a/src/apps/admin/src/lib/services/groups.service.ts +++ b/src/apps/admin/src/lib/services/groups.service.ts @@ -1,11 +1,17 @@ /** * Groups service */ +import type { AxiosInstance } from 'axios' import _ from 'lodash' import qs from 'qs' import { EnvironmentConfig } from '~/config' -import { xhrDeleteAsync, xhrGetAsync, xhrPostAsync } from '~/libs/core' +import { + xhrCreateInstance, + xhrDeleteAsync, + xhrGetAsync, + xhrPostAsync, +} from '~/libs/core/lib/xhr' import { adjustUserGroupMemberResponse, @@ -15,6 +21,8 @@ import { UserGroupMember, } from '../models' +const reportsDownloadClient: AxiosInstance = xhrCreateInstance() + /** * Get a groups of the particular member * @param params query params. @@ -146,3 +154,24 @@ export const removeGroupMember = async ( ) return result } + +/** + * Exports users assigned to a group in CSV format. + * @param groupId group id. + * @returns resolves to CSV blob. + */ +export const exportGroupUsersCsv = async ( + groupId: string, +): Promise => { + const response = await reportsDownloadClient.get( + `${EnvironmentConfig.REPORTS_API}/identity/users-by-group?groupId=${encodeURIComponent(groupId)}`, + { + headers: { + Accept: 'text/csv', + }, + responseType: 'blob', + }, + ) + + return response.data +} diff --git a/src/apps/admin/src/lib/services/index.ts b/src/apps/admin/src/lib/services/index.ts index bf260a1b9..4441676f9 100644 --- a/src/apps/admin/src/lib/services/index.ts +++ b/src/apps/admin/src/lib/services/index.ts @@ -14,4 +14,9 @@ export * from './default-reviewers.service' export * from './timeline-templates.service' export * from './phases.service' export * from './scorecards.service' -export * from './reports.service' +export { + downloadBlobFile, + downloadReportAsCsv, + downloadReportAsJson, + fetchReportsIndex, +} from './reports.service' diff --git a/src/apps/admin/src/lib/services/reports.service.ts b/src/apps/admin/src/lib/services/reports.service.ts index f2802ff85..5d19536ce 100644 --- a/src/apps/admin/src/lib/services/reports.service.ts +++ b/src/apps/admin/src/lib/services/reports.service.ts @@ -62,3 +62,22 @@ export const downloadReportAsJson = (path: string): Promise => ( export const downloadReportAsCsv = (path: string): Promise => ( downloadReportBlob(path, 'text/csv') ) + +/** + * Triggers a browser download for a report blob. + * @param blob the report data returned from the reports API. + * @param fileName the file name to present in the browser download prompt. + * @returns nothing. The helper is used by the admin reports pages after a blob response is received. + * @throws Does not throw intentionally. Browser download failures surface from the underlying DOM APIs. + */ +export const downloadBlobFile = (blob: Blob, fileName: string): void => { + const link = document.createElement('a') + const url = window.URL.createObjectURL(blob) + + link.href = url + link.setAttribute('download', fileName) + document.body.appendChild(link) + link.click() + link.parentNode?.removeChild(link) + window.URL.revokeObjectURL(url) +} diff --git a/src/apps/admin/src/lib/services/roles.service.ts b/src/apps/admin/src/lib/services/roles.service.ts index 83f37775a..f06f91b19 100644 --- a/src/apps/admin/src/lib/services/roles.service.ts +++ b/src/apps/admin/src/lib/services/roles.service.ts @@ -1,21 +1,25 @@ /** * Roles service */ +import type { AxiosInstance } from 'axios' import _ from 'lodash' import { EnvironmentConfig } from '~/config' import { + xhrCreateInstance, xhrDeleteAsync, xhrGetAsync, + xhrGetPaginatedAsync, xhrPatchAsync, xhrPostAsync, -} from '~/libs/core' +} from '~/libs/core/lib/xhr' import { adjustUserRoleResponse, UserRole } from '../models' import { PaginatedResponseV6 } from '../models/PaginatedResponseV6.model' import { RoleMemberInfo } from '../models/RoleMemberInfo.model' type RoleMemberRaw = { userId?: number; handle?: string | null; email?: string | null } +const reportsDownloadClient: AxiosInstance = xhrCreateInstance() /** * Fetchs roles of the specified subject @@ -191,43 +195,40 @@ export const fetchRoleMembersPaginated = async ( .map(([k, v]) => `${k}=${encodeURIComponent(String(v as string | number))}`) const url = params.length ? `${baseUrl}?${params.join('&')}` : baseUrl - const raw = await xhrGetAsync(url) - - // Support both array (non-paginated) and object (paginated) responses - if (Array.isArray(raw)) { - const mappedArr: RoleMemberInfo[] = (raw || []).map( - (m: RoleMemberRaw) => ({ - email: m?.email ?? undefined, - handle: m?.handle ?? undefined, - id: String(m?.userId ?? ''), - }), - ) - return { - data: mappedArr, - page: 1, - perPage: mappedArr.length, - total: mappedArr.length, - totalPages: 1, - } - } - - const dataArray = (raw?.data ?? []) as Array - const mapped: RoleMemberInfo[] = dataArray.map(m => ({ - email: m.email ?? undefined, - handle: m.handle ?? undefined, - id: String(m.userId ?? ''), + const result = await xhrGetPaginatedAsync(url) + const dataArray = Array.isArray(result.data) ? result.data : [] + const mapped: RoleMemberInfo[] = dataArray.map((m: RoleMemberRaw) => ({ + email: m?.email ?? undefined, + handle: m?.handle ?? undefined, + id: String(m?.userId ?? ''), })) - const safeTotal = Number(raw?.total ?? mapped.length) - const safePerPage = Number(raw?.perPage ?? mapped.length) - const computedTotalPages = raw?.totalPages - || (safePerPage ? Math.ceil(safeTotal / safePerPage) : 1) - return { data: mapped, - page: raw?.page || 1, - perPage: safePerPage, - total: safeTotal, - totalPages: computedTotalPages, + page: result.page || 1, + perPage: result.perPage || mapped.length, + total: result.total || mapped.length, + totalPages: result.totalPages || 1, } } + +/** + * Exports users assigned to a role in CSV format. + * @param roleId role id. + * @returns resolves to CSV blob. + */ +export const exportRoleUsersCsv = async ( + roleId: string, +): Promise => { + const response = await reportsDownloadClient.get( + `${EnvironmentConfig.REPORTS_API}/identity/users-by-role?roleId=${encodeURIComponent(roleId)}`, + { + headers: { + Accept: 'text/csv', + }, + responseType: 'blob', + }, + ) + + return response.data +} diff --git a/src/apps/admin/src/lib/services/user.service.ts b/src/apps/admin/src/lib/services/user.service.ts index fffe13f46..63bf7a381 100644 --- a/src/apps/admin/src/lib/services/user.service.ts +++ b/src/apps/admin/src/lib/services/user.service.ts @@ -100,6 +100,7 @@ export const searchUsers = async (options?: { 'id', 'handle', 'email', + 'ssoUserId', 'active', 'emailActive', 'emailVerified', @@ -135,6 +136,8 @@ export const searchUsersPaginated = async (options?: { filter?: string limit?: number offset?: number + sortBy?: string + sortOrder?: 'asc' | 'desc' }): Promise> => { let query = '' const opts: { @@ -142,6 +145,8 @@ export const searchUsersPaginated = async (options?: { filter?: string limit?: number offset?: number + sortBy?: string + sortOrder?: 'asc' | 'desc' } = options || {} _.forOwn( { @@ -151,6 +156,7 @@ export const searchUsersPaginated = async (options?: { 'id', 'handle', 'email', + 'ssoUserId', 'active', 'emailActive', 'emailVerified', @@ -164,6 +170,8 @@ export const searchUsersPaginated = async (options?: { filter: opts.filter, limit: opts.limit, offset: opts.offset, + sortBy: opts.sortBy, + sortOrder: opts.sortOrder, }, (value, key) => { if (value !== undefined && value !== null && value !== '') { diff --git a/src/apps/admin/src/lib/utils/access.ts b/src/apps/admin/src/lib/utils/access.ts new file mode 100644 index 000000000..76d8f96c7 --- /dev/null +++ b/src/apps/admin/src/lib/utils/access.ts @@ -0,0 +1,25 @@ +import { UserRole } from '~/libs/core' + +export const administratorOnlyRoles: UserRole[] = [ + UserRole.administrator, +] + +export const adminReportsAccessRoles: UserRole[] = [ + UserRole.administrator, + UserRole.productManager, + UserRole.talentManager, +] + +/** + * Returns true when the current user is an administrator. + */ +export function isAdministrator(roles?: string[]): boolean { + return !!roles?.includes(UserRole.administrator) +} + +/** + * Returns true when the current user should be able to enter the admin reports module. + */ +export function canAccessAdminReports(roles?: string[]): boolean { + return !!roles?.some(role => adminReportsAccessRoles.includes(role as UserRole)) +} diff --git a/src/apps/admin/src/lib/utils/index.ts b/src/apps/admin/src/lib/utils/index.ts index ab6460257..5e827a00b 100644 --- a/src/apps/admin/src/lib/utils/index.ts +++ b/src/apps/admin/src/lib/utils/index.ts @@ -5,3 +5,4 @@ export * from './challenge' export * from './number' export * from './string' export * from './others' +export * from './access' diff --git a/src/apps/admin/src/lib/utils/validation.ts b/src/apps/admin/src/lib/utils/validation.ts index 35046a48e..f7b5c2a1b 100644 --- a/src/apps/admin/src/lib/utils/validation.ts +++ b/src/apps/admin/src/lib/utils/validation.ts @@ -41,6 +41,9 @@ export const formUsersFiltersSchema: Yup.ObjectSchema .optional(), handle: Yup.string() .optional(), + ssoUserId: Yup.string() + .trim() + .optional(), status: Yup.string() .optional(), userId: Yup.string() diff --git a/src/apps/admin/src/permission-management/PermissionGroupMembersPage/PermissionGroupMembersPage.tsx b/src/apps/admin/src/permission-management/PermissionGroupMembersPage/PermissionGroupMembersPage.tsx index ea85c8010..e2a5ba63f 100644 --- a/src/apps/admin/src/permission-management/PermissionGroupMembersPage/PermissionGroupMembersPage.tsx +++ b/src/apps/admin/src/permission-management/PermissionGroupMembersPage/PermissionGroupMembersPage.tsx @@ -1,7 +1,7 @@ /** * Permission group members page. */ -import { FC, useContext, useEffect, useMemo, useState } from 'react' +import { FC, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { useParams } from 'react-router-dom' import _ from 'lodash' import classNames from 'classnames' @@ -13,6 +13,7 @@ import { PageDivider, PageTitle, } from '~/libs/ui' +import { downloadBlob } from '~/libs/shared/lib/utils/files' import { PlusIcon } from '@heroicons/react/solid' import { GroupMembersFilters } from '../../lib/components/GroupMembersFilters' @@ -21,6 +22,8 @@ import { useManagePermissionGroupMembers, useManagePermissionGroupMembersProps } import { AdminAppContext, PageContent, PageHeader } from '../../lib' import { AdminAppContextType, FormGroupMembersFilters, UserGroupMember } from '../../lib/models' import { useTableSelection, useTableSelectionProps } from '../../lib/hooks/useTableSelection' +import { exportGroupUsersCsv } from '../../lib/services' +import { handleError } from '../../lib/utils' import styles from './PermissionGroupMembersPage.module.scss' @@ -29,6 +32,19 @@ interface Props { } const pageTitle = 'Group Members' +const normalizeFileNameSegment = (value: string): string => ( + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)+/g, '') +) + +const buildGroupExportFileName = (groupName: string | undefined, groupId: string): string => { + const normalizedName = normalizeFileNameSegment(groupName || groupId) + return `group-users-${normalizedName || groupId}.csv` +} + export const PermissionGroupMembersPage: FC = (props: Props) => { const memberTypes = useMemo(() => ['group', 'user'], []) const { groupId = '' }: { groupId?: string } = useParams<{ @@ -60,6 +76,7 @@ export const PermissionGroupMembersPage: FC = (props: Props) => { cancelLoadGroup, groupsMapping, ) + const [isExporting, setIsExporting] = useState(false) const [datasIdsMapping, setDatasIdsMapping] = useState<{ [memberType: string]: number[] }>({ @@ -92,6 +109,26 @@ export const PermissionGroupMembersPage: FC = (props: Props) => { () => !groupsMapping[groupId], [groupsMapping, groupId], ) + const handleExport = useCallback(() => { + if (!groupId || isExporting) { + return + } + + setIsExporting(true) + exportGroupUsersCsv(groupId) + .then(blob => { + downloadBlob( + blob, + buildGroupExportFileName(groupsMapping[groupId], groupId), + ) + }) + .catch(error => { + handleError(error) + }) + .finally(() => { + setIsExporting(false) + }) + }, [groupId, groupsMapping, isExporting]) return (
@@ -99,6 +136,14 @@ export const PermissionGroupMembersPage: FC = (props: Props) => {

{pageTitle}

+ ( + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)+/g, '') +) + +const buildRoleExportFileName = (roleName: string | undefined, roleId: string): string => { + const normalizedName = normalizeFileNameSegment(roleName || roleId) + return `role-users-${normalizedName || roleId}.csv` +} + export const PermissionRoleMembersPage: FC = (props: Props) => { const { roleId = '' }: { roleId?: string } = useParams<{ roleId: string }>() + const [isExporting, setIsExporting] = useState(false) const { isLoading, roleInfo, @@ -40,6 +57,26 @@ export const PermissionRoleMembersPage: FC = (props: Props) => { }: useManagePermissionRoleMembersProps = useManagePermissionRoleMembers(roleId) const pageTitleWithRole = roleInfo?.roleName ? `${pageTitle}: ${roleInfo.roleName}` : pageTitle + const handleExport = useCallback(() => { + if (!roleId || isExporting) { + return + } + + setIsExporting(true) + exportRoleUsersCsv(roleId) + .then(blob => { + downloadBlob( + blob, + buildRoleExportFileName(roleInfo?.roleName, roleId), + ) + }) + .catch(error => { + handleError(error) + }) + .finally(() => { + setIsExporting(false) + }) + }, [isExporting, roleId, roleInfo?.roleName]) return (
@@ -47,6 +84,14 @@ export const PermissionRoleMembersPage: FC = (props: Props) => {

{pageTitleWithRole}

+ { - const normalized = name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/(^-|-$)+/g, '') - - const base = normalized || 'report' - return `${base}.${extension}` -} - -const formatMethod = (method?: string): string => ( - method ? method.toUpperCase() : 'GET' -) - -export const ReportsPage: FC = () => { - const [reportsIndex, setReportsIndex] = useState({}) - const [selectedBasePath, setSelectedBasePath] = useState('') - const [selectedReportPath, setSelectedReportPath] = useState('') - const [isLoading, setIsLoading] = useState(false) - const [downloadingFormat, setDownloadingFormat] = useState<'json' | 'csv' | undefined>(undefined) - const [parameterValues, setParameterValues] = useState>({}) - - useEffect(() => { - let isMounted = true - setIsLoading(true) - - fetchReportsIndex() - .then(data => { - if (!isMounted) return - setReportsIndex(data ?? {}) - }) - .catch(error => { - if (!isMounted) return - handleError(error) - }) - .finally(() => { - if (isMounted) { - setIsLoading(false) - } - }) - - return () => { - isMounted = false - } - }, []) - - const basePathOptions = useMemo(() => { - const groups: ReportGroup[] = Object.values(reportsIndex ?? {}) - const options = groups.map(group => ({ - label: group.label || group.basePath, - value: group.basePath, - })) - - options.sort((a, b) => a.label.localeCompare(b.label)) - return options - }, [reportsIndex]) - - const selectedGroup = useMemo(() => ( - selectedBasePath - ? Object.values(reportsIndex) - .find(group => group.basePath === selectedBasePath) - : undefined - ), [reportsIndex, selectedBasePath]) - - const reportOptions = useMemo(() => { - if (!selectedGroup?.reports?.length) { - return [] - } - - const options = selectedGroup.reports.map(report => ({ - label: report.name, - value: report.path, - })) - - options.sort((a, b) => a.label.localeCompare(b.label)) - return options - }, [selectedGroup]) - - const selectedReport = useMemo(() => ( - selectedGroup?.reports?.find(report => report.path === selectedReportPath) - ), [selectedGroup, selectedReportPath]) - - const handleBasePathChange = useCallback((event: ChangeEvent) => { - setSelectedBasePath(event.target.value) - setSelectedReportPath('') - setParameterValues({}) - }, []) - - const handleReportChange = useCallback((event: ChangeEvent) => { - setSelectedReportPath(event.target.value) - setParameterValues({}) - }, []) - - const handleParameterChange = useCallback((event: ChangeEvent) => { - if (!event.target?.name) return - - setParameterValues(previous => ({ - ...previous, - [event.target.name]: event.target.value, - })) - }, []) - - const createSelectParamChange = useCallback((name: string) => ( - event: ChangeEvent, - ) => { - setParameterValues(previous => ({ - ...previous, - [name]: event.target.value, - })) - }, []) - - const buildReportPathWithParams = useCallback((report: ReportDefinition): string => { - let path = report.path - const query = new URLSearchParams() - const params: ReportParameter[] = report.parameters ?? [] - - params.forEach(param => { - const rawValue = parameterValues[param.name] - if (rawValue === undefined || rawValue.trim() === '') { - return - } - - const isArray = param.type.endsWith('[]') - const values = isArray - ? rawValue.split(',') - .map(v => v.trim()) - .filter(Boolean) - : [rawValue.trim()] - - if (!values.length) return - - if (param.location === 'path') { - path = path.replace(`:${param.name}`, encodeURIComponent(values[0])) - } else { - values.forEach(value => query.append(param.name, value)) - } - }) - - const queryString = query.toString() - return queryString ? `${path}?${queryString}` : path - }, [parameterValues]) - - const handleDownload = useCallback(async (format: 'json' | 'csv') => { - if (!selectedReport) { - return - } - - try { - setDownloadingFormat(format) - - const requestPath = buildReportPathWithParams(selectedReport) - - const blob = format === 'json' - ? await downloadReportAsJson(requestPath) - : await downloadReportAsCsv(requestPath) - - const link = document.createElement('a') - const fileName = buildDownloadName(selectedReport.name, format) - const url = window.URL.createObjectURL(blob) - - link.href = url - link.setAttribute('download', fileName) - document.body.appendChild(link) - link.click() - link.parentNode?.removeChild(link) - window.URL.revokeObjectURL(url) - } catch (error) { - handleError(error) - } finally { - setDownloadingFormat(undefined) - } - }, [buildReportPathWithParams, selectedReport]) - - const isDownloading = downloadingFormat !== undefined - - const requiredParamsMissing = useMemo(() => { - const params = selectedReport?.parameters ?? [] - return params.some(param => param.required && !(parameterValues[param.name]?.trim())) - }, [parameterValues, selectedReport]) - - const hasUnresolvedPathParams = useMemo(() => ( - (selectedReport?.parameters ?? []) - .filter(param => param.location === 'path') - .some(param => !parameterValues[param.name]?.trim()) - ), [parameterValues, selectedReport]) - - const isDownloadDisabled = !selectedReport || isDownloading || requiredParamsMissing || hasUnresolvedPathParams - - const handleJsonDownload = useCallback(() => { - handleDownload('json') - }, [handleDownload]) - - const handleCsvDownload = useCallback(() => { - handleDownload('csv') - }, [handleDownload]) - - const renderParameterInput = useCallback((parameter: ReportParameter) => { - const commonProps = { - label: parameter.name, - name: parameter.name, - placeholder: parameter.type.endsWith('[]') ? 'Comma-separated values' : 'Enter value', - } - - if (parameter.type === 'boolean') { - const options: InputSelectOption[] = [ - { label: 'True', value: 'true' }, - { label: 'False', value: 'false' }, - ] - - return ( - - ) - } - - if (parameter.type === 'enum') { - const options: InputSelectOption[] = (parameter.options ?? []).map(option => ({ - label: option, - value: option, - })) - - return ( - - ) - } - - return ( - - ) - }, [createSelectParamChange, handleParameterChange, parameterValues]) - - return ( - <> - - {pageTitle} - - -
-

- Select a base path to view the available reports. After choosing a report, provide any - required parameters and download the data as JSON or CSV directly from the reports API. -

- - {isLoading ? ( -
- -
- ) : ( - <> - {basePathOptions.length ? ( -
- - - {selectedGroup && ( - - )} -
- ) : ( -
- No reports are currently available. -
- )} - - {selectedReport && ( - <> -
-
{selectedReport.name}
- {selectedReport.description && ( -
- {selectedReport.description} -
- )} -
- {formatMethod(selectedReport.method)} - {' '} - {selectedReport.path} -
-
- - {(selectedReport.parameters?.length ?? 0) > 0 && ( -
- {selectedReport.parameters?.map(parameter => ( -
-
- {parameter.name} - {parameter.required ? ' *' : ''} -
- {parameter.description && ( -
{parameter.description}
- )} -
- Location: - {' '} - {parameter.location || 'query'} - {' '} - • Type: - {' '} - {parameter.type} -
- {parameter.type.endsWith('[]') && ( -
- Use comma-separated values for lists. -
- )} - {renderParameterInput(parameter)} -
- ))} -
- )} - -
- - -
- - )} - - )} -
-
- - ) -} - -export default ReportsPage diff --git a/src/apps/admin/src/user-management/UserManagementPage/UserManagementPage.tsx b/src/apps/admin/src/user-management/UserManagementPage/UserManagementPage.tsx index 64ac93d05..ae7c05269 100644 --- a/src/apps/admin/src/user-management/UserManagementPage/UserManagementPage.tsx +++ b/src/apps/admin/src/user-management/UserManagementPage/UserManagementPage.tsx @@ -31,8 +31,10 @@ export const UserManagementPage: FC = (props: Props) => { doUpdateStatus, doDeleteUser, page, + sort, totalPages, onPageChange, + onSortChange, }: useManageUsersProps = useManageUsers() return ( @@ -64,8 +66,10 @@ export const UserManagementPage: FC = (props: Props) => { { const queryParams = new URLSearchParams({ page: String(page), @@ -93,6 +102,22 @@ export async function fetchCompletedProfiles( queryParams.set('countryCode', countryCode) } + if (openToWorkFilter === 'yes') { + queryParams.set('openToWork', 'true') + } + + if (openToWorkFilter === 'no') { + queryParams.set('openToWork', 'false') + } + + if (Array.isArray(skillIds) && skillIds.length > 0) { + skillIds.forEach(id => { + if (id) { + queryParams.append('skillId', String(id)) + } + }) + } + const response = await xhrGetAsync( `${EnvironmentConfig.REPORTS_API}/topcoder/completed-profiles?${queryParams.toString()}`, ) diff --git a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss index f0a0e396d..ec7051428 100644 --- a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss +++ b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss @@ -18,14 +18,28 @@ } } -.filterWrap { - min-width: 280px; - max-width: 360px; +.filterWrapper { + display: flex; + gap: $sp-4; + + :global([class*='__value-container']) { + min-height: 18px; + } @include ltemd { - max-width: unset; - min-width: unset; - width: 100%; + flex-direction: column; + align-items: stretch; + } + + .filterWrap { + min-width: 280px; + max-width: 360px; + + @include ltemd { + max-width: unset; + min-width: unset; + width: 100%; + } } } @@ -192,3 +206,13 @@ color: $link-blue; cursor: pointer; } + +.openToWorkYes { + color: $green-100; + font-weight: 600; +} + +.openToWorkNo { + color: $red-100; + font-weight: 600; +} diff --git a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx index 34ee7785b..00689bb5d 100644 --- a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx +++ b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx @@ -1,12 +1,21 @@ /* eslint-disable react/jsx-no-bind */ /* eslint-disable no-await-in-loop */ /* eslint-disable complexity */ -import { ChangeEvent, FC, useEffect, useMemo, useState } from 'react' +import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react' import useSWR, { SWRResponse } from 'swr' import { EnvironmentConfig } from '~/config' -import { CountryLookup, useCountryLookup, UserSkill, UserSkillDisplayModes } from '~/libs/core' -import { Button, InputSelect, InputSelectOption, LoadingSpinner } from '~/libs/ui' +import { CountryLookup, useCountryLookup, UserSkill, UserSkillDisplayModes, xhrGetAsync } from '~/libs/core' +import { + Button, + InputMultiselect, + InputMultiselectOption, + InputSelect, + InputSelectOption, + LoadingSpinner, + Tooltip, +} from '~/libs/ui' +import { getPreferredRoleLabelByValue } from '~/libs/shared/lib/utils/roles' import { PageWrapper } from '../../../lib' import { @@ -14,21 +23,65 @@ import { DEFAULT_PAGE_SIZE, fetchCompletedProfiles, fetchMemberSkillsData, + type OpenToWorkFilter, } from '../../../lib/services/profileCompletion.service' import styles from './ProfileCompletionPage.module.scss' +const DISPLAY_SKILLS_COUNT = 5 + export const ProfileCompletionPage: FC = () => { const [selectedCountry, setSelectedCountry] = useState('all') const [currentPage, setCurrentPage] = useState(1) + const [selectedOpenToWork, setSelectedOpenToWork] = useState('all') + const [selectedSkills, setSelectedSkills] = useState([]) const [memberSkills, setMemberSkills] = useState>(new Map()) + const [skillOptionsLoading, setSkillOptionsLoading] = useState(false) const countryLookup: CountryLookup[] | undefined = useCountryLookup() const countryCodeFilter = selectedCountry === 'all' ? undefined : selectedCountry + const loadSkillOptions = useCallback(async (query: string): Promise => { + setSkillOptionsLoading(true) + try { + const baseUrl = `${EnvironmentConfig.API.V5}/standardized-skills` + const params = new URLSearchParams({ + size: '25', + }) + if (query && query.trim().length > 0) { + params.append('term', query.trim()) + } + + const url = `${baseUrl}/skills/autocomplete?${params.toString()}` + const response: any = await xhrGetAsync(url) + + const skills = Array.isArray(response) ? response : [] + + return skills + .map((skill: any) => ({ + label: skill.name, + value: String(skill.id), + })) + .filter((option: InputMultiselectOption) => !!option.value) + } catch { + return [] + } finally { + setSkillOptionsLoading(false) + } + }, []) + const { data, error, isValidating }: SWRResponse = useSWR( - `customer-portal-completed-profiles:${countryCodeFilter || 'all'}:${currentPage}:${DEFAULT_PAGE_SIZE}`, - () => fetchCompletedProfiles(countryCodeFilter, currentPage, DEFAULT_PAGE_SIZE), + // eslint-disable-next-line max-len + `customer-portal-completed-profiles:${countryCodeFilter || 'all'}:${selectedOpenToWork}:${currentPage}:${DEFAULT_PAGE_SIZE}:${selectedSkills.map(skill => skill.value) + .sort() + .join(',')}`, + () => fetchCompletedProfiles( + countryCodeFilter, + currentPage, + DEFAULT_PAGE_SIZE, + selectedOpenToWork, + selectedSkills.map(skill => skill.value), + ), { revalidateOnFocus: false, }, @@ -118,13 +171,20 @@ export const ProfileCompletionPage: FC = () => { const userSkills = profile.userId ? (memberSkills.get(profile.userId) || []) : [] // Prioritize principal skills, then add additional skills - const allSkillsByPriority = [ + const principalSkills = [ ...userSkills.filter(skill => skill.displayMode?.name === UserSkillDisplayModes.principal), - ...userSkills.filter(skill => skill.displayMode?.name !== UserSkillDisplayModes.principal), ] - const displayedSkills = allSkillsByPriority.slice(0, 5) - const additionalSkillsCount = Math.max(0, allSkillsByPriority.length - 5) + const displayedSkills = principalSkills.slice(0, DISPLAY_SKILLS_COUNT) + const additionalSkillsCount = Math.max(0, principalSkills.length - DISPLAY_SKILLS_COUNT) + + const isOpenToWork = profile.isOpenToWork === true + const openToWorkLabel = isOpenToWork ? 'Yes' : 'No' + const openToWorkRolesText = profile.openToWork?.preferredRoles && profile.openToWork.preferredRoles.length + ? profile.openToWork.preferredRoles.map(getPreferredRoleLabelByValue) + .filter(Boolean) + .join(', ') + : 'No role preferences set' return { ...profile, @@ -136,11 +196,14 @@ export const ProfileCompletionPage: FC = () => { fullName: [profile.firstName, profile.lastName].filter(Boolean) .join(' ') .trim(), + isOpenToWork, locationLabel: [profile.city, profile.countryCode ? countryMap.get(profile.countryCode) || profile.countryName || profile.countryCode : profile.countryName] .filter(Boolean) .join(', '), + openToWorkLabel, + openToWorkRolesText, } }) .sort((a, b) => a.handle.localeCompare(b.handle)), [profiles, countryMap, memberSkills]) @@ -155,18 +218,52 @@ export const ProfileCompletionPage: FC = () => { className={styles.container} >
-
- ) => { - setSelectedCountry(event.target.value || 'all') - setCurrentPage(1) - }} - placeholder='Select country' - /> +
+
+ ) => { + setSelectedCountry(event.target.value || 'all') + setCurrentPage(1) + }} + placeholder='Select country' + /> +
+
+ ) => { + setSelectedOpenToWork((event.target.value || 'all') as OpenToWorkFilter) + setCurrentPage(1) + }} + placeholder='Select' + /> +
+
+ ) => { + const value = (event.target.value || []) as InputMultiselectOption[] + setSelectedSkills(value) + setCurrentPage(1) + }} + /> +
Fully Completed Profiles @@ -201,7 +298,8 @@ export const ProfileCompletionPage: FC = () => {
- + + @@ -230,6 +328,23 @@ export const ProfileCompletionPage: FC = () => { +
Member Handle LocationSkillsOpen to WorkPrincipal Skills {' '}
{profile.locationLabel || profile.countryLabel} + { + profile.openToWorkLabel === 'Yes' ? ( + + + {profile.openToWorkLabel} + + + ) : ( + + {profile.openToWorkLabel} + + ) + } + {profile.displayedSkills && profile.displayedSkills.length > 0 ? (
diff --git a/src/apps/engagements/src/components/assignment-card/AssignmentCard.spec.tsx b/src/apps/engagements/src/components/assignment-card/AssignmentCard.spec.tsx new file mode 100644 index 000000000..0d057a96c --- /dev/null +++ b/src/apps/engagements/src/components/assignment-card/AssignmentCard.spec.tsx @@ -0,0 +1,108 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, sort-keys */ +import '@testing-library/jest-dom' + +import React from 'react' +import { render, screen } from '@testing-library/react' + +import type { Engagement, EngagementAssignment } from '../../lib/models' +import { EngagementStatus } from '../../lib/models' + +import AssignmentCard from './AssignmentCard' + +jest.mock('react-markdown', () => ({ + __esModule: true, + default: ({ children }: { children?: React.ReactNode }) => <>{children}, +})) + +jest.mock('remark-frontmatter', () => ({ + __esModule: true, + default: jest.fn(), +})) + +jest.mock('remark-gfm', () => ({ + __esModule: true, + default: jest.fn(), +})) + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + URLS: { + USER_PROFILE: 'https://topcoder.com/members', + }, + }, +}), { virtual: true }) + +jest.mock('~/libs/ui', () => ({ + Button: (props: { + className?: string + disabled?: boolean + label: string + onClick?: () => void + }) => ( + + ), + IconSolid: { + CalendarIcon: () => , + ClockIcon: () => , + CurrencyDollarIcon: () => , + GlobeAltIcon: () => , + LocationMarkerIcon: () => , + }, +}), { virtual: true }) + +const engagement: Engagement = { + id: 'engagement-1', + nanoId: 'engagement-1-nano', + projectId: 'project-1', + title: 'QA Assignment', + description: 'Test description', + duration: {}, + timeZones: ['America/New_York'], + countries: ['US'], + requiredSkills: ['Testing'], + status: EngagementStatus.OPEN, + createdAt: '2026-03-25T00:00:00.000Z', + updatedAt: '2026-03-25T00:00:00.000Z', + createdBy: 'manager', +} + +const assignment: EngagementAssignment = { + id: 'assignment-1', + engagementId: 'engagement-1', + memberId: 'member-1', + memberHandle: 'member', + agreementRate: '761.25', + ratePerHour: '20.3', + standardHoursPerWeek: 37.5, + status: 'assigned', + createdAt: '2026-03-25T00:00:00.000Z', + updatedAt: '2026-03-25T00:00:00.000Z', +} + +describe('AssignmentCard', () => { + it('formats assignment currency values with two decimal places', () => { + render( + , + ) + + expect(screen.getByText('Rate / hr: $20.30')) + .toBeInTheDocument() + expect(screen.getByText('Rate / week: $761.25')) + .toBeInTheDocument() + expect(screen.getByText('Std hrs / week: 37.5 hrs')) + .toBeInTheDocument() + }) +}) diff --git a/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx b/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx index d85e92bd5..46be7f5a7 100644 --- a/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx +++ b/src/apps/engagements/src/components/assignment-card/AssignmentCard.tsx @@ -8,7 +8,13 @@ import { Button, IconSolid } from '~/libs/ui' import { EnvironmentConfig } from '~/config' import type { Engagement, EngagementAssignment } from '../../lib/models' -import { formatDate, formatLocation, truncateText } from '../../lib/utils' +import { + formatCurrencyAmount, + formatDate, + formatLocation, + formatStandardHoursPerWeek, + truncateText, +} from '../../lib/utils' import { StatusBadge } from '../status-badge' import styles from './AssignmentCard.module.scss' @@ -105,13 +111,12 @@ const formatAssignmentDate = (value?: string): string => { return formatted === 'Date TBD' ? FALLBACK_VALUE_LABEL : formatted } -const formatAgreementRate = (value?: string | number): string => { - if (value === null || value === undefined) { +const formatDurationMonths = (value?: number): string => { + if (!value) { return FALLBACK_VALUE_LABEL } - const normalized = typeof value === 'string' ? value.trim() : value.toString() - return normalized || FALLBACK_VALUE_LABEL + return `${value} month${value === 1 ? '' : 's'}` } const AssignmentCard: FC = (props: AssignmentCardProps) => { @@ -142,16 +147,24 @@ const AssignmentCard: FC = (props: AssignmentCardProps) => [assignment?.status], ) const paymentLabel = useMemo( - () => formatAgreementRate(assignment?.agreementRate), + () => formatCurrencyAmount(assignment?.agreementRate, FALLBACK_VALUE_LABEL), [assignment?.agreementRate], ) + const ratePerHourLabel = useMemo( + () => formatCurrencyAmount(assignment?.ratePerHour, FALLBACK_VALUE_LABEL), + [assignment?.ratePerHour], + ) const startDateLabel = useMemo( () => formatAssignmentDate(assignment?.startDate), [assignment?.startDate], ) - const endDateLabel = useMemo( - () => formatAssignmentDate(assignment?.endDate), - [assignment?.endDate], + const durationMonthsLabel = useMemo( + () => formatDurationMonths(assignment?.durationMonths), + [assignment?.durationMonths], + ) + const standardHoursPerWeekLabel = useMemo( + () => formatStandardHoursPerWeek(assignment?.standardHoursPerWeek, FALLBACK_VALUE_LABEL), + [assignment?.standardHoursPerWeek], ) const assignmentStatus = assignment?.status?.toLowerCase() const showAssignedActions = assignmentStatus === 'assigned' @@ -213,11 +226,11 @@ const AssignmentCard: FC = (props: AssignmentCardProps) =>
- {`Start: ${startDateLabel}`} + {`Billing start: ${startDateLabel}`}
- {`End: ${endDateLabel}`} + {`Duration: ${durationMonthsLabel}`}
@@ -229,7 +242,15 @@ const AssignmentCard: FC = (props: AssignmentCardProps) =>
- {`Payment: $${paymentLabel} per week`} + {`Rate / hr: ${ratePerHourLabel}`} +
+
+ + {`Std hrs / week: ${standardHoursPerWeekLabel}`} +
+
+ + {`Rate / week: ${paymentLabel}`}
diff --git a/src/apps/engagements/src/components/assignment-offer-modal/AssignmentOfferModal.tsx b/src/apps/engagements/src/components/assignment-offer-modal/AssignmentOfferModal.tsx index ae376c623..8f5fbdc6a 100644 --- a/src/apps/engagements/src/components/assignment-offer-modal/AssignmentOfferModal.tsx +++ b/src/apps/engagements/src/components/assignment-offer-modal/AssignmentOfferModal.tsx @@ -3,7 +3,11 @@ import { FC, useMemo } from 'react' import { BaseModal, Button } from '~/libs/ui' import type { Engagement, EngagementAssignment } from '../../lib/models' -import { formatDate } from '../../lib/utils' +import { + formatCurrencyAmount, + formatDate, + formatStandardHoursPerWeek, +} from '../../lib/utils' import styles from './AssignmentOfferModal.module.scss' @@ -29,14 +33,12 @@ const formatAssignmentDate = (value?: string): string => { return formatted === 'Date TBD' ? FALLBACK_LABEL : formatted } -const formatAgreementRate = (value?: string): string => { - if (value === null || value === undefined) { +const formatDurationMonths = (value?: number): string => { + if (!value) { return FALLBACK_LABEL } - const normalized = value.toString() - .trim() - return `$ ${normalized}` || FALLBACK_LABEL + return `${value} month${value === 1 ? '' : 's'}` } const formatRemarks = (value?: string): string => { @@ -62,16 +64,24 @@ const AssignmentOfferModal: FC = ( : 'Review the details below before accepting this offer.' const agreementRateLabel = useMemo( - () => formatAgreementRate(assignment.agreementRate), + () => formatCurrencyAmount(assignment.agreementRate, FALLBACK_LABEL), [assignment.agreementRate], ) const startDateLabel = useMemo( () => formatAssignmentDate(assignment.startDate), [assignment.startDate], ) - const endDateLabel = useMemo( - () => formatAssignmentDate(assignment.endDate), - [assignment.endDate], + const durationMonthsLabel = useMemo( + () => formatDurationMonths(assignment.durationMonths), + [assignment.durationMonths], + ) + const ratePerHourLabel = useMemo( + () => formatCurrencyAmount(assignment.ratePerHour, FALLBACK_LABEL), + [assignment.ratePerHour], + ) + const standardHoursPerWeekLabel = useMemo( + () => formatStandardHoursPerWeek(assignment.standardHoursPerWeek, FALLBACK_LABEL), + [assignment.standardHoursPerWeek], ) const otherRemarksLabel = useMemo( () => formatRemarks(assignment.otherRemarks), @@ -113,16 +123,24 @@ const AssignmentOfferModal: FC = (

Assignment details

- Agreement rate (per week) - {agreementRateLabel} + Billing start date + {startDateLabel}
- Tentative start date - {startDateLabel} + Duration (in months) + {durationMonthsLabel}
- Tentative end date - {endDateLabel} + Rate per hour + {ratePerHourLabel} +
+
+ Standard hours per week + {standardHoursPerWeekLabel} +
+
+ Assignment rate per week + {agreementRateLabel}
Other remarks diff --git a/src/apps/engagements/src/components/index.ts b/src/apps/engagements/src/components/index.ts index 3fbd5c5c4..59fe18061 100644 --- a/src/apps/engagements/src/components/index.ts +++ b/src/apps/engagements/src/components/index.ts @@ -10,3 +10,4 @@ export * from './member-experience-modal' export * from './assignment-offer-modal' export * from './assignment-card' export * from './engagements-tabs' +export * from './terms-agreement-modal' diff --git a/src/apps/engagements/src/components/terms-agreement-modal/TermsAgreementModal.module.scss b/src/apps/engagements/src/components/terms-agreement-modal/TermsAgreementModal.module.scss new file mode 100644 index 000000000..e5403f4f7 --- /dev/null +++ b/src/apps/engagements/src/components/terms-agreement-modal/TermsAgreementModal.module.scss @@ -0,0 +1,225 @@ +@import '@libs/ui/styles/includes'; + +.termsModalDescription { + color: $black-80; + margin-bottom: 12px; + line-height: 1.5; +} + +.termsModalLoading { + display: flex; + justify-content: center; + padding: 16px 0; +} + +.termsModalSpinner { + width: 24px; + height: 24px; +} + +.termsModalBody { + background: $tc-white; + border: 1px solid $black-10; + border-radius: 12px; + padding: 16px; + max-height: 60vh; + overflow-y: auto; +} + +.termsContent { + color: $black-80; + line-height: 1.6; + + :global(p) { + margin: 0 0 12px 0; + } + + :global(p:last-child) { + margin-bottom: 0; + } + + :global(ul), + :global(ol) { + margin: 0 0 12px 20px; + padding: 0; + list-style-position: outside; + } + + :global(ul) { + list-style: disc; + } + + :global(ol) { + list-style: decimal; + } + + :global(li) { + margin-bottom: 6px; + } + + :global(li:last-child) { + margin-bottom: 0; + } + + :global(h1) { + margin: 20px 0 10px; + font-size: 1.4rem; + font-weight: 700; + color: $black-100; + } + + :global(h2) { + margin: 18px 0 10px; + font-size: 1.25rem; + font-weight: 700; + color: $black-100; + } + + :global(h3) { + margin: 16px 0 8px; + font-size: 1.1rem; + font-weight: 600; + color: $black-100; + } + + :global(h4), + :global(h5), + :global(h6) { + margin: 14px 0 8px; + font-size: 1rem; + font-weight: 600; + color: $black-100; + } + + :global(strong) { + color: $black-100; + } + + :global(em) { + font-style: italic; + } + + :global(a) { + color: $teal-100; + text-decoration: underline; + } + + :global(blockquote) { + margin: 0 0 12px 0; + padding-left: 12px; + border-left: 3px solid $black-20; + color: $black-60; + } + + :global(code) { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 0.9em; + background: $black-5; + border: 1px solid $black-10; + border-radius: 6px; + padding: 1px 4px; + } + + :global(pre) { + margin: 0 0 12px 0; + background: $black-5; + border: 1px solid $black-10; + border-radius: 12px; + padding: 12px; + overflow-x: auto; + } + + :global(pre code) { + background: transparent; + border: none; + padding: 0; + font-size: 0.9rem; + } + + :global(table) { + width: 100%; + border-collapse: collapse; + margin: 0 0 12px 0; + } + + :global(th), + :global(td) { + border: 1px solid $black-10; + padding: 8px 10px; + text-align: left; + vertical-align: top; + } + + :global(thead th) { + background: $black-5; + color: $black-100; + } + + :global(img) { + max-width: 100%; + height: auto; + margin: 8px 0; + border-radius: 12px; + } + + :global(hr) { + border: none; + border-top: 1px solid $black-10; + margin: 16px 0; + } +} + +.termsDocuSign { + position: relative; + background: $tc-white; + border: 1px solid $black-10; + border-radius: 12px; + padding: 16px; + max-height: 70vh; + overflow: hidden; +} + +.termsDocuSignOverlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + background: rgba(255, 255, 255, 0.85); + z-index: 1; + text-align: center; + padding: 16px; +} + +.termsDocuSignStatus { + color: $black-80; + font-size: 0.95rem; +} + +.termsDocuSignFrame { + width: 100%; + height: 60vh; + border: 0; +} + +.termsModalFallback { + display: flex; + flex-direction: column; + gap: 8px; + color: $black-80; +} + +.termsModalLink { + color: $turq-160; + font-weight: 600; + text-decoration: underline; + width: fit-content; +} + +.termsModalError { + margin-top: 12px; + color: $red-120; + font-size: 0.9rem; +} diff --git a/src/apps/engagements/src/components/terms-agreement-modal/TermsAgreementModal.tsx b/src/apps/engagements/src/components/terms-agreement-modal/TermsAgreementModal.tsx new file mode 100644 index 000000000..ca179392e --- /dev/null +++ b/src/apps/engagements/src/components/terms-agreement-modal/TermsAgreementModal.tsx @@ -0,0 +1,244 @@ +import type { FC, SyntheticEvent } from 'react' + +import { BaseModal, Button, LoadingSpinner } from '~/libs/ui' + +import styles from './TermsAgreementModal.module.scss' + +/** + * Defines the content and callbacks needed to present a required engagement terms agreement. + */ +export interface TermsAgreementModalProps { + open: boolean + onClose: () => void + contextDescription: string + termsLabel?: string + termsTitle: string + termsBody?: string + termsLoading: boolean + termsError?: string + termsAgreeing: boolean + isElectronicallyAgreeable: boolean + isDocuSignTerm: boolean + docuSignUrl?: string + docuSignLoading: boolean + termsUrl?: string + onAgree: () => void + onOpenTermsLink: () => void + onDocuSignFrameLoad?: (event: SyntheticEvent) => void +} + +type TermsModalButtonProps = Pick< + TermsAgreementModalProps, + | 'isDocuSignTerm' + | 'isElectronicallyAgreeable' + | 'onAgree' + | 'onClose' + | 'onOpenTermsLink' + | 'termsAgreeing' + | 'termsLoading' + | 'termsUrl' +> + +type TermsModalDocuSignProps = Pick< + TermsAgreementModalProps, + | 'docuSignLoading' + | 'docuSignUrl' + | 'onDocuSignFrameLoad' + | 'termsAgreeing' + | 'termsTitle' + | 'termsUrl' +> + +type TermsModalBodyProps = Pick + +type TermsModalContentProps = TermsModalBodyProps + & TermsModalDocuSignProps + & Pick + +const getTermsModalDescription = ( + termsLabel: string | undefined, + contextDescription: string, +): string => { + const subject = termsLabel ? `the ${termsLabel}` : 'these Terms & Conditions' + return `You are seeing ${subject} because ${contextDescription}. You must agree to continue.` +} + +const renderTermsModalButtons = (props: TermsModalButtonProps): JSX.Element => { + if (props.isDocuSignTerm) { + return ( +