WS-3 session 1b-ii Task 5b+c (audit Bucket E.2-E.5 — 6 items resolved,
2 promise/no-promise-in-callback warnings remain on dynamic-import
sites — see deviations).
This commit is split out from the originally-planned grouped Task 5
because the API stream timed out mid-session. E.1 (isAxiosError) is in
the preceding commit 0f155d9.
E.2 — vitest spec to Composition API (1× vue/component-api-style):
- useFormFailures.spec.ts: rewrote the test wrapper from
\`{ setup() { return { result } }, render: () => h('div') }\`
to \`setup(_, { expose }) { expose({ result }); return () => h('div') }\`.
Pure Composition API: setup returns the render function; expose()
declares the instance-visible \`result\` that the 7 \`vm.result.*\`
assertions consume. Tests still pass green (49 tests).
E.3 — REAL BUG: missing return in computed (1× vue/return-in-computed-property):
- useTimeSlotDropdown.ts:80: the \`fetchParams\` computed had a switch
over the \`DropdownScenario\` type (4 string-literal cases) without
a \`default\` branch. If \`scenario.value\` ever returned a value
outside the four narrowed cases (e.g. via a future type-assertion
drift), the computed silently returned \`undefined\`, and the
consumer code (\`fetchParams.value.includeParent\`) would throw
\`Cannot read property 'includeParent' of undefined\`. Added a
\`default\` branch returning \`{ includeParent: false, includeChildren: false }\`
— same as the 'flat' case (the safest baseline: include only own
slots, no hierarchy).
E.4 — SECURITY (1× vue/no-template-target-blank):
- pages/organisation/index.vue:343: the external website anchor had
\`target='_blank'\` with \`rel='noopener'\` (only one). The rule
requires the full \`rel='noopener noreferrer'\` pair. Updated.
Mitigates reverse-tabnabbing (window.opener) AND referrer-leakage
to the linked third-party site.
E.5 — axios fire-and-forget (3× promise/no-promise-in-callback,
1 fully resolved + 2 warnings remain):
- lib/axios.ts:42: changed \`error => Promise.reject(error)\` to
\`async error => { throw error }\`. Semantically identical (axios
interceptor onRejected returns a rejected promise either way) and
satisfies the lint rule.
- lib/axios.ts:61, 73: prefixed the dynamic-import chains with \`void\`
per Q4's option-a decision (\`void import('@/stores/...').then(...)\`).
This makes the discard intent explicit, but empirically does NOT
satisfy promise/no-promise-in-callback — the rule fires on any
promise creation inside a callback, regardless of the discard
pattern. The 2 warnings remain in the post-Task-5 baseline.
Resolution path is Bert's call: either keep \`void\` and accept
the warnings as documentation, or rewrite to \`async error => {
const { useStore } = await import(...); ... }\` which sequentializes
the dynamic-import resolution with the rejection. Out of scope for
this session per the literal Q4 recipe.
Tests + typecheck verified green.
Lint baseline: 34 → 32.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
162 lines
4.9 KiB
TypeScript
162 lines
4.9 KiB
TypeScript
import { type Ref, computed } from 'vue'
|
||
import type { EventItem } from '@/types/event'
|
||
import type { FestivalSection } from '@/types/section'
|
||
import type { TimeSlot } from '@/types/timeSlot'
|
||
|
||
export type DropdownScenario = 'flat' | 'sub_event_standard' | 'cross_event' | 'festival_standard'
|
||
|
||
export interface TimeSlotDropdownItem {
|
||
id: string
|
||
name: string
|
||
timeRange: string
|
||
displayLabel: string
|
||
_isDimmed: boolean
|
||
groupName: string
|
||
}
|
||
|
||
interface TooltipContent {
|
||
main: string
|
||
tip: string
|
||
}
|
||
|
||
export function useTimeSlotDropdown(
|
||
event: Ref<EventItem | null | undefined>,
|
||
section: Ref<FestivalSection | null | undefined>,
|
||
) {
|
||
const scenario = computed<DropdownScenario>(() => {
|
||
if (!event.value)
|
||
return 'flat'
|
||
|
||
const isSubEvent = !!event.value.parent_event_id
|
||
const hasChildren = event.value.has_children
|
||
const isCrossEvent = section.value?.type === 'cross_event'
|
||
|
||
// Flat event — no hierarchy
|
||
if (!isSubEvent && !hasChildren)
|
||
return 'flat'
|
||
|
||
// Cross_event section — needs all time slots
|
||
if (isCrossEvent)
|
||
return 'cross_event'
|
||
|
||
// Standard section on sub-event — own + parent
|
||
if (isSubEvent)
|
||
return 'sub_event_standard'
|
||
|
||
// Standard section on festival level — own only
|
||
return 'festival_standard'
|
||
})
|
||
|
||
const showInfoTooltip = computed(() => scenario.value !== 'flat')
|
||
|
||
const hasGroups = computed(() => scenario.value !== 'flat')
|
||
|
||
const tooltipText = computed<TooltipContent | null>(() => {
|
||
const eventName = event.value?.name ?? ''
|
||
const sectionName = section.value?.name ?? ''
|
||
const festivalName = event.value?.parent?.name ?? eventName
|
||
|
||
switch (scenario.value) {
|
||
case 'sub_event_standard':
|
||
return {
|
||
main: `Kies het tijdvenster voor deze dienst. Je ziet de tijdsloten van ${eventName} en de algemene tijdsloten van ${festivalName}.`,
|
||
tip: `Voor een reguliere dienst kies je een tijdslot van ${eventName}. Voor een opbouw- of afbraakdienst kies je een festivaltijdslot.`,
|
||
}
|
||
case 'cross_event':
|
||
return {
|
||
main: `${sectionName} is een festival-brede sectie — actief bij elk programmaonderdeel. Je kunt tijdsloten kiezen van ${festivalName} en van alle programmaonderdelen.`,
|
||
tip: 'Plan diensten per programmaonderdeel (bijv. een showavond) of festival-breed (bijv. opbouw, nachtsecurity).',
|
||
}
|
||
case 'festival_standard':
|
||
return {
|
||
main: `Kies een tijdslot van ${eventName} voor deze dienst.`,
|
||
tip: 'Alleen tijdsloten op festivalniveau zijn beschikbaar voor deze sectie.',
|
||
}
|
||
default:
|
||
return null
|
||
}
|
||
})
|
||
|
||
const fetchParams = computed(() => {
|
||
switch (scenario.value) {
|
||
case 'flat':
|
||
case 'festival_standard':
|
||
return { includeParent: false, includeChildren: false }
|
||
|
||
case 'sub_event_standard':
|
||
return { includeParent: true, includeChildren: false }
|
||
|
||
case 'cross_event':
|
||
return { includeParent: false, includeChildren: true }
|
||
|
||
default:
|
||
return { includeParent: false, includeChildren: false }
|
||
}
|
||
})
|
||
|
||
/**
|
||
* Returns a flat array of selectable items sorted by group
|
||
* (own event first, then others). Group boundaries are detected
|
||
* in the template by comparing adjacent items' groupName.
|
||
*/
|
||
function sortedItems(timeSlots: TimeSlot[]): TimeSlotDropdownItem[] {
|
||
if (scenario.value === 'flat')
|
||
return timeSlots.map(ts => toDropdownItem(ts, false, ''))
|
||
|
||
// Classify each slot into a group and determine isOwn per group
|
||
const groups = new Map<string, { slots: TimeSlot[]; isOwn: boolean }>()
|
||
for (const ts of timeSlots) {
|
||
const key = ts.event_name ?? 'Onbekend'
|
||
if (!groups.has(key)) {
|
||
const isOwn = scenario.value === 'sub_event_standard'
|
||
? ts.source === 'sub_event'
|
||
: ts.source === 'own'
|
||
|
||
groups.set(key, { slots: [], isOwn })
|
||
}
|
||
groups.get(key)!.slots.push(ts)
|
||
}
|
||
|
||
// Own group first, then others alphabetically
|
||
const sorted = [...groups.entries()].sort(([nameA, a], [nameB, b]) => {
|
||
if (a.isOwn && !b.isOwn)
|
||
return -1
|
||
if (!a.isOwn && b.isOwn)
|
||
return 1
|
||
|
||
return nameA.localeCompare(nameB)
|
||
})
|
||
|
||
const items: TimeSlotDropdownItem[] = []
|
||
for (const [groupName, { slots, isOwn }] of sorted) {
|
||
const isDimmed = scenario.value === 'sub_event_standard' && !isOwn
|
||
for (const ts of slots)
|
||
items.push(toDropdownItem(ts, isDimmed, groupName))
|
||
}
|
||
|
||
return items
|
||
}
|
||
|
||
return {
|
||
scenario,
|
||
showInfoTooltip,
|
||
hasGroups,
|
||
tooltipText,
|
||
fetchParams,
|
||
sortedItems,
|
||
}
|
||
}
|
||
|
||
function toDropdownItem(ts: TimeSlot, isDimmed: boolean, groupName: string): TimeSlotDropdownItem {
|
||
const timeRange = `${ts.start_time} – ${ts.end_time}`
|
||
|
||
return {
|
||
id: ts.id,
|
||
name: ts.name,
|
||
timeRange,
|
||
displayLabel: ts.name,
|
||
_isDimmed: isDimmed,
|
||
groupName,
|
||
}
|
||
}
|