diff --git a/Changelog.md b/Changelog.md
index eb59a11fc5..406ee10c63 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -16,6 +16,7 @@
- Add JS autotester example (#7866)
- Return structured JSON from grade entry forms API show endpoint with optional student filter and CSV export (#7886)
- Added term-based suffixes to course names created via LTI to ensure uniqueness across academic years (#7881)
+- Added case-sensitive search toggle to group name filters in graders, groups, submissions, and annotation usage tables (#7938)
### 🐛 Bug fixes
- Prevent "No rows found" message from displaying in tables when data is loading (#7790)
diff --git a/app/javascript/Components/Helpers/table_helpers.jsx b/app/javascript/Components/Helpers/table_helpers.jsx
index ad531dab18..a79a0f0b55 100644
--- a/app/javascript/Components/Helpers/table_helpers.jsx
+++ b/app/javascript/Components/Helpers/table_helpers.jsx
@@ -110,6 +110,63 @@ export function textFilter({filter, onChange, column}) {
);
}
+/**
+ * Locale-aware substring match with optional case sensitivity. Both arguments
+ * are coerced to strings, so callers do not have to guard against null/undefined.
+ */
+export function caseSensitiveIncludes(haystack, needle, caseSensitive) {
+ const a = String(haystack);
+ const b = String(needle);
+ if (caseSensitive) return a.includes(b);
+ return a.toLocaleLowerCase().includes(b.toLocaleLowerCase());
+}
+
+/**
+ * Builds a filterMethod that matches `row[filter.id]` against `filter.value`
+ * with the given case sensitivity. Empty filters match every row.
+ */
+export function caseSensitiveStringFilterMethod(caseSensitive) {
+ return (filter, row) => {
+ if (!filter.value) return true;
+ return caseSensitiveIncludes(row[filter.id], filter.value, caseSensitive);
+ };
+}
+
+/**
+ * Builds a Filter component that pairs a text input with an "Aa" checkbox
+ * toggle for case-sensitive matching. `getCaseSensitive` is read on every
+ * render so the checkbox reflects the latest value without rebuilding the
+ * Filter — this keeps the rendered elements stable across re-renders.
+ */
+export function caseSensitiveTextFilter({getCaseSensitive, onToggle}) {
+ return ({filter, onChange, column}) => (
+
+
);
- } else {
- return (
-
this.props.onExtensionModal(row.original.extension, false)}
- title={I18n.t("add")}
- >
-
-
- );
- }
+ });
+ } else {
+ // Link to assigning a student to this scanned exam
+ const assign_url = Routes.assign_scans_course_assignment_groups_path(
+ this.props.course_id,
+ this.props.assignment_id,
+ {grouping_id: row.original._id}
+ );
+ return
{I18n.t("exam_templates.assign_scans.title")} ;
+ }
+ },
+ filterMethod: (filter, row) => {
+ if (!filter.value) return true;
+ return row._original.members.some(member => member[0].includes(filter.value));
+ },
+ sortable: false,
+ },
+ {
+ Header: I18n.t("groups.valid"),
+ Cell: row => {
+ const isValid =
+ row.original.instructor_approved || row.original.members.length >= this.props.groupMin;
+ if (isValid) {
+ return (
+
this.props.invalidate(row.original._id)}
+ >
+ ✔
+
+ );
+ }
+ return (
+
this.props.validate(row.original._id)}
+ >
+
+
+ );
+ },
+ filterMethod: (filter, row) => {
+ if (filter.value === "all") return true;
+ const expected = filter.value === "true";
+ const isValid =
+ row._original.instructor_approved ||
+ row._original.members.length >= this.props.groupMin;
+ return isValid === expected;
+ },
+ Filter: selectFilter,
+ filterOptions: [
+ {value: "true", text: I18n.t("groups.is_valid")},
+ {value: "false", text: I18n.t("groups.is_not_valid")},
+ ],
+ minWidth: 30,
+ sortable: false,
+ },
+ {
+ Header: this.props.extensionColumnHeader,
+ accessor: "extension",
+ show: !this.props.scanned_exam,
+ Cell: row => {
+ const timeExtension = getTimeExtension(row.original.extension, this.props.times);
+ if (!timeExtension) {
+ return (
+
this.props.onExtensionModal(row.original.extension, false)}
+ title={I18n.t("add")}
+ >
+
+
+ );
+ }
+ const lateSubmissionText = row.original.extension.apply_penalty
+ ? `(${I18n.t("groups.late_submissions_accepted")})`
+ : "";
+ return (
+
+ );
+ },
+ sortMethod: durationSort,
+ Filter: selectFilter,
+ filterMethod: (filter, row) => {
+ if (filter.value === "all") return true;
+ 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;
+ const applyPenalty = row._original.extension.apply_penalty;
+ if (withLateSubmission) return hasExtension && applyPenalty;
+ return hasExtension && !applyPenalty;
+ },
+ filterOptions: [
+ {
+ value: JSON.stringify({withExtension: false}),
+ text: I18n.t("groups.groups_without_extension"),
},
- 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;
+ {
+ value: JSON.stringify({withExtension: true, withLateSubmission: true}),
+ text: I18n.t("groups.groups_with_extension.with_late_submission"),
},
- 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"),
- },
- ],
- },
- ],
- };
- }
+ {
+ value: JSON.stringify({withExtension: true, withLateSubmission: false}),
+ text: I18n.t("groups.groups_with_extension.without_late_submission"),
+ },
+ ],
+ },
+ ];
+ };
static getDerivedStateFromProps(props, state) {
let filtered = state.filtered.filter(group => group.id !== "inactive");
diff --git a/app/javascript/Components/submission_table.jsx b/app/javascript/Components/submission_table.jsx
index b5f76abb85..6ffaa92a95 100644
--- a/app/javascript/Components/submission_table.jsx
+++ b/app/javascript/Components/submission_table.jsx
@@ -4,6 +4,8 @@ import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {CheckboxTable, withSelection} from "./markus_with_selection_hoc";
import {
+ caseSensitiveIncludes,
+ caseSensitiveTextFilter,
dateSort,
markingStateColumn,
selectFilter,
@@ -28,6 +30,7 @@ class RawSubmissionTable extends React.Component {
markingStateFilter: "all",
inactiveGroupsCount: 0,
filtered: [],
+ caseSensitive: true,
columns: this.getColumns({}, markingStates, "all"),
};
}
@@ -37,6 +40,35 @@ class RawSubmissionTable extends React.Component {
this.createChannelSubscriptions();
}
+ componentDidUpdate(prevProps, prevState) {
+ if (prevState.caseSensitive !== this.state.caseSensitive) {
+ // Rebuild columns to invalidate react-table's cached filter results;
+ // groupNameFilter reads this.state.caseSensitive on every call.
+ this.setState(state => ({
+ columns: this.getColumns(state.sections, state.marking_states, state.markingStateFilter),
+ }));
+ }
+ }
+
+ toggleCaseSensitive = () => {
+ this.setState(state => ({caseSensitive: !state.caseSensitive}));
+ };
+
+ groupNameFilterComponent = caseSensitiveTextFilter({
+ getCaseSensitive: () => this.state.caseSensitive,
+ onToggle: this.toggleCaseSensitive,
+ });
+
+ groupNameFilter = (filter, row) => {
+ if (!filter.value) return true;
+ const {caseSensitive} = this.state;
+ if (caseSensitiveIncludes(row._original.group_name, filter.value, caseSensitive)) return true;
+ if (!row._original.members) return false;
+ return row._original.members.some(member =>
+ caseSensitiveIncludes(member[0], filter.value, caseSensitive)
+ );
+ };
+
fetchData = () => {
fetch(
Routes.course_assignment_submissions_path(this.props.course_id, this.props.assignment_id),
@@ -111,21 +143,6 @@ class RawSubmissionTable extends React.Component {
return row.value + members;
};
- groupNameFilter = (filter, row) => {
- if (filter.value) {
- // Check group name
- if (row._original.group_name.includes(filter.value)) {
- return true;
- }
- // Check member names
- return (
- row._original.members && row._original.members.some(name => name.includes(filter.value))
- );
- } else {
- return true;
- }
- };
-
getColumns = (sections, marking_states, markingStateFilter) => [
{
show: false,
@@ -154,6 +171,7 @@ class RawSubmissionTable extends React.Component {
}
},
minWidth: 170,
+ Filter: this.groupNameFilterComponent,
filterMethod: this.groupNameFilter,
},
{
diff --git a/config/locales/common/en.yml b/config/locales/common/en.yml
index 32f0663353..375b8f8d0a 100644
--- a/config/locales/common/en.yml
+++ b/config/locales/common/en.yml
@@ -79,6 +79,8 @@ en:
select_filename: Select Filename
skip: Skip
table:
+ case_sensitive_indicator: Aa
+ case_sensitive_search: Match case
hide_details: Hide details
no_data: No rows found
search: Search