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

Use Chonky file browser with S3 data provided via GraphQL in the S3ObjectPicker component #4149

Merged
merged 1 commit into from
Sep 18, 2024
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
Original file line number Diff line number Diff line change
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
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": {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

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
Loading