Skip to content

Commit

Permalink
Use Chonky file browser with S3 data provided via GraphQL in the S3Ob…
Browse files Browse the repository at this point in the history
…jectPicker component
  • Loading branch information
mbklein committed Sep 16, 2024
1 parent 9c235f1 commit 5aeae54
Show file tree
Hide file tree
Showing 17 changed files with 4,733 additions and 2,017 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import WorkTabsPreservationFileSetDropzone from "@js/components/Work/Tabs/Preser
import WorkTabsPreservationFileSetForm from "@js/components/Work/Tabs/Preservation/FileSetForm";
import useAcceptedMimeTypes from "@js/hooks/useAcceptedMimeTypes";
import { useCodeLists } from "@js/context/code-list-context";
import S3ObjectPicker from "@js/components/Work/Tabs/Preservation/S3ObjectPicker"
import S3ObjectPicker from "@jscomponents/Work/Tabs/Preservation/S3ObjectPicker"

function WorkTabsPreservationFileSetModal({
closeModal,
Expand Down Expand Up @@ -262,6 +262,7 @@ function WorkTabsPreservationFileSetModal({
<div className="box">
<h3>Option 2: Choose from S3 Ingest Bucket</h3>
<S3ObjectPicker
onFiles={console.log}
onFileSelect={handleSelectS3Object}
fileSetRole={watchRole}
workTypeId={workTypeId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import UIFormField from "@js/components/UI/Form/Field.jsx";
import UIFormInput from "@js/components/UI/Form/Input.jsx";
import UIIconText from "../../../UI/IconText";
import WorkTabsPreservationFileSetDropzone from "@js/components/Work/Tabs/Preservation/FileSetDropzone";
import S3ObjectPicker from "@js/components/Work/Tabs/Preservation/S3ObjectPicker"
import S3ObjectPicker from "@jscomponents/Work/Tabs/Preservation/S3ObjectPicker"

function WorkTabsPreservationReplaceFileSet({
closeModal,
Expand Down
212 changes: 80 additions & 132 deletions app/assets/js/components/Work/Tabs/Preservation/S3ObjectPicker.jsx
Original file line number Diff line number Diff line change
@@ -1,144 +1,92 @@
import useAcceptedMimeTypes from "@js/hooks/useAcceptedMimeTypes";
import { Button } from "@nulib/design-system";
import {
LIST_INGEST_BUCKET_OBJECTS,
} from "@js/components/Work/work.gql.js";
import React, { useState } from "react";
/** @jsx jsx */
import { css, jsx } from "@emotion/react";
import { useQuery } from "@apollo/client";
import { FaSpinner } from "react-icons/fa";
import { formatBytes } from "@js/services/helpers";

import Error from "@js/components/UI/Error";
import UIFormInput from "@js/components/UI/Form/Input.jsx";

const tableContainerCss = css`
max-height: 30vh;
overflow-y: auto;
`;

const fileRowCss = css`
cursor: pointer;
`;

const selectedRowCss = css`
background-color: #f0f8ff !important;
`;
import React, { useEffect, useRef, useState } from "react";
import S3ObjectProvider from './S3ObjectProvider';
import { styled } from '@stitches/react';

const colHeaders = ["File Key", "Size", "Mime Type"];

const S3ObjectPicker = ({ onFileSelect, fileSetRole, workTypeId, defaultPrefix = "" }) => {
import {
ChonkyActions,
FileBrowser,
FileList,
FileNavbar,
FileToolbar,
} from "chonky";
import { ChonkyIconFA } from "chonky-icon-fontawesome";

const StyledFilePicker = styled('div', {
"& .chonky-toolbarRight": {
display: "none"
}
});

const S3ObjectPicker = ({
onFileSelect,
fileSetRole,
workTypeId,
defaultPrefix = "",
}) => {
const [prefix, setPrefix] = useState(defaultPrefix);
const [selectedFile, setSelectedFile] = useState(null);
const [error, _setError] = useState(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);

const { isFileValid } = useAcceptedMimeTypes();

const { loading: queryLoading, error: queryError, data, refetch } = useQuery(LIST_INGEST_BUCKET_OBJECTS, {
variables: { prefix }
});

const handleClear = () => {
setPrefix(defaultPrefix);
refetch({ prefix: defaultPrefix });
};

const handlePrefixChange = async (e) => {
const inputValue = e.target.value;
const newPrefix = inputValue.startsWith(defaultPrefix) ? inputValue : defaultPrefix + inputValue;
setPrefix(newPrefix);
await refetch({ prefix: newPrefix });
};

const handleRefresh = async () => {
await refetch({ prefix: prefix });
};

const handleFileClick = (fileSet) => {
setSelectedFile(fileSet.key);
onFileSelect(fileSet);
// Reset upload progress and isUploading state when selecting an S3 object
setUploadProgress(0);
setIsUploading(false);
};
const [error, setError] = useState(null);

const fileBrowserRef = useRef(null);
const providerRef = useRef(null);

useEffect(() => {
const fileSet = providerRef?.current?.findFileSetByUri(selectedFile);
fileSet && onFileSelect && onFileSelect(fileSet);
}, [selectedFile]);

const handleFileAction = (action) => {
switch (action.id) {
case ChonkyActions.OpenFiles.id:
const { targetFile } = action.payload;
if (targetFile.isDir) {
setPrefix(action.payload.targetFile.id);
}
break;

case ChonkyActions.ChangeSelection.id:
if (
action.payload.selection.size == 0 &&
files.find(({ id }) => selectedFile == id)
) {
fileBrowserRef.current.setFileSelection(new Set([selectedFile]));
return;
}

const handleDragAndDrop = (file) => {
// Simulating file upload process
setIsUploading(true);
setUploadProgress(0);
const interval = setInterval(() => {
setUploadProgress((prevProgress) => {
if (prevProgress >= 100) {
clearInterval(interval);
setIsUploading(false);
return 100;
const selectedFiles = [...action.payload.selection];
const clicked = selectedFiles[selectedFiles.length - 1];

if (selectedFiles.length > 1) {
// Reject multiselect
fileBrowserRef.current.setFileSelection(new Set([clicked]));
} else if (
clicked &&
clicked.match(/^s3:/) &&
selectedFile != clicked
) {
setSelectedFile(clicked);
}
return prevProgress + 10;
});
}, 500);
break;
}
};

if (queryLoading) return <FaSpinner className="spinner" />;
if (queryError) return <Error error={queryError} />;

return (
<div className="file-picker">
<div className="drag-drop-area" onDrop={handleDragAndDrop}>
{/* Drag and drop area */}
<p>Drag 'n' drop a file here, or click to select file</p>
{isUploading && (
<div className="progress-bar">
<div className="progress" style={{ width: `${uploadProgress}%` }}></div>
</div>
)}
</div>
<UIFormInput
placeholder="Enter prefix"
name="prefixSearch"
label="Prefix Search"
onChange={handlePrefixChange}
value={prefix}
/>
<div className="buttons mt-2">
<Button onClick={handleClear}>Clear</Button>
<Button onClick={handleRefresh}>Refresh</Button>
</div>
<StyledFilePicker className="file-picker" data-testid="file-picker">
{error && <div className="error">{error}</div>}
{data && data.ListIngestBucketObjects && (
<div className="table-container" css={tableContainerCss}>
<table className="table is-striped is-fullwidth">
<thead>
<tr>
{colHeaders.map((col) => (
<th key={col}>{col}</th>
))}
</tr>
</thead>
<tbody>
{data.ListIngestBucketObjects.filter(file => {
const { isValid } = isFileValid(fileSetRole, workTypeId, file.mimeType);
return isValid;
}).map((fileSet, index) => (
<tr
key={index}
onClick={() => handleFileClick(fileSet)}
className={selectedFile === fileSet.key ? "selected" : ""}
css={[fileRowCss, selectedFile === fileSet.key && selectedRowCss]}
>
<td>{fileSet.key}</td>
<td>{formatBytes(fileSet.size)}</td>
<td>{fileSet.mimeType}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<S3ObjectProvider fileSetRole={fileSetRole} workTypeId={workTypeId} prefix={prefix} ref={providerRef}>
<FileBrowser
ref={fileBrowserRef}
defaultFileViewActionId={ChonkyActions.EnableListView.id}
onFileAction={handleFileAction}
iconComponent={ChonkyIconFA}
>
<FileNavbar />
<FileToolbar />
<FileList/>
</FileBrowser>
</S3ObjectProvider>
</StyledFilePicker>
);
};

export default S3ObjectPicker;
export default S3ObjectPicker;
Original file line number Diff line number Diff line change
@@ -1,49 +1,37 @@
import React from "react";
import { render, fireEvent, waitFor } from "@testing-library/react";
import { render } from "@testing-library/react";
import { MockedProvider } from "@apollo/client/testing";
import S3ObjectPicker from "@js/components/Work/Tabs/Preservation/S3ObjectPicker";
import S3ObjectPicker from "./S3ObjectPicker";
import { LIST_INGEST_BUCKET_OBJECTS } from "@js/components/Work/work.gql.js";

const mocks = [
{
request: {
query: LIST_INGEST_BUCKET_OBJECTS,
variables: { prefix: "file_sets/" },
},
result: {
data: {
ListIngestBucketObjects: [
{ key: "file_sets/file3", size: 1000, mimeType: "image/jpeg" },
{ key: "file_sets/file4", size: 2000, mimeType: "image/png" },
],
},
},
},
{
request: {
query: LIST_INGEST_BUCKET_OBJECTS,
variables: { prefix: "" },
},
result: {
data: {
ListIngestBucketObjects: [
{ key: "file1", size: 1000, mimeType: "image/jpeg" },
{ key: "file2", size: 2000, mimeType: "image/png" },
{ key: "file_sets/file3", size: 1000, mimeType: "image/jpeg" },
{ key: "file_sets/file4", size: 2000, mimeType: "image/png" },
],
ListIngestBucketObjects: {
objects: [
{ uri: "s3://bucket/file1.jpg", key: "file1", size: 1000, mimeType: "image/jpeg", storageClass: "STANDARD", lastModified: new Date().toISOString() },
{ uri: "s3://bucket/file2.png", key: "file2", size: 2000, mimeType: "image/png", storageClass: "STANDARD", lastModified: new Date().toISOString() },
],
folders: ["file_sets"],
},
},
},
},
];

describe("S3ObjectPicker component", () => {
it("renders without crashing", () => {
render(
it("renders without crashing", async () => {
const { findByTestId } = render(
<MockedProvider mocks={mocks} addTypename={false}>
<S3ObjectPicker onFileSelect={() => { }} fileSetRole="A" workTypeId="IMAGE" />
</MockedProvider>
);
expect(await findByTestId("file-picker")).toBeInTheDocument();
});

it("renders an error message when there is a query error", async () => {
Expand All @@ -59,54 +47,8 @@ describe("S3ObjectPicker component", () => {
const { findByText } = render(
<MockedProvider mocks={errorMock} addTypename={false}>
<S3ObjectPicker onFileSelect={() => { }} fileSetRole="A" workTypeId="IMAGE" />
</MockedProvider>
</MockedProvider>,
);
expect(await findByText("An error occurred")).toBeInTheDocument();
});

it("renders the Clear and Refresh buttons", async () => {
const { findByText } = render(
<MockedProvider mocks={mocks} addTypename={false}>
<S3ObjectPicker onFileSelect={() => { }} fileSetRole="A" workTypeId="IMAGE" />
</MockedProvider>
);
expect(await findByText("Clear")).toBeInTheDocument();
expect(await findByText("Refresh")).toBeInTheDocument();
});

it("renders the table when data is available", async () => {
const { findByText } = render(
<MockedProvider mocks={mocks} addTypename={false}>
<S3ObjectPicker onFileSelect={() => { }} fileSetRole="A" workTypeId="IMAGE" />
</MockedProvider>
);
expect(await findByText("file1")).toBeInTheDocument();
expect(await findByText("file2")).toBeInTheDocument();
});

it("handles prefixed search", async () => {
const { findByText, getByPlaceholderText, queryByText } = render(
<MockedProvider mocks={mocks} addTypename={false}>
<S3ObjectPicker onFileSelect={() => { }} fileSetRole="A" workTypeId="IMAGE" />
</MockedProvider>
);

await findByText("file1");

const input = getByPlaceholderText("Enter prefix");
fireEvent.change(input, { target: { value: "file_sets/" } });

await waitFor(() => {
expect(input.value).toBe("file_sets/");
});

// Check that the prefixed files are present
expect(await findByText("file_sets/file3")).toBeInTheDocument();
expect(await findByText("file_sets/file4")).toBeInTheDocument();

// Check that the non-prefixed files are not present
expect(queryByText("file1")).not.toBeInTheDocument();
expect(queryByText("file2")).not.toBeInTheDocument();
});

});
Loading

0 comments on commit 5aeae54

Please sign in to comment.