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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,6 @@ config/environments/*.local.yml
# local docker compose overrides
docker-compose.override.yml
compose.override.yaml

# Optional VSCode container configuration
.devcontainer
1 change: 1 addition & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- Added new loading spinner icon for tables (#7602)
- Update message and page displaying cannot create new course via external LTI tool (#7669)
- Provide file viewer the option to render Microsoft files (#7676)
- Display late submission selection and add accompanying filter (#7689)

### 🐛 Bug fixes
- Fixed N+1 query problem in StudentsController by eager loading user association (#7678)
Expand Down
24 changes: 24 additions & 0 deletions app/javascript/Components/Helpers/table_helpers.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,27 @@ export function customNoDataComponent({children, loading}) {
export function customNoDataProps({state}) {
return {loading: state.loading, data: state.data};
}

export function getTimeExtension(extension, timePeriods) {
return timePeriods
.map(key => {
const val = extension[key];

if (!val) {
return null;
}
// don't build these strings dynamically or they will be missed by the i18n-tasks checkers.
if (key === "weeks") {
return `${val} ${I18n.t("durations.weeks", {count: val})}`;
} else if (key === "days") {
return `${val} ${I18n.t("durations.days", {count: val})}`;
} else if (key === "hours") {
return `${val} ${I18n.t("durations.hours", {count: val})}`;
} else if (key === "minutes") {
return `${val} ${I18n.t("durations.minutes", {count: val})}`;
}
return "";
})
.filter(Boolean)
.join(", ");
}
164 changes: 164 additions & 0 deletions app/javascript/Components/__tests__/groups_manager.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import {render, screen} from "@testing-library/react";
import {GroupsManager} from "../groups_manager";
import {beforeEach, describe, expect, it} from "@jest/globals";
import {getTimeExtension} from "../Helpers/table_helpers";

jest.mock("@fortawesome/react-fontawesome", () => ({
FontAwesomeIcon: () => {
return null;
},
}));

const groupMock = [
{
group_name: "c6scriab",
inactive: false,
instructor_approved: true,
members: [
{
0: "c6scriab",
1: "inviter",
2: false,
display_label: "(inviter)",
},
],
extension: {
apply_penalty: false,
grouping_id: 22,
id: null,
note: "",
},
section: "",
},
{
group_name: "group2",
inactive: false,
instructor_approved: true,
members: [
{
0: "student1",
1: "inviter",
2: false,
display_label: "(inviter)",
},
],
section: "",
extension: {
apply_penalty: true,
days: 2,
grouping_id: 16,
hours: 0,
id: 51,
minutes: 0,
note: "",
weeks: 0,
},
},
];
const studentMock = [
{
assigned: true,
first_name: "coolStudent",
hidden: false,
id: 8,
last_name: "Alberic",
user_name: "student1",
},
];

describe("GroupsManager", () => {
let filter_method = null;
let wrapper = React.createRef();

beforeEach(async () => {
fetch.mockReset();
fetch.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValueOnce({
templates: [],
groups: groupMock,
exam_templates: [],
students: studentMock,
clone_assignments: [],
}),
});
const props = {
course_id: 1,
timed: false,
assignment_id: 2,
scanned_exam: false,
examTemplates: [],
times: ["weeks", "days", "hours", "minutes"],
};
render(<GroupsManager {...props} ref={wrapper} />);
// wait for page to load and render content
await screen.findByText("abcd").catch(err => err);
// to view screen render: screen.debug(undefined, 300000)
});

describe("DueDateExtensions", () => {
beforeEach(() => {
filter_method =
wrapper.current.groupsTable.wrapped.checkboxTable.props.columns[5].filterMethod;
});

it("append (late submissions accepted) to assignments with extensions", async () => {
const groupWithExtension = groupMock[1];
const timePeriods = ["weeks", "days", "hours", "minutes"];
const timeExtension = getTimeExtension(groupWithExtension.extension, timePeriods);
const searchTerm = `${timeExtension} (${I18n.t("groups.late_submissions_accepted")})`;
expect(await screen.getByRole("link", {name: searchTerm})).toBeInTheDocument();
});

it("returns true when the selected value is all", () => {
expect(filter_method({value: "all"})).toEqual(true);
});

describe("withExtension: false", () => {
it("returns true when assignments without an extension are present", () => {
const rowMock = {_original: {extension: {}}};
const filterOptionsMock = JSON.stringify({withExtension: false});
expect(filter_method({value: filterOptionsMock}, rowMock)).toEqual(true);
});
it("returns false when assignments with an extension are present", () => {
const rowMock = {_original: {extension: {hours: 1}}};
const filterOptionsMock = JSON.stringify({withExtension: false});
expect(filter_method({value: filterOptionsMock}, rowMock)).toEqual(false);
});
});

describe("withExtension: true", () => {
describe("withLateSubmission: true", () => {
it("returns true when assignments have a late submission rule applied", () => {
const rowMock = {_original: {extension: {hours: 1, apply_penalty: true}}};
const filterOptionsMock = JSON.stringify({withExtension: true, withLateSubmission: true});
expect(filter_method({value: filterOptionsMock}, rowMock)).toEqual(true);
});
it("returns false when assignments are missing an extension", () => {
const rowMock = {_original: {extension: {apply_penalty: true}}};
const filterOptionsMock = JSON.stringify({withExtension: true, withLateSubmission: true});
expect(filter_method({value: filterOptionsMock}, rowMock)).toEqual(false);
});
});
describe("withLateSubmission: false", () => {
it("returns true when assignments are missing an extension", () => {
const rowMock = {_original: {extension: {hours: 1, apply_penalty: true}}};
const filterOptionsMock = JSON.stringify({
withExtension: true,
withLateSubmission: false,
});
expect(filter_method({value: filterOptionsMock}, rowMock)).toEqual(false);
});

it("returns false when assignments have a late submission rule applied", () => {
const rowMock = {_original: {extension: {hours: 1, apply_penalty: true}}};
const filterOptionsMock = JSON.stringify({
withExtension: true,
withLateSubmission: false,
});
expect(filter_method({value: filterOptionsMock}, rowMock)).toEqual(false);
});
});
});
});
});
63 changes: 41 additions & 22 deletions app/javascript/Components/groups_manager.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";

import {withSelection, CheckboxTable} from "./markus_with_selection_hoc";
import ExtensionModal from "./Modals/extension_modal";
import {durationSort, selectFilter} from "./Helpers/table_helpers";
import {durationSort, selectFilter, getTimeExtension} from "./Helpers/table_helpers";
import AutoMatchModal from "./Modals/auto_match_modal";
import CreateGroupModal from "./Modals/create_group_modal";
import RenameGroupModal from "./Modals/rename_group_modal";
Expand Down Expand Up @@ -556,26 +556,12 @@ class RawGroupsTable extends React.Component {
accessor: "extension",
show: !this.props.scanned_exam,
Cell: row => {
let extension = this.props.times
.map(key => {
const val = row.original.extension[key];
if (val) {
// don't build these strings dynamically or they will be missed by the i18n-tasks checkers.
if (key === "weeks") {
return `${val} ${I18n.t("durations.weeks", {count: val})}`;
} else if (key === "days") {
return `${val} ${I18n.t("durations.days", {count: val})}`;
} else if (key === "hours") {
return `${val} ${I18n.t("durations.hours", {count: val})}`;
} else if (key === "minutes") {
return `${val} ${I18n.t("durations.minutes", {count: val})}`;
} else {
return "";
}
}
})
.filter(Boolean)
.join(", ");
const timeExtension = getTimeExtension(row.original.extension, this.props.times);
const lateSubmissionText = row.original.extension.apply_penalty
? `(${I18n.t("groups.late_submissions_accepted")})`
: "";
const extension = `${timeExtension} ${lateSubmissionText}`;

if (!!extension) {
return (
<div>
Expand All @@ -599,8 +585,39 @@ class RawGroupsTable extends React.Component {
);
}
},
filterable: false,
sortMethod: durationSort,
Filter: selectFilter,
filterMethod: (filter, row) => {
if (filter.value === "all") {
return true;
}
const applyPenalty = row._original.extension.apply_penalty;
const {withExtension, withLateSubmission} = JSON.parse(filter.value);
// If there is an extension applied, the extension object will contain a property called hours
const hasExtension = Object.hasOwn(row._original.extension, "hours");

if (!withExtension) {
return !hasExtension;
}
if (withLateSubmission) {
return hasExtension && applyPenalty;
}
return hasExtension && !applyPenalty;
},
filterOptions: [
{
value: JSON.stringify({withExtension: false}),
text: I18n.t("groups.groups_without_extension"),
},
{
value: JSON.stringify({withExtension: true, withLateSubmission: true}),
text: I18n.t("groups.groups_with_extension.with_late_submission"),
},
{
value: JSON.stringify({withExtension: true, withLateSubmission: false}),
text: I18n.t("groups.groups_with_extension.without_late_submission"),
},
],
},
];

Expand Down Expand Up @@ -825,3 +842,5 @@ export function makeGroupsManager(elem, props) {
root.render(<GroupsManager {...props} ref={component} />);
return component;
}

export {GroupsManager};
2 changes: 2 additions & 0 deletions config/i18n-tasks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ ignore_unused:
- '*.*.default'
- 'exam_templates.*' # TODO: review these ones
- 'notification_mailer.*'
- 'durations.weeks.*' # TODO: review this
- 'durations.days.*' # TODO: review this
- 'durations.hours.*' # TODO: review this
- 'durations.minutes.*' # TODO: review this
- 'date.formats.*' # See https://github.com/glebm/i18n-tasks/issues/240
Expand Down
5 changes: 5 additions & 0 deletions config/locales/views/groups/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ en:
grace_day_over_limit: You have assigned one or more students who has less grace credits than the amount already used by group %{group} for this assignment.
group_name_already_in_use: This name is already in use for this assignment.
group_name_already_in_use_diff_assignment: The group can't be renamed, because it would result in the loss of submission data. Try a name that is not in use for another assignment.
groups_with_extension:
with_late_submission: Groups with Extension (Late Submissions Accepted)
without_late_submission: Groups with Extension (Late Submissions Not Accepted)
groups_without_extension: Groups Without Extension
help: Manage student groups. You can create new groups and manually add and remove students to groups.
invalid_group_warning: 'Your group is currently invalid. You probably haven''t met the group size minimum. You may not be able to submit anything, and your work may not be graded, until you have met this minimum. '
invalidate_confirm: This will prevent this group from submitting, even with the required number of students. Are you sure?
Expand All @@ -45,6 +49,7 @@ en:
success: Invitation(s) successful.
is_not_valid: Group is not valid
is_valid: Group is valid
late_submissions_accepted: late submissions accepted
manage_groups: Manage Groups
members:
cancel_invitation: Cancel invitation
Expand Down
1 change: 1 addition & 0 deletions doc/markus-contributors.txt
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ Marc Palermo
Mark Kazakevich
Mark Rada
Maryna Moskalenko
Mateo Naranjo
Matthew Austin
Mélanie Gaudet
Melissa Neubert
Expand Down
Loading