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}) => ( +
+ onChange(event.target.value)} + /> + +
+ ); +} + /** * Select-based search filter. Options are generated from the custom column attribute * filterOptions, which is a list of objects with keys "value" and "text". diff --git a/app/javascript/Components/__tests__/annotation_usage_panel.test.jsx b/app/javascript/Components/__tests__/annotation_usage_panel.test.jsx new file mode 100644 index 0000000000..a473599c1c --- /dev/null +++ b/app/javascript/Components/__tests__/annotation_usage_panel.test.jsx @@ -0,0 +1,75 @@ +/*** + * Tests for AnnotationUsagePanel Component + */ + +import {AnnotationUsagePanel} from "../annotation_usage_panel"; +import {render, screen, fireEvent} from "@testing-library/react"; + +jest.mock("@fortawesome/react-fontawesome", () => ({ + FontAwesomeIcon: () => null, +})); + +describe("For the AnnotationUsagePanel's group name search", () => { + beforeEach(async () => { + const applications = [ + { + result_id: 1, + user_name: "alice", + first_name: "Alice", + last_name: "First", + group_name: "Alpha_001", + count: 1, + }, + { + result_id: 2, + user_name: "bob", + first_name: "Bob", + last_name: "Second", + group_name: "alpha_002", + count: 1, + }, + { + result_id: 3, + user_name: "carol", + first_name: "Carol", + last_name: "Third", + group_name: "Beta_003", + count: 1, + }, + ]; + fetch.mockReset(); + fetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(applications), + }); + + render(); + + fireEvent.click(screen.getByText(I18n.t("annotations.usage"))); + await screen.findByText("(alice) Alice First"); + }); + + it("is case-sensitive by default", () => { + const groupSearch = screen.getByRole("textbox", { + name: `${I18n.t("search")} ${I18n.t("activerecord.models.submission.one")}`, + }); + fireEvent.change(groupSearch, {target: {value: "Alpha"}}); + + expect(screen.getByText("(alice) Alice First")).toBeInTheDocument(); + expect(screen.queryByText("(bob) Bob Second")).not.toBeInTheDocument(); + expect(screen.queryByText("(carol) Carol Third")).not.toBeInTheDocument(); + }); + + it("becomes case-insensitive when the toggle is unchecked", () => { + fireEvent.click(screen.getByTestId("group_name_case_sensitive")); + + const groupSearch = screen.getByRole("textbox", { + name: `${I18n.t("search")} ${I18n.t("activerecord.models.submission.one")}`, + }); + fireEvent.change(groupSearch, {target: {value: "alpha"}}); + + expect(screen.getByText("(alice) Alice First")).toBeInTheDocument(); + expect(screen.getByText("(bob) Bob Second")).toBeInTheDocument(); + expect(screen.queryByText("(carol) Carol Third")).not.toBeInTheDocument(); + }); +}); diff --git a/app/javascript/Components/__tests__/graders_manager.test.jsx b/app/javascript/Components/__tests__/graders_manager.test.jsx index 62ef5eed6c..62ffa1baec 100644 --- a/app/javascript/Components/__tests__/graders_manager.test.jsx +++ b/app/javascript/Components/__tests__/graders_manager.test.jsx @@ -206,3 +206,92 @@ describe("For the GradersManager's name search", () => { expect(screen.queryByText("Nelle Varoquaux")).not.toBeInTheDocument(); }); }); + +describe("For the GradersManager's group name search", () => { + let groups_sample; + beforeEach(async () => { + groups_sample = [ + { + _id: 1, + members: [["c1abc", "inviter", false]], + inactive: false, + group_name: "Alpha_group", + graders: [], + criteria_coverage_count: 0, + }, + { + _id: 2, + members: [["c2abc", "inviter", false]], + inactive: false, + group_name: "alpha_group_lower", + graders: [], + criteria_coverage_count: 0, + }, + { + _id: 3, + members: [["c3abc", "inviter", false]], + inactive: false, + group_name: "Beta_group", + graders: [], + criteria_coverage_count: 0, + }, + ]; + fetch.mockReset(); + fetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + graders: [], + criteria: [], + assign_graders_to_criteria: false, + loading: false, + sections: {}, + anonymize_groups: false, + hide_unassigned_criteria: false, + isGraderDistributionModalOpen: false, + groups: groups_sample, + }), + }); + render(); + await screen.findByText("Alpha_group"); + }); + + it("is case-sensitive by default", () => { + const groupSearch = screen.getByRole("textbox", { + name: `${I18n.t("search")} ${I18n.t("activerecord.models.group.one")}`, + }); + fireEvent.change(groupSearch, {target: {value: "Alpha"}}); + + expect(screen.getByText("Alpha_group")).toBeInTheDocument(); + expect(screen.queryByText("alpha_group_lower")).not.toBeInTheDocument(); + expect(screen.queryByText("Beta_group")).not.toBeInTheDocument(); + }); + + it("becomes case-insensitive when the toggle is unchecked", () => { + const groupSearch = screen.getByRole("textbox", { + name: `${I18n.t("search")} ${I18n.t("activerecord.models.group.one")}`, + }); + fireEvent.change(groupSearch, {target: {value: "alpha"}}); + expect(screen.queryByText("Alpha_group")).not.toBeInTheDocument(); + expect(screen.getByText("alpha_group_lower")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("group_name_case_sensitive")); + + expect(screen.getByText("Alpha_group")).toBeInTheDocument(); + expect(screen.getByText("alpha_group_lower")).toBeInTheDocument(); + expect(screen.queryByText("Beta_group")).not.toBeInTheDocument(); + }); + + it("returns to case-sensitive when toggled back", () => { + const toggle = screen.getByTestId("group_name_case_sensitive"); + fireEvent.click(toggle); // off + fireEvent.click(toggle); // on again + + const groupSearch = screen.getByRole("textbox", { + name: `${I18n.t("search")} ${I18n.t("activerecord.models.group.one")}`, + }); + fireEvent.change(groupSearch, {target: {value: "Alpha"}}); + + expect(screen.getByText("Alpha_group")).toBeInTheDocument(); + expect(screen.queryByText("alpha_group_lower")).not.toBeInTheDocument(); + }); +}); diff --git a/app/javascript/Components/__tests__/groups_manager.test.jsx b/app/javascript/Components/__tests__/groups_manager.test.jsx index ac5a53598e..1aae55cfbe 100644 --- a/app/javascript/Components/__tests__/groups_manager.test.jsx +++ b/app/javascript/Components/__tests__/groups_manager.test.jsx @@ -1,4 +1,4 @@ -import {render, screen} from "@testing-library/react"; +import {render, screen, fireEvent} from "@testing-library/react"; import {GroupsManager} from "../groups_manager"; import {beforeEach, describe, expect, it} from "@jest/globals"; import {getTimeExtension} from "../Helpers/table_helpers"; @@ -162,3 +162,75 @@ describe("GroupsManager", () => { }); }); }); + +describe("For the GroupsManager's group name search", () => { + 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(); + await screen.findByText("c6scriab"); + }); + + it("is case-sensitive by default", () => { + const groupSearch = screen.getByRole("textbox", { + name: `${I18n.t("search")} ${I18n.t("activerecord.models.group.one")}`, + }); + fireEvent.change(groupSearch, {target: {value: "C6"}}); + + expect(screen.queryByText("c6scriab")).not.toBeInTheDocument(); + expect(screen.queryByText("group2")).not.toBeInTheDocument(); + }); + + it("matches case-sensitively when given exact case", () => { + const groupSearch = screen.getByRole("textbox", { + name: `${I18n.t("search")} ${I18n.t("activerecord.models.group.one")}`, + }); + fireEvent.change(groupSearch, {target: {value: "c6"}}); + + expect(screen.getByText("c6scriab")).toBeInTheDocument(); + expect(screen.queryByText("group2")).not.toBeInTheDocument(); + }); + + it("becomes case-insensitive when the toggle is unchecked", () => { + fireEvent.click(screen.getByTestId("group_name_case_sensitive")); + + const groupSearch = screen.getByRole("textbox", { + name: `${I18n.t("search")} ${I18n.t("activerecord.models.group.one")}`, + }); + fireEvent.change(groupSearch, {target: {value: "C6"}}); + + expect(screen.getByText("c6scriab")).toBeInTheDocument(); + expect(screen.queryByText("group2")).not.toBeInTheDocument(); + }); + + it("returns to case-sensitive when toggled back", () => { + const toggle = screen.getByTestId("group_name_case_sensitive"); + fireEvent.click(toggle); // off + fireEvent.click(toggle); // on again + + const groupSearch = screen.getByRole("textbox", { + name: `${I18n.t("search")} ${I18n.t("activerecord.models.group.one")}`, + }); + fireEvent.change(groupSearch, {target: {value: "C6"}}); + + expect(screen.queryByText("c6scriab")).not.toBeInTheDocument(); + expect(screen.queryByText("group2")).not.toBeInTheDocument(); + }); +}); diff --git a/app/javascript/Components/__tests__/submission_table.test.jsx b/app/javascript/Components/__tests__/submission_table.test.jsx index 73254569bf..aeb19ebdd5 100644 --- a/app/javascript/Components/__tests__/submission_table.test.jsx +++ b/app/javascript/Components/__tests__/submission_table.test.jsx @@ -94,3 +94,109 @@ describe("For the SubmissionTable's display of inactive groups", () => { expect(screen.queryByText("group_0001")).not.toBeInTheDocument(); }); }); + +describe("For the SubmissionTable's group name search", () => { + beforeEach(async () => { + const groups_sample = [ + { + _id: 1, + max_mark: 21.0, + group_name: "Alpha_group", + tags: [], + marking_state: "released", + submission_time: "Monday, March 25, 2024, 01:49:14 PM EDT", + result_id: 1, + final_grade: 12.0, + members: [["c1abc", false]], + grace_credits_used: 0, + }, + { + _id: 2, + max_mark: 21.0, + group_name: "alpha_lower", + tags: [], + marking_state: "released", + submission_time: "Monday, March 25, 2024, 01:49:14 PM EDT", + result_id: 2, + final_grade: 12.0, + members: [["c2abc", false]], + grace_credits_used: 0, + }, + { + _id: 3, + max_mark: 21.0, + group_name: "Beta_group", + tags: [], + marking_state: "released", + submission_time: "Monday, March 25, 2024, 01:49:14 PM EDT", + result_id: 3, + final_grade: 12.0, + members: [["c3abc", false]], + grace_credits_used: 0, + }, + ]; + fetch.mockReset(); + fetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + groupings: groups_sample, + sections: {}, + }), + }); + + render( + + ); + await screen.findByText("Alpha_group"); + }); + + it("is case-sensitive by default", () => { + const groupSearch = screen.getByRole("textbox", { + name: `${I18n.t("search")} ${I18n.t("activerecord.models.group.one")}`, + }); + fireEvent.change(groupSearch, {target: {value: "Alpha"}}); + + expect(screen.getByText("Alpha_group")).toBeInTheDocument(); + expect(screen.queryByText("alpha_lower")).not.toBeInTheDocument(); + expect(screen.queryByText("Beta_group")).not.toBeInTheDocument(); + }); + + it("becomes case-insensitive when the toggle is unchecked", () => { + fireEvent.click(screen.getByTestId("group_name_case_sensitive")); + + const groupSearch = screen.getByRole("textbox", { + name: `${I18n.t("search")} ${I18n.t("activerecord.models.group.one")}`, + }); + fireEvent.change(groupSearch, {target: {value: "alpha"}}); + + expect(screen.getByText("Alpha_group")).toBeInTheDocument(); + expect(screen.getByText("alpha_lower")).toBeInTheDocument(); + expect(screen.queryByText("Beta_group")).not.toBeInTheDocument(); + }); + + it("returns to case-sensitive when toggled back", () => { + const toggle = screen.getByTestId("group_name_case_sensitive"); + fireEvent.click(toggle); // off + fireEvent.click(toggle); // on again + + const groupSearch = screen.getByRole("textbox", { + name: `${I18n.t("search")} ${I18n.t("activerecord.models.group.one")}`, + }); + fireEvent.change(groupSearch, {target: {value: "alpha"}}); + + expect(screen.queryByText("Alpha_group")).not.toBeInTheDocument(); + expect(screen.getByText("alpha_lower")).toBeInTheDocument(); + expect(screen.queryByText("Beta_group")).not.toBeInTheDocument(); + }); +}); diff --git a/app/javascript/Components/annotation_usage_panel.jsx b/app/javascript/Components/annotation_usage_panel.jsx index 0ee3f2f029..765043b957 100644 --- a/app/javascript/Components/annotation_usage_panel.jsx +++ b/app/javascript/Components/annotation_usage_panel.jsx @@ -3,15 +3,25 @@ import ReactTable from "react-table"; import {createRoot} from "react-dom/client"; import ReactDOM from "react-dom"; +import {caseSensitiveIncludes, caseSensitiveTextFilter} from "./Helpers/table_helpers"; + class AnnotationUsagePanel extends React.Component { constructor(props) { super(props); this.state = { applications: null, details: false, + caseSensitive: true, + columns: this.getColumns(true), }; } + componentDidUpdate(prevProps, prevState) { + if (prevState.caseSensitive !== this.state.caseSensitive) { + this.setState({columns: this.getColumns(this.state.caseSensitive)}); + } + } + toggle = () => { if (this.state.applications === null) { this.fetchData(); @@ -20,42 +30,55 @@ class AnnotationUsagePanel extends React.Component { } }; - columns = [ - { - Header: I18n.t("annotations.used_by"), - accessor: row => "(" + row["user_name"] + ") " + row["first_name"] + " " + row["last_name"], - id: "user", - minWidth: 200, - PivotValue: ({value}) => value, - }, - { - Header: I18n.t("activerecord.models.submission.one"), - accessor: "group_name", - aggregate: (vals, pivots) => { - let usageCount = pivots.reduce((accumulator, p) => accumulator + p._original["count"], 0); - return I18n.t("annotations.used_times", {count: usageCount}); + toggleCaseSensitive = () => { + this.setState(state => ({caseSensitive: !state.caseSensitive})); + }; + + groupNameFilter = caseSensitiveTextFilter({ + getCaseSensitive: () => this.state.caseSensitive, + onToggle: this.toggleCaseSensitive, + }); + + getColumns = caseSensitive => { + return [ + { + Header: I18n.t("annotations.used_by"), + accessor: row => `(${row.user_name}) ${row.first_name} ${row.last_name}`, + id: "user", + minWidth: 200, + PivotValue: ({value}) => value, }, - sortable: false, - Aggregated: row => "(" + row.value + ")", - filterMethod: (filter, row) => { - if (row._subRows === undefined) { - return row[filter.id].toLowerCase().includes(filter.value.toLowerCase()); - } else { + { + Header: I18n.t("activerecord.models.submission.one"), + accessor: "group_name", + id: "group_name", + aggregate: (vals, pivots) => { + const usageCount = pivots.reduce((sum, p) => sum + p._original.count, 0); + return I18n.t("annotations.used_times", {count: usageCount}); + }, + sortable: false, + Aggregated: row => `(${row.value})`, + Filter: this.groupNameFilter, + filterMethod: (filter, row) => { + if (row._subRows === undefined) { + return caseSensitiveIncludes(row[filter.id], filter.value, caseSensitive); + } return row._subRows.some(sr => - sr["group_name"].toLowerCase().includes(filter.value.toLowerCase()) + caseSensitiveIncludes(sr.group_name, filter.value, caseSensitive) ); - } - }, - Cell: row => { - return ( - - {row.original["group_name"] + - (row.original["count"] > 1 ? " (" + row.original["count"] + ")" : "")} - - ); + }, + Cell: row => { + const {group_name, count, result_id} = row.original; + const suffix = count > 1 ? ` (${count})` : ""; + return ( + + {`${group_name}${suffix}`} + + ); + }, }, - }, - ]; + ]; + }; fetchData = () => { const url = Routes.annotation_text_uses_course_assignment_annotation_categories_path( @@ -94,7 +117,7 @@ class AnnotationUsagePanel extends React.Component { @@ -121,3 +144,5 @@ export function makeAnnotationUsagePanel(elem, props) { const root = createRoot(elem); return root.render(); } + +export {AnnotationUsagePanel}; diff --git a/app/javascript/Components/graders_manager.jsx b/app/javascript/Components/graders_manager.jsx index df838b7ef8..1c8849c9f1 100644 --- a/app/javascript/Components/graders_manager.jsx +++ b/app/javascript/Components/graders_manager.jsx @@ -4,7 +4,12 @@ import {Tab, Tabs, TabList, TabPanel} from "react-tabs"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {withSelection, CheckboxTable} from "./markus_with_selection_hoc"; -import {selectFilter, textFilter} from "./Helpers/table_helpers"; +import { + caseSensitiveStringFilterMethod, + caseSensitiveTextFilter, + selectFilter, + textFilter, +} from "./Helpers/table_helpers"; import {GraderDistributionModal} from "./Modals/graders_distribution_modal"; import {SectionDistributionModal} from "./Modals/section_distribution_modal"; @@ -575,27 +580,32 @@ class RawGroupsTable extends React.Component { super(props); this.state = { filtered: [], - columns: this.getColumns(props.showSections, props.sections, props.showCoverage), + caseSensitive: true, + columns: this.getColumns(true), }; } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps, prevState) { if ( prevProps.showSections !== this.props.showSections || prevProps.sections !== this.props.sections || - prevProps.showCoverage !== this.props.showCoverage + prevProps.showCoverage !== this.props.showCoverage || + prevState.caseSensitive !== this.state.caseSensitive ) { - this.setState({ - columns: this.getColumns( - this.props.showSections, - this.props.sections, - this.props.showCoverage - ), - }); + this.setState({columns: this.getColumns(this.state.caseSensitive)}); } } - getColumns = (showSections, sections, showCoverage) => { + toggleCaseSensitive = () => { + this.setState(state => ({caseSensitive: !state.caseSensitive})); + }; + + groupNameFilter = caseSensitiveTextFilter({ + getCaseSensitive: () => this.state.caseSensitive, + onToggle: this.toggleCaseSensitive, + }); + + getColumns = caseSensitive => { return [ { accessor: "inactive", @@ -614,7 +624,7 @@ class RawGroupsTable extends React.Component { Header: I18n.t("activerecord.models.section", {count: 1}), accessor: "section", id: "section", - show: showSections || false, + show: this.props.showSections || false, minWidth: 70, Cell: ({value}) => { return this.props.sections[value] || ""; @@ -627,7 +637,7 @@ class RawGroupsTable extends React.Component { } }, Filter: selectFilter, - filterOptions: Object.entries(sections).map(kv => ({ + filterOptions: Object.entries(this.props.sections).map(kv => ({ value: kv[1], text: kv[1], })), @@ -637,6 +647,8 @@ class RawGroupsTable extends React.Component { accessor: "group_name", id: "group_name", minWidth: 150, + Filter: this.groupNameFilter, + filterMethod: caseSensitiveStringFilterMethod(caseSensitive), }, { Header: I18n.t("activerecord.models.ta.other"), @@ -673,7 +685,7 @@ class RawGroupsTable extends React.Component { minWidth: 70, className: "number", filterable: false, - show: showCoverage, + show: this.props.showCoverage, }, ]; }; diff --git a/app/javascript/Components/groups_manager.jsx b/app/javascript/Components/groups_manager.jsx index c1563a4099..9c458c26d4 100644 --- a/app/javascript/Components/groups_manager.jsx +++ b/app/javascript/Components/groups_manager.jsx @@ -4,7 +4,13 @@ import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {withSelection, CheckboxTable} from "./markus_with_selection_hoc"; import ExtensionModal from "./Modals/extension_modal"; -import {durationSort, selectFilter, getTimeExtension} from "./Helpers/table_helpers"; +import { + caseSensitiveStringFilterMethod, + caseSensitiveTextFilter, + durationSort, + getTimeExtension, + selectFilter, +} 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"; @@ -424,202 +430,202 @@ class RawGroupsTable extends React.Component { super(props); this.state = { filtered: [], - columns: [ - { - accessor: "inactive", - id: "inactive", - width: 0, - className: "rt-hidden", - headerClassName: "rt-hidden", - resizable: false, - }, - { - show: false, - accessor: "id", - id: "_id", - }, - { - Header: I18n.t("activerecord.models.group.one"), - accessor: "group_name", - id: "group_name", - Cell: row => { - return ( - - {row.value} - this.props.renameGroup(row.original._id, row.value)} - title={I18n.t("groups.rename_group")} - > - - - - ); - }, - }, - { - Header: I18n.t("activerecord.attributes.group.student_memberships"), - accessor: "members", - Cell: row => { - if (row.value.length > 0 || !this.props.scanned_exam) { - return row.value.map(member => { - let status; - if (member[1] === "pending") { - status = ({member[1]}); - } else { - status = member.display_label; - } - return ( - - ); - }); - } 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 row._original.members.some(member => member[0].includes(filter.value)); - } else { - return true; - } - }, - sortable: false, - }, - { - Header: I18n.t("groups.valid"), - Cell: row => { - let isValid = - row.original.instructor_approved || - row.original.members.length >= this.props.groupMin; - if (isValid) { - return ( - this.props.invalidate(row.original._id)} - > - ✔ - - ); - } else { - return ( - this.props.validate(row.original._id)} - > - - - ); - } - }, - filterMethod: (filter, row) => { - if (filter.value === "all") { - return true; - } else { - // Either 'true' or 'false' - const val = filter.value === "true"; - let isValid = - row._original.instructor_approved || - row._original.members.length >= this.props.groupMin; - return isValid === val; - } - }, - Filter: selectFilter, - filterOptions: [ - {value: "true", text: I18n.t("groups.is_valid")}, - {value: "false", text: I18n.t("groups.is_not_valid")}, - ], - minWidth: 30, - sortable: false, + caseSensitive: true, + columns: this.getColumns(true), + }; + } + + componentDidUpdate(prevProps, prevState) { + if (prevState.caseSensitive !== this.state.caseSensitive) { + this.setState({columns: this.getColumns(this.state.caseSensitive)}); + } + } + + toggleCaseSensitive = () => { + this.setState(state => ({caseSensitive: !state.caseSensitive})); + }; + + groupNameFilter = caseSensitiveTextFilter({ + getCaseSensitive: () => this.state.caseSensitive, + onToggle: this.toggleCaseSensitive, + }); + + getColumns = caseSensitive => { + return [ + { + accessor: "inactive", + id: "inactive", + width: 0, + className: "rt-hidden", + headerClassName: "rt-hidden", + resizable: false, + }, + { + show: false, + accessor: "id", + id: "_id", + }, + { + Header: I18n.t("activerecord.models.group.one"), + accessor: "group_name", + id: "group_name", + Filter: this.groupNameFilter, + filterMethod: caseSensitiveStringFilterMethod(caseSensitive), + Cell: row => { + return ( + + {row.value} + this.props.renameGroup(row.original._id, row.value)} + title={I18n.t("groups.rename_group")} + > + + + + ); }, - { - Header: props.extensionColumnHeader, - accessor: "extension", - show: !props.scanned_exam, - Cell: row => { - 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 (!!timeExtension) { + }, + { + Header: I18n.t("activerecord.attributes.group.student_memberships"), + accessor: "members", + Cell: row => { + if (row.value.length > 0 || !this.props.scanned_exam) { + return row.value.map(member => { + let status; + if (member[1] === "pending") { + status = ({member[1]}); + } else { + status = member.display_label; + } return ( -
+ ); - } 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