diff --git a/client/app/bundles/system/admin/admin/components/buttons/UsersButtons.tsx b/client/app/bundles/system/admin/admin/components/buttons/UsersButtons.tsx index f183556102..865c52887d 100644 --- a/client/app/bundles/system/admin/admin/components/buttons/UsersButtons.tsx +++ b/client/app/bundles/system/admin/admin/components/buttons/UsersButtons.tsx @@ -5,10 +5,10 @@ import { UserMiniEntity } from 'types/users'; import DeleteButton from 'lib/components/core/buttons/DeleteButton'; import { PromptText } from 'lib/components/core/dialogs/Prompt'; -import { USER_ROLES } from 'lib/constants/sharedConstants'; import { useAppDispatch } from 'lib/hooks/store'; import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; +import instanceRoleTranslations from 'lib/translations/instance/users/roles'; import { deleteUser } from '../../operations'; @@ -89,7 +89,7 @@ const UserManagementButtons: FC = (props) => { loading={isDeleting} onClick={onDelete} title={t(translations.deletionConfirmTitle, { - role: USER_ROLES[user.role], + role: t(instanceRoleTranslations[user.role]), name: user.name, email: user.email, })} diff --git a/client/app/bundles/system/admin/admin/components/tables/UsersTable.tsx b/client/app/bundles/system/admin/admin/components/tables/UsersTable.tsx index b5dbba5898..112f3673b4 100644 --- a/client/app/bundles/system/admin/admin/components/tables/UsersTable.tsx +++ b/client/app/bundles/system/admin/admin/components/tables/UsersTable.tsx @@ -12,7 +12,12 @@ import { TableOptions, TableState, } from 'types/components/DataTable'; -import { AdminStats, UserMiniEntity, UserRoles } from 'types/users'; +import { + AdminStats, + USER_SYSTEM_ROLES, + UserMiniEntity, + UserSystemRoles, +} from 'types/users'; import DataTable from 'lib/components/core/layouts/DataTable'; import Link from 'lib/components/core/Link'; @@ -20,12 +25,12 @@ import InlineEditTextField from 'lib/components/form/fields/DataTableInlineEdita import { DEFAULT_TABLE_ROWS_PER_PAGE, FIELD_DEBOUNCE_DELAY_MS, - USER_ROLES, } from 'lib/constants/sharedConstants'; import rebuildObjectFromRow from 'lib/helpers/mui-datatables-helpers'; import { useAppDispatch } from 'lib/hooks/store'; import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; +import instanceRoleTranslations from 'lib/translations/instance/users/roles'; import tableTranslations from 'lib/translations/table'; import { indexUsers, updateUser } from '../../operations'; @@ -117,7 +122,7 @@ const UsersTable: FC = (props) => { ) as UserMiniEntity; const newUser = { ...user, - role: newRole as UserRoles, + role: newRole as UserSystemRoles, }; return dispatch(updateUser(user.id, newUser)) .then(() => { @@ -125,7 +130,7 @@ const UsersTable: FC = (props) => { toast.success( t(translations.changeRoleSuccess, { name: user.name, - role: USER_ROLES[newRole], + role: t(instanceRoleTranslations[newRole as UserSystemRoles]), }), ); }) @@ -315,13 +320,14 @@ const UsersTable: FC = (props) => { value={value} variant="standard" > - {Object.keys(USER_ROLES).map((option) => ( + {/* UserSystemRoles ('normal' | 'administrator') is a subset of InstanceUserRoles */} + {USER_SYSTEM_ROLES.map((option) => ( - {USER_ROLES[option]} + {t(instanceRoleTranslations[option])} ))} diff --git a/client/app/bundles/system/admin/admin/components/tables/__test__/UsersTable.test.tsx b/client/app/bundles/system/admin/admin/components/tables/__test__/UsersTable.test.tsx new file mode 100644 index 0000000000..3df585b5d2 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/tables/__test__/UsersTable.test.tsx @@ -0,0 +1,43 @@ +import { render, screen, waitForElementToBeRemoved } from 'test-utils'; + +import UsersTable from '../UsersTable'; + +const baseUserCounts = { + totalUsers: { allCount: 1 }, + activeUsers: { allCount: 1 }, + coursesCount: 0, + usersCount: 1, + totalCourses: 0, + activeCourses: 0, + instancesCount: 1, +}; + +const baseUser = { + id: 42, + name: 'Bob', + email: 'bob@example.org', + role: 'normal' as const, + instances: [ + { name: 'Main Instance', host: 'main.coursemology.org', courses: [] }, + ], +}; + +describe('', () => { + describe('instances column', () => { + it('links to the user profile on the instance, not the admin list', async () => { + render( + } + title="Users" + userCounts={baseUserCounts} + users={[baseUser]} + />, + ); + await waitForElementToBeRemoved(() => screen.queryByRole('progressbar')); + + const link = screen.getByRole('link', { name: /Main Instance/ }); + expect(link).toHaveAttribute('href', '//main.coursemology.org/users/42'); + }); + }); +}); diff --git a/client/app/bundles/system/admin/instance/instance/components/buttons/PendingRoleRequestsButtons.tsx b/client/app/bundles/system/admin/instance/instance/components/buttons/PendingRoleRequestsButtons.tsx index f84819b2a4..b39e33e845 100644 --- a/client/app/bundles/system/admin/instance/instance/components/buttons/PendingRoleRequestsButtons.tsx +++ b/client/app/bundles/system/admin/instance/instance/components/buttons/PendingRoleRequestsButtons.tsx @@ -6,10 +6,10 @@ import { RoleRequestRowData } from 'types/system/instance/roleRequests'; import AcceptButton from 'lib/components/core/buttons/AcceptButton'; import DeleteButton from 'lib/components/core/buttons/DeleteButton'; import EmailButton from 'lib/components/core/buttons/EmailButton'; -import { ROLE_REQUEST_ROLES } from 'lib/constants/sharedConstants'; import { useAppDispatch } from 'lib/hooks/store'; import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; +import instanceRoleTranslations from 'lib/translations/instance/users/roles'; import { approveRoleRequest, rejectRoleRequest } from '../../operations'; import RejectWithMessageForm from '../forms/RejectWithMessageForm'; @@ -131,7 +131,7 @@ const PendingRoleRequestsButtons: FC = (props) => { = (props) => { loading={isDeleting} onClick={onDelete} title={t(translations.deletionConfirmTitle, { - role: USER_ROLES[user.role], + role: t(instanceRoleTranslations[user.role]), name: user.name, email: user.email, })} diff --git a/client/app/bundles/system/admin/instance/instance/components/forms/IndividualInvitation.tsx b/client/app/bundles/system/admin/instance/instance/components/forms/IndividualInvitation.tsx index 044ec194f2..2c3950f914 100644 --- a/client/app/bundles/system/admin/instance/instance/components/forms/IndividualInvitation.tsx +++ b/client/app/bundles/system/admin/instance/instance/components/forms/IndividualInvitation.tsx @@ -5,20 +5,22 @@ import { UseFieldArrayAppend, UseFieldArrayRemove, } from 'react-hook-form'; -import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; +import { defineMessages } from 'react-intl'; import { Close } from '@mui/icons-material'; import { Box, Grid, IconButton, Tooltip } from '@mui/material'; import { IndividualInvite, IndividualInvites, } from 'types/system/instance/invitations'; +import { INSTANCE_USER_ROLES } from 'types/system/instance/users'; import FormSelectField from 'lib/components/form/fields/SelectField'; import FormTextField from 'lib/components/form/fields/TextField'; -import { INSTANCE_USER_ROLES } from 'lib/constants/sharedConstants'; +import useTranslation from 'lib/hooks/useTranslation'; +import instanceRoleTranslations from 'lib/translations/instance/users/roles'; import tableTranslations from 'lib/translations/table'; -interface Props extends WrappedComponentProps { +interface Props { fieldsConfig: { control: Control; fields: IndividualInvite[]; @@ -43,13 +45,14 @@ const translations = defineMessages({ }, }); -const userRoleOptions = Object.keys(INSTANCE_USER_ROLES).map((roleValue) => ({ - label: INSTANCE_USER_ROLES[roleValue], - value: roleValue, -})); - const IndividualInvitation: FC = (props) => { - const { fieldsConfig, index, intl } = props; + const { fieldsConfig, index } = props; + const { t } = useTranslation(); + + const userRoleOptions = INSTANCE_USER_ROLES.map((roleValue) => ({ + label: t(instanceRoleTranslations[roleValue]), + value: roleValue, + })); const renderInvitationBody = ( @@ -62,8 +65,8 @@ const IndividualInvitation: FC = (props) => { fieldState={fieldState} fullWidth id={`name-${index}`} - label={intl.formatMessage(tableTranslations.name)} - placeholder={intl.formatMessage(translations.namePlaceholder)} + label={t(tableTranslations.name)} + placeholder={t(translations.namePlaceholder)} variant="standard" /> )} @@ -77,8 +80,8 @@ const IndividualInvitation: FC = (props) => { fieldState={fieldState} fullWidth id={`email-${index}`} - label={intl.formatMessage(tableTranslations.email)} - placeholder={intl.formatMessage(translations.emailPlaceholder)} + label={t(tableTranslations.email)} + placeholder={t(translations.emailPlaceholder)} variant="standard" /> )} @@ -90,7 +93,7 @@ const IndividualInvitation: FC = (props) => { )} @@ -101,7 +104,7 @@ const IndividualInvitation: FC = (props) => { return ( {renderInvitationBody} - + fieldsConfig.remove(index)} @@ -113,4 +116,4 @@ const IndividualInvitation: FC = (props) => { ); }; -export default injectIntl(IndividualInvitation); +export default IndividualInvitation; diff --git a/client/app/bundles/system/admin/instance/instance/components/tables/InstanceUserRoleRequestsTable.tsx b/client/app/bundles/system/admin/instance/instance/components/tables/InstanceUserRoleRequestsTable.tsx index 6f9a862d51..04d05a32b2 100644 --- a/client/app/bundles/system/admin/instance/instance/components/tables/InstanceUserRoleRequestsTable.tsx +++ b/client/app/bundles/system/admin/instance/instance/components/tables/InstanceUserRoleRequestsTable.tsx @@ -1,5 +1,5 @@ import { FC, memo, ReactElement, useMemo } from 'react'; -import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; +import { defineMessages } from 'react-intl'; import { MenuItem, TextField, Typography } from '@mui/material'; import equal from 'fast-deep-equal'; import { TableColumns, TableOptions } from 'types/components/DataTable'; @@ -7,16 +7,18 @@ import { RoleRequestMiniEntity, RoleRequestRowData, } from 'types/system/instance/roleRequests'; +import { INSTANCE_STAFF_ROLES } from 'types/system/instance/users'; import DataTable from 'lib/components/core/layouts/DataTable'; import Link from 'lib/components/core/Link'; import Note from 'lib/components/core/Note'; -import { ROLE_REQUEST_ROLES } from 'lib/constants/sharedConstants'; import rebuildObjectFromRow from 'lib/helpers/mui-datatables-helpers'; +import useTranslation from 'lib/hooks/useTranslation'; import { formatLongDateTime } from 'lib/moment'; +import instanceRoleTranslations from 'lib/translations/instance/users/roles'; import tableTranslations from 'lib/translations/table'; -interface Props extends WrappedComponentProps { +interface Props { title: string; roleRequests: RoleRequestMiniEntity[]; pendingRoleRequests?: boolean; @@ -52,18 +54,18 @@ const InstanceUserRoleRequestsTable: FC = (props) => { approvedRoleRequests = false, rejectedRoleRequests = false, renderRowActionComponent = null, - intl, } = props; + const { t } = useTranslation(); const requestTypePrefix: string = useMemo((): string => { if (approvedRoleRequests) { - return intl.formatMessage(translations.approved); + return t(translations.approved); } if (rejectedRoleRequests) { - return intl.formatMessage(translations.rejected); + return t(translations.rejected); } if (pendingRoleRequests) { - return intl.formatMessage(translations.pending); + return t(translations.pending); } return ''; }, [approvedRoleRequests, rejectedRoleRequests, pendingRoleRequests]); @@ -71,7 +73,7 @@ const InstanceUserRoleRequestsTable: FC = (props) => { if (roleRequests && roleRequests.length === 0) { return ( @@ -107,7 +109,7 @@ const InstanceUserRoleRequestsTable: FC = (props) => { const columns: TableColumns[] = [ { name: 'id', - label: intl.formatMessage(tableTranslations.id), + label: t(tableTranslations.id), options: { display: false, filter: false, @@ -116,7 +118,7 @@ const InstanceUserRoleRequestsTable: FC = (props) => { }, { name: 'name', - label: intl.formatMessage(tableTranslations.name), + label: t(tableTranslations.name), options: { alignCenter: false, customBodyRenderLite: (dataIndex): JSX.Element => { @@ -133,28 +135,28 @@ const InstanceUserRoleRequestsTable: FC = (props) => { }, { name: 'email', - label: intl.formatMessage(tableTranslations.email), + label: t(tableTranslations.email), options: { alignCenter: false, }, }, { name: 'organization', - label: intl.formatMessage(tableTranslations.organization), + label: t(tableTranslations.organization), options: { alignCenter: false, }, }, { name: 'designation', - label: intl.formatMessage(tableTranslations.designation), + label: t(tableTranslations.designation), options: { alignCenter: false, }, }, { name: 'reason', - label: intl.formatMessage(tableTranslations.reason), + label: t(tableTranslations.reason), options: { alignCenter: false, customBodyRenderLite: (dataIndex): JSX.Element => { @@ -174,14 +176,22 @@ const InstanceUserRoleRequestsTable: FC = (props) => { ...[ { name: 'role', - label: intl.formatMessage(tableTranslations.role), + label: t(tableTranslations.role), options: { alignCenter: false, + customBodyRenderLite: (dataIndex): JSX.Element => { + const roleRequest = roleRequests[dataIndex]; + return ( + + {t(instanceRoleTranslations[roleRequest.role])} + + ); + }, }, }, { name: 'createdAt', - label: intl.formatMessage(tableTranslations.requestedAt), + label: t(tableTranslations.requestedAt), options: { alignCenter: false, customBodyRenderLite: (dataIndex): JSX.Element => { @@ -196,14 +206,14 @@ const InstanceUserRoleRequestsTable: FC = (props) => { }, { name: 'confirmedBy', - label: intl.formatMessage(tableTranslations.approver), + label: t(tableTranslations.approver), options: { alignCenter: false, }, }, { name: 'confirmedAt', - label: intl.formatMessage(tableTranslations.approvedAt), + label: t(tableTranslations.approvedAt), options: { alignCenter: false, customBodyRenderLite: (dataIndex): JSX.Element => { @@ -226,7 +236,7 @@ const InstanceUserRoleRequestsTable: FC = (props) => { ...[ { name: 'role', - label: intl.formatMessage(tableTranslations.role), + label: t(tableTranslations.role), options: { alignCenter: false, customBodyRender: (value, tableMeta, updateValue): JSX.Element => { @@ -239,12 +249,12 @@ const InstanceUserRoleRequestsTable: FC = (props) => { value={value || 'normal'} variant="standard" > - {Object.keys(ROLE_REQUEST_ROLES).map((option) => ( + {INSTANCE_STAFF_ROLES.map((option) => ( - {ROLE_REQUEST_ROLES[option]} + {t(instanceRoleTranslations[option])} ))} @@ -254,7 +264,7 @@ const InstanceUserRoleRequestsTable: FC = (props) => { }, { name: 'createdAt', - label: intl.formatMessage(tableTranslations.requestedAt), + label: t(tableTranslations.requestedAt), options: { alignCenter: false, customBodyRenderLite: (dataIndex): JSX.Element => { @@ -271,7 +281,7 @@ const InstanceUserRoleRequestsTable: FC = (props) => { ? [ { name: 'actions', - label: intl.formatMessage(tableTranslations.actions), + label: t(tableTranslations.actions), options: { empty: true, sort: false, @@ -294,7 +304,7 @@ const InstanceUserRoleRequestsTable: FC = (props) => { ...[ { name: 'createdAt', - label: intl.formatMessage(tableTranslations.requestedAt), + label: t(tableTranslations.requestedAt), options: { alignCenter: false, customBodyRenderLite: (dataIndex): JSX.Element => { @@ -309,14 +319,14 @@ const InstanceUserRoleRequestsTable: FC = (props) => { }, { name: 'confirmedBy', - label: intl.formatMessage(tableTranslations.rejector), + label: t(tableTranslations.rejector), options: { alignCenter: false, }, }, { name: 'confirmedAt', - label: intl.formatMessage(tableTranslations.rejectedAt), + label: t(tableTranslations.rejectedAt), options: { alignCenter: false, customBodyRenderLite: (dataIndex): JSX.Element => { @@ -334,7 +344,7 @@ const InstanceUserRoleRequestsTable: FC = (props) => { }, { name: 'rejectionMessage', - label: intl.formatMessage(tableTranslations.rejectionMessage), + label: t(tableTranslations.rejectionMessage), options: { alignCenter: false, }, @@ -355,9 +365,6 @@ const InstanceUserRoleRequestsTable: FC = (props) => { ); }; -export default memo( - injectIntl(InstanceUserRoleRequestsTable), - (prevProps, nextProps) => { - return equal(prevProps.roleRequests, nextProps.roleRequests); - }, -); +export default memo(InstanceUserRoleRequestsTable, (prevProps, nextProps) => { + return equal(prevProps.roleRequests, nextProps.roleRequests); +}); diff --git a/client/app/bundles/system/admin/instance/instance/components/tables/InvitationResultInvitationsTable.tsx b/client/app/bundles/system/admin/instance/instance/components/tables/InvitationResultInvitationsTable.tsx index dc60edb7d6..6b7f852330 100644 --- a/client/app/bundles/system/admin/instance/instance/components/tables/InvitationResultInvitationsTable.tsx +++ b/client/app/bundles/system/admin/instance/instance/components/tables/InvitationResultInvitationsTable.tsx @@ -1,20 +1,21 @@ import { FC } from 'react'; -import { injectIntl, WrappedComponentProps } from 'react-intl'; import { Typography } from '@mui/material'; import { TableColumns, TableOptions } from 'types/components/DataTable'; import { InvitationListData } from 'types/system/instance/invitations'; import DataTable from 'lib/components/core/layouts/DataTable'; -import { INSTANCE_USER_ROLES } from 'lib/constants/sharedConstants'; +import useTranslation from 'lib/hooks/useTranslation'; +import instanceRoleTranslations from 'lib/translations/instance/users/roles'; import tableTranslations from 'lib/translations/table'; -interface Props extends WrappedComponentProps { +interface Props { title: JSX.Element; invitations: InvitationListData[]; } const InvitationResultInvitationsTable: FC = (props) => { - const { title, invitations, intl } = props; + const { title, invitations } = props; + const { t } = useTranslation(); const options: TableOptions = { download: true, @@ -41,7 +42,7 @@ const InvitationResultInvitationsTable: FC = (props) => { const columns: TableColumns[] = [ { name: 'id', - label: intl.formatMessage(tableTranslations.id), + label: t(tableTranslations.id), options: { display: false, filter: false, @@ -50,7 +51,7 @@ const InvitationResultInvitationsTable: FC = (props) => { }, { name: 'name', - label: intl.formatMessage(tableTranslations.name), + label: t(tableTranslations.name), options: { alignCenter: false, sort: false, @@ -58,7 +59,7 @@ const InvitationResultInvitationsTable: FC = (props) => { }, { name: 'email', - label: intl.formatMessage(tableTranslations.email), + label: t(tableTranslations.email), options: { alignCenter: false, sort: false, @@ -66,7 +67,7 @@ const InvitationResultInvitationsTable: FC = (props) => { }, { name: 'role', - label: intl.formatMessage(tableTranslations.role), + label: t(tableTranslations.role), options: { alignCenter: false, sort: false, @@ -78,7 +79,7 @@ const InvitationResultInvitationsTable: FC = (props) => { className="invitation_result_invitation_role" variant="body2" > - {INSTANCE_USER_ROLES[invitation.role]} + {t(instanceRoleTranslations[invitation.role])} ); }, @@ -86,7 +87,7 @@ const InvitationResultInvitationsTable: FC = (props) => { }, { name: 'sentAt', - label: intl.formatMessage(tableTranslations.invitationSentAt), + label: t(tableTranslations.invitationSentAt), options: { alignCenter: false, sort: false, @@ -106,4 +107,4 @@ const InvitationResultInvitationsTable: FC = (props) => { ); }; -export default injectIntl(InvitationResultInvitationsTable); +export default InvitationResultInvitationsTable; diff --git a/client/app/bundles/system/admin/instance/instance/components/tables/InvitationResultUsersTable.tsx b/client/app/bundles/system/admin/instance/instance/components/tables/InvitationResultUsersTable.tsx index 96424cf0cd..899bc59c3d 100644 --- a/client/app/bundles/system/admin/instance/instance/components/tables/InvitationResultUsersTable.tsx +++ b/client/app/bundles/system/admin/instance/instance/components/tables/InvitationResultUsersTable.tsx @@ -1,20 +1,21 @@ import { FC } from 'react'; -import { injectIntl, WrappedComponentProps } from 'react-intl'; import { Typography } from '@mui/material'; import { TableColumns, TableOptions } from 'types/components/DataTable'; import { InstanceUserListData } from 'types/system/instance/users'; import DataTable from 'lib/components/core/layouts/DataTable'; -import { INSTANCE_USER_ROLES } from 'lib/constants/sharedConstants'; +import useTranslation from 'lib/hooks/useTranslation'; +import instanceRoleTranslations from 'lib/translations/instance/users/roles'; import tableTranslations from 'lib/translations/table'; -interface Props extends WrappedComponentProps { +interface Props { title: JSX.Element; users: InstanceUserListData[]; } const InvitationResultUsersTable: FC = (props) => { - const { title, users, intl } = props; + const { title, users } = props; + const { t } = useTranslation(); const options: TableOptions = { download: true, @@ -41,7 +42,7 @@ const InvitationResultUsersTable: FC = (props) => { const columns: TableColumns[] = [ { name: 'id', - label: intl.formatMessage(tableTranslations.id), + label: t(tableTranslations.id), options: { display: false, filter: false, @@ -50,7 +51,7 @@ const InvitationResultUsersTable: FC = (props) => { }, { name: 'name', - label: intl.formatMessage(tableTranslations.name), + label: t(tableTranslations.name), options: { alignCenter: false, sort: false, @@ -58,7 +59,7 @@ const InvitationResultUsersTable: FC = (props) => { }, { name: 'email', - label: intl.formatMessage(tableTranslations.email), + label: t(tableTranslations.email), options: { alignCenter: false, sort: false, @@ -66,7 +67,7 @@ const InvitationResultUsersTable: FC = (props) => { }, { name: 'role', - label: intl.formatMessage(tableTranslations.role), + label: t(tableTranslations.role), options: { alignCenter: false, sort: false, @@ -78,7 +79,7 @@ const InvitationResultUsersTable: FC = (props) => { className="invitation_result_user_role" variant="body2" > - {INSTANCE_USER_ROLES[user.role]} + {t(instanceRoleTranslations[user.role])} ); }, @@ -98,4 +99,4 @@ const InvitationResultUsersTable: FC = (props) => { ); }; -export default injectIntl(InvitationResultUsersTable); +export default InvitationResultUsersTable; diff --git a/client/app/bundles/system/admin/instance/instance/components/tables/UserInvitationsTable.tsx b/client/app/bundles/system/admin/instance/instance/components/tables/UserInvitationsTable.tsx index facbacd3cf..1cfdb9ce73 100644 --- a/client/app/bundles/system/admin/instance/instance/components/tables/UserInvitationsTable.tsx +++ b/client/app/bundles/system/admin/instance/instance/components/tables/UserInvitationsTable.tsx @@ -8,9 +8,9 @@ import { InvitationMiniEntity } from 'types/system/instance/invitations'; import Note from 'lib/components/core/Note'; import { ColumnTemplate } from 'lib/components/table'; import Table from 'lib/components/table/Table'; -import { INSTANCE_USER_ROLES } from 'lib/constants/sharedConstants'; import useTranslation from 'lib/hooks/useTranslation'; import { formatMiniDateTime } from 'lib/moment'; +import instanceRoleTranslations from 'lib/translations/instance/users/roles'; import tableTranslations from 'lib/translations/table'; import InvitationActionButtons from '../buttons/InvitationActionButtons'; @@ -177,7 +177,7 @@ const UserInvitationsTable: FC = (props) => { of: 'role', title: t(tableTranslations.role), sortable: true, - cell: (datum) => INSTANCE_USER_ROLES[datum.role], + cell: (datum) => t(instanceRoleTranslations[datum.role]), }, { id: 'status', diff --git a/client/app/bundles/system/admin/instance/instance/components/tables/UsersTable.tsx b/client/app/bundles/system/admin/instance/instance/components/tables/UsersTable.tsx index 4ec5806f04..ad7e396768 100644 --- a/client/app/bundles/system/admin/instance/instance/components/tables/UsersTable.tsx +++ b/client/app/bundles/system/admin/instance/instance/components/tables/UsersTable.tsx @@ -13,6 +13,7 @@ import { TableState, } from 'types/components/DataTable'; import { + INSTANCE_USER_ROLES, InstanceAdminStats, InstanceUserMiniEntity, InstanceUserRoles, @@ -23,12 +24,12 @@ import Link from 'lib/components/core/Link'; import { DEFAULT_TABLE_ROWS_PER_PAGE, FIELD_DEBOUNCE_DELAY_MS, - INSTANCE_USER_ROLES, } from 'lib/constants/sharedConstants'; import rebuildObjectFromRow from 'lib/helpers/mui-datatables-helpers'; import { useAppDispatch } from 'lib/hooks/store'; import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; +import instanceRoleTranslations from 'lib/translations/instance/users/roles'; import tableTranslations from 'lib/translations/table'; import { indexUsers, updateUser } from '../../operations'; @@ -99,7 +100,7 @@ const UsersTable: FC = (props) => { toast.success( t(translations.changeRoleSuccess, { name: user.name, - role: INSTANCE_USER_ROLES[newRole], + role: t(instanceRoleTranslations[newRole as InstanceUserRoles]), }), ); }) @@ -277,13 +278,13 @@ const UsersTable: FC = (props) => { value={value} variant="standard" > - {Object.keys(INSTANCE_USER_ROLES).map((option) => ( + {INSTANCE_USER_ROLES.map((option) => ( - {INSTANCE_USER_ROLES[option]} + {t(instanceRoleTranslations[option])} ))} diff --git a/client/app/bundles/system/admin/instance/instance/components/tables/__test__/InvitationResultUsersTable.test.tsx b/client/app/bundles/system/admin/instance/instance/components/tables/__test__/InvitationResultUsersTable.test.tsx new file mode 100644 index 0000000000..d661898658 --- /dev/null +++ b/client/app/bundles/system/admin/instance/instance/components/tables/__test__/InvitationResultUsersTable.test.tsx @@ -0,0 +1,52 @@ +import { render, screen, waitForElementToBeRemoved } from 'test-utils'; + +import InvitationResultUsersTable from '../InvitationResultUsersTable'; + +const baseUser = { + id: 1, + userId: '5', + name: 'Alice', + email: 'alice@example.org', + role: 'administrator' as const, + courses: [], +}; + +describe('', () => { + describe('role column', () => { + it('renders the translated role label for an administrator', async () => { + render( + Invited Users} + users={[baseUser]} + />, + ); + await waitForElementToBeRemoved(() => screen.queryByRole('progressbar')); + + expect(screen.getByText('Administrator')).toBeInTheDocument(); + }); + + it('renders the translated role label for an instructor', async () => { + render( + Invited Users} + users={[{ ...baseUser, role: 'instructor' as const }]} + />, + ); + await waitForElementToBeRemoved(() => screen.queryByRole('progressbar')); + + expect(screen.getByText('Instructor')).toBeInTheDocument(); + }); + + it('renders the translated role label for a normal user', async () => { + render( + Invited Users} + users={[{ ...baseUser, role: 'normal' as const }]} + />, + ); + await waitForElementToBeRemoved(() => screen.queryByRole('progressbar')); + + expect(screen.getByText('Normal')).toBeInTheDocument(); + }); + }); +}); diff --git a/client/app/bundles/users/components/tables/InstancesTable.tsx b/client/app/bundles/users/components/tables/InstancesTable.tsx index e607a692ec..9c33ac11cf 100644 --- a/client/app/bundles/users/components/tables/InstancesTable.tsx +++ b/client/app/bundles/users/components/tables/InstancesTable.tsx @@ -1,5 +1,4 @@ import { FC } from 'react'; -import { injectIntl, WrappedComponentProps } from 'react-intl'; import { useParams } from 'react-router-dom'; import { Box, @@ -13,15 +12,17 @@ import { import { InstanceBasicMiniEntity } from 'types/system/instances'; import Link from 'lib/components/core/Link'; -import { INSTANCE_USER_ROLES } from 'lib/constants/sharedConstants'; +import useTranslation from 'lib/hooks/useTranslation'; +import instanceRoleTranslations from 'lib/translations/instance/users/roles'; import tableTranslations from 'lib/translations/table'; -interface Props extends WrappedComponentProps { +interface Props { title: string; instances: InstanceBasicMiniEntity[]; } -const InstancesTable: FC = ({ title, instances, intl }: Props) => { +const InstancesTable: FC = ({ title, instances }: Props) => { + const { t } = useTranslation(); const { userId } = useParams(); return ( @@ -30,10 +31,8 @@ const InstancesTable: FC = ({ title, instances, intl }: Props) => { - - {intl.formatMessage(tableTranslations.instance)} - - {intl.formatMessage(tableTranslations.role)} + {t(tableTranslations.instance)} + {t(tableTranslations.role)} @@ -52,7 +51,7 @@ const InstancesTable: FC = ({ title, instances, intl }: Props) => { {instance.instanceRole - ? INSTANCE_USER_ROLES[instance.instanceRole] + ? t(instanceRoleTranslations[instance.instanceRole]) : '-'} @@ -64,4 +63,4 @@ const InstancesTable: FC = ({ title, instances, intl }: Props) => { ); }; -export default injectIntl(InstancesTable); +export default InstancesTable; diff --git a/client/app/bundles/users/pages/UserShow.tsx b/client/app/bundles/users/pages/UserShow.tsx index ba4f72dd7c..b4c3f07180 100644 --- a/client/app/bundles/users/pages/UserShow.tsx +++ b/client/app/bundles/users/pages/UserShow.tsx @@ -1,12 +1,13 @@ import { FC, useEffect, useState } from 'react'; -import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; +import { defineMessages } from 'react-intl'; import { useParams } from 'react-router-dom'; import { Avatar, Grid, Typography } from '@mui/material'; import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import { INSTANCE_USER_ROLES } from 'lib/constants/sharedConstants'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; +import instanceRoleTranslations from 'lib/translations/instance/users/roles'; import CoursesTable from '../components/tables/CoursesTable'; import InstancesTable from '../components/tables/InstancesTable'; @@ -18,8 +19,6 @@ import { getUserEntity, } from '../selectors'; -interface Props extends WrappedComponentProps {} - const translations = defineMessages({ currentCourses: { id: 'users.UserShow.currentCourses', @@ -42,8 +41,9 @@ const styles = { }, }; -const UserShow: FC = (props) => { - const { intl } = props; +const UserShow: FC = () => { + const { t } = useTranslation(); + const [isLoading, setIsLoading] = useState(true); const { userId } = useParams(); @@ -98,13 +98,13 @@ const UserShow: FC = (props) => { item > {user.name} - - - {user.instanceRole - ? INSTANCE_USER_ROLES[user.instanceRole] - : '-'} - - + {user.instanceRole && ( + + + {t(instanceRoleTranslations[user.instanceRole])} + + + )} @@ -112,24 +112,24 @@ const UserShow: FC = (props) => { )} {completedCourses.length > 0 && ( )} {instances.length > 0 && ( )} ); }; -export default injectIntl(UserShow); +export default UserShow; diff --git a/client/app/bundles/users/pages/__test__/UserShow.test.tsx b/client/app/bundles/users/pages/__test__/UserShow.test.tsx index 4f1babcd06..67367836bf 100644 --- a/client/app/bundles/users/pages/__test__/UserShow.test.tsx +++ b/client/app/bundles/users/pages/__test__/UserShow.test.tsx @@ -61,7 +61,7 @@ describe('', () => { expect(screen.getByText('Normal')).toBeInTheDocument(); }); - it('displays a dash when instanceRole is absent', async () => { + it('renders no role element when instanceRole is absent', async () => { mock.onGet('/users/3').reply(200, { user: baseUser, currentCourses: [], @@ -74,7 +74,7 @@ describe('', () => { await waitFor(() => expect(screen.getByText('Caitlyn')).toBeInTheDocument(), ); - expect(screen.getByText('-')).toBeInTheDocument(); + expect(screen.queryByTestId('instance-role')).not.toBeInTheDocument(); }); }); }); diff --git a/client/app/lib/constants/sharedConstants.ts b/client/app/lib/constants/sharedConstants.ts index 00ae7ed3de..e1f1c9ee39 100644 --- a/client/app/lib/constants/sharedConstants.ts +++ b/client/app/lib/constants/sharedConstants.ts @@ -1,8 +1,4 @@ -import { - InstanceUserRoles, - RoleRequestRoles, -} from 'types/system/instance/users'; -import type { Locale, UserRoles } from 'types/users'; +import type { Locale } from 'types/users'; import mirrorCreator from 'utilities/mirrorCreator'; // Form options @@ -22,22 +18,6 @@ export const TIMELINE_ALGORITHMS = [ { value: 'otot', label: 'Otot' }, ]; -export const USER_ROLES: Record = { - normal: 'Normal', - administrator: 'Administrator', -}; - -export const INSTANCE_USER_ROLES: Record = { - normal: 'Normal', - instructor: 'Instructor', - administrator: 'Administrator', -}; - -export const ROLE_REQUEST_ROLES: Record = { - instructor: 'Instructor', - administrator: 'Administrator', -}; - export const AVAILABLE_LOCALES: { [key in Locale]: string } = { en: 'English', zh: '中文', @@ -79,9 +59,6 @@ export const ASSESSMENT_SIMILARITY_WORKFLOW_STATE = mirrorCreator([ export default { TIMELINE_ALGORITHMS, - USER_ROLES, - INSTANCE_USER_ROLES, - ROLE_REQUEST_ROLES, AVAILABLE_LOCALES, }; diff --git a/client/app/lib/translations/instance/users/roles.ts b/client/app/lib/translations/instance/users/roles.ts new file mode 100644 index 0000000000..e1117161eb --- /dev/null +++ b/client/app/lib/translations/instance/users/roles.ts @@ -0,0 +1,18 @@ +import { defineMessages } from 'react-intl'; + +const translations = defineMessages({ + normal: { + id: 'lib.translations.instance.users.roles.normal', + defaultMessage: 'Normal', + }, + instructor: { + id: 'lib.translations.instance.users.roles.instructor', + defaultMessage: 'Instructor', + }, + administrator: { + id: 'lib.translations.instance.users.roles.administrator', + defaultMessage: 'Administrator', + }, +}); + +export default translations; diff --git a/client/app/types/home.ts b/client/app/types/home.ts index 59713c2fd4..4b8cc7236e 100644 --- a/client/app/types/home.ts +++ b/client/app/types/home.ts @@ -1,5 +1,5 @@ import { InstanceUserRoles } from './system/instance/users'; -import { UserRoles } from './users'; +import { UserSystemRoles } from './users'; interface HomeLayoutUserData { id: number; @@ -7,7 +7,7 @@ interface HomeLayoutUserData { primaryEmail: string; url: string; avatarUrl: string; - role: UserRoles; + role: UserSystemRoles; instanceRole: InstanceUserRoles; canCreateNewCourse: boolean; } diff --git a/client/app/types/system/instance/users.ts b/client/app/types/system/instance/users.ts index b2225ab265..37f5704611 100644 --- a/client/app/types/system/instance/users.ts +++ b/client/app/types/system/instance/users.ts @@ -1,6 +1,10 @@ -export type InstanceUserRoles = 'normal' | 'administrator' | 'instructor'; +export const INSTANCE_STAFF_ROLES = ['instructor', 'administrator'] as const; -export type RoleRequestRoles = Exclude; +export const INSTANCE_USER_ROLES = ['normal', ...INSTANCE_STAFF_ROLES] as const; + +export type InstanceUserRoles = (typeof INSTANCE_USER_ROLES)[number]; + +export type RoleRequestRoles = (typeof INSTANCE_STAFF_ROLES)[number]; export interface InstanceUserListData { id: number; diff --git a/client/app/types/users.ts b/client/app/types/users.ts index bbc36e0b8a..73a5513c8c 100644 --- a/client/app/types/users.ts +++ b/client/app/types/users.ts @@ -2,7 +2,9 @@ import { CourseUserRole } from './course/courseUsers'; import { EnrolRequestListData } from './course/enrolRequests'; import { InstanceUserRoles } from './system/instance/users'; -export type UserRoles = 'normal' | 'administrator'; +export const USER_SYSTEM_ROLES = ['normal', 'administrator'] as const; + +export type UserSystemRoles = (typeof USER_SYSTEM_ROLES)[number]; export interface UserBasicListData { id: number; @@ -22,7 +24,7 @@ export interface UserListData { title: string; }[]; }[]; - role: UserRoles; + role: UserSystemRoles; } export interface UserBasicMiniEntity { @@ -42,7 +44,7 @@ export interface UserMiniEntity extends UserBasicMiniEntity { title: string; }[]; }[]; - role: UserRoles; + role: UserSystemRoles; } export interface UserData extends UserBasicMiniEntity { diff --git a/client/locales/en.json b/client/locales/en.json index 9010ae111c..857c8288eb 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -8001,6 +8001,15 @@ "lib.translations.form.validation.startEndDateValidationError": { "defaultMessage": "Must be after Start Date" }, + "lib.translations.instance.users.roles.normal": { + "defaultMessage": "Normal" + }, + "lib.translations.instance.users.roles.instructor": { + "defaultMessage": "Instructor" + }, + "lib.translations.instance.users.roles.administrator": { + "defaultMessage": "Administrator" + }, "lib.translations.messages.fetchingError": { "defaultMessage": "An error occurred when loading your data. Please reload and try again." }, diff --git a/client/locales/ko.json b/client/locales/ko.json index 1c765561a6..e83fec9896 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -7955,6 +7955,15 @@ "lib.translations.form.messages.unsavedChanges": { "defaultMessage": "저장되지 않은 변경 사항이 있습니다." }, + "lib.translations.instance.users.roles.normal": { + "defaultMessage": "일반 사용자" + }, + "lib.translations.instance.users.roles.instructor": { + "defaultMessage": "교사" + }, + "lib.translations.instance.users.roles.administrator": { + "defaultMessage": "관리자" + }, "lib.translations.myStudentsIncludingPhantoms": { "defaultMessage": "내 학생들 (팬텀 포함)" }, diff --git a/client/locales/zh.json b/client/locales/zh.json index cf919268bb..d97404f2ba 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -7985,6 +7985,15 @@ "lib.translations.form.validation.startEndDateValidationError": { "defaultMessage": "必须在开始日期之后" }, + "lib.translations.instance.users.roles.normal": { + "defaultMessage": "普通用户" + }, + "lib.translations.instance.users.roles.instructor": { + "defaultMessage": "教学导师" + }, + "lib.translations.instance.users.roles.administrator": { + "defaultMessage": "平台管理员" + }, "lib.translations.messages.fetchingError": { "defaultMessage": "加载数据时出错。请重新加载并重试。" }, diff --git a/spec/controllers/course/users_controller_spec.rb b/spec/controllers/course/users_controller_spec.rb index 8b0b26ce0e..33acde7f24 100644 --- a/spec/controllers/course/users_controller_spec.rb +++ b/spec/controllers/course/users_controller_spec.rb @@ -396,6 +396,16 @@ end end + context 'when the viewer is a teaching assistant' do + let!(:viewer) { create(:course_teaching_assistant, course: course, user: user) } + + it 'includes userId in the response' do + subject + user_data = JSON.parse(response.body)['user'] + expect(user_data['userId']).to eq(student.user_id) + end + end + context 'when the viewer is an instance administrator' do let(:user) { create(:instance_administrator, instance: instance).user } @@ -406,6 +416,16 @@ end end + context 'when the viewer is an observer' do + let!(:viewer) { create(:course_observer, course: course, user: user) } + + it 'includes userId in the response' do + subject + user_data = JSON.parse(response.body)['user'] + expect(user_data['userId']).to eq(student.user_id) + end + end + context 'when the viewer is a student' do let!(:viewer) { create(:course_student, course: course, user: user) } diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 09623808fa..0da7a4f897 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -72,6 +72,29 @@ end end end + + context 'when the viewer is a global administrator' do + let(:other_instance) { create(:instance) } + let(:target_user) { create(:user) } + + before do + user.update!(role: :administrator) + target_user # force creation within tenant context before leaving it + ActsAsTenant.without_tenant do + create(:instance_user, instance: other_instance, user: target_user, role: :instructor) + end + end + + subject { get :show, as: :json, params: { id: target_user.id } } + + it 'includes the instances block with the correct instanceRole in the JSON' do + subject + json = JSON.parse(response.body, symbolize_names: true) + expect(json[:instances]).to be_an(Array) + expect(json[:instances].length).to eq(1) + expect(json[:instances].first[:instanceRole]).to eq('instructor') + end + end end end end