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:
@@ -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
|
||||||
|
|||||||
@@ -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,70 +298,78 @@ 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>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'description',
|
||||||
|
header: 'Description',
|
||||||
|
className: 'cell-description',
|
||||||
|
render: (pkg) => pkg.description || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'format',
|
||||||
|
header: 'Format',
|
||||||
|
render: (pkg) => <Badge variant="default">{pkg.format}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tags',
|
||||||
|
header: 'Tags',
|
||||||
|
render: (pkg) => pkg.tag_count ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'artifacts',
|
||||||
|
header: 'Artifacts',
|
||||||
|
render: (pkg) => pkg.artifact_count ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'size',
|
||||||
|
header: 'Size',
|
||||||
|
render: (pkg) =>
|
||||||
|
pkg.total_size !== undefined && pkg.total_size > 0 ? formatBytes(pkg.total_size) : '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'latest_tag',
|
||||||
|
header: 'Latest',
|
||||||
|
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>
|
||||||
|
|
||||||
{(pkg.tag_count !== undefined || pkg.artifact_count !== undefined) && (
|
{pagination && pagination.total_pages > 1 && (
|
||||||
<div className="package-stats">
|
<Pagination
|
||||||
{pkg.tag_count !== undefined && (
|
page={pagination.page}
|
||||||
<div className="package-stats__item">
|
totalPages={pagination.total_pages}
|
||||||
<span className="package-stats__value">{pkg.tag_count}</span>
|
total={pagination.total}
|
||||||
<span className="package-stats__label">Tags</span>
|
limit={pagination.limit}
|
||||||
</div>
|
onPageChange={handlePageChange}
|
||||||
)}
|
/>
|
||||||
{pkg.artifact_count !== undefined && (
|
|
||||||
<div className="package-stats__item">
|
|
||||||
<span className="package-stats__value">{pkg.artifact_count}</span>
|
|
||||||
<span className="package-stats__label">Artifacts</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{pkg.total_size !== undefined && pkg.total_size > 0 && (
|
|
||||||
<div className="package-stats__item">
|
|
||||||
<span className="package-stats__value">{formatBytes(pkg.total_size)}</span>
|
|
||||||
<span className="package-stats__label">Size</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="project-meta">
|
|
||||||
{pkg.latest_tag && (
|
|
||||||
<span className="latest-tag">
|
|
||||||
Latest: <strong>{pkg.latest_tag}</strong>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="date">Created {new Date(pkg.created_at).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pagination && pagination.total_pages > 1 && (
|
|
||||||
<Pagination
|
|
||||||
page={pagination.page}
|
|
||||||
totalPages={pagination.total_pages}
|
|
||||||
total={pagination.total}
|
|
||||||
limit={pagination.limit}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canAdmin && projectName && (
|
{canAdmin && projectName && (
|
||||||
|
|||||||
Reference in New Issue
Block a user