@thepalaceproject/circulation-admin 1.43.0-post.9 → 1.44.0-post.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +22 -10
- package/README.md +11 -5
- package/dist/circulation-admin.js +1 -1
- package/jest.config.js +11 -44
- package/package.json +1 -5
- package/tests/jest/components/AdvancedSearchBuilder.test.tsx +111 -0
- package/tests/jest/components/Announcement.test.tsx +76 -0
- package/tests/jest/components/AnnouncementForm.test.tsx +229 -0
- package/tests/jest/components/AnnouncementsSection.test.tsx +146 -0
- package/tests/jest/components/Autocomplete.test.tsx +67 -0
- package/tests/jest/components/BookCoverEditor.test.tsx +393 -0
- package/tests/jest/components/BookDetailsEditor.test.tsx +275 -0
- package/tests/jest/components/BookDetailsTabContainer.test.tsx +237 -0
- package/tests/jest/components/BookEditForm.test.tsx +352 -0
- package/tests/jest/components/BookEditor.test.tsx +20 -21
- package/tests/jest/components/CatalogPage.test.tsx +158 -0
- package/tests/jest/components/CatalogServices.test.tsx +38 -1
- package/tests/jest/components/ChangePasswordForm.test.tsx +163 -0
- package/tests/jest/components/Classifications.test.tsx +166 -0
- package/tests/jest/components/ClassificationsForm.test.tsx +458 -0
- package/tests/jest/components/ClassificationsTable.test.tsx +57 -0
- package/tests/jest/components/Collections.test.tsx +335 -7
- package/tests/jest/components/ColorPicker.test.tsx +58 -0
- package/tests/jest/components/ComplaintForm.test.tsx +171 -0
- package/tests/jest/components/Complaints.test.tsx +259 -0
- package/tests/jest/components/ConfigTabContainer.test.tsx +196 -0
- package/tests/jest/components/ContextProvider.test.tsx +138 -0
- package/tests/jest/components/Contributors.test.tsx +157 -0
- package/tests/jest/components/CustomListEditor.test.tsx +367 -0
- package/tests/jest/components/CustomListEntriesEditor.test.tsx +1240 -0
- package/tests/jest/components/CustomLists.test.tsx +621 -4
- package/tests/jest/components/CustomListsForBook.test.tsx +353 -0
- package/tests/jest/components/DiagnosticsServiceTabs.test.tsx +141 -0
- package/tests/jest/components/DiagnosticsServiceType.test.tsx +75 -0
- package/tests/jest/components/DiscoveryServices.test.tsx +154 -7
- package/tests/jest/components/EditableConfigList.test.tsx +598 -7
- package/tests/jest/components/EditableInput.test.tsx +250 -1
- package/tests/jest/components/EditorField.test.tsx +95 -0
- package/tests/jest/components/GenreForm.test.tsx +152 -0
- package/tests/jest/components/Header.test.tsx +421 -0
- package/tests/jest/components/IndividualAdminEditForm.test.tsx +506 -1
- package/tests/jest/components/IndividualAdmins.test.tsx +124 -8
- package/tests/jest/components/InputList.test.tsx +493 -0
- package/tests/jest/components/Lane.test.tsx +132 -10
- package/tests/jest/components/LaneCustomListsEditor.test.tsx +450 -0
- package/tests/jest/components/LaneEditor.test.tsx +377 -9
- package/tests/jest/components/Lanes.test.tsx +446 -0
- package/tests/jest/components/LanesSidebar.test.tsx +621 -0
- package/tests/jest/components/Libraries.test.tsx +48 -0
- package/tests/jest/components/LibraryEditForm.test.tsx +383 -0
- package/tests/jest/components/LibraryRegistration.test.tsx +354 -0
- package/tests/jest/components/LibraryRegistrationForm.test.tsx +203 -0
- package/tests/jest/components/MetadataServices.test.tsx +37 -1
- package/tests/jest/components/NeighborhoodAnalyticsForm.test.tsx +167 -0
- package/tests/jest/components/PairedMenus.test.tsx +220 -0
- package/tests/jest/components/PatronAuthServices.test.tsx +114 -0
- package/tests/jest/components/ProtocolFormField.test.tsx +443 -7
- package/tests/jest/components/ResetAdobeId.test.tsx +302 -0
- package/tests/jest/components/SelfTests.test.tsx +601 -0
- package/tests/jest/components/SelfTestsTabContainer.test.tsx +216 -0
- package/tests/jest/components/ServiceEditForm.test.tsx +1092 -0
- package/tests/jest/components/SitewideAnnouncements.test.tsx +134 -0
- package/tests/jest/components/Stats.test.tsx +0 -7
- package/tests/jest/components/TextWithEditMode.test.tsx +218 -0
- package/tests/jest/components/ToolTip.test.tsx +46 -0
- package/tests/jest/components/TroubleshootingCategoryPage.test.tsx +69 -0
- package/tests/jest/components/TroubleshootingPage.test.tsx +94 -0
- package/tests/jest/features/book.test.ts +75 -80
- package/tests/jest/fixtures/classificationsData.ts +45 -0
- package/tests/jest/fixtures/genreData.ts +811 -0
- package/tests/jest/README.md +0 -6
- package/tests/jest/legacy-setup.ts +0 -13
- package/tests/jest/legacyJsdomEnvironment.js +0 -49
package/jest.config.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
|
2
|
-
|
|
3
|
-
// Options shared by both projects.
|
|
4
|
-
const sharedProjectConfig = {
|
|
2
|
+
module.exports = {
|
|
5
3
|
preset: "ts-jest",
|
|
4
|
+
// `jest-fixed-jsdom` restores Node's fetch/Response globals so MSW can
|
|
5
|
+
// intercept them.
|
|
6
|
+
testEnvironment: "jest-fixed-jsdom",
|
|
6
7
|
testEnvironmentOptions: {
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
8
|
+
// MSW / jest-fixed-jsdom rely on the default resolution conditions; the empty
|
|
9
|
+
// string keeps jsdom's "browser" condition from pulling in ESM-only builds
|
|
10
|
+
// that ts-jest would not transform.
|
|
10
11
|
customExportConditions: [""],
|
|
11
12
|
},
|
|
12
13
|
setupFiles: ["<rootDir>/jest.polyfills.js"],
|
|
14
|
+
setupFilesAfterEnv: ["<rootDir>/tests/jest/jest-setup.ts"],
|
|
13
15
|
moduleNameMapper: {
|
|
14
16
|
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
|
|
15
17
|
"<rootDir>/tests/__mocks__/fileMock.js",
|
|
@@ -20,44 +22,9 @@ const sharedProjectConfig = {
|
|
|
20
22
|
// files that no test imports — otherwise they are silently omitted from the
|
|
21
23
|
// report rather than counted as uncovered, and the coverage percentage rises
|
|
22
24
|
// while real coverage is unchanged.
|
|
23
|
-
roots: ["<rootDir>/src"],
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
module.exports = {
|
|
25
|
+
roots: ["<rootDir>/src", "<rootDir>/tests/jest"],
|
|
26
|
+
testMatch: ["<rootDir>/tests/jest/**/*.test.{js,jsx,ts,tsx}"],
|
|
27
27
|
coverageDirectory: "coverage/jest",
|
|
28
28
|
coverageReporters: ["lcov", "text-summary"],
|
|
29
|
-
|
|
30
|
-
// `collectCoverageFrom` off the *global* config. Set it only per-project and it
|
|
31
|
-
// is silently undefined globally, so every executed file gets instrumented —
|
|
32
|
-
// including test helpers and everything under `tests/`.
|
|
33
|
-
collectCoverageFrom: [
|
|
34
|
-
"src/**/*.{ts,tsx}",
|
|
35
|
-
"!src/**/__tests__/**",
|
|
36
|
-
"!src/**/*.d.ts",
|
|
37
|
-
],
|
|
38
|
-
projects: [
|
|
39
|
-
{
|
|
40
|
-
...sharedProjectConfig,
|
|
41
|
-
displayName: "unit",
|
|
42
|
-
roots: [...sharedProjectConfig.roots, "<rootDir>/tests/jest"],
|
|
43
|
-
testMatch: ["<rootDir>/tests/jest/**/*.test.{js,jsx,ts,tsx}"],
|
|
44
|
-
// `jest-fixed-jsdom` restores Node's fetch/Response globals so MSW can
|
|
45
|
-
// intercept them.
|
|
46
|
-
testEnvironment: "jest-fixed-jsdom",
|
|
47
|
-
setupFilesAfterEnv: ["<rootDir>/tests/jest/jest-setup.ts"],
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
// Legacy enzyme + chai + sinon tests, still in `src/**/__tests__/`. They are
|
|
51
|
-
// being rewritten as React Testing Library tests under `tests/jest/`; when the
|
|
52
|
-
// last one is gone, delete this project, its environment, and its setup file.
|
|
53
|
-
//
|
|
54
|
-
// These cannot use `jest-fixed-jsdom`: it swaps in undici's `FormData`, which
|
|
55
|
-
// rejects `new FormData(formElement)` and crashes any test that submits a form.
|
|
56
|
-
...sharedProjectConfig,
|
|
57
|
-
displayName: "legacy",
|
|
58
|
-
testMatch: ["<rootDir>/src/**/__tests__/*-test.{ts,tsx}"],
|
|
59
|
-
testEnvironment: "<rootDir>/tests/jest/legacyJsdomEnvironment.js",
|
|
60
|
-
setupFilesAfterEnv: ["<rootDir>/tests/jest/legacy-setup.ts"],
|
|
61
|
-
},
|
|
62
|
-
],
|
|
29
|
+
collectCoverageFrom: ["src/**/*.{ts,tsx}", "!src/**/*.d.ts"],
|
|
63
30
|
};
|
package/package.json
CHANGED
|
@@ -84,13 +84,10 @@
|
|
|
84
84
|
"@types/react-router": "^3.0.28",
|
|
85
85
|
"@types/recharts": "^1.8.28",
|
|
86
86
|
"@types/redux-mock-store": "^1.0.3",
|
|
87
|
-
"chai": "4.2.0",
|
|
88
87
|
"clean-webpack-plugin": "^2.0.1",
|
|
89
88
|
"copy-webpack-plugin": "14.0.0",
|
|
90
89
|
"css-loader": "^7.1.4",
|
|
91
90
|
"dotenv-cli": "^11.0.0",
|
|
92
|
-
"enzyme": "^3.11.0",
|
|
93
|
-
"enzyme-adapter-react-16": "^1.15.6",
|
|
94
91
|
"eslint": "^9.39.4",
|
|
95
92
|
"eslint-config-prettier": "^10.1.8",
|
|
96
93
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
|
@@ -119,7 +116,6 @@
|
|
|
119
116
|
"sass-lint": "^1.13.1",
|
|
120
117
|
"sass-loader": "^17.0.0",
|
|
121
118
|
"selenium-standalone": "^10.0.2",
|
|
122
|
-
"sinon": "22.0.0",
|
|
123
119
|
"style-loader": "^4.0.0",
|
|
124
120
|
"terser-webpack-plugin": "^5.3.6",
|
|
125
121
|
"ts-jest": "^29.2.6",
|
|
@@ -143,5 +139,5 @@
|
|
|
143
139
|
"*.{js,jsx,ts,tsx,css,md}": "prettier --write",
|
|
144
140
|
"*.{js,css,md}": "prettier --write"
|
|
145
141
|
},
|
|
146
|
-
"version": "1.
|
|
142
|
+
"version": "1.44.0-post.3"
|
|
147
143
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
|
|
4
|
+
// AdvancedSearchBuilder is a pure forwarding component: it renders an
|
|
5
|
+
// AdvancedSearchFilterInput and an AdvancedSearchFilterViewer and threads their
|
|
6
|
+
// callbacks up to the builder's handlers, prefixed with the builder `name`. Mock
|
|
7
|
+
// both children to markers that capture the props they receive, so those
|
|
8
|
+
// forwarding closures can be exercised directly — including `onMove`, which is
|
|
9
|
+
// otherwise only reachable through a real react-dnd drag that jsdom can't perform.
|
|
10
|
+
let inputProps: any;
|
|
11
|
+
let viewerProps: any;
|
|
12
|
+
jest.mock("../../../src/components/AdvancedSearchFilterInput", () => ({
|
|
13
|
+
__esModule: true,
|
|
14
|
+
default: (props: any) => {
|
|
15
|
+
inputProps = props;
|
|
16
|
+
return <div data-testid="filter-input" data-name={props.name} />;
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
jest.mock("../../../src/components/AdvancedSearchFilterViewer", () => ({
|
|
20
|
+
__esModule: true,
|
|
21
|
+
default: (props: any) => {
|
|
22
|
+
viewerProps = props;
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
data-testid="filter-viewer"
|
|
26
|
+
data-read-only={String(props.readOnly)}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
import AdvancedSearchBuilder from "../../../src/components/AdvancedSearchBuilder";
|
|
33
|
+
|
|
34
|
+
describe("AdvancedSearchBuilder", () => {
|
|
35
|
+
const query = { id: "0", key: "genre", value: "Horror" };
|
|
36
|
+
|
|
37
|
+
const renderBuilder = (overrides: Record<string, unknown> = {}) => {
|
|
38
|
+
const handlers = {
|
|
39
|
+
addQuery: jest.fn(),
|
|
40
|
+
updateClearFiltersFlag: jest.fn(),
|
|
41
|
+
updateQueryBoolean: jest.fn(),
|
|
42
|
+
moveQuery: jest.fn(),
|
|
43
|
+
removeQuery: jest.fn(),
|
|
44
|
+
selectQuery: jest.fn(),
|
|
45
|
+
};
|
|
46
|
+
render(
|
|
47
|
+
<AdvancedSearchBuilder
|
|
48
|
+
isOwner
|
|
49
|
+
name="include"
|
|
50
|
+
query={query as any}
|
|
51
|
+
selectedQueryId="0"
|
|
52
|
+
{...handlers}
|
|
53
|
+
{...overrides}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
return handlers;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
inputProps = undefined;
|
|
61
|
+
viewerProps = undefined;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("renders the filter input and the viewer", () => {
|
|
65
|
+
renderBuilder();
|
|
66
|
+
|
|
67
|
+
expect(screen.getByTestId("filter-input")).toHaveAttribute(
|
|
68
|
+
"data-name",
|
|
69
|
+
"include"
|
|
70
|
+
);
|
|
71
|
+
expect(screen.getByTestId("filter-viewer")).toBeInTheDocument();
|
|
72
|
+
expect(viewerProps.query).toBe(query);
|
|
73
|
+
expect(viewerProps.selectedQueryId).toBe("0");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("hides the filter input and marks the viewer read-only when not the owner", () => {
|
|
77
|
+
renderBuilder({ isOwner: false });
|
|
78
|
+
|
|
79
|
+
expect(screen.queryByTestId("filter-input")).not.toBeInTheDocument();
|
|
80
|
+
expect(screen.getByTestId("filter-viewer")).toHaveAttribute(
|
|
81
|
+
"data-read-only",
|
|
82
|
+
"true"
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("forwards the input's onAdd and clear-filters callbacks with the builder name", () => {
|
|
87
|
+
const { addQuery, updateClearFiltersFlag } = renderBuilder();
|
|
88
|
+
|
|
89
|
+
const newQuery = { key: "title", value: "The Shining" };
|
|
90
|
+
inputProps.onAdd(newQuery);
|
|
91
|
+
inputProps.onClearFiltersFlagChange(true);
|
|
92
|
+
|
|
93
|
+
expect(addQuery).toHaveBeenCalledWith("include", newQuery);
|
|
94
|
+
expect(updateClearFiltersFlag).toHaveBeenCalledWith("include", true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("forwards the viewer's callbacks to their handlers with the builder name", () => {
|
|
98
|
+
const { updateQueryBoolean, moveQuery, removeQuery, selectQuery } =
|
|
99
|
+
renderBuilder();
|
|
100
|
+
|
|
101
|
+
viewerProps.onBooleanChange("2", "or");
|
|
102
|
+
viewerProps.onMove("2", "33");
|
|
103
|
+
viewerProps.onRemove("2");
|
|
104
|
+
viewerProps.onSelect("2");
|
|
105
|
+
|
|
106
|
+
expect(updateQueryBoolean).toHaveBeenCalledWith("include", "2", "or");
|
|
107
|
+
expect(moveQuery).toHaveBeenCalledWith("include", "2", "33");
|
|
108
|
+
expect(removeQuery).toHaveBeenCalledWith("include", "2");
|
|
109
|
+
expect(selectQuery).toHaveBeenCalledWith("include", "2");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
|
|
5
|
+
import Announcement from "../../../src/components/Announcement";
|
|
6
|
+
|
|
7
|
+
describe("Announcement", () => {
|
|
8
|
+
let stubDelete: jest.Mock;
|
|
9
|
+
let stubEdit: jest.Mock;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
stubDelete = jest.fn();
|
|
13
|
+
stubEdit = jest.fn();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const renderAnnouncement = (
|
|
17
|
+
overrides: Partial<React.ComponentProps<typeof Announcement>> = {}
|
|
18
|
+
) =>
|
|
19
|
+
render(
|
|
20
|
+
<Announcement
|
|
21
|
+
content="test"
|
|
22
|
+
start="2020-05-12"
|
|
23
|
+
finish="2020-05-28"
|
|
24
|
+
id="1"
|
|
25
|
+
delete={stubDelete}
|
|
26
|
+
edit={stubEdit}
|
|
27
|
+
{...overrides}
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
it("renders the content and dates", () => {
|
|
32
|
+
const { container } = renderAnnouncement();
|
|
33
|
+
|
|
34
|
+
expect(
|
|
35
|
+
container.querySelector("section.announcement-info")
|
|
36
|
+
).toBeInTheDocument();
|
|
37
|
+
expect(container.querySelector(".dates")).toHaveTextContent(
|
|
38
|
+
"05/12/2020 – 05/28/2020"
|
|
39
|
+
);
|
|
40
|
+
expect(screen.getByText("test")).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("renders an edit button", async () => {
|
|
44
|
+
const user = userEvent.setup();
|
|
45
|
+
renderAnnouncement();
|
|
46
|
+
|
|
47
|
+
const editButton = screen.getByRole("button", { name: "Edit" });
|
|
48
|
+
await user.click(editButton);
|
|
49
|
+
|
|
50
|
+
expect(stubEdit).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(stubEdit).toHaveBeenCalledWith("1");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("renders a delete button", async () => {
|
|
55
|
+
const user = userEvent.setup();
|
|
56
|
+
renderAnnouncement();
|
|
57
|
+
|
|
58
|
+
const deleteButton = screen.getByRole("button", { name: "Delete" });
|
|
59
|
+
await user.click(deleteButton);
|
|
60
|
+
|
|
61
|
+
expect(stubDelete).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(stubDelete).toHaveBeenCalledWith("1");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("formats dates", () => {
|
|
66
|
+
// The dates are stored as year-month-day but displayed as month/day/year.
|
|
67
|
+
const { container } = renderAnnouncement({
|
|
68
|
+
start: "2020-03-14",
|
|
69
|
+
finish: "2021-12-25",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(container.querySelector(".dates")).toHaveTextContent(
|
|
73
|
+
"03/14/2020 – 12/25/2021"
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
|
|
5
|
+
import AnnouncementForm from "../../../src/components/AnnouncementForm";
|
|
6
|
+
|
|
7
|
+
// 66 characters — within the 15-350 valid range.
|
|
8
|
+
const CONTENT =
|
|
9
|
+
"Here is some sample content which comes out to over 15 characters.";
|
|
10
|
+
|
|
11
|
+
const getTextarea = () => screen.getByRole("textbox") as HTMLTextAreaElement;
|
|
12
|
+
const getDateInputs = (container: HTMLElement) =>
|
|
13
|
+
Array.from(
|
|
14
|
+
container.querySelectorAll<HTMLInputElement>('input[type="date"]')
|
|
15
|
+
);
|
|
16
|
+
const getCounter = () => screen.getByText(/Current length:/);
|
|
17
|
+
|
|
18
|
+
// Enter valid content and a start/finish date.
|
|
19
|
+
const fillOutForm = async (
|
|
20
|
+
user: ReturnType<typeof userEvent.setup>,
|
|
21
|
+
container: HTMLElement
|
|
22
|
+
) => {
|
|
23
|
+
await user.type(getTextarea(), CONTENT);
|
|
24
|
+
const [start, finish] = getDateInputs(container);
|
|
25
|
+
// Date inputs are read via a ref, so `fireEvent.change` (set value + dispatch)
|
|
26
|
+
// reliably sets the value.
|
|
27
|
+
fireEvent.change(start, { target: { value: "2020-06-01" } });
|
|
28
|
+
fireEvent.change(finish, { target: { value: "2020-07-01" } });
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("AnnouncementForm", () => {
|
|
32
|
+
let add: jest.Mock;
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
add = jest.fn();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("renders the input fields", () => {
|
|
38
|
+
const { container } = render(<AnnouncementForm add={add} />);
|
|
39
|
+
|
|
40
|
+
// Content field: a textarea carrying the 15/350 length constraints.
|
|
41
|
+
const textarea = getTextarea();
|
|
42
|
+
expect(textarea.tagName).toBe("TEXTAREA");
|
|
43
|
+
expect(textarea).toHaveAttribute("minlength", "15");
|
|
44
|
+
expect(textarea).toHaveAttribute("maxlength", "350");
|
|
45
|
+
expect(
|
|
46
|
+
screen.getByText("New Announcement Text (15-350 characters)")
|
|
47
|
+
).toBeInTheDocument();
|
|
48
|
+
expect(screen.getByText("(Current length: 0/350)")).toBeInTheDocument();
|
|
49
|
+
|
|
50
|
+
// Two date fields, each with its label and description.
|
|
51
|
+
const dates = getDateInputs(container);
|
|
52
|
+
expect(dates).toHaveLength(2);
|
|
53
|
+
// EditableInput prepends "(Optional) " to each description, so match the
|
|
54
|
+
// full sentence as a substring rather than the whole text content.
|
|
55
|
+
expect(screen.getByText("Start Date")).toBeInTheDocument();
|
|
56
|
+
expect(
|
|
57
|
+
screen.getByText(
|
|
58
|
+
/If no start date is chosen, the default start date is today's date\./
|
|
59
|
+
)
|
|
60
|
+
).toBeInTheDocument();
|
|
61
|
+
expect(screen.getByText("End Date")).toBeInTheDocument();
|
|
62
|
+
expect(
|
|
63
|
+
screen.getByText(
|
|
64
|
+
/If no expiration date is chosen, the default expiration date is 2 months from the start date\./
|
|
65
|
+
)
|
|
66
|
+
).toBeInTheDocument();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// The other tests only compare the date fields against whatever they rendered
|
|
70
|
+
// with, so they would all still pass if the defaults came out blank. Pin the
|
|
71
|
+
// clock and assert the actual values.
|
|
72
|
+
it("prefills the date fields with today and two months from today", () => {
|
|
73
|
+
jest.useFakeTimers().setSystemTime(new Date(2026, 2, 15));
|
|
74
|
+
try {
|
|
75
|
+
const { container } = render(<AnnouncementForm add={add} />);
|
|
76
|
+
|
|
77
|
+
const [start, finish] = getDateInputs(container);
|
|
78
|
+
expect(start.value).toBe("2026-03-15");
|
|
79
|
+
expect(finish.value).toBe("2026-05-15");
|
|
80
|
+
} finally {
|
|
81
|
+
jest.useRealTimers();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("renders the buttons", () => {
|
|
86
|
+
render(<AnnouncementForm add={add} />);
|
|
87
|
+
expect(screen.getAllByRole("button")).toHaveLength(2);
|
|
88
|
+
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
|
89
|
+
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("keeps track of whether the content is too short or too long", async () => {
|
|
93
|
+
const user = userEvent.setup();
|
|
94
|
+
const { container } = render(<AnnouncementForm add={add} />);
|
|
95
|
+
|
|
96
|
+
// Empty content: 0/350, flagged as the wrong length, Add disabled.
|
|
97
|
+
expect(getCounter()).toHaveTextContent("(Current length: 0/350)");
|
|
98
|
+
expect(getCounter().parentElement).toHaveClass("wrong-length");
|
|
99
|
+
expect(screen.getByRole("button", { name: "Add" })).toBeDisabled();
|
|
100
|
+
|
|
101
|
+
// Valid content: length flag cleared and Add enabled.
|
|
102
|
+
await fillOutForm(user, container);
|
|
103
|
+
expect(getCounter()).toHaveTextContent("(Current length: 66/350)");
|
|
104
|
+
expect(getCounter().parentElement).not.toHaveClass("wrong-length");
|
|
105
|
+
expect(screen.getByRole("button", { name: "Add" })).not.toBeDisabled();
|
|
106
|
+
|
|
107
|
+
// Content at the 350-character cap is flagged as too long again.
|
|
108
|
+
const longString =
|
|
109
|
+
"Here's some extremely/gratuitously long content. The point of the content is just to get up to 350 characters so that I can test whether the class name will change. I realize even as I am typing this that it probably would have been a better idea to do it with filler text; oh well...? Anyway, I have now written the most boring announcement EVER.";
|
|
110
|
+
fireEvent.change(getTextarea(), { target: { value: longString } });
|
|
111
|
+
expect(getCounter()).toHaveTextContent("(Current length: 350/350)");
|
|
112
|
+
expect(getCounter().parentElement).toHaveClass("wrong-length");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("adds a new announcement", async () => {
|
|
116
|
+
const user = userEvent.setup();
|
|
117
|
+
const { container } = render(<AnnouncementForm add={add} />);
|
|
118
|
+
await fillOutForm(user, container);
|
|
119
|
+
|
|
120
|
+
await user.click(screen.getByRole("button", { name: "Add" }));
|
|
121
|
+
|
|
122
|
+
expect(add).toHaveBeenCalledTimes(1);
|
|
123
|
+
expect(add.mock.calls[0][0].content).toBe(CONTENT);
|
|
124
|
+
expect(add.mock.calls[0][0].start).toBe("2020-06-01");
|
|
125
|
+
expect(add.mock.calls[0][0].finish).toBe("2020-07-01");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("cancels adding a new announcement", async () => {
|
|
129
|
+
const user = userEvent.setup();
|
|
130
|
+
const { container } = render(<AnnouncementForm add={add} />);
|
|
131
|
+
const [start0, finish0] = getDateInputs(container);
|
|
132
|
+
const defaultStart = start0.value;
|
|
133
|
+
const defaultFinish = finish0.value;
|
|
134
|
+
|
|
135
|
+
await fillOutForm(user, container);
|
|
136
|
+
expect(getTextarea()).toHaveValue(CONTENT);
|
|
137
|
+
|
|
138
|
+
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
|
139
|
+
|
|
140
|
+
// Cancelling a brand-new announcement adds nothing and restores the
|
|
141
|
+
// empty content field and the default dates.
|
|
142
|
+
expect(add).not.toHaveBeenCalled();
|
|
143
|
+
expect(getTextarea()).toHaveValue("");
|
|
144
|
+
expect(getCounter()).toHaveTextContent("(Current length: 0/350)");
|
|
145
|
+
const [start1, finish1] = getDateInputs(container);
|
|
146
|
+
expect(start1.value).toBe(defaultStart);
|
|
147
|
+
expect(finish1.value).toBe(defaultFinish);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("edits an existing announcement", async () => {
|
|
151
|
+
const user = userEvent.setup();
|
|
152
|
+
const { container, rerender } = render(<AnnouncementForm add={add} />);
|
|
153
|
+
const [start0, finish0] = getDateInputs(container);
|
|
154
|
+
const defaultStart = start0.value;
|
|
155
|
+
const defaultFinish = finish0.value;
|
|
156
|
+
|
|
157
|
+
// Supplying an existing announcement's props switches the form to edit mode.
|
|
158
|
+
rerender(
|
|
159
|
+
<AnnouncementForm
|
|
160
|
+
add={add}
|
|
161
|
+
content={CONTENT}
|
|
162
|
+
start="07/01/2020"
|
|
163
|
+
finish="08/01/2020"
|
|
164
|
+
/>
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// The form is populated with the existing announcement (dates reformatted).
|
|
168
|
+
expect(getTextarea()).toHaveValue(CONTENT);
|
|
169
|
+
const [start1, finish1] = getDateInputs(container);
|
|
170
|
+
expect(start1.value).toBe("2020-07-01");
|
|
171
|
+
expect(finish1.value).toBe("2020-08-01");
|
|
172
|
+
|
|
173
|
+
// Edit the content and save it.
|
|
174
|
+
fireEvent.change(getTextarea(), {
|
|
175
|
+
target: { value: "Here is an edited version of the content" },
|
|
176
|
+
});
|
|
177
|
+
await user.click(screen.getByRole("button", { name: "Add" }));
|
|
178
|
+
|
|
179
|
+
expect(add).toHaveBeenCalledTimes(1);
|
|
180
|
+
expect(add.mock.calls[0][0].content).toBe(
|
|
181
|
+
"Here is an edited version of the content"
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// The form resets to an empty content field and the default dates.
|
|
185
|
+
expect(getTextarea()).toHaveValue("");
|
|
186
|
+
const [start2, finish2] = getDateInputs(container);
|
|
187
|
+
expect(start2.value).toBe(defaultStart);
|
|
188
|
+
expect(finish2.value).toBe(defaultFinish);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("cancels editing an existing announcement", async () => {
|
|
192
|
+
const user = userEvent.setup();
|
|
193
|
+
const { container, rerender } = render(<AnnouncementForm add={add} />);
|
|
194
|
+
const [start0, finish0] = getDateInputs(container);
|
|
195
|
+
const defaultStart = start0.value;
|
|
196
|
+
const defaultFinish = finish0.value;
|
|
197
|
+
|
|
198
|
+
rerender(
|
|
199
|
+
<AnnouncementForm
|
|
200
|
+
add={add}
|
|
201
|
+
content={CONTENT}
|
|
202
|
+
start="07/01/2020"
|
|
203
|
+
finish="08/01/2020"
|
|
204
|
+
/>
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
expect(getTextarea()).toHaveValue(CONTENT);
|
|
208
|
+
const [start1, finish1] = getDateInputs(container);
|
|
209
|
+
expect(start1.value).toBe("2020-07-01");
|
|
210
|
+
expect(finish1.value).toBe("2020-08-01");
|
|
211
|
+
|
|
212
|
+
fireEvent.change(getTextarea(), {
|
|
213
|
+
target: { value: "Here is an edited version of the content" },
|
|
214
|
+
});
|
|
215
|
+
// Cancelling while editing re-submits the (edited) announcement rather than
|
|
216
|
+
// discarding it — unlike cancelling a brand-new one.
|
|
217
|
+
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
|
218
|
+
|
|
219
|
+
expect(add).toHaveBeenCalledTimes(1);
|
|
220
|
+
expect(add.mock.calls[0][0].content).toBe(
|
|
221
|
+
"Here is an edited version of the content"
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
expect(getTextarea()).toHaveValue("");
|
|
225
|
+
const [start2, finish2] = getDateInputs(container);
|
|
226
|
+
expect(start2.value).toBe(defaultStart);
|
|
227
|
+
expect(finish2.value).toBe(defaultFinish);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { render, screen, within } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
|
|
5
|
+
import AnnouncementsSection from "../../../src/components/AnnouncementsSection";
|
|
6
|
+
import { SettingData } from "../../../src/interfaces";
|
|
7
|
+
|
|
8
|
+
const announcements = [
|
|
9
|
+
{
|
|
10
|
+
content: "First Announcement",
|
|
11
|
+
start: "2020-05-12",
|
|
12
|
+
finish: "2020-06-12",
|
|
13
|
+
id: "perm_1",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
content: "Second Announcement",
|
|
17
|
+
start: "2020-05-28",
|
|
18
|
+
finish: "2020-06-28",
|
|
19
|
+
id: "perm_2",
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const setting: SettingData = {
|
|
24
|
+
description: "announcements",
|
|
25
|
+
format: "date-range",
|
|
26
|
+
key: "announcements",
|
|
27
|
+
label: "Announcements",
|
|
28
|
+
type: "list",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const renderSection = (ref?: React.RefObject<AnnouncementsSection>) =>
|
|
32
|
+
render(
|
|
33
|
+
<AnnouncementsSection
|
|
34
|
+
ref={ref}
|
|
35
|
+
setting={setting}
|
|
36
|
+
// Fresh copies so the in-place sort during render can't leak between tests.
|
|
37
|
+
value={announcements.map((a) => ({ ...a }))}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const announcementEls = (container: HTMLElement) =>
|
|
42
|
+
Array.from(container.querySelectorAll<HTMLElement>(".announcement"));
|
|
43
|
+
|
|
44
|
+
describe("AnnouncementsSection", () => {
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
jest.restoreAllMocks();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("renders a list of announcements", () => {
|
|
50
|
+
const { container } = renderSection();
|
|
51
|
+
|
|
52
|
+
expect(
|
|
53
|
+
screen.getByRole("heading", { name: "Scheduled Announcements:" })
|
|
54
|
+
).toBeInTheDocument();
|
|
55
|
+
|
|
56
|
+
const items = announcementEls(container);
|
|
57
|
+
expect(items).toHaveLength(2);
|
|
58
|
+
|
|
59
|
+
// Sorted by start date: First (05/12) then Second (05/28).
|
|
60
|
+
expect(
|
|
61
|
+
within(items[0]).getByText("First Announcement")
|
|
62
|
+
).toBeInTheDocument();
|
|
63
|
+
expect(
|
|
64
|
+
within(items[0]).getByText("05/12/2020 – 06/12/2020")
|
|
65
|
+
).toBeInTheDocument();
|
|
66
|
+
expect(
|
|
67
|
+
within(items[1]).getByText("Second Announcement")
|
|
68
|
+
).toBeInTheDocument();
|
|
69
|
+
expect(
|
|
70
|
+
within(items[1]).getByText("05/28/2020 – 06/28/2020")
|
|
71
|
+
).toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("renders a form", () => {
|
|
75
|
+
const { container } = renderSection();
|
|
76
|
+
expect(container.querySelectorAll(".announcement-form")).toHaveLength(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("adds an announcement", async () => {
|
|
80
|
+
const user = userEvent.setup();
|
|
81
|
+
const { container } = renderSection();
|
|
82
|
+
expect(announcementEls(container)).toHaveLength(2);
|
|
83
|
+
|
|
84
|
+
// The only textbox on the page is the form's content field.
|
|
85
|
+
await user.type(screen.getByRole("textbox"), "Third Announcement");
|
|
86
|
+
await user.click(screen.getByRole("button", { name: "Add" }));
|
|
87
|
+
|
|
88
|
+
expect(announcementEls(container)).toHaveLength(3);
|
|
89
|
+
expect(screen.getByText("Third Announcement")).toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("edits an announcement", async () => {
|
|
93
|
+
const user = userEvent.setup();
|
|
94
|
+
const { container } = renderSection();
|
|
95
|
+
expect(announcementEls(container)).toHaveLength(2);
|
|
96
|
+
|
|
97
|
+
// Each announcement has its own Edit button; edit the first one.
|
|
98
|
+
await user.click(screen.getAllByRole("button", { name: "Edit" })[0]);
|
|
99
|
+
|
|
100
|
+
// The edited announcement leaves the list and is loaded into the form.
|
|
101
|
+
const items = announcementEls(container);
|
|
102
|
+
expect(items).toHaveLength(1);
|
|
103
|
+
expect(
|
|
104
|
+
within(items[0]).getByText("Second Announcement")
|
|
105
|
+
).toBeInTheDocument();
|
|
106
|
+
expect(screen.getByRole("textbox")).toHaveValue("First Announcement");
|
|
107
|
+
|
|
108
|
+
// Its dates are loaded too, which is what lets it be re-saved unchanged.
|
|
109
|
+
const dates =
|
|
110
|
+
container.querySelectorAll<HTMLInputElement>('input[type="date"]');
|
|
111
|
+
expect(dates[0]).toHaveValue("2020-05-12");
|
|
112
|
+
expect(dates[1]).toHaveValue("2020-06-12");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("deletes an announcement", async () => {
|
|
116
|
+
const user = userEvent.setup();
|
|
117
|
+
jest.spyOn(window, "confirm").mockReturnValue(true);
|
|
118
|
+
const { container } = renderSection();
|
|
119
|
+
expect(announcementEls(container)).toHaveLength(2);
|
|
120
|
+
|
|
121
|
+
await user.click(screen.getAllByRole("button", { name: "Delete" })[0]);
|
|
122
|
+
|
|
123
|
+
const items = announcementEls(container);
|
|
124
|
+
expect(items).toHaveLength(1);
|
|
125
|
+
expect(
|
|
126
|
+
within(items[0]).getByText("Second Announcement")
|
|
127
|
+
).toBeInTheDocument();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("removes any temporary IDs before submitting the list of announcements", async () => {
|
|
131
|
+
const user = userEvent.setup();
|
|
132
|
+
const ref = React.createRef<AnnouncementsSection>();
|
|
133
|
+
renderSection(ref);
|
|
134
|
+
|
|
135
|
+
// Add a third announcement through the form; it is assigned a temporary id.
|
|
136
|
+
await user.type(screen.getByRole("textbox"), "Third Announcement");
|
|
137
|
+
await user.click(screen.getByRole("button", { name: "Add" }));
|
|
138
|
+
|
|
139
|
+
// `getValue` is the imperative API the parent form calls on submit: it keeps
|
|
140
|
+
// the permanent ids and strips the temporary one.
|
|
141
|
+
const list = ref.current.getValue();
|
|
142
|
+
expect(list[0].id).toBe("perm_1");
|
|
143
|
+
expect(list[1].id).toBe("perm_2");
|
|
144
|
+
expect(list[2].id).toBeUndefined();
|
|
145
|
+
});
|
|
146
|
+
});
|