Skip to content
Open
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
1 change: 1 addition & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions app/javascript/Components/Helpers/table_helpers.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I appreciate the coercion, but for null and undefined: String(null) === "null" and String(undefined) === "undefined", which I do not think are appropriate values for this function. I'd be happy to have them "coerced" into the empty string "".

*/
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 <input> elements stable across re-renders.
*/
export function caseSensitiveTextFilter({getCaseSensitive, onToggle}) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Okay so looking at the overall code changes, I think we can simplify greatly by keeping the toggled "caseSensitive" state in this component rather than lift it up to the parent component. The key idea here is that we can use the onChange callback to pass arbitrary data directly between this filter component and the table.

So you should be able to do something like

export function caseSensitiveTextFilter({onChange, column}) => {
  const { filterValue, setFilterValue } = React.useState("");
  const { caseSensitive, setCaseSensitive } = React.useState(false);

  React.useEffect(() => {
    // Key line
    onChange({filterValue: filterValue, caseSensitive: caseSensitive});
  }, [filterValue, caseSensitive]);

  return (
    // The same, except the input element onChange callbacks call setFilterValue/setCaseSensitive
    ...
  );
});

Then you can modify the filterMethod functions used in the components to access filter.value.filterValue and filter.value.caseSensitive directly, rather than requiring the latter to be passed in by the parent component.

return ({filter, onChange, column}) => (
<div style={{display: "flex", alignItems: "center", gap: "4px"}}>
<input
type="text"
style={{flex: 1, minWidth: 0}}
value={filter ? filter.value : ""}
aria-label={`${I18n.t("search")} ${column.Header || ""}`}
onChange={event => onChange(event.target.value)}
/>
<label
title={I18n.t("table.case_sensitive_search")}
style={{display: "flex", alignItems: "center", cursor: "pointer"}}
>
<input
type="checkbox"
checked={getCaseSensitive()}
onChange={onToggle}
aria-label={I18n.t("table.case_sensitive_search")}
data-testid={`${column.id}_case_sensitive`}
/>
<span style={{fontSize: "1.05em", marginLeft: "2px"}}>
{I18n.t("table.case_sensitive_indicator")}
</span>
</label>
</div>
);
}

/**
* Select-based search filter. Options are generated from the custom column attribute
* filterOptions, which is a list of objects with keys "value" and "text".
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<AnnotationUsagePanel course_id={1} assignment_id={1} annotation_id={1} num_used={3} />);

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();
});
});
89 changes: 89 additions & 0 deletions app/javascript/Components/__tests__/graders_manager.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<GradersManager sections={{}} course_id={1} assignment_id={1} />);
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();
});
});
74 changes: 73 additions & 1 deletion app/javascript/Components/__tests__/groups_manager.test.jsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(<GroupsManager {...props} />);
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();
});
});
Loading
Loading