Skip to content
Merged
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
12 changes: 10 additions & 2 deletions app/controllers/system/admin/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ def index
format.json do
load_users
load_counts
@instances_preload_service = User::InstancePreloadService.new(@users.map(&:id))
user_ids = @users.map(&:id)
@instances_preload_service = User::InstancePreloadService.new(user_ids)
@user_course_hash = get_user_course_hash(user_ids)
end
end
end
Expand All @@ -16,7 +18,7 @@ def update
@instances_preload_service = User::InstancePreloadService.new(@user.id)
if @user.update(user_params)
render 'system/admin/users/_user_list_data',
locals: { user: @user },
locals: { user: @user, course_users: get_user_course_hash([@user.id]).fetch(@user.id, []) },
status: :ok
else
render json: { errors: @user.errors.full_messages.to_sentence }, status: :bad_request
Expand All @@ -42,6 +44,12 @@ def destroy

private

def get_user_course_hash(user_ids)
ActsAsTenant.without_tenant do
CourseUser.includes(:course).where(user_id: user_ids).group_by(&:user_id)
end
end

def user_params
params.require(:user).permit(:name, :role)
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ json.userId instance_user.user.id
json.name instance_user.user.name
json.email instance_user.user.email
json.role instance_user.role
json.courses instance_user.user.courses.count
json.courses instance_user.user.courses.each do |course|
json.id course.id
json.title course.title
end
6 changes: 6 additions & 0 deletions app/views/system/admin/users/_user_list_data.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@
json.id user.id
json.name user.name
json.email user.email

courses_by_instance = course_users.group_by { |cu| cu.course.instance_id }
json.instances @instances_preload_service.instances_for(user.id)&.each do |instance|
json.name instance.name
json.host instance.host
json.courses courses_by_instance.fetch(instance.id, []) do |course_user|
json.id course_user.course.id
json.title course_user.course.title
end
end
json.role user.role
2 changes: 1 addition & 1 deletion app/views/system/admin/users/index.json.jbuilder
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# frozen_string_literal: true
json.users @users.each do |user|
json.partial! 'user_list_data', user: user
json.partial! 'user_list_data', user: user, course_users: @user_course_hash.fetch(user.id, [])
end

json.counts do
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FC, memo, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
import { defineMessages } from 'react-intl';
import equal from 'fast-deep-equal';
import { UserMiniEntity } from 'types/users';

Expand All @@ -12,7 +12,7 @@ import useTranslation from 'lib/hooks/useTranslation';

import { deleteUser } from '../../operations';

interface Props extends WrappedComponentProps {
interface Props {
user: UserMiniEntity;
}

Expand All @@ -27,20 +27,20 @@ const translations = defineMessages({
},
deletionConfirmTitle: {
id: 'system.admin.admin.UsersButton.deletionConfirmTitle',
defaultMessage: 'Deleting {role} {name} ({email})',
defaultMessage: 'Deleting {role} User {name} ({email})',
},
deletionPromptContent: {
id: 'system.admin.admin.UsersButton.deletionPromptContent',
defaultMessage:
'After deleting this user, all associated instance users in the following instances will be deleted.',
'Deleting this user will PERMANENTLY delete associated data in the following {count, plural, one {course} other {courses}}:',
},
associatedInstances: {
id: 'system.admin.admin.UsersButton.associatedInstances',
defaultMessage: '{index}. {instanceName}',
associatedCourses: {
id: 'system.admin.admin.UsersButton.associatedCourses',
defaultMessage: '{courseName} ({instanceName})',
},
deletionConfirm: {
id: 'system.admin.admin.UsersButton.deletionConfirm',
defaultMessage: 'Are you sure?',
defaultMessage: 'Are you sure you wish to proceed?',
},
deleteTooltip: {
id: 'system.admin.admin.UsersButton.deleteTooltip',
Expand All @@ -49,24 +49,31 @@ const translations = defineMessages({
});

const UserManagementButtons: FC<Props> = (props) => {
const { intl, user } = props;
const { user } = props;
const dispatch = useAppDispatch();
const [isDeleting, setIsDeleting] = useState(false);
const { t } = useTranslation();

const userCoursesWithInstanceNames = user.instances.flatMap((instance) =>
instance.courses.map((course) => ({
...course,
instanceName: instance.name,
})),
);

const onDelete = (): Promise<void> => {
setIsDeleting(true);
return dispatch(deleteUser(user.id))
.then(() => {
toast.success(intl.formatMessage(translations.deletionSuccess));
toast.success(t(translations.deletionSuccess));
})
.catch((error) => {
setIsDeleting(false);
const errorMessage = error.response?.data?.errors
? error.response.data.errors
: '';
toast.error(
intl.formatMessage(translations.deletionFailure, {
t(translations.deletionFailure, {
error: errorMessage,
}),
);
Expand All @@ -88,17 +95,25 @@ const UserManagementButtons: FC<Props> = (props) => {
})}
tooltip={t(translations.deleteTooltip)}
>
{user.instances.length > 1 && (
{userCoursesWithInstanceNames.length > 0 && (
<>
<PromptText>{t(translations.deletionPromptContent)}</PromptText>
{user.instances.map((instance, index) => (
<PromptText key={`instance-${instance.host}`}>
{t(translations.associatedInstances, {
index: index + 1,
instanceName: instance.name,
})}
</PromptText>
))}
<PromptText>
{t(translations.deletionPromptContent, {
count: userCoursesWithInstanceNames.length,
})}
</PromptText>
<ol>
{userCoursesWithInstanceNames.map((course) => (
<PromptText key={`course-${course.id}`}>
<li>
{t(translations.associatedCourses, {
instanceName: course.instanceName,
courseName: course.title,
})}
</li>
</PromptText>
))}
</ol>
</>
)}
<PromptText>{t(translations.deletionConfirm)}</PromptText>
Expand All @@ -107,9 +122,6 @@ const UserManagementButtons: FC<Props> = (props) => {
);
};

export default memo(
injectIntl(UserManagementButtons),
(prevProps, nextProps) => {
return equal(prevProps.user, nextProps.user);
},
);
export default memo(UserManagementButtons, (prevProps, nextProps) => {
return equal(prevProps.user, nextProps.user);
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FC, ReactElement, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
import { defineMessages } from 'react-intl';
import {
CircularProgress,
MenuItem,
Expand All @@ -25,11 +25,12 @@ import {
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 tableTranslations from 'lib/translations/table';

import { indexUsers, updateUser } from '../../operations';

interface Props extends WrappedComponentProps {
interface Props {
users: UserMiniEntity[];
userCounts: AdminStats;
filter: { active: boolean; role: string };
Expand Down Expand Up @@ -62,13 +63,18 @@ const translations = defineMessages({
id: 'system.admin.users.UsersTable.fetchFilteredUsersFailure',
defaultMessage: 'Failed to fetch users.',
},
userInstanceEntry: {
id: 'system.admin.users.UsersTable.instanceEntry',
defaultMessage:
'{instanceName}{courseCount, plural, =0 {} one { (1 course)} other { ({courseCount} courses)}}',
},
});

const UsersTable: FC<Props> = (props) => {
const { title, renderRowActionComponent, intl, filter, users, userCounts } =
props;
const { title, renderRowActionComponent, filter, users, userCounts } = props;
const [isLoading, setIsLoading] = useState(false);
const dispatch = useAppDispatch();
const { t } = useTranslation();

const [tableState, setTableState] = useState<TableState>({
count: userCounts.usersCount,
Expand All @@ -88,14 +94,14 @@ const UsersTable: FC<Props> = (props) => {
return dispatch(updateUser(user.id, newUser))
.then(() => {
toast.success(
intl.formatMessage(translations.renameSuccess, {
t(translations.renameSuccess, {
oldName: user.name,
newName,
}),
);
})
.catch((error) => {
toast.error(intl.formatMessage(translations.updateNameFailure));
toast.error(t(translations.updateNameFailure));
throw error;
});
};
Expand All @@ -117,14 +123,14 @@ const UsersTable: FC<Props> = (props) => {
.then(() => {
updateValue(newRole);
toast.success(
intl.formatMessage(translations.changeRoleSuccess, {
t(translations.changeRoleSuccess, {
name: user.name,
role: USER_ROLES[newRole],
}),
);
})
.catch(() => {
toast.error(intl.formatMessage(translations.updateRoleFailure));
toast.error(t(translations.updateRoleFailure));
});
};

Expand All @@ -142,9 +148,7 @@ const UsersTable: FC<Props> = (props) => {
active: filter.active,
}),
)
.catch(() =>
toast.error(intl.formatMessage(translations.fetchFilteredUsersFailure)),
)
.catch(() => toast.error(t(translations.fetchFilteredUsersFailure)))
.finally(() => {
setIsLoading(false);
});
Expand All @@ -165,9 +169,7 @@ const UsersTable: FC<Props> = (props) => {
search: searchText ? searchText.trim() : searchText,
}),
)
.catch(() =>
toast.error(intl.formatMessage(translations.fetchFilteredUsersFailure)),
)
.catch(() => toast.error(t(translations.fetchFilteredUsersFailure)))
.finally(() => {
setIsLoading(false);
});
Expand Down Expand Up @@ -196,7 +198,7 @@ const UsersTable: FC<Props> = (props) => {
rowsPerPage: DEFAULT_TABLE_ROWS_PER_PAGE,
rowsPerPageOptions: [DEFAULT_TABLE_ROWS_PER_PAGE],
search: true,
searchPlaceholder: intl.formatMessage(translations.searchText),
searchPlaceholder: t(translations.searchText),
selectableRows: 'none',
serverSide: true,
setTableProps: (): Record<string, unknown> => {
Expand Down Expand Up @@ -224,7 +226,7 @@ const UsersTable: FC<Props> = (props) => {
},
{
name: 'name',
label: intl.formatMessage(tableTranslations.name),
label: t(tableTranslations.name),
options: {
alignCenter: false,
sort: false,
Expand All @@ -247,7 +249,7 @@ const UsersTable: FC<Props> = (props) => {
},
{
name: 'email',
label: intl.formatMessage(tableTranslations.email),
label: t(tableTranslations.email),
options: {
alignCenter: false,
sort: false,
Expand All @@ -267,7 +269,7 @@ const UsersTable: FC<Props> = (props) => {
},
{
name: 'instances',
label: intl.formatMessage(tableTranslations.instances),
label: t(tableTranslations.instances),
options: {
alignCenter: false,
sort: false,
Expand All @@ -281,7 +283,10 @@ const UsersTable: FC<Props> = (props) => {
href={`//${instance.host}/admin/users`}
underline="hover"
>
{instance.name}
{t(translations.userInstanceEntry, {
instanceName: instance.name,
courseCount: instance.courses.length,
})}
</Link>
</li>
))}
Expand All @@ -292,7 +297,7 @@ const UsersTable: FC<Props> = (props) => {
},
{
name: 'role',
label: intl.formatMessage(tableTranslations.role),
label: t(tableTranslations.role),
options: {
alignCenter: false,
sort: false,
Expand Down Expand Up @@ -326,7 +331,7 @@ const UsersTable: FC<Props> = (props) => {
},
{
name: 'actions',
label: intl.formatMessage(tableTranslations.actions),
label: t(tableTranslations.actions),
options: {
empty: true,
sort: false,
Expand Down Expand Up @@ -359,4 +364,4 @@ const UsersTable: FC<Props> = (props) => {
);
};

export default injectIntl(UsersTable);
export default UsersTable;
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const InstanceAdminNavigator = (): JSX.Element => {
const handle: DataHandle = () => ({
getData: async (): Promise<string> => {
const data = await fetchInstance();
return `${data.name} Admin Panel`;
return `${data.name} Instance Admin Panel`;
},
});

Expand Down
Loading