Skip to content

Commit 2bc25b7

Browse files
committed
Add front end for authority bulk create
1 parent a6c9601 commit 2bc25b7

File tree

10 files changed

+228
-6
lines changed

10 files changed

+228
-6
lines changed

Diff for: .github/workflows/test.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ jobs:
162162
- uses: actions/checkout@v2
163163
- name: Provision Localstack using Cloud Pod
164164
run: |
165-
pip install localstack==2.1.0 localstack-plugin-persistence
165+
pip install localstack==2.1.0
166166
curl -O https://nul-public.s3.amazonaws.com/meadow/test/localstack.pod
167167
localstack pod load file://$PWD/localstack.pod
168168
- uses: actions/setup-node@v3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import React from "react";
2+
import PropTypes from "prop-types";
3+
import { Button } from "@nulib/design-system";
4+
import { useDropzone } from "react-dropzone";
5+
import { useForm, FormProvider } from "react-hook-form";
6+
import { IconCsv } from "@js/components/Icon";
7+
import UIIconText from "@js/components/UI/IconText";
8+
9+
/** @jsx jsx */
10+
import { css, jsx } from "@emotion/react";
11+
const dropZone = css`
12+
background: #efefef;
13+
border: 3px dashed #ccc;
14+
`;
15+
16+
function DashboardsLocalAuthoritiesModalBulkAdd({
17+
isOpen,
18+
handleClose,
19+
}) {
20+
const methods = useForm();
21+
const [currentFile, setCurrentFile] = React.useState();
22+
23+
const onDrop = React.useCallback((acceptedFiles) => {
24+
const file = acceptedFiles[0];
25+
setCurrentFile(file);
26+
27+
// Chrome isn't updating the file input object correctly
28+
// using dropzone so let's do it manually.
29+
const input = document.getElementById('csv-file-input');
30+
const fileList = new DataTransfer();
31+
fileList.items.add(file);
32+
input.files = fileList.files;
33+
}, []);
34+
const accept = {
35+
"text/csv": [".csv"],
36+
"application/csv": [".csv"],
37+
"application/vnd.ms-excel": [".csv"],
38+
};
39+
const { getRootProps, getInputProps, isDragActive } = useDropzone({ accept, onDrop });
40+
41+
return (
42+
<FormProvider {...methods}>
43+
<form
44+
method="POST"
45+
action="/api/authority_records/bulk_create"
46+
encType="multipart/form-data"
47+
name="modal-nul-authority-bulk-add"
48+
data-testid="modal-nul-authority-bulk-add"
49+
className={`modal ${isOpen ? "is-active" : ""}`}
50+
onSubmit={() => {
51+
methods.reset();
52+
handleClose();
53+
}}
54+
role="form"
55+
>
56+
<div className="modal-background"></div>
57+
<div className="modal-card">
58+
<header className="modal-card-head">
59+
<p className="modal-card-title">
60+
Bulk add new NUL Authority Records
61+
</p>
62+
<button
63+
className="delete"
64+
aria-label="close"
65+
type="button"
66+
onClick={handleClose}
67+
></button>
68+
</header>
69+
<section className="modal-card-body">
70+
<div
71+
{...getRootProps()}
72+
className="p-6 is-clickable has-text-centered"
73+
css={dropZone}
74+
>
75+
<input
76+
{...getInputProps()}
77+
id="csv-file-input"
78+
data-testid="dropzone-input"
79+
name="records"
80+
/>
81+
<p className="has-text-centered">
82+
<UIIconText
83+
icon={<IconCsv className="has-text-grey" />}
84+
isCentered
85+
>
86+
{isDragActive
87+
? "Drop the file here ..."
88+
: "Drag 'n' drop a file here, or click to select file"}
89+
</UIIconText>
90+
</p>
91+
</div>
92+
{currentFile && (
93+
<React.Fragment>
94+
<p className="mt-5 pb-0 mb-0 subtitle">Current file</p>
95+
<table className="table is-fullwidth">
96+
<thead>
97+
<tr>
98+
<th>Name</th>
99+
<th>Path</th>
100+
<th>Size</th>
101+
<th>Type</th>
102+
</tr>
103+
</thead>
104+
<tbody>
105+
<tr>
106+
<td>{currentFile.name}</td>
107+
<td>{currentFile.path}</td>
108+
<td>{currentFile.size}</td>
109+
<td>{currentFile.type}</td>
110+
</tr>
111+
</tbody>
112+
</table>
113+
</React.Fragment>
114+
)}
115+
</section>
116+
<footer className="modal-card-foot buttons is-right">
117+
<Button isText onClick={handleClose} data-testid="cancel-button">
118+
Cancel
119+
</Button>
120+
<Button isPrimary type="submit" data-testid="submit-button">
121+
Upload
122+
</Button>
123+
</footer>
124+
</div>
125+
</form>
126+
</FormProvider>
127+
);
128+
}
129+
130+
DashboardsLocalAuthoritiesModalBulkAdd.propTypes = {
131+
isOpen: PropTypes.bool,
132+
};
133+
134+
export default DashboardsLocalAuthoritiesModalBulkAdd;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import React from "react";
2+
import { render, screen, waitFor } from "@testing-library/react";
3+
import DashboardsLocalAuthoritiesModalBulkAdd from "./ModalBulkAdd";
4+
import userEvent from "@testing-library/user-event";
5+
6+
const cancelCallback = jest.fn();
7+
8+
const props = {
9+
isOpen: true,
10+
handleClose: cancelCallback,
11+
};
12+
13+
describe("DashboardsLocalAuthoritiesModalBulkAdd component", () => {
14+
beforeEach(() => {
15+
render(<DashboardsLocalAuthoritiesModalBulkAdd {...props} />);
16+
});
17+
18+
it("renders", () => {
19+
expect(screen.getByTestId("modal-nul-authority-bulk-add"));
20+
});
21+
22+
it("renders all add form elements ", () => {
23+
expect(screen.getByRole("form"));
24+
expect(screen.getByTestId("dropzone-input"));
25+
expect(screen.getByTestId("submit-button"));
26+
expect(screen.getByTestId("cancel-button"));
27+
});
28+
29+
it("calls the Cancel callback function", async () => {
30+
const user = userEvent.setup();
31+
await user.click(screen.getByTestId("cancel-button"));
32+
expect(cancelCallback).toHaveBeenCalled();
33+
});
34+
});

Diff for: app/assets/js/components/Dashboards/LocalAuthorities/TitleBar.jsx

+18-1
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { ActionHeadline, PageTitle } from "@js/components/UI/UI";
33
import { Button } from "@nulib/design-system";
44
import { CREATE_NUL_AUTHORITY_RECORD } from "@js/components/Dashboards/dashboards.gql";
55
import DashboardsLocalAuthoritiesModalAdd from "@js/components/Dashboards/LocalAuthorities/ModalAdd";
6+
import DashboardsLocalAuthoritiesModalBulkAdd from "@js/components/Dashboards/LocalAuthorities/ModalBulkAdd";
67
import FormDownloadWrapper from "@js/components/UI/Form/DownloadWrapper";
7-
import { IconAdd } from "@js/components/Icon";
8+
import { IconAdd, IconAddMany } from "@js/components/Icon";
89
import { IconCsv } from "@js/components/Icon";
910
import NLogo from "@js/components/northwesternN.svg";
1011
import React from "react";
@@ -14,6 +15,8 @@ import { useMutation } from "@apollo/client";
1415

1516
function DashboardsLocalAuthoritiesTitleBar() {
1617
const [isAddModalOpen, setIsAddModalOpen] = React.useState();
18+
const [isBulkAddModalOpen, setIsBulkAddModalOpen] = React.useState();
19+
1720
const [createNulAuthorityRecord, { _data, _error, _loading }] = useMutation(
1821
CREATE_NUL_AUTHORITY_RECORD,
1922
{
@@ -63,6 +66,15 @@ function DashboardsLocalAuthoritiesTitleBar() {
6366
<span>Add Local Authority</span>
6467
</Button>
6568
</p>
69+
<p className="control">
70+
<Button
71+
onClick={() => setIsBulkAddModalOpen(true)}
72+
data-testid="bulk-add-button"
73+
>
74+
<IconAddMany />
75+
<span>Bulk Add</span>
76+
</Button>
77+
</p>
6678
<span className="control">
6779
<FormDownloadWrapper formAction="/api/authority_records/nul_authority_records.csv">
6880
<Button data-testid="button-csv-authority-export" type="submit">
@@ -79,6 +91,11 @@ function DashboardsLocalAuthoritiesTitleBar() {
7991
handleAddLocalAuthority={handleAddLocalAuthority}
8092
handleClose={() => setIsAddModalOpen(false)}
8193
/>
94+
95+
<DashboardsLocalAuthoritiesModalBulkAdd
96+
isOpen={isBulkAddModalOpen}
97+
handleClose={() => setIsBulkAddModalOpen(false)}
98+
/>
8299
</React.Fragment>
83100
);
84101
}

Diff for: app/assets/js/components/Dashboards/LocalAuthorities/TitleBar.test.js

+9
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ describe("DashboardsLocalAuthoritiesTitleBar component", () => {
1313
expect(screen.getByTestId("nul-authorities-title-bar"));
1414
expect(screen.getByTestId("local-authorities-dashboard-title"));
1515
expect(screen.getByTestId("add-button"));
16+
expect(screen.getByTestId("bulk-add-button"));
1617
expect(screen.getByTestId("button-csv-authority-export"));
1718
});
1819

@@ -23,4 +24,12 @@ describe("DashboardsLocalAuthoritiesTitleBar component", () => {
2324
await user.click(screen.getByTestId("add-button"));
2425
expect(modalEl).toHaveClass("is-active");
2526
});
27+
28+
it("renders the Bulk Add modal and displays the modal successfully when clicking the Bulk Add button", async () => {
29+
const user = userEvent.setup();
30+
const modalEl = screen.getByTestId("modal-nul-authority-bulk-add");
31+
expect(modalEl).not.toHaveClass("is-active");
32+
await user.click(screen.getByTestId("bulk-add-button"));
33+
expect(modalEl).toHaveClass("is-active");
34+
});
2635
});

Diff for: app/assets/js/components/Icon/AddMany.jsx

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import React from "react";
2+
import { GrChapterAdd } from "react-icons/gr";
3+
4+
export default function IconAddMany(props) {
5+
return <GrChapterAdd {...props} />;
6+
}

Diff for: app/assets/js/components/Icon/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import IconAdd from "@js/components/Icon/Add";
2+
import IconAddMany from "@js/components/Icon/AddMany";
23
import IconAlert from "@js/components/Icon/Alert";
34
import IconArrowDown from "@js/components/Icon/ArrowDown";
45
import IconArrowLeft from "@js/components/Icon/ArrowLeft";
@@ -35,6 +36,7 @@ import IconUser from "@js/components/Icon/User";
3536

3637
export {
3738
IconAdd,
39+
IconAddMany,
3840
IconAlert,
3941
IconArrowDown,
4042
IconArrowLeft,

Diff for: app/lib/meadow_web/controllers/authority_records_controller.ex

+8-2
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,18 @@ defmodule MeadowWeb.AuthorityRecordsController do
1313
end
1414

1515
defp do_bulk_create({:error, :bad_format}, conn) do
16-
send_resp(conn, 400, "Bad Request")
16+
case conn |> get_req_header("referer") do
17+
[referer | _] -> redirect(conn, external: referer)
18+
_ -> send_resp(conn, 400, "Bad Request")
19+
end
1720
end
1821

1922
defp do_bulk_create({:ok, records}, conn) do
2023
results =
2124
records
2225
|> AuthorityRecords.create_authority_records()
2326
|> Enum.map(fn {status, %{id: id, label: label, hint: hint}} ->
24-
["info:nul/#{id}", label, hint, status]
27+
[id, label, hint, status]
2528
end)
2629

2730
file = "authority_import_#{DateTime.utc_now() |> DateTime.to_unix()}.csv"
@@ -56,6 +59,9 @@ defmodule MeadowWeb.AuthorityRecordsController do
5659
_ ->
5760
{:error, :bad_format}
5861
end
62+
rescue
63+
NimbleCSV.ParseError -> {:error, :bad_format}
64+
exception -> reraise exception, __STACKTRACE__
5965
end
6066

6167
def export(conn, %{"file" => file} = params) do

Diff for: app/lib/nul/authority_records.ex

+1-1
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ defmodule NUL.AuthorityRecords do
101101
records =
102102
Enum.map(list_of_attrs, fn attrs ->
103103
Map.merge(attrs, %{
104-
id: Ecto.UUID.generate(),
104+
id: "info:nul/" <> Ecto.UUID.generate(),
105105
inserted_at: inserted_at,
106106
updated_at: inserted_at
107107
})

Diff for: app/test/meadow_web/controllers/authority_records_controller_test.exs

+15-1
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ defmodule MeadowWeb.AuthorityRecordsControllerTest do
7878
end
7979

8080
@tag fixture: "test/fixtures/authority_records/bad_authority_import.csv"
81-
test "bad data", %{conn: conn, upload: upload} do
81+
test "bad data, no referer", %{conn: conn, upload: upload} do
8282
conn =
8383
conn
8484
|> auth_user(user_fixture("TestAdmins"))
@@ -87,6 +87,20 @@ defmodule MeadowWeb.AuthorityRecordsControllerTest do
8787
assert response(conn, 400)
8888
end
8989

90+
@tag fixture: "test/fixtures/authority_records/bad_authority_import.csv"
91+
test "bad data, w/referer", %{conn: conn, upload: upload} do
92+
referer = "https://example.edu/dashboards/nul-local-authorities"
93+
94+
conn =
95+
conn
96+
|> auth_user(user_fixture("TestAdmins"))
97+
|> put_req_header("referer", referer)
98+
|> post("/api/authority_records/bulk_create", %{records: upload})
99+
100+
assert response(conn, 302)
101+
assert [^referer] = get_resp_header(conn, "location")
102+
end
103+
90104
@tag fixture: "test/fixtures/authority_records/good_authority_import.csv"
91105
test "good data", %{conn: conn, upload: upload} do
92106
precount = Meadow.Repo.aggregate(AuthorityRecord, :count)

0 commit comments

Comments
 (0)