Skip to content

Commit c986317

Browse files
committed
feat: Create file storage file view
1 parent 39f353c commit c986317

File tree

17 files changed

+388
-134
lines changed

17 files changed

+388
-134
lines changed

api/serializers/site.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const allowedAttributes = [
1111
'defaultBranch',
1212
'domain',
1313
'engine',
14+
'liveDomain',
1415
'owner',
1516
'publishedAt',
1617
'repository',
@@ -58,6 +59,29 @@ const viewLinks = {
5859
viewLink: 'site',
5960
};
6061

62+
function getLiveDomain(branchConfigs, domains) {
63+
if (!branchConfigs || !domains) {
64+
return '';
65+
}
66+
67+
const config = branchConfigs.find((c) => c.context === 'site');
68+
69+
if (!config) {
70+
return '';
71+
}
72+
73+
const provisioned = domains.filter((d) => d.state === 'provisioned');
74+
const domain = provisioned.find((d) => d.siteBranchConfigId === config.id);
75+
76+
if (!domain || !domain?.names) {
77+
return '';
78+
}
79+
80+
const domainName = domain.names.split(',')[0];
81+
82+
return `https://${domainName}`;
83+
}
84+
6185
// Eventually replace `serialize`
6286
function serializeNew(site, isSystemAdmin = false) {
6387
const object = site.get({
@@ -96,6 +120,9 @@ function serializeNew(site, isSystemAdmin = false) {
96120
const serializeObject = (site, isSystemAdmin) => {
97121
const json = serializeNew(site, isSystemAdmin);
98122

123+
const liveDomain = getLiveDomain(json.SiteBranchConfigs, json.Domains);
124+
json.liveDomain = liveDomain;
125+
99126
if (json.Domains) {
100127
json.domains = site.Domains.map((d) =>
101128
pick(

frontend/hooks/useFileStorageFile.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
2+
import { useNavigate } from 'react-router-dom';
3+
import federalist from '@util/federalistApi';
4+
5+
export default function useFileStorageFile(fileStorageServiceId, fileStorageFileId) {
6+
const navigate = useNavigate();
7+
const queryClient = useQueryClient();
8+
9+
const { data, isLoading, isFetching, isPending, isPlaceholderData, isError, error } =
10+
useQuery({
11+
queryKey: ['fileStorageFile', fileStorageServiceId, fileStorageFileId],
12+
queryFn: () => federalist.fetchPublicFile(fileStorageServiceId, fileStorageFileId),
13+
});
14+
15+
const deleteMutation = useMutation({
16+
mutationFn: async (redirectTo) => {
17+
await federalist.deletePublicItem(fileStorageServiceId, fileStorageFileId);
18+
return navigate(redirectTo);
19+
},
20+
});
21+
22+
return {
23+
data,
24+
error,
25+
deleteError: deleteMutation.error,
26+
deleteIsPending: deleteMutation.isPending,
27+
isError,
28+
isFetching,
29+
isLoading,
30+
isPending,
31+
isPlaceholderData,
32+
queryClient,
33+
deleteItem: (redirectTo) => deleteMutation.mutateAsync(redirectTo),
34+
};
35+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { waitFor, renderHook } from '@testing-library/react';
2+
import * as router from 'react-router-dom';
3+
import nock from 'nock';
4+
5+
import { createTestQueryClient } from '@support/queryClient';
6+
import { getFileStorageFile, deletePublicItem } from '@support/nocks/fileStorage';
7+
import useFileStorageFile from './useFileStorageFile';
8+
9+
jest.mock('react-router-dom', () => ({
10+
...jest.requireActual('react-router-dom'),
11+
useNavigate: jest.fn(),
12+
}));
13+
14+
const createWrapper = createTestQueryClient();
15+
16+
const props = {
17+
fileStorageServiceId: 123,
18+
fileId: 123,
19+
};
20+
21+
describe('useFileStorage', () => {
22+
beforeEach(() => {
23+
nock.cleanAll();
24+
});
25+
26+
afterAll(() => {
27+
jest.clearAllMocks();
28+
nock.restore();
29+
});
30+
31+
it('should fetch public file successfully', async () => {
32+
const data = {
33+
id: props.fileId,
34+
fileStorageServiceId: props.fileStorageServiceId,
35+
name: 'test',
36+
};
37+
await getFileStorageFile({ ...props }, data);
38+
jest.spyOn(router, 'useNavigate').mockReturnValue(jest.fn());
39+
40+
const { result } = renderHook(() => useFileStorageFile(...Object.values(props)), {
41+
wrapper: createWrapper(),
42+
});
43+
44+
await waitFor(() => expect(!result.current.isPending).toBe(true));
45+
46+
expect(result.current.data).toEqual(data);
47+
});
48+
49+
it('should handle public file fetch error', async () => {
50+
const error = {
51+
message: 'An error occurred',
52+
};
53+
await getFileStorageFile({ ...props, statusCode: 401 }, error);
54+
jest.spyOn(router, 'useNavigate').mockReturnValue(jest.fn());
55+
56+
const { result } = renderHook(() => useFileStorageFile(...Object.values(props)), {
57+
wrapper: createWrapper(),
58+
});
59+
60+
await waitFor(() => expect(!result.current.isPending).toBe(true));
61+
62+
expect(result.current.error.message).toEqual(error.message);
63+
});
64+
65+
it('should delete a public file successfully', async () => {
66+
const redirectTo = '/foo/bar/baz';
67+
const data = {
68+
id: props.fileId,
69+
fileStorageServiceId: props.fileStorageServiceId,
70+
name: 'test',
71+
};
72+
await getFileStorageFile({ ...props }, data);
73+
await deletePublicItem(props.fileStorageServiceId, props.fileId);
74+
75+
const mockedNavigate = jest.fn();
76+
jest.spyOn(router, 'useNavigate').mockReturnValue(mockedNavigate);
77+
78+
const { result } = renderHook(() => useFileStorageFile(...Object.values(props)), {
79+
wrapper: createWrapper(),
80+
});
81+
82+
await waitFor(() => expect(!result.current.isPending).toBe(true));
83+
await waitFor(() => result.current.deleteItem(redirectTo));
84+
85+
expect(mockedNavigate).toHaveBeenCalledWith(redirectTo);
86+
});
87+
});

frontend/pages/sites/$siteId/storage/FileList.jsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ const SORT_KEY_NAME = 'name';
1313
const SORT_KEY_LAST_MODIFIED = 'updatedAt';
1414

1515
const FileListRow = ({
16+
siteId,
1617
item,
1718
baseUrl,
1819
path,
1920
currentSortKey,
2021
onNavigate,
2122
onDelete,
22-
onViewDetails,
2323
highlight = false,
2424
}) => {
2525
const copyUrl = `${baseUrl}/${item.key}`;
@@ -65,15 +65,10 @@ const FileListRow = ({
6565
{item.name}
6666
</a>
6767
) : (
68-
// eslint-disable-next-line jsx-a11y/anchor-is-valid
6968
<a
70-
href="#"
69+
href={`/sites/${siteId}/storage/files/${item.id}`}
7170
title="View file details"
7271
className="usa-link file-name"
73-
onClick={(e) => {
74-
e.preventDefault();
75-
onViewDetails(item.name);
76-
}}
7772
>
7873
{item.name}
7974
</a>
@@ -126,6 +121,7 @@ const FileList = ({
126121
currentSortOrder,
127122
highlightItem,
128123
children,
124+
siteId,
129125
}) => {
130126
const TABLE_CAPTION = `
131127
Listing all contents for the current folder, sorted by ${currentSortKey} in
@@ -209,6 +205,7 @@ const FileList = ({
209205
onDelete={onDelete}
210206
onViewDetails={onViewDetails}
211207
highlight={highlightItem === item.name}
208+
siteId={siteId}
212209
/>
213210
))}
214211
</tbody>
@@ -242,6 +239,7 @@ const SortIcon = ({ sort = '' }) => (
242239
);
243240

244241
FileList.propTypes = {
242+
siteId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
245243
path: PropTypes.string.isRequired,
246244
baseUrl: PropTypes.string.isRequired,
247245
data: PropTypes.arrayOf(
@@ -262,9 +260,11 @@ FileList.propTypes = {
262260
};
263261

264262
FileListRow.propTypes = {
263+
siteId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
265264
path: PropTypes.string.isRequired,
266265
baseUrl: PropTypes.string.isRequired,
267266
item: PropTypes.shape({
267+
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
268268
name: PropTypes.string.isRequired,
269269
key: PropTypes.string.isRequired,
270270
type: PropTypes.string.isRequired,

frontend/pages/sites/$siteId/storage/FileList.test.jsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ import FileList from './FileList.jsx';
55

66
const mockFiles = [
77
{
8-
id: 20,
8+
id: '20',
99
name: 'Documents',
1010
key: '~assets/Documents',
1111
type: 'directory',
1212
updatedAt: '2024-02-10T12:30:00Z',
1313
},
1414
{
15-
id: 21,
15+
id: '21',
1616
name: 'report.pdf',
1717
key: '~assets/report.pdf',
1818
type: 'application/pdf',
@@ -21,7 +21,7 @@ const mockFiles = [
2121
size: 23456,
2222
},
2323
{
24-
id: 22,
24+
id: '22',
2525
name: 'presentation.ppt',
2626
key: '~assets/presentation.ppt',
2727
type: 'application/vnd.ms-powerpoint',
@@ -32,6 +32,7 @@ const mockFiles = [
3232
];
3333

3434
const mockProps = {
35+
siteId: '1',
3536
path: '/',
3637
baseUrl: 'https://custom.domain.gov',
3738
data: mockFiles,
@@ -74,9 +75,14 @@ describe('FileList', () => {
7475
});
7576

7677
it('calls onViewDetails when a file name is clicked', () => {
78+
const { id, name } = mockFiles[1];
79+
const href = `/sites/${mockProps.siteId}/storage/files/${id}`;
80+
7781
render(<FileList {...mockProps} />);
78-
fireEvent.click(screen.getByRole('link', { name: 'report.pdf' }));
79-
expect(mockProps.onViewDetails).toHaveBeenCalledWith('report.pdf');
82+
83+
const link = screen.getByRole('link', { name });
84+
85+
expect(link).toHaveAttribute('href', href);
8086
});
8187

8288
it('calls onSort and reverses sort when a sortable header is clicked', () => {

frontend/pages/sites/$siteId/storage/FileDetails.jsx renamed to frontend/pages/sites/$siteId/storage/files/$fileId/FileDetails.jsx

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,18 @@ import prettyBytes from 'pretty-bytes';
66

77
const FileDetails = ({
88
name,
9-
id,
109
fullPath,
11-
updatedBy,
12-
updatedAt,
10+
lastModifiedBy,
11+
lastModifiedAt,
1312
size,
1413
mimeType,
1514
onDelete,
16-
onClose,
1715
}) => {
18-
const thisItem = {
19-
id: id,
20-
type: 'file',
21-
};
22-
if (id < 1) return null;
2316
return (
2417
<div className="file-details">
2518
<div className="file-details__header bg-base-lightest">
2619
<IconAttachment className="usa-icon margin-right-1" />
2720
<h3>File details</h3>
28-
<button
29-
title="close file details"
30-
className="usa-button usa-button--unstyled file-details__close"
31-
onClick={onClose}
32-
>
33-
<svg className="usa-icon" aria-hidden="true" role="img">
34-
<use href="/img/sprite.svg#close"></use>
35-
</svg>
36-
</button>
3721
</div>
3822
<table className="usa-table usa-table--borderless file-details__table">
3923
<thead className="usa-sr-only">
@@ -61,12 +45,12 @@ const FileDetails = ({
6145
</td>
6246
</tr>
6347
<tr>
64-
<th scope="row">Uploaded by</th>
65-
<td className="text-bold">{updatedBy}</td>
48+
<th scope="row">Last modified by</th>
49+
<td className="text-bold">{lastModifiedBy}</td>
6650
</tr>
6751
<tr>
68-
<th scope="row">Uploaded at</th>
69-
<td>{updatedAt && dateAndTimeSimple(updatedAt)}</td>
52+
<th scope="row">Last modified at</th>
53+
<td>{dateAndTimeSimple(lastModifiedAt)}</td>
7054
</tr>
7155
<tr>
7256
<th scope="row">File size</th>
@@ -86,10 +70,7 @@ const FileDetails = ({
8670
type="button"
8771
title="Remove from public storage"
8872
className="usa-button usa-button--outline delete-button"
89-
onClick={() => {
90-
onDelete(thisItem);
91-
onClose();
92-
}}
73+
onClick={() => onDelete()}
9374
>
9475
Delete
9576
</button>
@@ -105,12 +86,11 @@ FileDetails.propTypes = {
10586
name: PropTypes.string.isRequired,
10687
id: PropTypes.number.isRequired,
10788
fullPath: PropTypes.string.isRequired,
108-
updatedBy: PropTypes.string.isRequired,
109-
updatedAt: PropTypes.string.isRequired,
89+
lastModifiedBy: PropTypes.string.isRequired,
90+
lastModifiedAt: PropTypes.string.isRequired,
11091
size: PropTypes.number.isRequired,
11192
mimeType: PropTypes.string.isRequired,
11293
onDelete: PropTypes.func.isRequired,
113-
onClose: PropTypes.func.isRequired,
11494
};
11595

11696
export default FileDetails;

0 commit comments

Comments
 (0)