RFC-TIMETABLE v0.2 Session 4 — Frontend Timetable + Test Coverage Closure #18

Merged
bert.hausmans merged 26 commits from feat/timetable-session-4 into main 2026-05-10 00:32:37 +02:00
3 changed files with 183 additions and 10 deletions
Showing only changes of commit 3b255a36de - Show all commits

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,