diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/page.spec.ts b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/page.spec.ts new file mode 100644 index 000000000..515121f6f --- /dev/null +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/page.spec.ts @@ -0,0 +1,55 @@ +import { Route } from '#lib/route.ts'; +import { factory } from '#test/unit/factories.ts'; +import { test } from '#test/unit/fixtures.ts'; +import { expect } from 'vitest'; +import Page from './+page.svelte'; +import { load } from './+page.ts'; + +test('creating a tag under the current tag shows it as a child', async ({ data, router, render, settle, app, api }) => { + data.addTags(factory.tag({ name: 'Travel' })); + await router.goto(Route.tags({ path: 'Travel' })); + await render(Page, load); + await settle(); + + await app.tags.createButton.click(); + await app.tags.form.tagInput.fill('Japan'); + await app.tags.form.createButton.click(); + await settle(); + + expect(api.upsertTags).toHaveBeenCalledWith({ body: { tags: ['Travel/Japan'] } }); + await expect.element(app.tags.child('Japan')).toBeVisible(); +}); + +test('renaming the current tag opens its new path', async ({ data, router, render, settle, app, api }) => { + const travel = factory.tag({ name: 'Travel' }); + data.addTags(travel); + await router.goto(Route.tags({ path: 'Travel' })); + await render(Page, load); + await settle(); + + await app.tags.editButton.click(); + await app.tags.form.nameInput.fill('Trips'); + await app.tags.form.saveButton.click(); + await settle(); + + expect(api.updateTag).toHaveBeenCalledWith({ params: { id: travel.id }, body: { name: 'Trips', color: null } }); + expect(router.url.href).toBe(new URL(Route.tags({ path: 'Trips' }), router.url).href); + await expect.element(app.tags.explorerLink('Trips')).toBeVisible(); +}); + +test('deleting the current tag opens its parent', async ({ data, router, render, settle, app, api }) => { + const travel = factory.tag({ name: 'Travel' }); + const japan = factory.tag({ name: 'Japan', value: 'Travel/Japan', parentId: travel.id }); + data.addTags([travel, japan]); + await router.goto(Route.tags({ path: 'Travel/Japan' })); + await render(Page, load); + await settle(); + + await app.tags.deleteButton.click(); + await app.tags.form.deleteButton.click(); + await settle(); + + expect(api.deleteTag).toHaveBeenCalledWith({ params: { id: japan.id } }); + expect(router.url.href).toBe(new URL(Route.tags({ path: 'Travel' }), router.url).href); + await expect.element(app.tags.explorerLink('Japan')).not.toBeInTheDocument(); +}); diff --git a/web/test/common/data.ts b/web/test/common/data.ts index 37929ba9f..c396a86b3 100644 --- a/web/test/common/data.ts +++ b/web/test/common/data.ts @@ -42,6 +42,7 @@ import { type StackResponseDto, type StackUpdateDto, type TagResponseDto, + type TagUpdateDto, type TimeBucketAssetResponseDto, type TimeBucketsResponseDto, type UpdateAlbumDto, @@ -506,6 +507,85 @@ export class MockData { return [...this.tags.values()]; } + upsertTags(values: string[]) { + return values.map((value) => this.upsertTag(value)); + } + + updateTag(id: string, dto: TagUpdateDto) { + const tag = this.tags.get(id); + if (!tag) { + return; + } + + const name = dto.name ?? tag.name; + const value = tag.value.slice(0, tag.value.length - tag.name.length) + name; + for (const descendant of this.tagList()) { + if (descendant.value.startsWith(`${tag.value}/`)) { + this.tags.set(descendant.id, { ...descendant, value: value + descendant.value.slice(tag.value.length) }); + } + } + + const updated = { ...tag, name, value, color: dto.color === undefined ? tag.color : (dto.color ?? undefined) }; + this.tags.set(id, updated); + return updated; + } + + deleteTag(id: string) { + const tag = this.tags.get(id); + if (!tag) { + return false; + } + + for (const descendant of this.tagList()) { + if (descendant.id === id || descendant.value.startsWith(`${tag.value}/`)) { + this.tags.delete(descendant.id); + } + } + return true; + } + + tagAssets(assetIds: string[], tagIds: string[]) { + const tags = tagIds.map((id) => this.tags.get(id)).filter((tag) => tag !== undefined); + let count = 0; + for (const assetId of assetIds) { + const asset = this.assets.get(assetId); + if (!asset) { + continue; + } + + const existing = asset.tags ?? []; + const added = tags.filter((tag) => existing.every(({ id }) => id !== tag.id)); + count += added.length; + this.assets.set(assetId, { ...asset, tags: [...existing, ...added] }); + } + return count; + } + + untagAssets(tagId: string, assetIds: string[]) { + return assetIds.map((id) => { + const asset = this.assets.get(id); + if (!asset?.tags?.some((tag) => tag.id === tagId)) { + return { id, success: false }; + } + + this.assets.set(id, { ...asset, tags: asset.tags.filter((tag) => tag.id !== tagId) }); + return { id, success: true }; + }); + } + + private upsertTag(value: string): TagResponseDto { + const existing = this.tagList().find((tag) => tag.value === value); + if (existing) { + return existing; + } + + const separator = value.lastIndexOf('/'); + const parent = separator === -1 ? undefined : this.upsertTag(value.slice(0, separator)); + const tag = factory.tag({ name: value.slice(separator + 1), value, parentId: parent?.id }); + this.tags.set(tag.id, tag); + return tag; + } + addActivities(activities: MaybeArray) { for (const activity of toList(activities)) { this.activities.set(activity.id, activity); diff --git a/web/test/common/handlers.ts b/web/test/common/handlers.ts index 3b682324f..2c2534c82 100644 --- a/web/test/common/handlers.ts +++ b/web/test/common/handlers.ts @@ -116,6 +116,14 @@ export const createHandlers = (data: MockData): Handlers => { const tag = data.getTag(params.id); return tag ? { json: tag } : notFound; }, + upsertTags: ({ body }) => ({ json: data.upsertTags(body.tags) }), + updateTag: ({ params, body }) => { + const tag = data.updateTag(params.id, body); + return tag ? { json: tag } : notFound; + }, + deleteTag: ({ params }) => (data.deleteTag(params.id) ? { status: 204 } : notFound), + bulkTagAssets: ({ body }) => ({ json: { count: data.tagAssets(body.assetIds, body.tagIds) } }), + untagAssets: ({ params, body }) => ({ json: data.untagAssets(params.id, body.ids) }), getSearchSuggestions: ({ params }) => ({ json: data.searchSuggestions(params) as string[] }), updatePeople: ({ body }) => ({ json: data.updatePeople(body.people) }), diff --git a/web/test/medium/app.ts b/web/test/medium/app.ts index d2ca01156..acaa07ad0 100644 --- a/web/test/medium/app.ts +++ b/web/test/medium/app.ts @@ -337,6 +337,17 @@ const createViewer = (page: Page) => { }, navbarActions: page.getByTestId('asset-viewer-navbar-actions'), tags: page.getByTestId('detail-panel-tags').getByRole('link'), + tag: (value: string) => page.getByTestId('detail-panel-tags').getByRole('link', { name: value, exact: true }), + addTagButton: page.getByTestId('detail-panel-tags').getByRole('button', { name: 'add_tag', exact: true }), + removeTagButton: page.getByTestId('detail-panel-tags').getByRole('button', { name: 'remove_tag', exact: true }), + tagAssets: { + input: page.getByRole('dialog').getByLabel('tag', { exact: true }), + submitButton: page.getByRole('dialog').getByRole('button', { name: 'tag_assets', exact: true }), + pick: async (value: string) => { + await page.getByRole('dialog').getByLabel('tag', { exact: true }).fill(value); + await page.getByRole('dialog').getByLabel('tag', { exact: true }).press('Enter'); + }, + }, media, image: (quality: 'thumbnail' | 'preview' | 'original') => content.getByTestId(quality).filter({ visible: true }), stackThumbnail: (asset: Asset) => stack.locator(`[data-asset="${asset.id}"]`), diff --git a/web/test/medium/specs/asset-viewer/tags.spec.ts b/web/test/medium/specs/asset-viewer/tags.spec.ts new file mode 100644 index 000000000..18bf9a3d0 --- /dev/null +++ b/web/test/medium/specs/asset-viewer/tags.spec.ts @@ -0,0 +1,58 @@ +import { Route } from '#lib/route.ts'; +import { factory } from '#test/common/factories.ts'; +import { expect, test } from '#test/medium/fixtures.ts'; + +test.describe('detail panel tags', () => { + test.beforeEach(({ data }) => { + data.setPreferences(factory.preferences({ tags: { enabled: true, sidebarWeb: false } })); + }); + + test('tagging with an existing tag shows it in the panel', async ({ page, data, api, app }) => { + const travel = factory.tag({ name: 'Travel' }); + const asset = factory.asset(); + data.addTags(travel).addAssets(asset); + await page.goto(Route.viewAsset({ id: asset.id })); + await app.viewer.waitForAsset(asset); + await app.viewer.openDetailPanel(); + + await app.viewer.addTagButton.click(); + await app.viewer.tagAssets.pick('Travel'); + await app.viewer.tagAssets.submitButton.click(); + + await expect(app.viewer.tag('Travel')).toBeVisible(); + expect(api.upsertTags).not.toHaveBeenCalled(); + expect(api.bulkTagAssets).toHaveBeenCalledWith({ body: { assetIds: [asset.id], tagIds: [travel.id] } }); + }); + + test('tagging with a new tag creates it and shows it in the panel', async ({ page, data, api, app }) => { + const asset = factory.asset(); + data.addAssets(asset); + await page.goto(Route.viewAsset({ id: asset.id })); + await app.viewer.waitForAsset(asset); + await app.viewer.openDetailPanel(); + + await app.viewer.addTagButton.click(); + await app.viewer.tagAssets.pick('Japan'); + await app.viewer.tagAssets.submitButton.click(); + + await expect(app.viewer.tag('Japan')).toBeVisible(); + expect(api.upsertTags).toHaveBeenCalledWith({ body: { tags: ['Japan'] } }); + const [japan] = data.tagList(); + expect(api.bulkTagAssets).toHaveBeenCalledWith({ body: { assetIds: [asset.id], tagIds: [japan.id] } }); + }); + + test('removing a tag drops it from the panel', async ({ page, data, api, app }) => { + const travel = factory.tag({ name: 'Travel' }); + const asset = factory.asset({ tags: [travel] }); + data.addTags(travel).addAssets(asset); + await page.goto(Route.viewAsset({ id: asset.id })); + await app.viewer.waitForAsset(asset); + await app.viewer.openDetailPanel(); + await expect(app.viewer.tag('Travel')).toBeVisible(); + + await app.viewer.removeTagButton.click(); + + await expect(app.viewer.tag('Travel')).not.toBeVisible(); + expect(api.untagAssets).toHaveBeenCalledWith({ params: { id: travel.id }, body: { ids: [asset.id] } }); + }); +}); diff --git a/web/test/unit/app.ts b/web/test/unit/app.ts index adc806e2b..bddb86c79 100644 --- a/web/test/unit/app.ts +++ b/web/test/unit/app.ts @@ -112,6 +112,20 @@ export const createApp = () => { groupPosition: (current: number, total: number) => page.getByText(`${current} / ${total}`), emptyMessage: page.getByText('no_duplicates_found'), }, + tags: { + createButton: button('create_tag'), + editButton: button('edit_tag'), + deleteButton: button('delete_tag'), + explorerLink: (name: string) => sidebar.getByRole('link', { name, exact: true }), + child: (name: string) => main.getByRole('button', { name, exact: true }), + form: { + tagInput: page.getByLabelText('tag', { exact: true }), + nameInput: page.getByLabelText('name', { exact: true }), + createButton: page.getByRole('dialog').getByRole('button', { name: 'create', exact: true }), + saveButton: page.getByRole('dialog').getByRole('button', { name: 'save', exact: true }), + deleteButton: page.getByRole('dialog').getByRole('button', { name: 'delete', exact: true }), + }, + }, trash: { emptyTrashButton: button('empty_trash'), restoreAllButton: button('restore_all'),