From 3b255a36de09a919dfed745fe7fe068b0835a4ab Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 08:58:22 +0200 Subject: [PATCH] feat(events): add Programma tab to EventTabsNav for timetable access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timetable canvas page at /events/{event}/timetable was added in RFC-TIMETABLE Session 4 but had no UI entry point. EventTabsNav now exposes it as the "Programma" tab between Artiesten and Briefings on flat events, and between Artiesten and Briefings on festivals (in the re-ordered tab list, post-Artiesten / pre-Briefings). Changes: - baseTabs gains the Programma entry at position 6 (after Artiesten). - The festival re-order computed switches from positional indexing (baseTabs[5], [6], [7]) to name-based lookup via a findTab helper — insertions to baseTabs no longer break the festival branch. - Icon: tabler-calendar-time. Conservative Dutch label "Programma" — doesn't collide with "Programmaonderdelen" (the festival sub-events page) since festivals see both tabs side-by-side. vitest.config.ts: extend the component-project AutoImport to include 'vue-router' so tests of components that auto-import useRoute/useRouter mount cleanly. (EventTabsNav was the first such test.) tests/component/EventTabsNav.test.ts (NEW, 4 assertions): - Programma tab is rendered with the correct label - it carries the tabler-calendar-time icon - the route binding resolves to the events-id-timetable name with the /events/.../timetable URL pattern - the tab is also visible on a festival (re-ordered tab list path) Mocks the useEvents composables so the component skips its skeleton/ error branches and renders tabs immediately. Test count: 385 → 389. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../src/components/events/EventTabsNav.vue | 23 ++- apps/app/tests/component/EventTabsNav.test.ts | 168 ++++++++++++++++++ apps/app/vitest.config.ts | 2 +- 3 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 apps/app/tests/component/EventTabsNav.test.ts 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,