Replace package cards with sortable data table on Project page

- Convert package grid to DataTable matching Home page style
- Add sortable columns: Name, Created
- Show package stats: Tags, Artifacts, Size, Latest tag
- Row click navigates to package page
- Keep existing search and format filter working
This commit is contained in:
Mondo Diaz
2026-01-15 15:07:38 +00:00
parent eecf610ae3
commit 0c5132a9ca
2 changed files with 77 additions and 74 deletions

View File

@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Replaced project card grid with sortable data table on Home page for better handling of large project lists - Replaced project card grid with sortable data table on Home page for better handling of large project lists
- Replaced package card grid with sortable data table on Project page for consistency
- Improved pod naming: Orchard pods now named `orchard-{env}-server-*` for clarity (#51) - Improved pod naming: Orchard pods now named `orchard-{env}-server-*` for clarity (#51)
### Fixed ### Fixed

View File

@@ -1,23 +1,17 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, Link, useSearchParams, useNavigate, useLocation } from 'react-router-dom'; import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { Project, Package, PaginatedResponse, AccessLevel } from '../types'; import { Project, Package, PaginatedResponse, AccessLevel } from '../types';
import { getProject, listPackages, createPackage, getMyProjectAccess, UnauthorizedError, ForbiddenError } from '../api'; import { getProject, listPackages, createPackage, getMyProjectAccess, UnauthorizedError, ForbiddenError } from '../api';
import { Breadcrumb } from '../components/Breadcrumb'; import { Breadcrumb } from '../components/Breadcrumb';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { DataTable } from '../components/DataTable';
import { SearchInput } from '../components/SearchInput'; import { SearchInput } from '../components/SearchInput';
import { SortDropdown, SortOption } from '../components/SortDropdown';
import { FilterChip, FilterChipGroup } from '../components/FilterChip'; import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { Pagination } from '../components/Pagination'; import { Pagination } from '../components/Pagination';
import { AccessManagement } from '../components/AccessManagement'; import { AccessManagement } from '../components/AccessManagement';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import './Home.css'; import './Home.css';
const SORT_OPTIONS: SortOption[] = [
{ value: 'name', label: 'Name' },
{ value: 'created_at', label: 'Created' },
{ value: 'updated_at', label: 'Updated' },
];
const FORMAT_OPTIONS = ['generic', 'npm', 'pypi', 'docker', 'deb', 'rpm', 'maven', 'nuget', 'helm']; const FORMAT_OPTIONS = ['generic', 'npm', 'pypi', 'docker', 'deb', 'rpm', 'maven', 'nuget', 'helm'];
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
@@ -140,8 +134,9 @@ function ProjectPage() {
updateParams({ search: value, page: '1' }); updateParams({ search: value, page: '1' });
}; };
const handleSortChange = (newSort: string, newOrder: 'asc' | 'desc') => { const handleSortChange = (columnKey: string) => {
updateParams({ sort: newSort, order: newOrder, page: '1' }); const newOrder = columnKey === sort ? (order === 'asc' ? 'desc' : 'asc') : 'asc';
updateParams({ sort: columnKey, order: newOrder, page: '1' });
}; };
const handleFormatChange = (value: string) => { const handleFormatChange = (value: string) => {
@@ -294,7 +289,6 @@ function ProjectPage() {
</option> </option>
))} ))}
</select> </select>
<SortDropdown options={SORT_OPTIONS} value={sort} order={order} onChange={handleSortChange} />
</div> </div>
{hasActiveFilters && ( {hasActiveFilters && (
@@ -304,58 +298,68 @@ function ProjectPage() {
</FilterChipGroup> </FilterChipGroup>
)} )}
{packages.length === 0 ? ( <div className="data-table--responsive">
<div className="empty-state"> <DataTable
{hasActiveFilters ? ( data={packages}
<p>No packages match your filters. Try adjusting your search.</p> keyExtractor={(pkg) => pkg.id}
) : ( onRowClick={(pkg) => navigate(`/project/${projectName}/${pkg.name}`)}
<p>No packages yet. Create your first package to start uploading artifacts!</p> onSort={handleSortChange}
)} sortKey={sort}
</div> sortOrder={order}
) : ( emptyMessage={
<> hasActiveFilters
<div className="project-grid"> ? 'No packages match your filters. Try adjusting your search.'
{packages.map((pkg) => ( : 'No packages yet. Create your first package to start uploading artifacts!'
<Link to={`/project/${projectName}/${pkg.name}`} key={pkg.id} className="project-card card"> }
<div className="package-card__header"> columns={[
<h3>{pkg.name}</h3> {
<Badge variant="default">{pkg.format}</Badge> key: 'name',
</div> header: 'Name',
{pkg.description && <p>{pkg.description}</p>} sortable: true,
render: (pkg) => <span className="cell-name">{pkg.name}</span>,
{(pkg.tag_count !== undefined || pkg.artifact_count !== undefined) && ( },
<div className="package-stats"> {
{pkg.tag_count !== undefined && ( key: 'description',
<div className="package-stats__item"> header: 'Description',
<span className="package-stats__value">{pkg.tag_count}</span> className: 'cell-description',
<span className="package-stats__label">Tags</span> render: (pkg) => pkg.description || '—',
</div> },
)} {
{pkg.artifact_count !== undefined && ( key: 'format',
<div className="package-stats__item"> header: 'Format',
<span className="package-stats__value">{pkg.artifact_count}</span> render: (pkg) => <Badge variant="default">{pkg.format}</Badge>,
<span className="package-stats__label">Artifacts</span> },
</div> {
)} key: 'tags',
{pkg.total_size !== undefined && pkg.total_size > 0 && ( header: 'Tags',
<div className="package-stats__item"> render: (pkg) => pkg.tag_count ?? '—',
<span className="package-stats__value">{formatBytes(pkg.total_size)}</span> },
<span className="package-stats__label">Size</span> {
</div> key: 'artifacts',
)} header: 'Artifacts',
</div> render: (pkg) => pkg.artifact_count ?? '—',
)} },
{
<div className="project-meta"> key: 'size',
{pkg.latest_tag && ( header: 'Size',
<span className="latest-tag"> render: (pkg) =>
Latest: <strong>{pkg.latest_tag}</strong> pkg.total_size !== undefined && pkg.total_size > 0 ? formatBytes(pkg.total_size) : '—',
</span> },
)} {
<span className="date">Created {new Date(pkg.created_at).toLocaleDateString()}</span> key: 'latest_tag',
</div> header: 'Latest',
</Link> render: (pkg) =>
))} pkg.latest_tag ? <strong style={{ color: 'var(--accent-primary)' }}>{pkg.latest_tag}</strong> : '—',
},
{
key: 'created_at',
header: 'Created',
sortable: true,
className: 'cell-date',
render: (pkg) => new Date(pkg.created_at).toLocaleDateString(),
},
]}
/>
</div> </div>
{pagination && pagination.total_pages > 1 && ( {pagination && pagination.total_pages > 1 && (
@@ -367,8 +371,6 @@ function ProjectPage() {
onPageChange={handlePageChange} onPageChange={handlePageChange}
/> />
)} )}
</>
)}
{canAdmin && projectName && ( {canAdmin && projectName && (
<AccessManagement projectName={projectName} /> <AccessManagement projectName={projectName} />