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 file storage UI to front end #4728

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
3 changes: 2 additions & 1 deletion api/controllers/site.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const {
Site,
Event,
Domain,
FileStorageService,
SiteBranchConfig,
SiteBuildTask,
BuildTaskType,
Expand All @@ -24,7 +25,7 @@ module.exports = wrapHandlers({
const { user } = req;

const sites = await Site.forUser(user).findAll({
include: [Domain, SiteBranchConfig, SiteBuildTask],
include: [Domain, FileStorageService, SiteBranchConfig, SiteBuildTask],
});

if (!sites) {
Expand Down
6 changes: 6 additions & 0 deletions api/serializers/site.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const allowedAttributes = [
'isActive',
'organizationId',
'Domains',
'FileStorageService',
'Organization',
'SiteBranchConfigs',
'SiteBuildTasks',
Expand Down Expand Up @@ -124,6 +125,11 @@ const serializeObject = (site, isSystemAdmin) => {
delete json.Organization;
}

if (json.FileStorageService) {
json.fileStorageServiceId = site.FileStorageService?.id;
delete json.FileStorageService;
}

if (site.Domains) {
json.canEditLiveUrl = !DomainService.isSiteUrlManagedByDomain(
site,
Expand Down
1 change: 1 addition & 0 deletions ci/pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ do:
APP_HOSTNAME: https://((((deploy-env))-pages-domain))
PROXY_DOMAIN: sites.((((deploy-env))-pages-domain))
FEATURE_BUILD_TASKS: "active"
FEATURE_FILE_STORAGE_SERVICE: true

- task: deploy-api
file: src/ci/partials/deploy.yml
Expand Down
93 changes: 93 additions & 0 deletions frontend/hooks/useFileStorage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useRef, useEffect } from 'react';
import federalist from '@util/federalistApi';
import { REFETCH_INTERVAL } from './utils';

export default function useFileStorage(
fileStorageId,
path = '',
sortKey = null,
sortOrder = null,
page = 1,
) {
const previousData = useRef();
const queryClient = useQueryClient();

const fetchPublicFiles = async () => {
const response = await federalist.fetchPublicFiles(
fileStorageId,
path,
sortKey,
sortOrder,
page,
);
if (response.error) {
throw new Error(response.message);
}
// because we need things that aren't just on data
return {
data: response.data,
currentPage: response.currentPage,
totalPages: response.totalPages,
totalItems: response.totalItems,
};
};

const { data, isLoading, isFetching, isPending, isPlaceholderData, error, refetch } =
useQuery({
queryKey: ['fileStorage', fileStorageId, path, sortKey, sortOrder, page],
queryFn: fetchPublicFiles,
refetchInterval: REFETCH_INTERVAL,
refetchIntervalInBackground: false,
enabled: !!fileStorageId,
keepPreviousData: true,
staleTime: 2000,
placeholderData: previousData.current || [],
onError: (err) => {
throw new Error('Error fetching public files:', err);
},
});

useEffect(() => {
if (data !== undefined) {
previousData.current = data;
}
}, [data]);

const result = data || {};

const deleteMutation = useMutation({
mutationFn: async (item) => {
return await federalist.deletePublicItem(fileStorageId, item.id);
},
onSuccess: () => {
// Invalidate the query to refetch the file list after deletion.
return queryClient.invalidateQueries({
queryKey: ['fileStorage', fileStorageId, path, sortKey, sortOrder, page],
});
},
onError: (err) => {
throw new Error('Error deleting file:', err);
},
});

async function deleteItem(item) {
return deleteMutation.mutate(item);
}

return {
data: result.data,
currentPage: result.currentPage,
totalPages: result.totalPages,
totalItems: result.totalItems,
isLoading,
isFetching,
isPending,
isPlaceholderData,
fetchError: error,
refetch,
deleteItem,
deleteError: deleteMutation.error,
deleteSuccess: deleteMutation.isSuccess,
};
}
116 changes: 116 additions & 0 deletions frontend/pages/file-storage/FileDetails.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import React from 'react';
import PropTypes from 'prop-types';
import { IconAttachment } from '@shared/icons';
import { dateAndTimeSimple } from '@util/datetime';
import prettyBytes from 'pretty-bytes';

const FileDetails = ({
name,
id,
fullPath,
updatedBy,
updatedAt,
size,
mimeType,
onDelete,
onClose,
}) => {
const thisItem = {
id: id,
type: 'file',
};
if (id < 1) return null;
return (
<div className="file-details">
<div className="file-details__header bg-base-lightest">
<IconAttachment className="usa-icon margin-right-1" />
<h3>File details</h3>
<button
title="close file details"
className="usa-button usa-button--unstyled file-details__close"
onClick={onClose}
>
<svg className="usa-icon" aria-hidden="true" role="img">
<use href="/img/sprite.svg#close"></use>
</svg>
</button>
</div>
<table className="usa-table usa-table--borderless file-details__table">
<thead className="usa-sr-only">
<tr>
<th scope="col">Property</th>
<th scope="col">Value</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">File name</th>
<td className="font-mono-xs text-ls-neg-1 text-bold">{name}</td>
</tr>
<tr>
<th scope="row">Full path</th>
<td className="font-mono-xs text-ls-neg-1">
<a
style={{ wordBreak: 'break-all' }}
href={fullPath}
target="_blank"
rel="noopener noreferrer"
>
{fullPath}
</a>
</td>
</tr>
<tr>
<th scope="row">Uploaded by</th>
<td className="text-bold">{updatedBy}</td>
</tr>
<tr>
<th scope="row">Uploaded at</th>
<td>{updatedAt && dateAndTimeSimple(updatedAt)}</td>
</tr>
<tr>
<th scope="row">File size</th>
<td>{size && prettyBytes(size)}</td>
</tr>
<tr>
<th scope="row">MIME type</th>
<td>{mimeType}</td>
</tr>
<tr>
<th scope="row">Actions</th>
<td>
<a href={fullPath} download className="usa-button">
Download
</a>
<button
type="button"
title="Remove from public storage"
className="usa-button usa-button--outline delete-button"
onClick={() => {
onDelete(thisItem);
onClose();
}}
>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
);
};

FileDetails.propTypes = {
name: PropTypes.string.isRequired,
id: PropTypes.number.isRequired,
fullPath: PropTypes.string.isRequired,
updatedBy: PropTypes.string.isRequired,
updatedAt: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
mimeType: PropTypes.string.isRequired,
onDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};

export default FileDetails;
110 changes: 110 additions & 0 deletions frontend/pages/file-storage/FileDetails.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter } from 'react-router-dom';
import FileDetails from './FileDetails';
import prettyBytes from 'pretty-bytes';

jest.mock('pretty-bytes', () => ({
__esModule: true,
default: jest.fn(),
}));

describe('FileDetails', () => {
const mockOnDelete = jest.fn();
const mockOnClose = jest.fn();
const fileProps = {
id: 20,
name: 'test-document.pdf',
fullPath: 'https://example.com/files/test-document.pdf',
updatedBy: 'user@example.com',
updatedAt: '2025-02-19T12:00:00Z',
size: 1048576, // 1MB
mimeType: 'application/pdf',
onDelete: mockOnDelete,
onClose: mockOnClose,
};

beforeEach(() => {
jest.clearAllMocks();
prettyBytes.mockReturnValue('1 MB');
});

it('renders file details correctly', () => {
render(
<MemoryRouter>
<FileDetails {...fileProps} />
</MemoryRouter>,
);

// File name
expect(screen.getByText(fileProps.name)).toBeInTheDocument();

// Full path (link)
expect(screen.getByRole('link', { name: fileProps.fullPath })).toHaveAttribute(
'href',
fileProps.fullPath,
);

// Uploaded by
expect(screen.getByText(fileProps.updatedBy)).toBeInTheDocument();

// Uploaded on (formatted date check)
expect(screen.getByText(/Feb 19, 2025/i)).toBeInTheDocument();

// File size
expect(screen.getByText('1 MB')).toBeInTheDocument();

// MIME type
expect(screen.getByText(fileProps.mimeType)).toBeInTheDocument();
});

it('calls onClose when the close button is clicked', () => {
render(
<MemoryRouter>
<FileDetails {...fileProps} />
</MemoryRouter>,
);

const closeButton = screen.getByTitle('close file details');
fireEvent.click(closeButton);

expect(mockOnClose).toHaveBeenCalledTimes(1);
});

it('calls onDelete when the delete button is clicked', () => {
render(
<MemoryRouter>
<FileDetails {...fileProps} />
</MemoryRouter>,
);

const deleteButton = screen.getByRole('button', { name: /delete/i });
fireEvent.click(deleteButton);

expect(mockOnDelete).toHaveBeenCalledTimes(1);
expect(mockOnDelete).toHaveBeenCalledWith({ type: 'file', id: fileProps.id });
});

it('renders a working download link', () => {
render(
<MemoryRouter>
<FileDetails {...fileProps} />
</MemoryRouter>,
);

const downloadLink = screen.getByRole('link', { name: /download/i });
expect(downloadLink).toHaveAttribute('href', fileProps.fullPath);
expect(downloadLink).toHaveAttribute('download');
});

it('renders nothing if given an invalid id', () => {
const invalidProps = { ...fileProps, id: 0 };
const { container } = render(
<MemoryRouter>
<FileDetails {...invalidProps} />
</MemoryRouter>,
);
expect(container).toBeEmptyDOMElement();
});
});
Loading
Loading