diff --git a/apps/app/src/components/events/EventTabsNav.vue b/apps/app/src/components/events/EventTabsNav.vue index 42978a76..fee71cc3 100644 --- a/apps/app/src/components/events/EventTabsNav.vue +++ b/apps/app/src/components/events/EventTabsNav.vue @@ -156,6 +156,7 @@ const baseTabs = [ { label: 'Tijdsloten', icon: 'tabler-clock', route: 'events-id-time-slots' }, { label: 'Secties & Shifts', icon: 'tabler-layout-grid', route: 'events-id-sections' }, { label: 'Artiesten', icon: 'tabler-music', route: 'events-id-artists' }, + { label: 'Programma', icon: 'tabler-calendar-time', route: 'events-id-timetable' }, { label: 'Briefings', icon: 'tabler-mail', route: 'events-id-briefings' }, { label: 'Instellingen', icon: 'tabler-settings', route: 'events-id-settings' }, ] @@ -174,23 +175,27 @@ const tabs = computed(() => { if (!event.value?.is_festival) return baseTabs - // Festival tab order: Overzicht | Programmaonderdelen | Tijdsloten | Secties & Shifts | Personen | Publiekslijsten | Artiesten | Briefings | Instellingen + // Festival tab order: Overzicht | Programmaonderdelen | Tijdsloten | Secties & Shifts | Personen | Publiekslijsten | Artiesten | Programma | Briefings | Instellingen const festivalTab = { label: programmaonderdelenLabel.value, icon: 'tabler-calendar-event', route: 'events-id-programmaonderdelen', } + // Look up by route name so insertions/reorders in baseTabs don't break this. + const findTab = (routeName: string) => baseTabs.find(t => t.route === routeName)! + return [ - baseTabs[0], // Overzicht + findTab('events-id'), festivalTab, - baseTabs[3], // Tijdsloten - baseTabs[4], // Secties & Shifts - baseTabs[1], // Personen - baseTabs[2], // Publiekslijsten - baseTabs[5], // Artiesten - baseTabs[6], // Briefings - baseTabs[7], // Instellingen + findTab('events-id-time-slots'), + findTab('events-id-sections'), + findTab('events-id-persons'), + findTab('events-id-crowd-lists'), + findTab('events-id-artists'), + findTab('events-id-timetable'), + findTab('events-id-briefings'), + findTab('events-id-settings'), ] }) diff --git a/apps/app/tests/component/EventTabsNav.test.ts b/apps/app/tests/component/EventTabsNav.test.ts new file mode 100644 index 00000000..d2316c7c --- /dev/null +++ b/apps/app/tests/component/EventTabsNav.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from 'vitest' +import { computed, ref } from 'vue' +import { mountWithVuexy } from '../utils/mountWithVuexy' +import EventTabsNav from '@/components/events/EventTabsNav.vue' + +/** + * Programma tab smoke (Session 4 follow-up): asserts the timetable + * navigation entry is present, labelled, iconed, and routed correctly + * — both for flat events and for festivals (which re-order the tabs). + * + * useEvents queries are mocked so the component skips its skeleton/error + * branches and renders the real tabs immediately. useTransitionEventStatus + * is mocked too — the header status menu is out of scope here. + */ + +interface MinimalEvent { + id: string + organisation_id: string + name: string + slug: string + status: string + start_date: string + end_date: string + event_type: 'event' | 'festival' | 'series' + event_type_label: string + is_festival: boolean + is_sub_event: boolean + parent_event_id: string | null + parent: null + sub_event_label: string | null + children_count: number + allowed_transitions: string[] +} + +let eventFixtureCurrent: MinimalEvent + +function eventFixture(overrides: Partial = {}): MinimalEvent { + return { + id: 'ev_1', + organisation_id: 'org_1', + name: 'Test Event', + slug: 'test-event', + status: 'draft', + start_date: '2026-07-10', + end_date: '2026-07-12', + event_type: 'event', + event_type_label: 'Evenement', + is_festival: false, + is_sub_event: false, + parent_event_id: null, + parent: null, + sub_event_label: null, + children_count: 0, + allowed_transitions: [], + ...overrides, + } +} + +vi.mock('@/composables/api/useEvents', () => ({ + useEventDetail: () => ({ + data: computed(() => eventFixtureCurrent), + isLoading: ref(false), + isError: ref(false), + refetch: vi.fn(), + }), + useEventChildren: () => ({ + data: computed(() => [] as MinimalEvent[]), + isLoading: ref(false), + isError: ref(false), + }), + useTransitionEventStatus: () => ({ + mutateAsync: vi.fn().mockResolvedValue(undefined), + isPending: ref(false), + }), + + // EditEventDialog is rendered as a child component; it consults + // useUpdateEvent + useUploadEventImage on setup. Mock both so they + // return safe no-op shapes. + useUpdateEvent: () => ({ + mutate: vi.fn(), + mutateAsync: vi.fn().mockResolvedValue(undefined), + isPending: ref(false), + }), + useUploadEventImage: () => ({ + mutate: vi.fn(), + mutateAsync: vi.fn().mockResolvedValue(undefined), + isPending: ref(false), + }), +})) + +const eventsRoutes = [ + { path: '/events/:id', name: 'events-id', component: { template: '
' } }, + { path: '/events/:id/persons', name: 'events-id-persons', component: { template: '
' } }, + { path: '/events/:id/crowd-lists', name: 'events-id-crowd-lists', component: { template: '
' } }, + { path: '/events/:id/time-slots', name: 'events-id-time-slots', component: { template: '
' } }, + { path: '/events/:id/sections', name: 'events-id-sections', component: { template: '
' } }, + { path: '/events/:id/artists', name: 'events-id-artists', component: { template: '
' } }, + { path: '/events/:id/briefings', name: 'events-id-briefings', component: { template: '
' } }, + { path: '/events/:id/timetable', name: 'events-id-timetable', component: { template: '
' } }, + { path: '/events/:id/settings', name: 'events-id-settings', component: { template: '
' } }, + { path: '/events/:id/programmaonderdelen', name: 'events-id-programmaonderdelen', component: { template: '
' } }, + { path: '/events', name: 'events', component: { template: '
' } }, + { path: '/', name: 'home', component: { template: '
' } }, +] + +async function mountTabs(event: MinimalEvent) { + eventFixtureCurrent = event + + const result = mountWithVuexy(EventTabsNav, { + routes: eventsRoutes, + initialPath: `/events/${event.id}`, + initialState: { + auth: { currentOrganisation: { id: 'org_1', name: 'Org' } }, + }, + }) + + await (result.wrapper as unknown as { __routerReady: Promise }).__routerReady + await result.wrapper.vm.$nextTick() + await result.wrapper.vm.$nextTick() + + return result +} + +describe('EventTabsNav — Programma tab', () => { + it('renders a tab labeled "Programma"', async () => { + const { wrapper } = await mountTabs(eventFixture()) + + const tabs = wrapper.findAll('.v-tab') + const labels = tabs.map(t => t.text()) + + expect(labels).toContain('Programma') + }) + + it('uses the tabler-calendar-time icon on the Programma tab', async () => { + const { wrapper } = await mountTabs(eventFixture()) + + const programmaTab = wrapper.findAll('.v-tab').find(t => t.text() === 'Programma') + + expect(programmaTab).toBeDefined() + expect(programmaTab!.html()).toContain('tabler-calendar-time') + }) + + it('the Programma tab targets the events-id-timetable route', async () => { + const { wrapper } = await mountTabs(eventFixture()) + + const programmaTab = wrapper.findAll('.v-tab').find(t => t.text() === 'Programma') + + expect(programmaTab).toBeDefined() + + // Two independent proofs that the tab is wired to the right route: + // - the rendered VTab `value` attribute equals the route name + // - the resolved href (with whatever id is in the test route) ends in /timetable + expect(programmaTab!.attributes('value')).toBe('events-id-timetable') + expect(programmaTab!.html()).toMatch(/href="\/events\/[^/"]+\/timetable"/) + }) + + it('also exposes the Programma tab on a festival (re-ordered tabs)', async () => { + const { wrapper } = await mountTabs(eventFixture({ + is_festival: true, + event_type: 'festival', + sub_event_label: 'dag', + })) + + const labels = wrapper.findAll('.v-tab').map(t => t.text()) + + expect(labels).toContain('Programma') + }) +}) diff --git a/apps/app/vitest.config.ts b/apps/app/vitest.config.ts index 1ddc2537..99614750 100644 --- a/apps/app/vitest.config.ts +++ b/apps/app/vitest.config.ts @@ -23,7 +23,7 @@ const sharedAliases = { } const sharedAutoImport = AutoImport({ - imports: ['vue', '@vueuse/core'], + imports: ['vue', '@vueuse/core', 'vue-router'], dirs: ['./src/@core/utils', './src/@core/composable/', './src/composables/', './src/utils/'], vueTemplate: true, dts: false,