feat(#248): admin tag page complete overhaul — tree panel, merge, subtree delete, new edit components #249

Merged
marcel merged 51 commits from feat/issue-221-tag-hierarchy into main 2026-04-17 10:24:10 +02:00
2 changed files with 89 additions and 16 deletions
Showing only changes of commit 9b5af67780 - Show all commits

View File

@@ -1,8 +1,34 @@
import { createApiClient } from '$lib/api.server';
import type { components } from '$lib/generated/api';
import type { LayoutServerLoad } from './$types';
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
export type FlatTag = {
id: string;
name: string;
color?: string;
parentId?: string;
documentCount: number;
};
function flattenTree(nodes: TagTreeNodeDTO[], result: FlatTag[] = []): FlatTag[] {
for (const node of nodes) {
result.push({
id: node.id!,
name: node.name!,
color: node.color ?? undefined,
parentId: node.parentId ?? undefined,
documentCount: node.documentCount ?? 0
});
if (node.children?.length) flattenTree(node.children, result);
}
return result;
}
export const load: LayoutServerLoad = async ({ fetch }) => {
const api = createApiClient(fetch);
const result = await api.GET('/api/tags');
return { tags: result.data ?? [] };
const result = await api.GET('/api/tags/tree');
const tree = result.data ?? [];
return { tree, tags: flattenTree(tree) };
};

View File

@@ -5,37 +5,84 @@ vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() }));
import { createApiClient } from '$lib/api.server';
function mockApi(tags: unknown[]) {
function mockTreeApi(tree: unknown[]) {
vi.mocked(createApiClient).mockReturnValue({
GET: vi.fn().mockResolvedValueOnce({ response: { ok: true }, data: tags })
GET: vi.fn().mockResolvedValueOnce({ response: { ok: true }, data: tree })
} as ReturnType<typeof createApiClient>);
}
beforeEach(() => vi.clearAllMocks());
const sampleTree = [
{
id: 'parent1',
name: 'Familie',
color: 'teal',
documentCount: 3,
parentId: null,
children: [
{
id: 'child1',
name: 'Eltern',
color: null,
documentCount: 2,
parentId: 'parent1',
children: []
}
]
},
{
id: 'root2',
name: 'Urlaub',
color: null,
documentCount: 1,
parentId: null,
children: []
}
];
describe('admin/tags layout load', () => {
it('returns the tags list', async () => {
mockApi([
{ id: 't1', name: 'Familie' },
{ id: 't2', name: 'Urlaub' }
]);
it('returns the tree list', async () => {
mockTreeApi(sampleTree);
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
expect(result.tags).toHaveLength(2);
expect(result.tags[0].name).toBe('Familie');
expect(result.tree).toHaveLength(2);
expect(result.tree[0].name).toBe('Familie');
});
it('returns an empty array when the API returns nothing', async () => {
mockApi([]);
it('returns an empty tree when the API returns nothing', async () => {
mockTreeApi([]);
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
expect(result.tags).toEqual([]);
expect(result.tree).toEqual([]);
});
it('calls GET /api/tags', async () => {
it('calls GET /api/tags/tree', async () => {
const mockGet = vi.fn().mockResolvedValue({ response: { ok: true }, data: [] });
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
await load({ fetch: vi.fn() as unknown as typeof fetch });
expect(mockGet).toHaveBeenCalledWith('/api/tags');
expect(mockGet).toHaveBeenCalledWith('/api/tags/tree');
});
it('flattens the tree into a flat tags array', async () => {
mockTreeApi(sampleTree);
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
// Both parent and child should be in the flat array
expect(result.tags).toHaveLength(3);
expect(result.tags.map((t) => t.name)).toContain('Eltern');
});
it('preserves parentId on child tags in the flat array', async () => {
mockTreeApi(sampleTree);
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
const child = result.tags.find((t) => t.name === 'Eltern');
expect(child?.parentId).toBe('parent1');
});
it('sets parentId to undefined on root tags in the flat array', async () => {
mockTreeApi(sampleTree);
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
const root = result.tags.find((t) => t.name === 'Familie');
expect(root?.parentId).toBeUndefined();
});
});