Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ def show
course_users = @user.course_users.with_course_statistics.from_instance(current_tenant)
@current_courses = course_users.merge(Course.current).order(created_at: :desc)
@completed_courses = course_users.merge(Course.completed).order(created_at: :desc)
@instance_user = current_tenant.instance_users.find_by(user: @user)
@instances = other_instances
end
end
Expand All @@ -17,6 +18,11 @@ def show

def other_instances
tenant = current_tenant
ActsAsTenant.without_tenant { Instance.containing_user(@user) - [tenant] }
ActsAsTenant.without_tenant do
@user.instance_users.
includes(:instance).
where.not(instance_id: tenant.id).
to_a
end
end
end
1 change: 1 addition & 0 deletions app/views/course/users/_user_list_data.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ should_show_timeline ||= false
should_show_phantom ||= false

json.id course_user.id if course_user.id
json.userId course_user.user_id if current_course_user&.staff? || current_user&.administrator?
json.name course_user.name.strip
json.imageUrl user_image(course_user.user)
json.email course_user.user.primary_email&.email || course_user.user.email
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true
json.id role_request.id
json.userId role_request.user.id
json.name role_request.user.name
json.email role_request.user.email
json.organization role_request.organization
Expand Down
7 changes: 4 additions & 3 deletions app/views/users/_instance_list_data.json.jbuilder
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# frozen_string_literal: true
json.id instance.id
json.name instance.name
json.host instance.host
json.id instance_user.instance.id
json.name instance_user.instance.name
json.host instance_user.instance.host
json.instanceRole instance_user.role
5 changes: 3 additions & 2 deletions app/views/users/show.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ json.user do
json.id @user.id
json.name @user.name.strip
json.imageUrl user_image(@user)
json.instanceRole @instance_user&.role
end

if @current_courses.any?
Expand All @@ -18,7 +19,7 @@ if @completed_courses.any?
end

if current_user&.administrator? && @instances.any?
json.instances @instances.each do |instance|
json.partial! 'instance_list_data', instance: instance
json.instances @instances.each do |instance_user|
json.partial! 'instance_list_data', instance_user: instance_user
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,13 @@ const UserProfileCard: FC<Props> = ({ user }) => {
direction="column"
item
>
<Typography variant="h5">{user.name}</Typography>
{user.userId ? (
<Link to={`/users/${user.userId}`}>
<Typography variant="h5">{user.name}</Typography>
</Link>
) : (
<Typography variant="h5">{user.name}</Typography>
)}
<Typography>
<strong>{t(roleTranslations[user.role])}</strong>
</Typography>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { render, screen, waitForElementToBeRemoved } from 'test-utils';

import UserProfileCard from '../UserProfileCard';

const baseUser = {
id: 2,
name: 'test',
email: 'test@example.org',
role: 'student' as const,
level: 0,
exp: 0,
isSuspended: false,
canReadStatistics: false,
referenceTimelineId: null,
};

describe('<UserProfileCard />', () => {
describe('when the viewer is staff (userId present)', () => {
it('renders the name as a link to the global user profile', async () => {
render(<UserProfileCard user={{ ...baseUser, userId: 3 }} />);
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));

const link = screen.getByRole('link', { name: 'test' });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', '/users/3');
});
});

describe('when the viewer is a student (userId absent)', () => {
it('renders the name as plain text without a link', async () => {
render(<UserProfileCard user={baseUser} />);
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));

expect(screen.getByText('test')).toBeInTheDocument();
expect(
screen.queryByRole('link', { name: 'test' }),
).not.toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -89,7 +89,7 @@ const UserManagementButtons: 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,
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,20 @@ import {
TableOptions,
TableState,
} from 'types/components/DataTable';
import { AdminStats, UserMiniEntity, UserRoles } from 'types/users';
import { AdminStats, USER_ROLES, UserMiniEntity, UserRoles } from 'types/users';

import DataTable from 'lib/components/core/layouts/DataTable';
import Link from 'lib/components/core/Link';
import InlineEditTextField from 'lib/components/form/fields/DataTableInlineEditable/TextField';
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';
Expand Down Expand Up @@ -125,7 +125,7 @@ const UsersTable: FC<Props> = (props) => {
toast.success(
t(translations.changeRoleSuccess, {
name: user.name,
role: USER_ROLES[newRole],
role: t(instanceRoleTranslations[newRole as UserRoles]),
}),
);
})
Expand Down Expand Up @@ -280,7 +280,7 @@ const UsersTable: FC<Props> = (props) => {
{user.instances.map((instance) => (
<li key={instance.name} className="list-none">
<Link
href={`//${instance.host}/admin/users`}
href={`//${instance.host}/users/${user.id}`}
underline="hover"
>
{t(translations.userInstanceEntry, {
Expand Down Expand Up @@ -315,13 +315,14 @@ const UsersTable: FC<Props> = (props) => {
value={value}
variant="standard"
>
{Object.keys(USER_ROLES).map((option) => (
{/* UserRoles ('normal' | 'administrator') is a subset of InstanceUserRoles */}
{USER_ROLES.map((option) => (
<MenuItem
key={`role-${userId}-${option}`}
id={`role-${userId}-${option}`}
value={option}
>
{USER_ROLES[option]}
{t(instanceRoleTranslations[option])}
</MenuItem>
))}
</TextField>
Expand Down
Original file line number Diff line number Diff line change
@@ -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('<UsersTable />', () => {
describe('instances column', () => {
it('links to the user profile on the instance, not the admin list', async () => {
render(
<UsersTable
filter={{ active: true, role: 'normal' }}
renderRowActionComponent={() => <span />}
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');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -131,7 +131,7 @@ const PendingRoleRequestsButtons: FC<Props> = (props) => {
<DeleteButton
className={`role-request-reject-${roleRequest.id} p-0`}
confirmMessage={t(translations.rejectConfirm, {
role: ROLE_REQUEST_ROLES[roleRequest.role!],
role: t(instanceRoleTranslations[roleRequest.role!]),
name: roleRequest.name,
email: roleRequest.email,
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { InstanceUserMiniEntity } from 'types/system/instance/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';

Expand Down Expand Up @@ -78,7 +78,7 @@ const UserManagementButtons: 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,
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IndividualInvites>;
fields: IndividualInvite[];
Expand All @@ -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> = (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 = (
<Grid alignItems="center" container flexWrap="nowrap">
Expand All @@ -62,8 +65,8 @@ const IndividualInvitation: FC<Props> = (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"
/>
)}
Expand All @@ -77,8 +80,8 @@ const IndividualInvitation: FC<Props> = (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"
/>
)}
Expand All @@ -90,7 +93,7 @@ const IndividualInvitation: FC<Props> = (props) => {
<FormSelectField
field={field}
fieldState={fieldState}
label={intl.formatMessage(tableTranslations.role)}
label={t(tableTranslations.role)}
options={userRoleOptions}
/>
)}
Expand All @@ -101,7 +104,7 @@ const IndividualInvitation: FC<Props> = (props) => {
return (
<Box key={index} className="flex items-center justify-start">
{renderInvitationBody}
<Tooltip title={intl.formatMessage(translations.removeInvitation)}>
<Tooltip title={t(translations.removeInvitation)}>
<IconButton
className="p-3"
onClick={(): void => fieldsConfig.remove(index)}
Expand All @@ -113,4 +116,4 @@ const IndividualInvitation: FC<Props> = (props) => {
);
};

export default injectIntl(IndividualInvitation);
export default IndividualInvitation;
Loading