RFC-TIMETABLE v0.2 Session 4 — Frontend Timetable + Test Coverage Closure #18
@@ -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'),
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
168
apps/app/tests/component/EventTabsNav.test.ts
Normal file
168
apps/app/tests/component/EventTabsNav.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user