feat(events): add Programma tab to EventTabsNav for timetable access

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)
This commit is contained in:
2026-05-09 08:58:22 +02:00
parent fb5ba5052e
commit 3b255a36de
3 changed files with 183 additions and 10 deletions

View File

@@ -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'),
]
})

View File

@@ -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> = {}): 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: '<div />' } },
{ path: '/events/:id/persons', name: 'events-id-persons', component: { template: '<div />' } },
{ path: '/events/:id/crowd-lists', name: 'events-id-crowd-lists', component: { template: '<div />' } },
{ path: '/events/:id/time-slots', name: 'events-id-time-slots', component: { template: '<div />' } },
{ path: '/events/:id/sections', name: 'events-id-sections', component: { template: '<div />' } },
{ path: '/events/:id/artists', name: 'events-id-artists', component: { template: '<div />' } },
{ path: '/events/:id/briefings', name: 'events-id-briefings', component: { template: '<div />' } },
{ path: '/events/:id/timetable', name: 'events-id-timetable', component: { template: '<div />' } },
{ path: '/events/:id/settings', name: 'events-id-settings', component: { template: '<div />' } },
{ path: '/events/:id/programmaonderdelen', name: 'events-id-programmaonderdelen', component: { template: '<div />' } },
{ path: '/events', name: 'events', component: { template: '<div />' } },
{ path: '/', name: 'home', component: { template: '<div />' } },
]
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<void> }).__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')
})
})

View File

@@ -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,