diff --git a/web/src/components/layout/scaffold/Tab.test.ts b/web/src/components/layout/scaffold/Tab.test.ts new file mode 100644 index 000000000..d6a0066d7 --- /dev/null +++ b/web/src/components/layout/scaffold/Tab.test.ts @@ -0,0 +1,168 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick, ref } from 'vue'; + +import type { Tab as TabType } from '~/compositions/useTabs'; + +import Tab from './Tab.vue'; + +async function mountConditionalTab() { + const visible = ref(true); + const tabs = ref([]); + + const host = defineComponent({ + setup() { + return () => + h('div', [ + visible.value + ? h(Tab, { + to: { name: 'repo-pipeline-errors' }, + title: 'Errors', + icon: 'alert', + }) + : null, + ]); + }, + }); + + mount(host, { + global: { + provide: { tabs }, + }, + }); + await nextTick(); + + return { visible, tabs }; +} + +describe('tab', () => { + it('registers itself on mount', async () => { + const { tabs } = await mountConditionalTab(); + + expect(tabs.value).toHaveLength(1); + expect(tabs.value[0].title).toBe('Errors'); + }); + + it('does not register the same route twice', async () => { + const duplicateVisible = ref(true); + const tabs = ref([]); + + const host = defineComponent({ + setup() { + return () => + h('div', [ + h(Tab, { to: { name: 'repo-pipeline-errors' }, title: 'Errors' }), + duplicateVisible.value ? h(Tab, { to: { name: 'repo-pipeline-errors' }, title: 'Errors' }) : null, + ]); + }, + }); + + mount(host, { + global: { + provide: { tabs }, + }, + }); + await nextTick(); + + expect(tabs.value).toHaveLength(1); + + // the second instance was skipped by the dedup, so its unmount must not + // remove the entry registered by the first, still-mounted instance + duplicateVisible.value = false; + await nextTick(); + + expect(tabs.value).toHaveLength(1); + }); + + it('keeps the tab when the registering duplicate unmounts before the skipped one', async () => { + const firstVisible = ref(true); + const secondVisible = ref(true); + const tabs = ref([]); + + const host = defineComponent({ + setup() { + return () => + h('div', [ + firstVisible.value ? h(Tab, { to: { name: 'repo-pipeline-errors' }, title: 'Errors' }) : null, + secondVisible.value ? h(Tab, { to: { name: 'repo-pipeline-errors' }, title: 'Errors' }) : null, + ]); + }, + }); + + mount(host, { + global: { + provide: { tabs }, + }, + }); + await nextTick(); + + expect(tabs.value).toHaveLength(1); + + // the registering instance goes away, but a matching instance is still + // mounted, so the shared tab must survive + firstVisible.value = false; + await nextTick(); + + expect(tabs.value).toHaveLength(1); + expect(tabs.value[0].title).toBe('Errors'); + + // once the last matching instance unmounts, the tab must disappear + secondVisible.value = false; + await nextTick(); + + expect(tabs.value).toHaveLength(0); + }); + + it('unregisters itself on unmount', async () => { + const { visible, tabs } = await mountConditionalTab(); + + expect(tabs.value).toHaveLength(1); + + visible.value = false; + await nextTick(); + + expect(tabs.value).toHaveLength(0); + }); + + it('registers again after being re-rendered', async () => { + const { visible, tabs } = await mountConditionalTab(); + + visible.value = false; + await nextTick(); + visible.value = true; + await nextTick(); + + expect(tabs.value).toHaveLength(1); + expect(tabs.value[0].title).toBe('Errors'); + }); + + it('keeps template order when a tab mounts later than its siblings', async () => { + const middleVisible = ref(false); + const tabs = ref([]); + + const host = defineComponent({ + setup() { + return () => + h('div', [ + h(Tab, { to: { name: 'repo-pipeline' }, title: 'Tasks' }), + middleVisible.value ? h(Tab, { to: { name: 'repo-pipeline-errors' }, title: 'Errors' }) : null, + h(Tab, { to: { name: 'repo-pipeline-config' }, title: 'Config' }), + ]); + }, + }); + + mount(host, { + global: { + provide: { tabs }, + }, + }); + await nextTick(); + + expect(tabs.value.map(({ title }) => title)).toStrictEqual(['Tasks', 'Config']); + + middleVisible.value = true; + await nextTick(); + + expect(tabs.value.map(({ title }) => title)).toStrictEqual(['Tasks', 'Errors', 'Config']); + }); +}); diff --git a/web/src/components/layout/scaffold/Tab.vue b/web/src/components/layout/scaffold/Tab.vue index b180c2c59..03362f6a8 100644 --- a/web/src/components/layout/scaffold/Tab.vue +++ b/web/src/components/layout/scaffold/Tab.vue @@ -1,12 +1,19 @@ - + - + + diff --git a/web/src/compositions/useTabs.ts b/web/src/compositions/useTabs.ts index 4ebad6965..fd919c1e4 100644 --- a/web/src/compositions/useTabs.ts +++ b/web/src/compositions/useTabs.ts @@ -12,6 +12,7 @@ export interface Tab { icon?: IconNames; iconClass?: string; matchChildren?: boolean; + anchor?: HTMLElement; } export function useTabsProvider() {