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 @@ -18,6 +18,7 @@
- Added term-based suffixes to course names created via LTI to ensure uniqueness across academic years (#7881)
- Added `db:populate_course_dates` rake task to backfill `start_at`/`end_at` for existing courses, and permit those fields when creating courses through the admin UI (#7925)
- Sync due date when creating or updating LTI gradebook line items, and re-sync automatically when an assessment is edited (#7872)
- 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
64 changes: 64 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,70 @@ export function textFilter({filter, onChange, column}) {
);
}

/**
* Locale-aware substring match with optional case sensitivity. null/undefined
* arguments are treated as the empty string, so callers do not have to guard
* against them.
*/
export function caseSensitiveIncludes(haystack, needle, caseSensitive) {
const a = haystack == null ? "" : String(haystack);
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 don't like using == as it introduces type coercion. Please use the more explicit haystack === null || haystack === undefined instead.

const b = needle == null ? "" : String(needle);
if (caseSensitive) return a.includes(b);
return a.toLocaleLowerCase().includes(b.toLocaleLowerCase());
}

/**
* filterMethod that matches `row[filter.id]` against the filter's search text
* with the filter's own case sensitivity. Pair with `caseSensitiveTextFilter`,
* which stores `{filterValue, caseSensitive}` as the filter value. Empty
* filters match every row.
*/
export function caseSensitiveStringFilterMethod(filter, row) {
const {filterValue, caseSensitive} = filter.value;
if (!filterValue) return true;
return caseSensitiveIncludes(row[filter.id], filterValue, caseSensitive);
}

/**
* A Filter component pairing a text input with an "Aa" checkbox toggle for
* case-sensitive matching. It owns both the search text and the toggle,
* passing them to the table together via `onChange` as
* `{filterValue, caseSensitive}`. Matching defaults to case-sensitive. Pair
* with `caseSensitiveStringFilterMethod`, or a custom filterMethod that reads
* `filter.value.filterValue` and `filter.value.caseSensitive`.
*/
export function caseSensitiveTextFilter({filter, onChange, column}) {
const filterValue = filter ? filter.value.filterValue : "";
const caseSensitive = filter ? filter.value.caseSensitive : true;
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.

let's make the default false (this is the most typical usecase)


return (
<div style={{display: "flex", alignItems: "center", gap: "4px"}}>
<input
type="text"
style={{flex: 1, minWidth: 0}}
value={filterValue}
aria-label={`${I18n.t("search")} ${column.Header || ""}`}
onChange={event => onChange({filterValue: event.target.value, caseSensitive})}
/>
<label
title={I18n.t("table.case_sensitive_search")}
style={{display: "flex", alignItems: "center", cursor: "pointer"}}
>
<input
type="checkbox"
checked={caseSensitive}
onChange={event => onChange({filterValue, caseSensitive: event.target.checked})}
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