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
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import SubmitButton from '../../forms/SubmitButton';
import DateTime from '../../widgets/DateTime';
import Button, { TheButtonGroup } from '../../widgets/TheButton';
import Confirm from '../../forms/Confirm';
import Icon, { BanIcon, EditIcon, DeleteIcon, SaveIcon, SquareIcon } from '../../icons';
import Icon, { BanIcon, EditIcon, DeleteIcon, SquareIcon } from '../../icons';
import { createUserNameComparator } from '../../helpers/users.js';
import { arrayToObject, safeGet } from '../../../helpers/common.js';
import withLinks from '../../../helpers/withLinks.js';
Expand Down Expand Up @@ -128,7 +128,6 @@ class ShadowAssignmentPointsTable extends Component {
hasSucceeded={submitSucceeded}
hasFailed={submitFailed}
invalid={invalid}
defaultIcon={<SaveIcon gapRight={2} />}
messages={{
submit: <FormattedMessage id="generic.save" defaultMessage="Save" />,
submitting: <FormattedMessage id="generic.saving" defaultMessage="Saving..." />,
Expand Down
61 changes: 44 additions & 17 deletions src/components/Groups/GroupsTree/GroupsTreeNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Icon, { GroupIcon, GroupExamsIcon, LoadingIcon } from '../../icons';
import withLinks from '../../../helpers/withLinks.js';
import { isRegularObject } from '../../../helpers/common.js';
import { isExam } from '../../../helpers/groups.js';
import { OverlayTrigger, Popover } from 'react-bootstrap';

/**
* Assemble the right CSS classes for the list item.
Expand All @@ -30,7 +31,22 @@ const prepareClassList = lruMemoize((clickable, archived) => {

const DEFAULT_ICON = ['far', 'square'];

const clickEventDisipator = ev => ev.stopPropagation();
const clickEventDissipator = ev => ev.stopPropagation();

Copilot AI Sep 26, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed spelling from 'Disipator' to 'Dissipator' in the function name.

Copilot uses AI. Check for mistakes.

const adminsList = (primaryAdmins, autoloadAuthors, simpleClassName = '') =>
primaryAdmins.map(admin => (
<React.Fragment key={isRegularObject(admin) ? admin.id : admin}>
{isRegularObject(admin) ? (
<span className={`${simpleClassName} simpleName text-nowrap`}>
{admin.firstName} {admin.lastName}
</span>
) : autoloadAuthors ? (
<UsersNameContainer userId={admin} isSimple simpleClassName={simpleClassName} />
) : (
<LoadingIcon />
)}
</React.Fragment>
));

const GroupsTreeNode = React.memo(
({ group, selectedGroupId = null, autoloadAuthors = false, isExpanded = false, buttonsCreator, links }) => {
Expand Down Expand Up @@ -69,21 +85,32 @@ const GroupsTreeNode = React.memo(
{primaryAdmins && primaryAdmins.length > 0 && (
<span className="ps-2">
(
<em className="small">
{primaryAdmins.map(admin => (
<React.Fragment key={isRegularObject(admin) ? admin.id : admin}>
{isRegularObject(admin) ? (
<span className="simpleName text-nowrap">
{admin.firstName} {admin.lastName}
</span>
) : autoloadAuthors ? (
<UsersNameContainer userId={admin} isSimple />
) : (
<LoadingIcon />
)}
</React.Fragment>
))}
</em>
{primaryAdmins.length > 2 ? (
<OverlayTrigger
placement="bottom"
overlay={
<Popover id={`admins-${id}`}>
<Popover.Header>
<FormattedMessage
id="app.groupTree.treeViewLeaf.adminPopover.title"
defaultMessage="Group administrators"
/>
:
</Popover.Header>
<Popover.Body>{adminsList(primaryAdmins, autoloadAuthors, 'd-block')}</Popover.Body>
</Popover>
}>
<em className="small">
<FormattedMessage
id="app.groupTree.treeViewLeaf.adminsCount"
defaultMessage="{count} {count, plural, one {admin} other {admins}}"
values={{ count: primaryAdmins.length }}
/>
</em>
</OverlayTrigger>
) : (
<em className="small">{adminsList(primaryAdmins, autoloadAuthors)}</em>
)}
)
</span>
)}
Expand Down Expand Up @@ -156,7 +183,7 @@ const GroupsTreeNode = React.memo(
)}

{buttonsCreator && (
<span className="float-end" onClick={clickEventDisipator}>
<span className="float-end" onClick={clickEventDissipator}>
{buttonsCreator(group, selectedGroupId, links)}
</span>
)}
Expand Down
234 changes: 130 additions & 104 deletions src/components/Groups/helpers/GroupInfoTable.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage, FormattedNumber } from 'react-intl';
import { Table } from 'react-bootstrap';

import Box from '../../widgets/Box';
import Callout from '../../widgets/Callout';
import Markdown from '../../widgets/Markdown';
import { SuccessOrFailureIcon } from '../../icons';
import { InstanceIcon, SuccessOrFailureIcon } from '../../icons';
import { getLocalizedDescription } from '../../../helpers/localizedData.js';
import { objectMap, identity } from '../../../helpers/common.js';
import ResourceRenderer from '../../helpers/ResourceRenderer/ResourceRenderer.js';

const knownBindingProviderLabels = {
sis: <FormattedMessage id="app.groupDetail.bindings.sis" defaultMessage="SIS UK scheduling event codes" />,
};
import { getConfigVar } from '../../../helpers/config.js';
import InsetPanel from '../../widgets/InsetPanel/InsetPanel.js';

const getDescription = (localizedTexts, locale) => {
const description = getLocalizedDescription({ localizedTexts }, locale);
Expand All @@ -28,117 +28,144 @@ const getDescription = (localizedTexts, locale) => {
);
};

const EXTERNAL_ATTRIBUTES = getConfigVar('EXTERNAL_ATTRIBUTES', {});

const getLocalizedLabel = (label, locale) => {
if (typeof label === 'object') {
if (locale in label) {
return label[locale];
}
if ('en' in label) {
return label.en;
}
const keys = Object.keys(label);
if (keys.length > 0) {
return label[keys[0]];
}
}
return typeof label === 'string' ? label : null;
};

const translateAttributeService = (service, locale) => {
const name = EXTERNAL_ATTRIBUTES[service]?.NAME;
return (name && getLocalizedLabel(name, locale)) || null;
};

const translateAttributeKey = (service, key, locale) => {
const name = EXTERNAL_ATTRIBUTES[service]?.KEYS[key];
return (name && getLocalizedLabel(name, locale)) || null;
};

const GroupInfoTable = ({
group: { externalId, organizational, localizedTexts, public: isPublic = false, privateData },
group: { organizational, localizedTexts, public: isPublic = false, privateData },
externalAttributes,
isAdmin,
locale,
}) => (
<div>
<Box
title={<FormattedMessage id="app.groupDetail.description" defaultMessage="Group Description" />}
description={getDescription(localizedTexts, locale)}
type="primary"
collapsable
noPadding
unlimitedHeight>
<Table>
<tbody>
{!organizational && privateData && (
<tr>
<th>
<FormattedMessage
id="app.groupDetail.hasPublicStats"
defaultMessage="Students can see progress of other students"
/>
:
</th>
<td>
<SuccessOrFailureIcon success={privateData.publicStats} />
</td>
</tr>
)}
{!organizational && (
<tr>
<th>
<FormattedMessage id="app.groupDetail.isPublic" defaultMessage="Everyone can see and join this group" />
:
</th>
<td>
<SuccessOrFailureIcon success={isPublic} />
</td>
</tr>
)}
{privateData && Boolean(privateData.threshold) && !organizational && (
<tr>
<th>
<FormattedMessage
id="app.groupDetail.threshold"
defaultMessage="Minimum percent of the total points count needed to complete the course"
/>
:
</th>
<td>
<FormattedNumber value={privateData.threshold} style="percent" />
</td>
</tr>
)}
{privateData && Boolean(privateData.pointsLimit) && !organizational && (
<tr>
<th>
<FormattedMessage
id="app.groupDetail.pointsLimit"
defaultMessage="Minimal amount of points needed to complete the course"
/>
:
</th>
<td>
<FormattedNumber value={privateData.pointsLimit} />
</td>
</tr>
)}
{Boolean(externalId) && (
<tr>
<th>
<FormattedMessage
id="app.groupDetail.externalId"
defaultMessage="External identification of the group"
/>
:
</th>
<td>
<code>{externalId}</code>
</td>
</tr>
)}
<>
<InsetPanel className="m-3">{getDescription(localizedTexts, locale)}</InsetPanel>
<Table>
<tbody>
{!organizational && privateData && (
<tr>
<th>
<FormattedMessage
id="app.groupDetail.hasPublicStats"
defaultMessage="Students can see progress of other students"
/>
:
</th>
<td>
<SuccessOrFailureIcon success={privateData.publicStats} />
</td>
</tr>
)}
{!organizational && (
<tr>
<th>
<FormattedMessage
id="app.groupDetail.isPublic"
defaultMessage="Everyone can see and join this group"
/>
:
</th>
<td>
<SuccessOrFailureIcon success={isPublic} />
</td>
</tr>
)}
{privateData && Boolean(privateData.threshold) && !organizational && (
<tr>
<th>
<FormattedMessage
id="app.groupDetail.threshold"
defaultMessage="Minimum percent of the total points count needed to complete the course"
/>
:
</th>
<td>
<FormattedNumber value={privateData.threshold} style="percent" />
</td>
</tr>
)}
{privateData && Boolean(privateData.pointsLimit) && !organizational && (
<tr>
<th>
<FormattedMessage
id="app.groupDetail.pointsLimit"
defaultMessage="Minimal amount of points needed to complete the course"
/>
:
</th>
<td>
<FormattedNumber value={privateData.pointsLimit} />
</td>
</tr>
)}
</tbody>
</Table>

{privateData &&
privateData.bindings &&
Object.values(
objectMap(privateData.bindings, (codes, provider) =>
codes && codes.length > 0 ? (
<tr key={`bindings-${provider}`}>
<th>
{knownBindingProviderLabels[provider] || (
<FormattedMessage
id="app.groupDetail.bindings.genericProvider"
defaultMessage='External binding to "{provider}"'
/>
)}
:
{externalAttributes && (
<ResourceRenderer resource={externalAttributes}>
{attributes => (
<Table borderless size="sm">
<thead>
<tr>
<th colSpan={5}>
<FormattedMessage id="app.groupDetail.externalAttributes" defaultMessage="External Attributes:" />
</th>
<td>
{codes.map(code => (
<div key={`bindings-${provider}-${code}`}>
<code>{code}</code>
</div>
))}
</td>
</tr>
) : null
)
).filter(identity)}
</tbody>
</Table>
</thead>
<tbody>
{attributes.map(({ id, service, key, value }) => (
<tr key={id}>
<td className="shrink-col text-nowrap">
<InstanceIcon gapLeft gapRight className="text-muted" />
{translateAttributeService(service, locale) || <code>{service}</code>}
</td>
<td className="text-center shrink-col opacity-50 small">❭</td>
<td className="shrink-col text-nowrap">
{translateAttributeKey(service, key, locale) || <code>{key}</code>}
</td>
<td className="text-center shrink-col opacity-50 small">❭</td>
<td>
<code>{value}</code>
</td>
</tr>
))}
</tbody>
</Table>
)}
</ResourceRenderer>
)}
</>
</Box>
{isPublic && isAdmin && (
<Callout variant="warning">
Expand All @@ -153,7 +180,6 @@ const GroupInfoTable = ({

GroupInfoTable.propTypes = {
group: PropTypes.shape({
externalId: PropTypes.string,
parentGroupId: PropTypes.string,
threshold: PropTypes.number,
public: PropTypes.bool.isRequired,
Expand All @@ -163,9 +189,9 @@ GroupInfoTable.propTypes = {
threshold: PropTypes.number,
pointsLimit: PropTypes.number,
publicStats: PropTypes.bool.isRequired,
bindings: PropTypes.object,
}),
}),
externalAttributes: ImmutablePropTypes.map,
isAdmin: PropTypes.bool,
locale: PropTypes.string.isRequired,
};
Expand Down
Loading