mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 15:35:39 +02:00
@@ -85,6 +85,13 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- Link archives to Printables, Thingiverse, or any other URL
|
||||
- Globe button opens external link when set, falls back to auto-detected MakerWorld URL
|
||||
- Edit via archive edit modal
|
||||
- **Tag Management** - Centralized tag management for print archives (Issue #183):
|
||||
- View all tags with usage counts in a dedicated modal
|
||||
- Rename tags across all archives with one action
|
||||
- Delete tags from all archives with confirmation
|
||||
- Search and filter tags by name
|
||||
- Sort by usage count or alphabetically
|
||||
- Access via gear icon next to tag filter dropdown on Archives page
|
||||
- Included in backup/restore
|
||||
- **External Network Camera Support** - Add external cameras (MJPEG, RTSP, HTTP snapshot) to replace built-in printer cameras (Issue #143):
|
||||
- Configure per-printer external camera URL and type in Settings → Camera
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support)
|
||||
- Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
|
||||
- Archive comparison (side-by-side diff)
|
||||
- Tag management (rename/delete across all archives)
|
||||
|
||||
### 📊 Monitoring & Control
|
||||
- Real-time printer status via WebSocket
|
||||
|
||||
@@ -552,6 +552,103 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tags")
|
||||
async def get_all_tags(db: AsyncSession = Depends(get_db)):
|
||||
"""List all unique tags with usage counts.
|
||||
|
||||
Returns a list of tags sorted by count (descending), then by name.
|
||||
"""
|
||||
# Query all archives with non-null tags
|
||||
result = await db.execute(select(PrintArchive.tags).where(PrintArchive.tags.isnot(None)))
|
||||
all_tags_rows = result.all()
|
||||
|
||||
# Count occurrences of each tag
|
||||
tag_counts: dict[str, int] = {}
|
||||
for (tags_str,) in all_tags_rows:
|
||||
if tags_str:
|
||||
for tag in tags_str.split(","):
|
||||
tag = tag.strip()
|
||||
if tag:
|
||||
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
||||
|
||||
# Convert to list and sort by count (desc), then name (asc)
|
||||
tags_list = [{"name": name, "count": count} for name, count in tag_counts.items()]
|
||||
tags_list.sort(key=lambda x: (-x["count"], x["name"].lower()))
|
||||
|
||||
return tags_list
|
||||
|
||||
|
||||
@router.put("/tags/{tag_name}")
|
||||
async def rename_tag(
|
||||
tag_name: str,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Rename a tag across all archives.
|
||||
|
||||
Request body should contain {"new_name": "new tag name"}.
|
||||
Returns the count of affected archives.
|
||||
"""
|
||||
body = await request.json()
|
||||
new_name = body.get("new_name", "").strip()
|
||||
|
||||
if not new_name:
|
||||
raise HTTPException(400, "new_name is required")
|
||||
|
||||
if new_name == tag_name:
|
||||
return {"affected": 0}
|
||||
|
||||
# Find all archives containing the old tag
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))
|
||||
archives = list(result.scalars().all())
|
||||
|
||||
affected = 0
|
||||
for archive in archives:
|
||||
if not archive.tags:
|
||||
continue
|
||||
tags = [t.strip() for t in archive.tags.split(",")]
|
||||
if tag_name in tags:
|
||||
# Replace old tag with new tag
|
||||
new_tags = [new_name if t == tag_name else t for t in tags]
|
||||
# Remove duplicates while preserving order
|
||||
seen = set()
|
||||
unique_tags = []
|
||||
for t in new_tags:
|
||||
if t not in seen:
|
||||
seen.add(t)
|
||||
unique_tags.append(t)
|
||||
archive.tags = ", ".join(unique_tags)
|
||||
affected += 1
|
||||
|
||||
await db.commit()
|
||||
return {"affected": affected}
|
||||
|
||||
|
||||
@router.delete("/tags/{tag_name}")
|
||||
async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Delete a tag from all archives.
|
||||
|
||||
Returns the count of affected archives.
|
||||
"""
|
||||
# Find all archives containing the tag
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))
|
||||
archives = list(result.scalars().all())
|
||||
|
||||
affected = 0
|
||||
for archive in archives:
|
||||
if not archive.tags:
|
||||
continue
|
||||
tags = [t.strip() for t in archive.tags.split(",")]
|
||||
if tag_name in tags:
|
||||
# Remove the tag
|
||||
new_tags = [t for t in tags if t != tag_name]
|
||||
archive.tags = ", ".join(new_tags) if new_tags else None
|
||||
affected += 1
|
||||
|
||||
await db.commit()
|
||||
return {"affected": affected}
|
||||
|
||||
|
||||
@router.get("/{archive_id}", response_model=ArchiveResponse)
|
||||
async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get a specific archive."""
|
||||
|
||||
@@ -434,3 +434,127 @@ class TestArchiveF3DEndpoints:
|
||||
"""Verify filament-requirements with plate_id returns 404 for non-existent archive."""
|
||||
response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1")
|
||||
assert response.status_code == 404
|
||||
|
||||
# ========================================================================
|
||||
# Tag Management endpoints (Issue #183)
|
||||
# ========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_tags_empty(self, async_client: AsyncClient):
|
||||
"""Verify empty list when no tags exist."""
|
||||
response = await async_client.get("/api/v1/archives/tags")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_tags_with_data(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
|
||||
"""Verify tags are returned with counts."""
|
||||
printer = await printer_factory()
|
||||
await archive_factory(printer.id, print_name="Archive 1", tags="functional, test")
|
||||
await archive_factory(printer.id, print_name="Archive 2", tags="functional, calibration")
|
||||
await archive_factory(printer.id, print_name="Archive 3", tags="test")
|
||||
|
||||
response = await async_client.get("/api/v1/archives/tags")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
# Convert to dict for easier lookup
|
||||
tags_dict = {t["name"]: t["count"] for t in data}
|
||||
assert tags_dict.get("functional") == 2
|
||||
assert tags_dict.get("test") == 2
|
||||
assert tags_dict.get("calibration") == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_tags_sorted_by_count(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
"""Verify tags are sorted by count descending, then by name."""
|
||||
printer = await printer_factory()
|
||||
await archive_factory(printer.id, tags="alpha")
|
||||
await archive_factory(printer.id, tags="beta, alpha")
|
||||
await archive_factory(printer.id, tags="gamma, beta, alpha")
|
||||
|
||||
response = await async_client.get("/api/v1/archives/tags")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# alpha=3, beta=2, gamma=1
|
||||
assert data[0]["name"] == "alpha"
|
||||
assert data[0]["count"] == 3
|
||||
assert data[1]["name"] == "beta"
|
||||
assert data[1]["count"] == 2
|
||||
assert data[2]["name"] == "gamma"
|
||||
assert data[2]["count"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_rename_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
|
||||
"""Verify renaming a tag updates all archives."""
|
||||
printer = await printer_factory()
|
||||
a1 = await archive_factory(printer.id, print_name="Archive 1", tags="old-tag, other")
|
||||
a2 = await archive_factory(printer.id, print_name="Archive 2", tags="old-tag")
|
||||
await archive_factory(printer.id, print_name="Archive 3", tags="different")
|
||||
|
||||
response = await async_client.put("/api/v1/archives/tags/old-tag", json={"new_name": "new-tag"})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["affected"] == 2
|
||||
|
||||
# Verify the archives were updated
|
||||
response = await async_client.get(f"/api/v1/archives/{a1.id}")
|
||||
assert "new-tag" in response.json()["tags"]
|
||||
assert "old-tag" not in response.json()["tags"]
|
||||
|
||||
response = await async_client.get(f"/api/v1/archives/{a2.id}")
|
||||
assert response.json()["tags"] == "new-tag"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_rename_tag_no_change(self, async_client: AsyncClient):
|
||||
"""Verify renaming to same name returns 0 affected."""
|
||||
response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": "some-tag"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["affected"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_rename_tag_empty_name_error(self, async_client: AsyncClient):
|
||||
"""Verify renaming to empty name returns error."""
|
||||
response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": ""})
|
||||
assert response.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_delete_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
|
||||
"""Verify deleting a tag removes it from all archives."""
|
||||
printer = await printer_factory()
|
||||
a1 = await archive_factory(printer.id, print_name="Archive 1", tags="delete-me, keep")
|
||||
a2 = await archive_factory(printer.id, print_name="Archive 2", tags="delete-me")
|
||||
await archive_factory(printer.id, print_name="Archive 3", tags="different")
|
||||
|
||||
response = await async_client.delete("/api/v1/archives/tags/delete-me")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["affected"] == 2
|
||||
|
||||
# Verify the archives were updated
|
||||
response = await async_client.get(f"/api/v1/archives/{a1.id}")
|
||||
assert response.json()["tags"] == "keep"
|
||||
|
||||
response = await async_client.get(f"/api/v1/archives/{a2.id}")
|
||||
# Should be None or empty when last tag is removed
|
||||
assert response.json()["tags"] is None or response.json()["tags"] == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_delete_tag_not_found(self, async_client: AsyncClient):
|
||||
"""Verify deleting non-existent tag returns 0 affected."""
|
||||
response = await async_client.delete("/api/v1/archives/tags/nonexistent-tag")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["affected"] == 0
|
||||
|
||||
@@ -38,7 +38,11 @@ describe('EditArchiveModal', () => {
|
||||
return HttpResponse.json(mockProjects);
|
||||
}),
|
||||
http.get('/api/v1/archives/tags', () => {
|
||||
return HttpResponse.json(['test', 'calibration', 'functional']);
|
||||
return HttpResponse.json([
|
||||
{ name: 'test', count: 2 },
|
||||
{ name: 'calibration', count: 1 },
|
||||
{ name: 'functional', count: 3 },
|
||||
]);
|
||||
}),
|
||||
http.patch('/api/v1/archives/:id', async ({ request }) => {
|
||||
const body = await request.json();
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Tests for the TagManagementModal component.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
import { TagManagementModal } from '../../components/TagManagementModal';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
|
||||
const mockTags = [
|
||||
{ name: 'functional', count: 5 },
|
||||
{ name: 'calibration', count: 3 },
|
||||
{ name: 'test', count: 2 },
|
||||
{ name: 'art', count: 1 },
|
||||
];
|
||||
|
||||
describe('TagManagementModal', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
server.use(
|
||||
http.get('/api/v1/archives/tags', () => {
|
||||
return HttpResponse.json(mockTags);
|
||||
}),
|
||||
http.put('/api/v1/archives/tags/:tagName', async () => {
|
||||
return HttpResponse.json({ affected: 2 });
|
||||
}),
|
||||
http.delete('/api/v1/archives/tags/:tagName', () => {
|
||||
return HttpResponse.json({ affected: 1 });
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the modal title', async () => {
|
||||
render(<TagManagementModal onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText('Manage Tags')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state initially', () => {
|
||||
render(<TagManagementModal onClose={mockOnClose} />);
|
||||
|
||||
// Should show loading spinner before data loads
|
||||
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays tags with counts', async () => {
|
||||
render(<TagManagementModal onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('functional')).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
expect(screen.getByText('calibration')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows total tag count and usage', async () => {
|
||||
render(<TagManagementModal onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// 4 tags, 11 total usages
|
||||
expect(screen.getByText(/4 tags across 11 usages/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('search functionality', () => {
|
||||
it('filters tags by search input', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TagManagementModal onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('functional')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search tags...');
|
||||
await user.type(searchInput, 'cal');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('calibration')).toBeInTheDocument();
|
||||
expect(screen.queryByText('functional')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('art')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows no results message when search has no matches', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TagManagementModal onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('functional')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search tags...');
|
||||
await user.type(searchInput, 'nonexistent');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No tags match your search')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sorting', () => {
|
||||
it('sorts by count by default', async () => {
|
||||
render(<TagManagementModal onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const tagElements = screen.getAllByText(/functional|calibration|test|art/);
|
||||
// First should be functional (count 5)
|
||||
expect(tagElements[0]).toHaveTextContent('functional');
|
||||
});
|
||||
});
|
||||
|
||||
it('can sort by name', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TagManagementModal onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('functional')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const sortSelect = screen.getByDisplayValue('Sort by Count');
|
||||
await user.selectOptions(sortSelect, 'name');
|
||||
|
||||
await waitFor(() => {
|
||||
const tagElements = screen.getAllByText(/functional|calibration|test|art/);
|
||||
// First should be 'art' alphabetically
|
||||
expect(tagElements[0]).toHaveTextContent('art');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rename functionality', () => {
|
||||
it('enters edit mode when clicking edit button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TagManagementModal onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('functional')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the tag row and click its edit button
|
||||
const tagRow = screen.getByText('functional').closest('div');
|
||||
const editButton = within(tagRow!).getByTitle('Rename tag');
|
||||
await user.click(editButton);
|
||||
|
||||
// Should show input with current value
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('functional')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits rename on Enter key', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TagManagementModal onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('functional')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const tagRow = screen.getByText('functional').closest('div');
|
||||
const editButton = within(tagRow!).getByTitle('Rename tag');
|
||||
await user.click(editButton);
|
||||
|
||||
const input = screen.getByDisplayValue('functional');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'new-name{Enter}');
|
||||
|
||||
// Should show success (mutation called)
|
||||
await waitFor(() => {
|
||||
// After successful rename, edit mode should close
|
||||
expect(screen.queryByDisplayValue('new-name')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels edit on Escape key', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TagManagementModal onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('functional')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const tagRow = screen.getByText('functional').closest('div');
|
||||
const editButton = within(tagRow!).getByTitle('Rename tag');
|
||||
await user.click(editButton);
|
||||
|
||||
const input = screen.getByDisplayValue('functional');
|
||||
await user.type(input, '-modified{Escape}');
|
||||
|
||||
// Should exit edit mode without saving
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByDisplayValue('functional-modified')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('functional')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete functionality', () => {
|
||||
it('shows delete confirmation when clicking delete button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TagManagementModal onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('functional')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const tagRow = screen.getByText('functional').closest('div');
|
||||
const deleteButton = within(tagRow!).getByTitle('Delete tag');
|
||||
await user.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete "functional" from 5 archives?/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels delete confirmation on X button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TagManagementModal onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('functional')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const tagRow = screen.getByText('functional').closest('div');
|
||||
const deleteButton = within(tagRow!).getByTitle('Delete tag');
|
||||
await user.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete "functional"/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the confirmation row and click the cancel (X) button within it
|
||||
const confirmationText = screen.getByText(/Delete "functional"/i);
|
||||
const confirmationRow = confirmationText.closest('div');
|
||||
// The X button is the last button in the confirmation row
|
||||
const buttons = within(confirmationRow!.parentElement!).getAllByRole('button');
|
||||
const cancelButton = buttons[buttons.length - 1]; // X button is last
|
||||
await user.click(cancelButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should return to normal display - the tag name should be visible again
|
||||
expect(screen.getByText('functional')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('modal behavior', () => {
|
||||
it('calls onClose when close button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TagManagementModal onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage Tags')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find close button in header (X icon)
|
||||
const headerCloseButton = screen.getAllByRole('button')[0];
|
||||
await user.click(headerCloseButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClose when Close button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TagManagementModal onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Manage Tags')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: /close/i });
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty state', () => {
|
||||
it('shows empty message when no tags exist', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/archives/tags', () => {
|
||||
return HttpResponse.json([]);
|
||||
})
|
||||
);
|
||||
|
||||
render(<TagManagementModal onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No tags found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -335,6 +335,11 @@ export interface ArchiveStats {
|
||||
total_energy_cost: number;
|
||||
}
|
||||
|
||||
export interface TagInfo {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface FailureAnalysis {
|
||||
period_days: number;
|
||||
total_prints: number;
|
||||
@@ -1934,6 +1939,17 @@ export const api = {
|
||||
deleteArchive: (id: number) =>
|
||||
request<void>(`/archives/${id}`, { method: 'DELETE' }),
|
||||
getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
|
||||
// Tag management
|
||||
getTags: () => request<TagInfo[]>('/archives/tags'),
|
||||
renameTag: (oldName: string, newName: string) =>
|
||||
request<{ affected: number }>(`/archives/tags/${encodeURIComponent(oldName)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ new_name: newName }),
|
||||
}),
|
||||
deleteTag: (name: string) =>
|
||||
request<{ affected: number }>(`/archives/tags/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
recalculateCosts: () =>
|
||||
request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
|
||||
getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => {
|
||||
|
||||
@@ -68,30 +68,49 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
queryFn: () => api.getProjects(),
|
||||
});
|
||||
|
||||
// Get all archives to extract existing tags if not provided
|
||||
const { data: archives } = useQuery({
|
||||
queryKey: ['archives'],
|
||||
queryFn: () => api.getArchives(undefined, 1000, 0),
|
||||
// Fetch all tags using the dedicated API
|
||||
const { data: tagsData } = useQuery({
|
||||
queryKey: ['tags'],
|
||||
queryFn: api.getTags,
|
||||
enabled: existingTags.length === 0,
|
||||
});
|
||||
|
||||
// Extract unique tags from all archives
|
||||
// Use existing tags prop if provided, otherwise use fetched tags
|
||||
const allTags = existingTags.length > 0
|
||||
? existingTags
|
||||
: [...new Set(
|
||||
archives?.flatMap(a => a.tags?.split(',').map(t => t.trim()) || []).filter(Boolean) || []
|
||||
)].sort();
|
||||
: (tagsData?.map(t => t.name) || []);
|
||||
|
||||
// Get current tags as array
|
||||
const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean);
|
||||
|
||||
// Filter suggestions based on what's not already added
|
||||
const tagSuggestions = allTags.filter(t => !currentTags.includes(t));
|
||||
// Get the text being typed after the last comma (for autocomplete filtering)
|
||||
const currentInput = tags.includes(',')
|
||||
? tags.substring(tags.lastIndexOf(',') + 1).trim().toLowerCase()
|
||||
: tags.trim().toLowerCase();
|
||||
|
||||
// Add a tag
|
||||
// Filter suggestions: not already added AND matches current input (if any)
|
||||
const tagSuggestions = allTags.filter(t =>
|
||||
!currentTags.includes(t) &&
|
||||
(currentInput === '' || t.toLowerCase().includes(currentInput))
|
||||
);
|
||||
|
||||
// Add a tag (replaces any partial input with the selected tag)
|
||||
const addTag = (tag: string) => {
|
||||
if (!currentTags.includes(tag)) {
|
||||
const newTags = [...currentTags, tag].join(', ');
|
||||
// If there's partial input being typed, replace it with the selected tag
|
||||
// Otherwise, just append the tag
|
||||
let baseTags: string[];
|
||||
if (currentInput && !allTags.includes(currentInput)) {
|
||||
// User is typing a partial tag - replace it with the selected one
|
||||
baseTags = tags.includes(',')
|
||||
? tags.substring(0, tags.lastIndexOf(',')).split(',').map(t => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
} else {
|
||||
// No partial input or input is already a complete tag - append
|
||||
baseTags = currentTags;
|
||||
}
|
||||
|
||||
if (!baseTags.includes(tag)) {
|
||||
const newTags = [...baseTags, tag].join(', ');
|
||||
setTags(newTags);
|
||||
}
|
||||
// Clear any pending blur timeout to prevent hiding suggestions
|
||||
@@ -342,7 +361,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
{showTagSuggestions && tagSuggestions.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
|
||||
<div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
|
||||
Existing tags (click to add)
|
||||
{currentInput ? `Matching "${currentInput}"` : 'Existing tags'} (click to add)
|
||||
</div>
|
||||
<div className="p-2 flex flex-wrap gap-1.5">
|
||||
{tagSuggestions.map((tag) => (
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { X, Tag, Pencil, Trash2, Loader2, Search, Check, AlertTriangle } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { TagInfo } from '../api/client';
|
||||
import { Card, CardContent } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
interface TagManagementModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TagManagementModal({ onClose }: TagManagementModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const [search, setSearch] = useState('');
|
||||
const [editingTag, setEditingTag] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [sortBy, setSortBy] = useState<'count' | 'name'>('count');
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (editingTag) {
|
||||
setEditingTag(null);
|
||||
} else if (deleteConfirm) {
|
||||
setDeleteConfirm(null);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose, editingTag, deleteConfirm]);
|
||||
|
||||
const { data: tags, isLoading } = useQuery({
|
||||
queryKey: ['tags'],
|
||||
queryFn: api.getTags,
|
||||
});
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: ({ oldName, newName }: { oldName: string; newName: string }) =>
|
||||
api.renameTag(oldName, newName),
|
||||
onSuccess: (data, { oldName, newName }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
showToast(`Renamed "${oldName}" to "${newName}" in ${data.affected} archive${data.affected !== 1 ? 's' : ''}`);
|
||||
setEditingTag(null);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || 'Failed to rename tag', 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (name: string) => api.deleteTag(name),
|
||||
onSuccess: (data, name) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
showToast(`Deleted "${name}" from ${data.affected} archive${data.affected !== 1 ? 's' : ''}`);
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || 'Failed to delete tag', 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const startEdit = (tag: TagInfo) => {
|
||||
setEditingTag(tag.name);
|
||||
setEditValue(tag.name);
|
||||
setDeleteConfirm(null);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingTag(null);
|
||||
setEditValue('');
|
||||
};
|
||||
|
||||
const submitEdit = () => {
|
||||
if (!editingTag || !editValue.trim()) return;
|
||||
const newName = editValue.trim();
|
||||
if (newName === editingTag) {
|
||||
cancelEdit();
|
||||
return;
|
||||
}
|
||||
renameMutation.mutate({ oldName: editingTag, newName });
|
||||
};
|
||||
|
||||
const handleEditKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submitEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (name: string) => {
|
||||
setDeleteConfirm(name);
|
||||
setEditingTag(null);
|
||||
};
|
||||
|
||||
const executeDelete = () => {
|
||||
if (deleteConfirm) {
|
||||
deleteMutation.mutate(deleteConfirm);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter and sort tags
|
||||
const filteredTags = tags
|
||||
?.filter(t => t.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'count') {
|
||||
return b.count - a.count || a.name.localeCompare(b.name);
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const totalUsage = tags?.reduce((sum, t) => sum + t.count, 0) || 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<Card className="w-full max-w-lg max-h-[80vh] flex flex-col">
|
||||
<CardContent className="p-0 flex flex-col min-h-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-xl font-semibold text-white">Manage Tags</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-bambu-gray hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search and sort */}
|
||||
<div className="p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tags..."
|
||||
className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as 'count' | 'name')}
|
||||
>
|
||||
<option value="count">Sort by Count</option>
|
||||
<option value="name">Sort by Name</option>
|
||||
</select>
|
||||
</div>
|
||||
{tags && (
|
||||
<p className="text-xs text-bambu-gray mt-2">
|
||||
{tags.length} tag{tags.length !== 1 ? 's' : ''} across {totalUsage} usage{totalUsage !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags list */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-bambu-gray" />
|
||||
</div>
|
||||
) : !filteredTags?.length ? (
|
||||
<div className="text-center py-8 text-bambu-gray">
|
||||
{search ? 'No tags match your search' : 'No tags found'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredTags.map((tag) => (
|
||||
<div
|
||||
key={tag.name}
|
||||
className="flex items-center gap-2 p-2 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors group"
|
||||
>
|
||||
{editingTag === tag.name ? (
|
||||
// Edit mode
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 px-2 py-1 bg-bambu-dark-tertiary border border-bambu-green rounded text-white text-sm focus:outline-none"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={submitEdit}
|
||||
disabled={!editValue.trim() || renameMutation.isPending}
|
||||
className="p-1.5"
|
||||
>
|
||||
{renameMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={cancelEdit}
|
||||
className="p-1.5"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : deleteConfirm === tag.name ? (
|
||||
// Delete confirmation
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
|
||||
<span className="text-sm text-bambu-gray-light flex-1">
|
||||
Delete "{tag.name}" from {tag.count} archive{tag.count !== 1 ? 's' : ''}?
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={executeDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="p-1.5"
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="p-1.5"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
// Normal display
|
||||
<>
|
||||
<Tag className="w-4 h-4 text-bambu-gray flex-shrink-0" />
|
||||
<span className="text-white flex-1 truncate">{tag.name}</span>
|
||||
<span className="px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray text-xs">
|
||||
{tag.count}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => startEdit(tag)}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
|
||||
title="Rename tag"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => confirmDelete(tag.name)}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
|
||||
title="Delete tag"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
|
||||
<Button variant="secondary" onClick={onClose} className="flex-1">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
FolderKanban,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { openInSlicer } from '../utils/slicer';
|
||||
@@ -66,6 +67,7 @@ import { ProjectPageModal } from '../components/ProjectPageModal';
|
||||
import { TimelapseViewer } from '../components/TimelapseViewer';
|
||||
import { CompareArchivesModal } from '../components/CompareArchivesModal';
|
||||
import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
|
||||
import { TagManagementModal } from '../components/TagManagementModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -2006,6 +2008,7 @@ export function ArchivesPage() {
|
||||
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [showCompareModal, setShowCompareModal] = useState(false);
|
||||
const [showTagManagement, setShowTagManagement] = useState(false);
|
||||
const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
|
||||
|
||||
// Clear highlight after 5 seconds and scroll to highlighted element
|
||||
@@ -2637,6 +2640,13 @@ export function ArchivesPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setShowTagManagement(true)}
|
||||
className="p-2 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-green transition-colors"
|
||||
title="Manage Tags"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
@@ -2865,6 +2875,11 @@ export function ArchivesPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tag Management Modal */}
|
||||
{showTagManagement && (
|
||||
<TagManagementModal onClose={() => setShowTagManagement(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user