diff --git a/frontend/pages/file-storage/FileDetails.jsx b/frontend/pages/file-storage/FileDetails.jsx new file mode 100644 index 000000000..fb1c121ae --- /dev/null +++ b/frontend/pages/file-storage/FileDetails.jsx @@ -0,0 +1,105 @@ +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, + fullPath, + updatedBy, + updatedAt, + size, + mimeType, + onDelete, + onClose, +}) => { + return ( +
+
+ +

File details

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyValue
File name{name}
Full path + + {fullPath} + +
Uploaded by{updatedBy}
Uploaded at{updatedAt && dateAndTimeSimple(updatedAt)}
File size{size && prettyBytes(size)}
MIME type{mimeType}
Actions + + Download + + +
+
+ ); +}; + +FileDetails.propTypes = { + name: PropTypes.string.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, +}; + +export default FileDetails; diff --git a/frontend/pages/file-storage/FileDetails.test.jsx b/frontend/pages/file-storage/FileDetails.test.jsx new file mode 100644 index 000000000..0b129c4b0 --- /dev/null +++ b/frontend/pages/file-storage/FileDetails.test.jsx @@ -0,0 +1,99 @@ +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 = { + 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( + + + , + ); + + // 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( + + + , + ); + + const closeButton = screen.getByTitle('close file details'); + fireEvent.click(closeButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('calls onDelete when the delete button is clicked', () => { + render( + + + , + ); + + const deleteButton = screen.getByRole('button', { name: /delete/i }); + fireEvent.click(deleteButton); + + expect(mockOnDelete).toHaveBeenCalledTimes(1); + expect(mockOnDelete).toHaveBeenCalledWith({ type: 'file', name: fileProps.name }); + }); + + it('renders a working download link', () => { + render( + + + , + ); + + const downloadLink = screen.getByRole('link', { name: /download/i }); + expect(downloadLink).toHaveAttribute('href', fileProps.fullPath); + expect(downloadLink).toHaveAttribute('download'); + }); +}); diff --git a/frontend/pages/file-storage/FileList.jsx b/frontend/pages/file-storage/FileList.jsx new file mode 100644 index 000000000..2278451f1 --- /dev/null +++ b/frontend/pages/file-storage/FileList.jsx @@ -0,0 +1,278 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { dateAndTimeSimple, timeFrom } from '@util/datetime'; +import { IconAttachment, IconFolder, IconLink } from '@shared/icons'; + +// constants and functions used by both components + +function ariaFormatSort(direction) { + if (direction === 'asc') return 'ascending'; + if (direction === 'desc') return 'descending'; + return null; +} +const SORT_KEY_NAME = 'name'; +const SORT_KEY_LAST_MODIFIED = 'updatedAt'; + +const FileListRow = ({ + item, + storageRoot, + path, + currentSortKey, + onNavigate, + onDelete, + onViewDetails, +}) => { + const copyUrl = `${storageRoot}${path}${item.name}`; + // handle copying the file's URL + const [copySuccess, setCopySuccess] = useState(false); + const handleCopy = async (url) => { + try { + await navigator.clipboard.writeText(url); + console.log('copied', copyUrl); + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 4000); + } catch (err) { + console.error('Failed to copy: ', err); + } + }; + + return ( + + +
+ + {item.type === 'directory' ? ( + + ) : ( + + )} + + {item.type === 'directory' ? ( + { + e.preventDefault(); + onNavigate(`${path.replace(/\/+$/, '')}/${item.name}/`); + }} + > + {item.name} + + ) : ( + { + e.preventDefault(); + onViewDetails(item.name); + }} + > + {item.name} + + )} +
+ + + + + {item.updatedAt ? timeFrom(item.updatedAt) : 'N/A'} + + + + {item.type !== 'directory' && ( + + )} + + + + ); +}; + +const FileList = ({ + path, + storageRoot, + data, + onDelete, + onNavigate, + onSort, + onViewDetails, + currentSortKey, + currentSortOrder, +}) => { + const EMPTY_STATE_MESSAGE = 'No files or folders found.'; + const TABLE_CAPTION = ` + Listing all contents for the current folder, sorted by ${currentSortKey} in + ${ariaFormatSort(currentSortOrder)} order + `; // TODO: Create and update an aria live region to announce all changes + + return ( + + + + + + + + + + + {data.map((item) => ( + + ))} + {data.length === 0 && ( + + + + )} + +
{TABLE_CAPTION}
+ Name + + + Updated at + + + Actions +
+ {EMPTY_STATE_MESSAGE} +
+ ); +}; + +const SortIcon = ({ sort = false }) => ( + + {sort === 'desc' && ( + + + + )} + {sort === 'asc' && ( + + + + )} + {!sort && ( + + + + )} + +); + +FileList.propTypes = { + path: PropTypes.string.isRequired, + storageRoot: PropTypes.string.isRequired, + data: PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + updatedAt: PropTypes.string.isRequired, + }), + ).isRequired, + onDelete: PropTypes.func.isRequired, + onNavigate: PropTypes.func.isRequired, + onViewDetails: PropTypes.func.isRequired, + onSort: PropTypes.func.isRequired, + currentSortKey: PropTypes.string.isRequired, + currentSortOrder: PropTypes.oneOf(['asc', 'desc']).isRequired, +}; + +FileListRow.propTypes = { + path: PropTypes.string.isRequired, + storageRoot: PropTypes.string.isRequired, + item: PropTypes.shape({ + name: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + updatedAt: PropTypes.string.isRequired, + }).isRequired, + onDelete: PropTypes.func.isRequired, + onNavigate: PropTypes.func.isRequired, + onViewDetails: PropTypes.func.isRequired, + currentSortKey: PropTypes.string.isRequired, +}; + +SortIcon.propTypes = { + sort: PropTypes.string, +}; + +export default FileList; diff --git a/frontend/pages/file-storage/FileList.test.jsx b/frontend/pages/file-storage/FileList.test.jsx new file mode 100644 index 000000000..462cbab80 --- /dev/null +++ b/frontend/pages/file-storage/FileList.test.jsx @@ -0,0 +1,150 @@ +import React, { act } from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MemoryRouter } from 'react-router-dom'; +import FileList from './FileList.jsx'; + +const mockFiles = [ + { name: 'Documents', type: 'directory', updatedAt: '2024-02-10T12:30:00Z' }, + { + name: 'report.pdf', + type: 'application/pdf', + updatedAt: '2025-01-09T15:45:00Z', + updatedBy: 'user@federal.gov', + size: 23456, + }, + { + name: 'presentation.ppt', + type: 'application/vnd.ms-powerpoint', + updatedAt: '2024-02-08T09:15:00Z', + updatedBy: 'user@federal.gov', + size: 23456, + }, +]; + +const mockProps = { + path: '/', + storageRoot: 'https://custom.domain.gov/~assets', + data: mockFiles, + onDelete: jest.fn(), + onNavigate: jest.fn(), + onViewDetails: jest.fn(), + onSort: jest.fn(), + currentSortKey: 'name', + currentSortOrder: 'asc', +}; + +describe('FileList', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly with file and folder names', () => { + render( + + + , + ); + expect(screen.getByRole('link', { name: 'Documents' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'report.pdf' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'presentation.ppt' })).toBeInTheDocument(); + }); + + it('does not trigger onViewDetails when clicking a folder', () => { + render( + + + , + ); + fireEvent.click(screen.getByRole('link', { name: 'Documents' })); + expect(mockProps.onViewDetails).not.toHaveBeenCalled(); + }); + + it('calls onNavigate when a folder is clicked', () => { + render( + + + , + ); + fireEvent.click(screen.getByRole('link', { name: 'Documents' })); + expect(mockProps.onNavigate).toHaveBeenCalledWith('/Documents/'); + }); + + it('does not trigger onNavigate when clicking a file', () => { + render( + + + , + ); + fireEvent.click(screen.getByRole('link', { name: 'report.pdf' })); + expect(mockProps.onNavigate).not.toHaveBeenCalled(); + }); + + it('calls onViewDetails when a file name is clicked', () => { + render( + + + , + ); + fireEvent.click(screen.getByRole('link', { name: 'report.pdf' })); + expect(mockProps.onViewDetails).toHaveBeenCalledWith('report.pdf'); + }); + + it('calls onSort and updates the sort dir when a sortable header is clicked', () => { + render( + + + , + ); + + fireEvent.click(screen.getByLabelText('Sort by name')); + expect(mockProps.onSort).toHaveBeenCalledWith('name'); + expect(screen.getByRole('columnheader', { name: /name/i })).toHaveAttribute( + 'aria-sort', + 'ascending', + ); + }); + + it('calls onDelete when a file delete button is clicked', () => { + render( + + + , + ); + fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0]); + expect(mockProps.onDelete).toHaveBeenCalledWith(mockFiles[0]); + }); + + it('renders empty state message when no files are present', () => { + render( + + + , + ); + expect(screen.getByText('No files or folders found.')).toBeInTheDocument(); + }); + + it('copies file link to clipboard when copy button is clicked', async () => { + Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockResolvedValue(), + }, + }); + + render( + + + , + ); + + const copyButton = screen.getAllByText('Copy link')[0]; + + await act(async () => { + fireEvent.click(copyButton); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + 'https://custom.domain.gov/~assets/report.pdf', + ); + }); +}); diff --git a/frontend/pages/file-storage/LocationBar.jsx b/frontend/pages/file-storage/LocationBar.jsx new file mode 100644 index 000000000..e48b01871 --- /dev/null +++ b/frontend/pages/file-storage/LocationBar.jsx @@ -0,0 +1,111 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import { IconGlobe } from '@shared/icons'; + +const LocationBar = ({ path, siteId, storageRoot, onNavigate }) => { + const baseUrl = `/sites/${siteId}/storage`; + const cleanedPath = path.replace(/^\/+/, ''); // Only trim leading slashes, keep trailing + const segments = cleanedPath ? cleanedPath.split('/').filter(Boolean) : []; + + const showDomain = segments.length <= 1; // Show storageRoot only if it's the current or parent folder + const grandparentPath = segments.slice(0, segments.length - 1).join('/') + '/'; + + const displayedBreadcrumbs = useMemo(() => { + if (segments.length > 1) { + return segments.slice(-2).map((seg) => `${seg}/`); // Ensure it only includes the last two segments + } + return segments.length === 1 ? [`${segments[0]}/`] : []; + }, [segments]); + + return ( + + ); +}; + +LocationBar.propTypes = { + path: PropTypes.string.isRequired, + siteId: PropTypes.string.isRequired, + storageRoot: PropTypes.string.isRequired, + onNavigate: PropTypes.func, +}; + +export default LocationBar; diff --git a/frontend/pages/file-storage/LocationBar.test.jsx b/frontend/pages/file-storage/LocationBar.test.jsx new file mode 100644 index 000000000..39dab4d47 --- /dev/null +++ b/frontend/pages/file-storage/LocationBar.test.jsx @@ -0,0 +1,132 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import LocationBar from './LocationBar'; + +describe('LocationBar Component', () => { + const mockOnNavigate = jest.fn(); + const storageRoot = 'https://example.gov/~assets'; + const siteId = '123'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders root directory correctly, in text (not link) if current directory', () => { + render( + /* MemoryRouter allows us to render components that use react Link */ + + + , + ); + + expect(screen.getByText(`${storageRoot}/`)).toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: `${storageRoot}/` }), + ).not.toBeInTheDocument(); + expect(screen.queryByText('../')).not.toBeInTheDocument(); + }); + + it('renders single-level folder correctly, with parent as link', () => { + render( + + + , + ); + + expect(screen.getByRole('link', { name: `${storageRoot}/` })).toBeInTheDocument(); + expect(screen.getByText('foo/')).toBeInTheDocument(); + }); + + test('renders nested folders with ../ link', () => { + render( + + + , + ); + + expect(screen.getByRole('link', { name: '../' })).toBeInTheDocument(); + expect(screen.getByText('bar/')).toBeInTheDocument(); + }); + + it('clicking a breadcrumb calls onNavigate', () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole('link', { name: '../' })); + expect(mockOnNavigate).toHaveBeenCalledWith('foo/'); + + fireEvent.click(screen.getByRole('link', { name: 'foo/' })); + expect(mockOnNavigate).toHaveBeenCalledWith('foo/'); + }); + + it('renders deep nested path correctly', () => { + render( + + + , + ); + + expect(screen.getByRole('link', { name: '../' })).toBeInTheDocument(); + expect(screen.getByText('baz/')).toBeInTheDocument(); + }); + + it('does not render ../ at root level', () => { + render( + + + , + ); + + expect(screen.queryByRole('link', { name: '../' })).not.toBeInTheDocument(); + }); + + test('clicking domain/~assets resets to root if it is a link', () => { + render( + + + , + ); + fireEvent.click(screen.getByRole('link', { name: `${storageRoot}/` })); + expect(mockOnNavigate).toHaveBeenCalledWith('/'); + }); +}); diff --git a/frontend/pages/file-storage/Log.js b/frontend/pages/file-storage/Log.js new file mode 100644 index 000000000..aba321ddc --- /dev/null +++ b/frontend/pages/file-storage/Log.js @@ -0,0 +1,20 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import LoadingIndicator from '@shared/LoadingIndicator'; +const isLoading = false; + +function FileStorageLogs() { + if (isLoading) { + return ; + } + + return ( +
+ +
+ ); +} + +export { FileStorageLogs }; +export default FileStorageLogs; diff --git a/frontend/pages/file-storage/index.js b/frontend/pages/file-storage/index.js new file mode 100644 index 000000000..bb454c936 --- /dev/null +++ b/frontend/pages/file-storage/index.js @@ -0,0 +1,214 @@ +import React from 'react'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import AlertBanner from '@shared/alertBanner'; +import LocationBar from '../file-storage/LocationBar'; +import FileDetails from '../file-storage/FileDetails'; +import FileList from '../file-storage/FileList'; +import LoadingIndicator from '@shared/LoadingIndicator'; +import Pagination from '@shared/Pagination'; + +import { currentSite } from '@selectors/site'; + + + +function FileStoragePage() { + const [searchParams, setSearchParams] = useSearchParams(); + const { id } = useParams(); + let path = decodeURIComponent(searchParams.get('path') || "/").replace(/^\/+/, "/"); + + // Ensure path always ends in "/" because we use it for asset url links + if (path !== "/") { + path = path.replace(/\/+$/, "") + "/"; + } + + const detailsFile = searchParams.get('details'); + const site = useSelector((state) => currentSite(state.sites, id)); + const storageRoot = `${site.siteOrigin}/~assets` + + + // Mocked API state for now + const publicFilesActions = { isLoading: false, error: null }; + const mockFiles = [ + { + name: "Documents", + type: "directory", + updatedAt: "2025-02-12T22:30:00Z", + updatedBy: "user@federal.gov", + size: null, + }, + { + name: "report.pdf", + type: "application/pdf", + updatedAt: "2025-01-09T15:45:00Z", + updatedBy: "user@federal.gov", + size: 23456, + }, + { + name: "super-dooper-way-too-long-file-name-must-truncate-presentation.pptx", + type: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + updatedAt: "2024-02-08T09:15:00Z", + updatedBy: "user@federal.gov", + size: 63827592, + } + ]; + const fileDetails = mockFiles.find(file => file.name === detailsFile); + + + const mockPagination = { + currentPage: parseInt(searchParams.get('page')) || 1, + totalPages: 20, + totalItems: 392, + }; + + const DEFAULT_SORT_KEY = "name"; + const DEFAULT_SORT_ORDER = "asc"; + const REVERSE_SORT_ORDER = "desc"; + + const sortKey = searchParams.get('sortKey') || DEFAULT_SORT_KEY; + const sortOrder = searchParams.get('sortOrder') || DEFAULT_SORT_ORDER; + + const fetchedPublicFiles = { + ...mockPagination, + sortKey, + sortOrder, + data: mockFiles + }; + + const handleNavigate = (newPath) => { + const decodedPath = decodeURIComponent(newPath); + // Remove trailing slash if it exists + let normalizedPath = `${decodedPath.replace(/\/+$/, "")}`; + + + if (normalizedPath !== "/") { + normalizedPath += "/"; // Ensure folders always end with "/" + } + searchParams.delete('details'); + searchParams.delete('page'); + + setSearchParams((prev) => { + const newParams = new URLSearchParams(prev); + newParams.set("path", normalizedPath); + return newParams; + }); + window.scrollTo(0, 0,); + + }; + + + const handlePageChange = (newPage) => { + if (newPage === mockPagination.currentPage) return; + setSearchParams(prev => { + const newParams = new URLSearchParams(prev); + newParams.set('page', newPage); + return newParams; + }); + }; + + const handleViewDetails = (file) => { + setSearchParams({ + ...Object.fromEntries(searchParams), + details: file + }); + }; + + const handleCloseDetails = () => { + setSearchParams(prev => { + const newParams = new URLSearchParams(prev); + newParams.delete('details'); + return newParams; + }); + }; + + + const handleDelete = (item) => { + const isFolder = item.type === 'directory'; + const confirmMessage = isFolder + ? `Are you sure you want to delete the folder "${item.name}"? Please check that it does not contain any files.` + : `Are you sure you want to delete the file "${item.name}"?`; + + if (!window.confirm(confirmMessage)) return; + + console.log("Deleting:", item); // Replace with actual API call + }; + + const handleSort = (sortKey) => { + const currentSortKey = searchParams.get('sortKey') || DEFAULT_SORT_KEY; + const currentSortOrder = searchParams.get('sortOrder') || DEFAULT_SORT_ORDER; + + const isSameKey = currentSortKey === sortKey; + const newSortOrder = isSameKey && currentSortOrder === DEFAULT_SORT_ORDER ? REVERSE_SORT_ORDER : DEFAULT_SORT_ORDER; + + setSearchParams({ + ...Object.fromEntries(searchParams), + sortKey, + sortOrder: newSortOrder + }); + }; + + + if (publicFilesActions.isLoading) { + return ; + } + + if (publicFilesActions.error) { + return ( + + ); + } + + return ( +
+ + {detailsFile && ( + + )} + {!detailsFile && ( + <> + + + + + )} +
+ ); +} + +export { FileStoragePage }; +export default FileStoragePage; diff --git a/frontend/pages/sites/$siteId/index.jsx b/frontend/pages/sites/$siteId/index.jsx index 33d20e456..a742e5b95 100644 --- a/frontend/pages/sites/$siteId/index.jsx +++ b/frontend/pages/sites/$siteId/index.jsx @@ -32,10 +32,15 @@ export const SITE_NAVIGATION_CONFIG = [ icon: 'IconGear', }, { - display: 'Uploaded files', + display: 'Published builds', route: 'published', icon: 'IconCloudUpload', }, + { + display: 'Public storage', + route: 'storage', + icon: 'IconAttachment', + }, ]; export const SITE_TITLE_CONFIG = [ diff --git a/frontend/routes.js b/frontend/routes.js index 0aae280ab..c5d4fe333 100644 --- a/frontend/routes.js +++ b/frontend/routes.js @@ -17,6 +17,8 @@ import DomainList from '@pages/sites/$siteId/custom-domains'; import NewCustomDomain from '@pages/sites/$siteId/custom-domains/new'; import EditCustomDomain from '@pages/sites/$siteId/custom-domains/$domainId/edit'; import Reports from '@pages/sites/$siteId/reports'; +import FileStoragePage from '@pages/file-storage'; +import FileStorageLogs from '@pages/file-storage/Log'; import Settings from '@pages/settings'; import NotFound from '@pages/NotFound'; import ErrorMessage from '@pages/ErrorMessage'; @@ -56,6 +58,8 @@ export default ( } /> } /> } /> + } /> + } /> } /> redirect('../reports')} /> {process.env.FEATURE_BUILD_TASKS === 'active' && ( diff --git a/frontend/sass/_icons.scss b/frontend/sass/_icons.scss index a5aa024d3..59d08daaf 100644 --- a/frontend/sass/_icons.scss +++ b/frontend/sass/_icons.scss @@ -1,10 +1,4 @@ -$color-blue: #1c5982; -$color-blue-dark: #112e51; -$color-blue-bright: rgb(5, 104, 253); - - -$link-color: $color-blue; -$link-color-active: $color-blue-dark; +@use 'variables' as *; .repo-link { svg { diff --git a/frontend/sass/_tables.scss b/frontend/sass/_tables.scss index c9e82ba90..f2c16b473 100644 --- a/frontend/sass/_tables.scss +++ b/frontend/sass/_tables.scss @@ -1,3 +1,4 @@ +@use 'variables' as *; #js-app .usa-table--borderless { thead, @@ -472,3 +473,235 @@ font-size: 1em; line-height: 1.4; } + +/* --- Sortable table --- */ + +$selectedColumnBackground: #62a5d522; +$selectedColumnText: #071018; +$sortableBorderColor: #5c759980; + +// Shared table styles +@mixin table-header-unsorted-styles { + position: relative; + &::after { + border-bottom-color: transparent; + border-bottom-style: solid; + border-bottom-width: 1px; + bottom: 0; + content: ""; + height: 0; + left: 0; + position: absolute; + width: 100%; + } +} + +@mixin table-button-default-styles { + width: 2rem; + height: 2rem; + color: $color-gray-dark; + cursor: pointer; + display: inline-block; + margin: 0; + position: absolute; + right: 0.25rem; + text-align: center; + text-decoration: none; + top: 50%; + transform: translate(0, -50%); + .usa-icon { + width: 1.75rem; + height: 1.75rem; + vertical-align: middle; + & > g { + fill: transparent; + } + } +} + +// The SVG in the sortable column button contains three icon shapes. +// This CSS controls which of the shapes is 'filled' when active. + +@mixin table-button-unsorted-styles { + @include table-button-default-styles; + .usa-icon > g.unsorted { + fill: $color-gray-dark; + } + &:hover .usa-icon > g.unsorted { + fill: $color-blue; + } +} + +@mixin table-button-sorted-ascending-styles { + @include table-button-default-styles; + .usa-icon > .ascending { + fill: $color-blue-dark; + } +} + +@mixin table-button-sorted-descending-styles { + @include table-button-default-styles; + .usa-icon > .descending { + fill: $color-blue-dark; + } +} + + +// specificity because our old tables have a lot of overrides +#js-app .usa-table--sortable { + + /* stylelint-disable selector-class-pattern */ + th[data-sortable], + th[data-is-sortable] { + @include table-header-unsorted-styles; + &:not([aria-sort]), + &[aria-sort="none"] { + .usa-table__sort-button { + @include table-button-unsorted-styles; + } + } + + &[aria-sort="descending"], + &[aria-sort="ascending"] { + background-color: $selectedColumnBackground; + } + + &[aria-sort="descending"] { + .usa-table__sort-button { + @include table-button-sorted-descending-styles; + } + } + + &[aria-sort="ascending"] { + .usa-table__sort-button { + @include table-button-sorted-ascending-styles; + } + } + } + /* stylelint-enable selector-class-pattern */ + + thead { + th[aria-sort] { + background-color: $selectedColumnBackground; + color: $selectedColumnText; + } + th { + border-color: $sortableBorderColor; + } + } + + td[data-sort-active], + th[data-sort-active] { + background-color: $selectedColumnBackground; + color: $selectedColumnText; + } + + td { + white-space: nowrap; + border-color: $sortableBorderColor; + } + + td.file-name-cell{ + max-width: 0; + } + .width-last-mod { + width: 18ch; + } + .width-actions { + width: 24ch; + } + .file-icon { + width: 1em; + height: 1.2em; + margin-right: .5rem; + svg { + display: inline-block; + fill: currentColor; + height: 1em; + position: relative; + width: 1em; + flex-shrink: 0; + } + } + .file-name-wrap { + display: flex; + height: 2rem; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + } + .file-name { + text-overflow: ellipsis; + width: 100%; + white-space: nowrap; + display: block; + overflow: hidden; + -webkit-font-smoothing: auto; + color: $color-blue-dark; + height: 1.2em; + line-height: 1.2em; + } + .delete-button { + padding: 0.4em 0.6em; + font-size: 1.1em; + border-radius: 4px; + white-space: nowrap; + margin-right: 0; + // mimics error colors from USWDS -- no outline button for this + box-shadow: inset 0 0 0 2px #d83933; + color: #d83933; + &:hover { + box-shadow: inset 0 0 0 2px #b50909; + color: #b50909 + } + &:active { + box-shadow: inset 0 0 0 2px #8b0a03; + color: #8b0a03 + } + } +} + +/* File details */ + +.file-details {} +.file-details__header { + display: flex; + flex-flow: row nowrap; + padding: 1rem; + align-items: center; + .usa-icon { + height: 1.5rem; + width: 1.5rem; + } + h3 { + margin: 0; + } +} +.file-details__close { + margin-left: auto; + color: $color-blue-dark; +} +.file-details__table { + margin: 0; + width: 100%; + th, td { + border-color: $sortableBorderColor; + } + .text-bold, + th { + font-weight: 600; + } + th { + color: #5d5d5d; + min-height: 3rem; + min-width: 20ch; + } +} + +.border-base { + border-color: $sortableBorderColor; +} + +a { + cursor: pointer; +} \ No newline at end of file diff --git a/frontend/sass/_variables.scss b/frontend/sass/_variables.scss index 0a4d56bcb..46a87443e 100644 --- a/frontend/sass/_variables.scss +++ b/frontend/sass/_variables.scss @@ -6,7 +6,7 @@ $white-shaded: #eef4fb; $color-blue: #1c5982; $color-blue-dark: #112e51; -$color-blue-bright: rgb(5, 104, 253); +$color-blue-bright: #0568fd; $color-white: $white; $color-gray: #526577; diff --git a/frontend/shared/Pagination.jsx b/frontend/shared/Pagination.jsx new file mode 100644 index 000000000..fce9bb3bc --- /dev/null +++ b/frontend/shared/Pagination.jsx @@ -0,0 +1,159 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const MAX_VISIBLE_PAGES = 5; +// Minimum distance from start/end before showing ellipsis +const ELLIPSIS_THRESHOLD = Math.floor(MAX_VISIBLE_PAGES / 2) + 1; + +const Pagination = ({ + currentPage, + totalPages, + totalItems, + itemsOnCurrentPage, + onPageChange, +}) => { + const getPageNumbers = () => { + const pageNumbers = []; + + if (totalPages <= MAX_VISIBLE_PAGES) { + return Array.from({ length: totalPages }, (_, i) => ({ + type: i + 1 === currentPage ? 'current' : 'page', + page: i + 1, + })); + } + + pageNumbers.push({ type: currentPage === 1 ? 'current' : 'page', page: 1 }); + + if (currentPage > ELLIPSIS_THRESHOLD) { + pageNumbers.push({ type: 'ellipsis' }); + } + + let startPage = Math.max(2, currentPage - 1); + let endPage = Math.min(totalPages - 1, currentPage + 1); + + for (let i = startPage; i <= endPage; i++) { + pageNumbers.push({ type: i === currentPage ? 'current' : 'page', page: i }); + } + + if (currentPage < totalPages - ELLIPSIS_THRESHOLD) { + pageNumbers.push({ type: 'ellipsis' }); + } + + pageNumbers.push({ + type: currentPage === totalPages ? 'current' : 'page', + page: totalPages, + }); + + return pageNumbers; + }; + + return ( +
+

+ Showing {itemsOnCurrentPage} of {totalItems} total files in this folder +

+ {totalPages > 1 && ( + + )} +
+ ); +}; + +const PaginationButton = ({ + page, + label, + children, + type = 'button', + isCurrent = false, + isDisabled = false, + customClass = undefined, + onClick, +}) => ( +
  • + +
  • +); + +PaginationButton.propTypes = { + page: PropTypes.number.isRequired, + label: PropTypes.string.isRequired, + children: PropTypes.node, + type: PropTypes.string, + isCurrent: PropTypes.bool, + isDisabled: PropTypes.bool, + customClass: PropTypes.string, + onClick: PropTypes.func.isRequired, +}; + +Pagination.propTypes = { + currentPage: PropTypes.number.isRequired, + totalPages: PropTypes.number.isRequired, + totalItems: PropTypes.number.isRequired, + itemsOnCurrentPage: PropTypes.number.isRequired, + onPageChange: PropTypes.func.isRequired, +}; + +export default Pagination; diff --git a/frontend/shared/Pagination.test.jsx b/frontend/shared/Pagination.test.jsx new file mode 100644 index 000000000..ae171b284 --- /dev/null +++ b/frontend/shared/Pagination.test.jsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Pagination from './Pagination'; + +const mockOnPageChange = jest.fn(); + +const defaultProps = { + currentPage: 1, + totalPages: 12, + totalItems: 111, + itemsOnCurrentPage: 10, + onPageChange: mockOnPageChange, +}; + +const renderPagination = (props = {}) => { + return render(); +}; + +describe('Pagination Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('renders pagination label correctly', () => { + renderPagination(); + expect( + screen.getByText(/Showing 10 of 111 total files in this folder/i), + ).toBeInTheDocument(); + }); + + test('displays Previous button disabled on first page', () => { + renderPagination(); + expect(screen.getByLabelText(/Previous page/i)).toBeDisabled(); + }); + + test('displays Next button disabled on last page', () => { + renderPagination({ currentPage: defaultProps.totalPages }); + expect(screen.getByLabelText(/Next page/i)).toBeDisabled(); + }); + + test('calls onPageChange when clicking Next button', () => { + renderPagination(); + fireEvent.click(screen.getByLabelText(/Next page/i)); + expect(mockOnPageChange).toHaveBeenCalledWith(2); + }); + + test('calls onPageChange when clicking Previous button', () => { + renderPagination({ currentPage: 2 }); + fireEvent.click(screen.getByLabelText(/Previous page/i)); + expect(mockOnPageChange).toHaveBeenCalledWith(1); + }); + + test('calls onPageChange when clicking a numbered page', () => { + renderPagination({ currentPage: 5 }); + fireEvent.click(screen.getByText('4')); + expect(mockOnPageChange).toHaveBeenCalledWith(4); + }); + + test('highlights the current page', () => { + renderPagination({ currentPage: 3 }); + const currentPageElement = screen.getByRole('button', { current: 'page' }); + expect(currentPageElement).toHaveTextContent('3'); + }); + + test('clicking current page does not trigger onPageChange', () => { + renderPagination({ currentPage: 4 }); + fireEvent.click(screen.getByText('4')); + expect(mockOnPageChange).not.toHaveBeenCalled(); + }); + + test('displays full range of pages if total pages is 5 or fewer', () => { + renderPagination({ totalPages: 4, totalItems: 50 }); + // eslint-disable-next-line no-plusplus + for (let i = 1; i <= 4; i++) { + expect(screen.getByText(i.toString())).toBeInTheDocument(); + } + expect(screen.queryByText('…')).not.toBeInTheDocument(); + }); + + test('hides pagination controls if only one page exists', () => { + renderPagination({ totalPages: 1, totalItems: 10 }); + expect( + screen.queryByRole('navigation', { name: 'Pagination' }), + ).not.toBeInTheDocument(); + }); + + test('displays ellipsis for later pages when early in the pages', () => { + renderPagination({ currentPage: 2 }); + expect(screen.getByText('…')).toBeInTheDocument(); + expect(screen.getByText('12')).toBeInTheDocument(); + }); + + test('displays ellipsis for earlier pages when near the end of pages', () => { + renderPagination({ currentPage: 9 }); + expect(screen.getByText('…')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + test('displays both ellipsis when near the center of pages', () => { + renderPagination({ currentPage: 5 }); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('12')).toBeInTheDocument(); + expect(screen.getAllByText('…')).toHaveLength(2); // Two ellipses + }); + + test('ellipsis elements are not interactive', () => { + renderPagination({ currentPage: 6 }); + const ellipses = screen.getAllByText('…'); + ellipses.forEach((ellipsis) => { + expect(ellipsis).not.toHaveAttribute('role'); + expect(ellipsis).not.toHaveAttribute('tabindex'); + }); + }); +}); diff --git a/frontend/shared/icons.js b/frontend/shared/icons.js index 040cbe049..91cc865ed 100644 --- a/frontend/shared/icons.js +++ b/frontend/shared/icons.js @@ -1,3 +1,4 @@ +import IconAttachment from '../../public/images/icons/icon-attachment.svg'; import IconBook from '../../public/images/icons/icon-book.svg'; import IconBranch from '../../public/images/icons/icon-branch.svg'; import IconCheckCircle from '../../public/images/icons/icon-check-circle-solid.svg'; @@ -8,8 +9,10 @@ import IconEnvelope from '../../public/images/icons/icon-envelope.svg'; import IconExclamationCircle from '../../public/images/icons/icon-exclamation-circle-solid.svg'; import IconExperiment from '../../public/images/icons/icon-experiment.svg'; import IconExternalLink from '../../public/images/icons/icon-external-link.svg'; +import IconFolder from '../../public/images/icons/icon-folder.svg'; import IconGear from '../../public/images/icons/icon-gear.svg'; import IconGitHub from '../../public/images/icons/icon-github.svg'; +import IconGlobe from '../../public/images/icons/icon-globe.svg'; import IconIgnore from '../../public/images/icons/icon-ignore.svg'; import IconLink from '../../public/images/icons/icon-link.svg'; import IconPeople from '../../public/images/icons/icon-people.svg'; @@ -22,6 +25,7 @@ import IconX from '../../public/images/icons/icon-x.svg'; import IconTrash from '../../public/images/icons/icon-trash.svg'; export { + IconAttachment, IconBook, IconBranch, IconCheckCircle, @@ -31,8 +35,10 @@ export { IconExclamationCircle, IconExperiment, IconExternalLink, + IconFolder, IconGear, IconGitHub, + IconGlobe, IconIgnore, IconLink, IconPeople, diff --git a/package.json b/package.json index 0c6c3a341..3c220d584 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "passport": "^0.7.0", "passport-github": "^1.1.0", "passport-oauth2": "^1.8.0", + "pretty-bytes": "^6.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-highlight": "^0.15.0", diff --git a/public/images/icons/icon-attachment.svg b/public/images/icons/icon-attachment.svg new file mode 100644 index 000000000..804eb69d0 --- /dev/null +++ b/public/images/icons/icon-attachment.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/icons/icon-folder.svg b/public/images/icons/icon-folder.svg new file mode 100644 index 000000000..1be587606 --- /dev/null +++ b/public/images/icons/icon-folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/icons/icon-globe.svg b/public/images/icons/icon-globe.svg new file mode 100644 index 000000000..cb73d24ca --- /dev/null +++ b/public/images/icons/icon-globe.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/icons/icon-link.svg b/public/images/icons/icon-link.svg index 052f2e33f..0bedcfecb 100644 --- a/public/images/icons/icon-link.svg +++ b/public/images/icons/icon-link.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/yarn.lock b/yarn.lock index a30792608..8a8f28af3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9254,6 +9254,11 @@ prettier@^3.3.3: resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.3.tgz#30c54fe0be0d8d12e6ae61dbb10109ea00d53105" integrity sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew== +pretty-bytes@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-6.1.1.tgz#38cd6bb46f47afbf667c202cfc754bffd2016a3b" + integrity sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ== + pretty-format@^27.0.2: version "27.5.1" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e"