Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add authority bulk create (backend & frontend) #3708

Merged
merged 3 commits into from
Dec 6, 2023
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ jobs:
- uses: actions/checkout@v2
- name: Provision Localstack using Cloud Pod
run: |
pip install localstack==2.1.0 localstack-plugin-persistence
pip install localstack==2.1.0
curl -O https://nul-public.s3.amazonaws.com/meadow/test/localstack.pod
localstack pod load file://$PWD/localstack.pod
- uses: actions/setup-node@v3
Expand Down
134 changes: 134 additions & 0 deletions app/assets/js/components/Dashboards/LocalAuthorities/ModalBulkAdd.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import React from "react";
import PropTypes from "prop-types";
import { Button } from "@nulib/design-system";
import { useDropzone } from "react-dropzone";
import { useForm, FormProvider } from "react-hook-form";
import { IconCsv } from "@js/components/Icon";
import UIIconText from "@js/components/UI/IconText";

/** @jsx jsx */
import { css, jsx } from "@emotion/react";
const dropZone = css`
background: #efefef;
border: 3px dashed #ccc;
`;

function DashboardsLocalAuthoritiesModalBulkAdd({
isOpen,
handleClose,
}) {
const methods = useForm();
const [currentFile, setCurrentFile] = React.useState();

const onDrop = React.useCallback((acceptedFiles) => {
const file = acceptedFiles[0];
setCurrentFile(file);

// Chrome isn't updating the file input object correctly
// using dropzone so let's do it manually.
const input = document.getElementById('csv-file-input');
const fileList = new DataTransfer();
fileList.items.add(file);
input.files = fileList.files;
}, []);
const accept = {
"text/csv": [".csv"],
"application/csv": [".csv"],
"application/vnd.ms-excel": [".csv"],
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({ accept, onDrop });

return (
<FormProvider {...methods}>
<form
method="POST"
action="/api/authority_records/bulk_create"
encType="multipart/form-data"
name="modal-nul-authority-bulk-add"
data-testid="modal-nul-authority-bulk-add"
className={`modal ${isOpen ? "is-active" : ""}`}
onSubmit={() => {
methods.reset();
handleClose();
}}
role="form"
>
<div className="modal-background"></div>
<div className="modal-card">
<header className="modal-card-head">
<p className="modal-card-title">
Bulk add new NUL Authority Records
</p>
<button
className="delete"
aria-label="close"
type="button"
onClick={handleClose}
></button>
</header>
<section className="modal-card-body">
<div
{...getRootProps()}
className="p-6 is-clickable has-text-centered"
css={dropZone}
>
<input
{...getInputProps()}
id="csv-file-input"
data-testid="dropzone-input"
name="records"
/>
<p className="has-text-centered">
<UIIconText
icon={<IconCsv className="has-text-grey" />}
isCentered
>
{isDragActive
? "Drop the file here ..."
: "Drag 'n' drop a file here, or click to select file"}
</UIIconText>
</p>
</div>
{currentFile && (
<React.Fragment>
<p className="mt-5 pb-0 mb-0 subtitle">Current file</p>
<table className="table is-fullwidth">
<thead>
<tr>
<th>Name</th>
<th>Path</th>
<th>Size</th>
<th>Type</th>
</tr>
</thead>
<tbody>
<tr>
<td>{currentFile.name}</td>
<td>{currentFile.path}</td>
<td>{currentFile.size}</td>
<td>{currentFile.type}</td>
</tr>
</tbody>
</table>
</React.Fragment>
)}
</section>
<footer className="modal-card-foot buttons is-right">
<Button isText onClick={handleClose} data-testid="cancel-button">
Cancel
</Button>
<Button isPrimary type="submit" data-testid="submit-button">
Upload
</Button>
</footer>
</div>
</form>
</FormProvider>
);
}

DashboardsLocalAuthoritiesModalBulkAdd.propTypes = {
isOpen: PropTypes.bool,
};

export default DashboardsLocalAuthoritiesModalBulkAdd;
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import DashboardsLocalAuthoritiesModalBulkAdd from "./ModalBulkAdd";
import userEvent from "@testing-library/user-event";

const cancelCallback = jest.fn();

const props = {
isOpen: true,
handleClose: cancelCallback,
};

describe("DashboardsLocalAuthoritiesModalBulkAdd component", () => {
beforeEach(() => {
render(<DashboardsLocalAuthoritiesModalBulkAdd {...props} />);
});

it("renders", () => {
expect(screen.getByTestId("modal-nul-authority-bulk-add"));
});

it("renders all add form elements ", () => {
expect(screen.getByRole("form"));
expect(screen.getByTestId("dropzone-input"));
expect(screen.getByTestId("submit-button"));
expect(screen.getByTestId("cancel-button"));
});

it("calls the Cancel callback function", async () => {
const user = userEvent.setup();
await user.click(screen.getByTestId("cancel-button"));
expect(cancelCallback).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { ActionHeadline, PageTitle } from "@js/components/UI/UI";
import { Button } from "@nulib/design-system";
import { CREATE_NUL_AUTHORITY_RECORD } from "@js/components/Dashboards/dashboards.gql";
import DashboardsLocalAuthoritiesModalAdd from "@js/components/Dashboards/LocalAuthorities/ModalAdd";
import DashboardsLocalAuthoritiesModalBulkAdd from "@js/components/Dashboards/LocalAuthorities/ModalBulkAdd";
import FormDownloadWrapper from "@js/components/UI/Form/DownloadWrapper";
import { IconAdd } from "@js/components/Icon";
import { IconAdd, IconAddMany } from "@js/components/Icon";
import { IconCsv } from "@js/components/Icon";
import NLogo from "@js/components/northwesternN.svg";
import React from "react";
Expand All @@ -14,6 +15,8 @@ import { useMutation } from "@apollo/client";

function DashboardsLocalAuthoritiesTitleBar() {
const [isAddModalOpen, setIsAddModalOpen] = React.useState();
const [isBulkAddModalOpen, setIsBulkAddModalOpen] = React.useState();

const [createNulAuthorityRecord, { _data, _error, _loading }] = useMutation(
CREATE_NUL_AUTHORITY_RECORD,
{
Expand Down Expand Up @@ -63,6 +66,15 @@ function DashboardsLocalAuthoritiesTitleBar() {
<span>Add Local Authority</span>
</Button>
</p>
<p className="control">
<Button
onClick={() => setIsBulkAddModalOpen(true)}
data-testid="bulk-add-button"
>
<IconAddMany />
<span>Bulk Add</span>
</Button>
</p>
<span className="control">
<FormDownloadWrapper formAction="/api/authority_records/nul_authority_records.csv">
<Button data-testid="button-csv-authority-export" type="submit">
Expand All @@ -79,6 +91,11 @@ function DashboardsLocalAuthoritiesTitleBar() {
handleAddLocalAuthority={handleAddLocalAuthority}
handleClose={() => setIsAddModalOpen(false)}
/>

<DashboardsLocalAuthoritiesModalBulkAdd
isOpen={isBulkAddModalOpen}
handleClose={() => setIsBulkAddModalOpen(false)}
/>
</React.Fragment>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ describe("DashboardsLocalAuthoritiesTitleBar component", () => {
expect(screen.getByTestId("nul-authorities-title-bar"));
expect(screen.getByTestId("local-authorities-dashboard-title"));
expect(screen.getByTestId("add-button"));
expect(screen.getByTestId("bulk-add-button"));
expect(screen.getByTestId("button-csv-authority-export"));
});

Expand All @@ -23,4 +24,12 @@ describe("DashboardsLocalAuthoritiesTitleBar component", () => {
await user.click(screen.getByTestId("add-button"));
expect(modalEl).toHaveClass("is-active");
});

it("renders the Bulk Add modal and displays the modal successfully when clicking the Bulk Add button", async () => {
const user = userEvent.setup();
const modalEl = screen.getByTestId("modal-nul-authority-bulk-add");
expect(modalEl).not.toHaveClass("is-active");
await user.click(screen.getByTestId("bulk-add-button"));
expect(modalEl).toHaveClass("is-active");
});
});
6 changes: 6 additions & 0 deletions app/assets/js/components/Icon/AddMany.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import React from "react";
import { GrChapterAdd } from "react-icons/gr";

export default function IconAddMany(props) {
return <GrChapterAdd {...props} />;
}
2 changes: 2 additions & 0 deletions app/assets/js/components/Icon/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import IconAdd from "@js/components/Icon/Add";
import IconAddMany from "@js/components/Icon/AddMany";
import IconAlert from "@js/components/Icon/Alert";
import IconArrowDown from "@js/components/Icon/ArrowDown";
import IconArrowLeft from "@js/components/Icon/ArrowLeft";
Expand Down Expand Up @@ -35,6 +36,7 @@ import IconUser from "@js/components/Icon/User";

export {
IconAdd,
IconAddMany,
IconAlert,
IconArrowDown,
IconArrowLeft,
Expand Down
57 changes: 57 additions & 0 deletions app/lib/meadow_web/controllers/authority_records_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,63 @@ defmodule MeadowWeb.AuthorityRecordsController do

plug(:authorize_user)

def bulk_create(conn, %{"records" => %Plug.Upload{path: path}}) do
csv_to_maps(path)
|> do_bulk_create(conn)
end

defp do_bulk_create({:error, :bad_format}, conn) do
case conn |> get_req_header("referer") do
[referer | _] -> redirect(conn, external: referer)
_ -> send_resp(conn, 400, "Bad Request")
end
end

defp do_bulk_create({:ok, records}, conn) do
results =
records
|> AuthorityRecords.create_authority_records()
|> Enum.map(fn {status, %{id: id, label: label, hint: hint}} ->
[id, label, hint, status]
end)

file = "authority_import_#{DateTime.utc_now() |> DateTime.to_unix()}.csv"

conn =
conn
|> put_resp_content_type("text/csv")
|> put_resp_header("content-disposition", ~s[attachment; filename="#{file}"])
|> send_chunked(:ok)

[~w(id label hint status) | results]
|> CSV.dump_to_stream()
|> Stream.each(fn csv_row ->
chunk(conn, csv_row)
end)
|> Stream.run()

conn
end

defp csv_to_maps(file) do
[headers | rows] =
File.stream!(file, [], :line)
|> CSV.parse_stream(skip_headers: false)
|> Enum.to_list()

case Enum.sort(headers) do
~w(hint label) ->
headers = Enum.map(headers, &String.to_atom/1)
{:ok, Enum.map(rows, fn row -> Enum.zip(headers, row) |> Enum.into(%{}) end)}

_ ->
{:error, :bad_format}
end
rescue
NimbleCSV.ParseError -> {:error, :bad_format}
exception -> reraise exception, __STACKTRACE__
end

def export(conn, %{"file" => file} = params) do
export(conn, Path.extname(file), params)
end
Expand Down
1 change: 1 addition & 0 deletions app/lib/meadow_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ defmodule MeadowWeb.Router do
pipe_through(:api)

post("/export/:file", MeadowWeb.ExportController, :export)
post("/authority_records/bulk_create", MeadowWeb.AuthorityRecordsController, :bulk_create)
post("/authority_records/:file", MeadowWeb.AuthorityRecordsController, :export)
post("/create_shared_links/:file", MeadowWeb.SharedLinksController, :export)

Expand Down
Loading
Loading