Fix circular dependency resolution by switching to artifact-centric display

- Add artifact: prefix handling in resolve_dependencies for direct artifact
  ID references, enabling dependency resolution for tagless artifacts
- Refactor PackagePage from tag-based to artifact-based data display
- Add PackageArtifact type with tags array for artifact-centric API responses
- Update download URLs to use artifact:ID prefix when no tags exist
- Conditionally show "View Ensure File" only when artifact has tags
This commit is contained in:
Mondo Diaz
2026-02-03 10:00:15 -06:00
parent 9f13221012
commit 9a795a301a
4 changed files with 136 additions and 108 deletions

View File

@@ -3,8 +3,8 @@ import {
Package,
Tag,
TagDetail,
Artifact,
ArtifactDetail,
PackageArtifact,
UploadResponse,
PaginatedResponse,
ListParams,
@@ -276,10 +276,10 @@ export async function listPackageArtifacts(
projectName: string,
packageName: string,
params: ArtifactListParams = {}
): Promise<PaginatedResponse<Artifact & { tags: string[] }>> {
): Promise<PaginatedResponse<PackageArtifact>> {
const query = buildQueryString(params as Record<string, unknown>);
const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/artifacts${query}`);
return handleResponse<PaginatedResponse<Artifact & { tags: string[] }>>(response);
return handleResponse<PaginatedResponse<PackageArtifact>>(response);
}
// Upload

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useSearchParams, useNavigate, useLocation, Link } from 'react-router-dom';
import { TagDetail, Package, PaginatedResponse, AccessLevel, Dependency, DependentInfo } from '../types';
import { listTags, getDownloadUrl, getPackage, getMyProjectAccess, createTag, getArtifactDependencies, getReverseDependencies, getEnsureFile, UnauthorizedError, ForbiddenError } from '../api';
import { PackageArtifact, Package, PaginatedResponse, AccessLevel, Dependency, DependentInfo } from '../types';
import { listPackageArtifacts, getDownloadUrl, getPackage, getMyProjectAccess, createTag, getArtifactDependencies, getReverseDependencies, getEnsureFile, UnauthorizedError, ForbiddenError } from '../api';
import { Breadcrumb } from '../components/Breadcrumb';
import { Badge } from '../components/Badge';
import { SearchInput } from '../components/SearchInput';
@@ -57,7 +57,7 @@ function PackagePage() {
const { user } = useAuth();
const [pkg, setPkg] = useState<Package | null>(null);
const [tagsData, setTagsData] = useState<PaginatedResponse<TagDetail> | null>(null);
const [artifactsData, setArtifactsData] = useState<PaginatedResponse<PackageArtifact> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [accessDenied, setAccessDenied] = useState(false);
@@ -75,7 +75,7 @@ function PackagePage() {
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
// Dependencies state
const [selectedTag, setSelectedTag] = useState<TagDetail | null>(null);
const [selectedArtifact, setSelectedArtifact] = useState<PackageArtifact | null>(null);
const [dependencies, setDependencies] = useState<Dependency[]>([]);
const [depsLoading, setDepsLoading] = useState(false);
const [depsError, setDepsError] = useState<string | null>(null);
@@ -138,13 +138,13 @@ function PackagePage() {
try {
setLoading(true);
setAccessDenied(false);
const [pkgData, tagsResult, accessResult] = await Promise.all([
const [pkgData, artifactsResult, accessResult] = await Promise.all([
getPackage(projectName, packageName),
listTags(projectName, packageName, { page, search, sort, order }),
listPackageArtifacts(projectName, packageName, { page, search, sort, order }),
getMyProjectAccess(projectName),
]);
setPkg(pkgData);
setTagsData(tagsResult);
setArtifactsData(artifactsResult);
setAccessLevel(accessResult.access_level);
setError(null);
} catch (err) {
@@ -168,25 +168,15 @@ function PackagePage() {
loadData();
}, [loadData]);
// Auto-select tag when tags are loaded (prefer version from URL, then first tag)
// Re-run when package changes to pick up new tags
// Auto-select artifact when artifacts are loaded (prefer first artifact)
// Re-run when package changes to pick up new artifacts
useEffect(() => {
if (tagsData?.items && tagsData.items.length > 0) {
const versionParam = searchParams.get('version');
if (versionParam) {
// Find tag matching the version parameter
const matchingTag = tagsData.items.find(t => t.version === versionParam);
if (matchingTag) {
setSelectedTag(matchingTag);
setDependencies([]);
return;
}
}
// Fall back to first tag
setSelectedTag(tagsData.items[0]);
if (artifactsData?.items && artifactsData.items.length > 0) {
// Fall back to first artifact
setSelectedArtifact(artifactsData.items[0]);
setDependencies([]);
}
}, [tagsData, searchParams, projectName, packageName]);
}, [artifactsData, projectName, packageName]);
// Fetch dependencies when selected tag changes
const fetchDependencies = useCallback(async (artifactId: string) => {
@@ -204,10 +194,10 @@ function PackagePage() {
}, []);
useEffect(() => {
if (selectedTag) {
fetchDependencies(selectedTag.artifact_id);
if (selectedArtifact) {
fetchDependencies(selectedArtifact.id);
}
}, [selectedTag, fetchDependencies]);
}, [selectedArtifact, fetchDependencies]);
// Fetch reverse dependencies
const fetchReverseDeps = useCallback(async (pageNum: number = 1) => {
@@ -254,11 +244,11 @@ function PackagePage() {
}
}, [projectName, packageName]);
// Fetch ensure file for selected tag
// Fetch ensure file for selected artifact (if it has tags)
const fetchEnsureFile = useCallback(async () => {
if (!selectedTag) return;
fetchEnsureFileForTag(selectedTag.name);
}, [selectedTag, fetchEnsureFileForTag]);
if (!selectedArtifact || selectedArtifact.tags.length === 0) return;
fetchEnsureFileForTag(selectedArtifact.tags[0]);
}, [selectedArtifact, fetchEnsureFileForTag]);
// Keyboard navigation - go back with backspace
useEffect(() => {
@@ -331,25 +321,35 @@ function PackagePage() {
};
const hasActiveFilters = search !== '';
const tags = tagsData?.items || [];
const pagination = tagsData?.pagination;
const artifacts = artifactsData?.items || [];
const pagination = artifactsData?.pagination;
const handleTagSelect = (tag: TagDetail) => {
setSelectedTag(tag);
const handleArtifactSelect = (artifact: PackageArtifact) => {
setSelectedArtifact(artifact);
};
const handleMenuOpen = (e: React.MouseEvent, tagId: string) => {
const handleMenuOpen = (e: React.MouseEvent, artifactId: string) => {
e.stopPropagation();
if (openMenuId === tagId) {
if (openMenuId === artifactId) {
setOpenMenuId(null);
setMenuPosition(null);
} else {
const rect = e.currentTarget.getBoundingClientRect();
setMenuPosition({ top: rect.bottom + 4, left: rect.right - 180 });
setOpenMenuId(tagId);
setOpenMenuId(artifactId);
}
};
// Helper to get version from artifact metadata
const getArtifactVersion = (a: PackageArtifact): string | null => {
return (a.format_metadata?.version as string) || null;
};
// Helper to get download ref - prefer first tag, fallback to artifact ID
const getDownloadRef = (a: PackageArtifact): string => {
return a.tags.length > 0 ? a.tags[0] : `artifact:${a.id}`;
};
// System projects show Version first, regular projects show Tag first
const columns = isSystemProject
? [
@@ -358,44 +358,44 @@ function PackagePage() {
key: 'version',
header: 'Version',
sortable: true,
render: (t: TagDetail) => (
render: (a: PackageArtifact) => (
<strong
className={`tag-name-link ${selectedTag?.id === t.id ? 'selected' : ''}`}
onClick={() => handleTagSelect(t)}
className={`tag-name-link ${selectedArtifact?.id === a.id ? 'selected' : ''}`}
onClick={() => handleArtifactSelect(a)}
style={{ cursor: 'pointer' }}
>
<span className="version-badge">{t.version || t.name}</span>
<span className="version-badge">{getArtifactVersion(a) || a.tags[0] || a.id.slice(0, 12)}</span>
</strong>
),
},
{
key: 'artifact_original_name',
key: 'original_name',
header: 'Filename',
className: 'cell-truncate',
render: (t: TagDetail) => (
<span title={t.artifact_original_name || t.name}>{t.artifact_original_name || t.name}</span>
render: (a: PackageArtifact) => (
<span title={a.original_name || a.id}>{a.original_name || a.id.slice(0, 12)}</span>
),
},
{
key: 'artifact_size',
key: 'size',
header: 'Size',
render: (t: TagDetail) => <span>{formatBytes(t.artifact_size)}</span>,
render: (a: PackageArtifact) => <span>{formatBytes(a.size)}</span>,
},
{
key: 'created_at',
header: 'Cached',
sortable: true,
render: (t: TagDetail) => (
<span>{new Date(t.created_at).toLocaleDateString()}</span>
render: (a: PackageArtifact) => (
<span>{new Date(a.created_at).toLocaleDateString()}</span>
),
},
{
key: 'actions',
header: '',
render: (t: TagDetail) => (
render: (a: PackageArtifact) => (
<div className="action-buttons">
<a
href={getDownloadUrl(projectName!, packageName!, t.name)}
href={getDownloadUrl(projectName!, packageName!, getDownloadRef(a))}
className="btn btn-icon"
download
title="Download"
@@ -408,7 +408,7 @@ function PackagePage() {
</a>
<button
className="btn btn-icon"
onClick={(e) => handleMenuOpen(e, t.id)}
onClick={(e) => handleMenuOpen(e, a.id)}
title="More actions"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -422,56 +422,56 @@ function PackagePage() {
},
]
: [
// Regular project columns: Tag, Version, Filename
// Regular project columns: Tag, Version, Filename, Size, Created
{
key: 'name',
key: 'tags',
header: 'Tag',
sortable: true,
render: (t: TagDetail) => (
render: (a: PackageArtifact) => (
<strong
className={`tag-name-link ${selectedTag?.id === t.id ? 'selected' : ''}`}
onClick={() => handleTagSelect(t)}
className={`tag-name-link ${selectedArtifact?.id === a.id ? 'selected' : ''}`}
onClick={() => handleArtifactSelect(a)}
style={{ cursor: 'pointer' }}
>
{t.name}
{a.tags.length > 0 ? a.tags[0] : '—'}
</strong>
),
},
{
key: 'version',
header: 'Version',
render: (t: TagDetail) => (
<span className="version-badge">{t.version || '—'}</span>
render: (a: PackageArtifact) => (
<span className="version-badge">{getArtifactVersion(a) || '—'}</span>
),
},
{
key: 'artifact_original_name',
key: 'original_name',
header: 'Filename',
className: 'cell-truncate',
render: (t: TagDetail) => (
<span title={t.artifact_original_name || undefined}>{t.artifact_original_name || '—'}</span>
render: (a: PackageArtifact) => (
<span title={a.original_name || undefined}>{a.original_name || '—'}</span>
),
},
{
key: 'artifact_size',
key: 'size',
header: 'Size',
render: (t: TagDetail) => <span>{formatBytes(t.artifact_size)}</span>,
render: (a: PackageArtifact) => <span>{formatBytes(a.size)}</span>,
},
{
key: 'created_at',
header: 'Created',
sortable: true,
render: (t: TagDetail) => (
<span title={`by ${t.created_by}`}>{new Date(t.created_at).toLocaleDateString()}</span>
render: (a: PackageArtifact) => (
<span title={`by ${a.created_by}`}>{new Date(a.created_at).toLocaleDateString()}</span>
),
},
{
key: 'actions',
header: '',
render: (t: TagDetail) => (
render: (a: PackageArtifact) => (
<div className="action-buttons">
<a
href={getDownloadUrl(projectName!, packageName!, t.name)}
href={getDownloadUrl(projectName!, packageName!, getDownloadRef(a))}
className="btn btn-icon"
download
title="Download"
@@ -484,7 +484,7 @@ function PackagePage() {
</a>
<button
className="btn btn-icon"
onClick={(e) => handleMenuOpen(e, t.id)}
onClick={(e) => handleMenuOpen(e, a.id)}
title="More actions"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -498,8 +498,8 @@ function PackagePage() {
},
];
// Find the tag for the open menu
const openMenuTag = tags.find(t => t.id === openMenuId);
// Find the artifact for the open menu
const openMenuArtifact = artifacts.find(a => a.id === openMenuId);
// Close menu when clicking outside
const handleClickOutside = () => {
@@ -511,8 +511,8 @@ function PackagePage() {
// Render dropdown menu as a portal-like element
const renderActionMenu = () => {
if (!openMenuId || !menuPosition || !openMenuTag) return null;
const t = openMenuTag;
if (!openMenuId || !menuPosition || !openMenuArtifact) return null;
const a = openMenuArtifact;
return (
<div
className="action-menu-backdrop"
@@ -523,21 +523,23 @@ function PackagePage() {
style={{ top: menuPosition.top, left: menuPosition.left }}
onClick={(e) => e.stopPropagation()}
>
<button onClick={() => { setViewArtifactId(t.artifact_id); setShowArtifactIdModal(true); setOpenMenuId(null); setMenuPosition(null); }}>
<button onClick={() => { setViewArtifactId(a.id); setShowArtifactIdModal(true); setOpenMenuId(null); setMenuPosition(null); }}>
View Artifact ID
</button>
<button onClick={() => { navigator.clipboard.writeText(t.artifact_id); setOpenMenuId(null); setMenuPosition(null); }}>
<button onClick={() => { navigator.clipboard.writeText(a.id); setOpenMenuId(null); setMenuPosition(null); }}>
Copy Artifact ID
</button>
<button onClick={() => { fetchEnsureFileForTag(t.name); setOpenMenuId(null); setMenuPosition(null); }}>
View Ensure File
</button>
{a.tags.length > 0 && (
<button onClick={() => { fetchEnsureFileForTag(a.tags[0]); setOpenMenuId(null); setMenuPosition(null); }}>
View Ensure File
</button>
)}
{canWrite && !isSystemProject && (
<button onClick={() => { setCreateTagArtifactId(t.artifact_id); setShowCreateTagModal(true); setOpenMenuId(null); setMenuPosition(null); }}>
<button onClick={() => { setCreateTagArtifactId(a.id); setShowCreateTagModal(true); setOpenMenuId(null); setMenuPosition(null); }}>
Create/Update Tag
</button>
)}
<button onClick={() => { handleTagSelect(t); setShowDepsModal(true); setOpenMenuId(null); setMenuPosition(null); }}>
<button onClick={() => { handleArtifactSelect(a); setShowDepsModal(true); setOpenMenuId(null); setMenuPosition(null); }}>
View Dependencies
</button>
</div>
@@ -545,7 +547,7 @@ function PackagePage() {
);
};
if (loading && !tagsData) {
if (loading && !artifactsData) {
return <div className="loading">Loading...</div>;
}
@@ -653,7 +655,7 @@ function PackagePage() {
<SearchInput
value={search}
onChange={handleSearchChange}
placeholder="Filter tags..."
placeholder="Filter artifacts..."
className="list-controls__search"
/>
</div>
@@ -666,13 +668,13 @@ function PackagePage() {
<div className="data-table--responsive">
<DataTable
data={tags}
data={artifacts}
columns={columns}
keyExtractor={(t) => t.id}
keyExtractor={(a) => a.id}
emptyMessage={
hasActiveFilters
? 'No tags match your filters. Try adjusting your search.'
: 'No tags yet. Upload an artifact with a tag to create one!'
? 'No artifacts match your filters. Try adjusting your search.'
: 'No artifacts yet. Upload a file to get started!'
}
onSort={handleSortChange}
sortKey={sort}
@@ -752,11 +754,11 @@ function PackagePage() {
</div>
{/* Dependency Graph Modal */}
{showGraph && selectedTag && (
{showGraph && selectedArtifact && (
<DependencyGraph
projectName={projectName!}
packageName={packageName!}
tagName={selectedTag.name}
tagName={selectedArtifact.tags.length > 0 ? selectedArtifact.tags[0] : `artifact:${selectedArtifact.id}`}
onClose={() => setShowGraph(false)}
/>
)}
@@ -916,11 +918,11 @@ function PackagePage() {
)}
{/* Dependencies Modal */}
{showDepsModal && selectedTag && (
{showDepsModal && selectedArtifact && (
<div className="modal-overlay" onClick={() => setShowDepsModal(false)}>
<div className="deps-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>Dependencies for {selectedTag.version || selectedTag.name}</h3>
<h3>Dependencies for {selectedArtifact.original_name || selectedArtifact.id.slice(0, 12)}</h3>
<button
className="modal-close"
onClick={() => setShowDepsModal(false)}
@@ -934,13 +936,15 @@ function PackagePage() {
</div>
<div className="modal-body">
<div className="deps-modal-controls">
<button
className="btn btn-secondary btn-small"
onClick={fetchEnsureFile}
disabled={ensureFileLoading}
>
View Ensure File
</button>
{selectedArtifact?.tags && selectedArtifact.tags.length > 0 && (
<button
className="btn btn-secondary btn-small"
onClick={fetchEnsureFile}
disabled={ensureFileLoading}
>
View Ensure File
</button>
)}
<button
className="btn btn-secondary btn-small"
onClick={() => { setShowDepsModal(false); setShowGraph(true); }}

View File

@@ -53,6 +53,21 @@ export interface Artifact {
ref_count: number;
}
export interface PackageArtifact {
id: string;
sha256: string;
size: number;
content_type: string | null;
original_name: string | null;
checksum_md5?: string | null;
checksum_sha1?: string | null;
s3_etag?: string | null;
created_at: string;
created_by: string;
format_metadata?: Record<string, unknown> | null;
tags: string[];
}
export interface Tag {
id: string;
package_id: string;