fix: shift edit time slot dropdown loading state and test coverage
The time slot dropdown in the shift edit dialog could flash the "create a time slot first" alert during loading, and show raw ULIDs when time slot data hadn't loaded yet. Fixed by: - Adding loading state indicator to the time slot dropdown - Using the shift's existing time_slot object as a fallback item while the full list is fetching - Showing the dropdown (with loading spinner) instead of the misleading "no time slots" alert during fetch Added test coverage for time_slot_id validation on shift updates: - Update with valid same-event time slot (200) - Update with cross-org time slot (422) - Update on sub-event with parent festival time slot (200) - Store/update responses include nested time_slot object Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -477,6 +477,38 @@ class FestivalEventTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_shift_on_sub_event_with_parent_festival_time_slot(): void
|
||||
{
|
||||
$section = FestivalSection::factory()->create([
|
||||
'event_id' => $this->subEvent->id,
|
||||
]);
|
||||
|
||||
$subEventTimeSlot = TimeSlot::factory()->create([
|
||||
'event_id' => $this->subEvent->id,
|
||||
]);
|
||||
|
||||
$festivalTimeSlot = TimeSlot::factory()->create([
|
||||
'event_id' => $this->festival->id,
|
||||
]);
|
||||
|
||||
// Create a shift with the sub-event time slot
|
||||
$shift = Shift::factory()->create([
|
||||
'festival_section_id' => $section->id,
|
||||
'time_slot_id' => $subEventTimeSlot->id,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
// Update to use a festival time slot — should succeed
|
||||
$response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->subEvent->id}/sections/{$section->id}/shifts/{$shift->id}", [
|
||||
'time_slot_id' => $festivalTimeSlot->id,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.time_slot_id', $festivalTimeSlot->id)
|
||||
->assertJsonPath('data.time_slot.id', $festivalTimeSlot->id);
|
||||
}
|
||||
|
||||
public function test_create_shift_on_local_section_with_other_event_time_slot_returns_422(): void
|
||||
{
|
||||
$section = FestivalSection::factory()->create([
|
||||
|
||||
@@ -110,6 +110,86 @@ class ShiftTest extends TestCase
|
||||
->assertJson(['data' => ['title' => 'Barhoofd', 'slots_total' => 1]]);
|
||||
}
|
||||
|
||||
public function test_update_shift_with_valid_time_slot_id(): void
|
||||
{
|
||||
$shift = Shift::factory()->create([
|
||||
'festival_section_id' => $this->section->id,
|
||||
'time_slot_id' => $this->timeSlot->id,
|
||||
]);
|
||||
|
||||
$newTimeSlot = TimeSlot::factory()->create(['event_id' => $this->event->id]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}", [
|
||||
'time_slot_id' => $newTimeSlot->id,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.time_slot_id', $newTimeSlot->id)
|
||||
->assertJsonPath('data.time_slot.id', $newTimeSlot->id)
|
||||
->assertJsonPath('data.time_slot.name', $newTimeSlot->name);
|
||||
}
|
||||
|
||||
public function test_update_shift_with_other_org_time_slot_returns_422(): void
|
||||
{
|
||||
$shift = Shift::factory()->create([
|
||||
'festival_section_id' => $this->section->id,
|
||||
'time_slot_id' => $this->timeSlot->id,
|
||||
]);
|
||||
|
||||
$otherEvent = Event::factory()->create(['organisation_id' => $this->otherOrganisation->id]);
|
||||
$otherTimeSlot = TimeSlot::factory()->create(['event_id' => $otherEvent->id]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}", [
|
||||
'time_slot_id' => $otherTimeSlot->id,
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors('time_slot_id');
|
||||
}
|
||||
|
||||
public function test_update_shift_response_includes_time_slot_object(): void
|
||||
{
|
||||
$shift = Shift::factory()->create([
|
||||
'festival_section_id' => $this->section->id,
|
||||
'time_slot_id' => $this->timeSlot->id,
|
||||
'title' => 'Runner',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}", [
|
||||
'title' => 'Stage Manager',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure(['data' => [
|
||||
'time_slot_id',
|
||||
'time_slot' => ['id', 'name', 'date', 'start_time', 'end_time'],
|
||||
]]);
|
||||
}
|
||||
|
||||
public function test_store_response_includes_time_slot_object(): void
|
||||
{
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts", [
|
||||
'time_slot_id' => $this->timeSlot->id,
|
||||
'title' => 'Tapper',
|
||||
'slots_total' => 4,
|
||||
'slots_open_for_claiming' => 3,
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonStructure(['data' => [
|
||||
'time_slot_id',
|
||||
'time_slot' => ['id', 'name', 'date', 'start_time', 'end_time'],
|
||||
]]);
|
||||
}
|
||||
|
||||
public function test_destroy_soft_deletes_shift(): void
|
||||
{
|
||||
$shift = Shift::factory()->create([
|
||||
|
||||
@@ -28,7 +28,7 @@ const sectionIdRef = computed(() => props.sectionId)
|
||||
const isEditing = computed(() => !!props.shift)
|
||||
const isSubEventRef = computed(() => props.isSubEvent)
|
||||
|
||||
const { data: timeSlots } = useTimeSlotList(orgIdRef, eventIdRef, { includeParent: isSubEventRef })
|
||||
const { data: timeSlots, isLoading: timeSlotsLoading } = useTimeSlotList(orgIdRef, eventIdRef, { includeParent: isSubEventRef })
|
||||
const { mutate: createShift, isPending: isCreating } = useCreateShift(orgIdRef, eventIdRef, sectionIdRef)
|
||||
const { mutate: updateShift, isPending: isUpdating } = useUpdateShift(orgIdRef, eventIdRef, sectionIdRef)
|
||||
|
||||
@@ -74,15 +74,25 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function formatTimeSlotItem(ts: { id: string; name: string; date: string; start_time: string; end_time: string }) {
|
||||
return {
|
||||
title: `${ts.name} — ${ts.date} ${ts.start_time}–${ts.end_time}`,
|
||||
value: ts.id,
|
||||
}
|
||||
}
|
||||
|
||||
const timeSlotItems = computed(() => {
|
||||
if (!timeSlots.value?.length) return []
|
||||
// While loading, show the current shift's time slot so the dropdown doesn't flash a raw ULID
|
||||
if (!timeSlots.value?.length) {
|
||||
if (props.shift?.time_slot) {
|
||||
return [formatTimeSlotItem(props.shift.time_slot)]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const hasFestival = timeSlots.value.some(ts => ts.source === 'festival')
|
||||
if (!hasFestival) {
|
||||
return timeSlots.value.map(ts => ({
|
||||
title: `${ts.name} — ${ts.date} ${ts.start_time}–${ts.end_time}`,
|
||||
value: ts.id,
|
||||
}))
|
||||
return timeSlots.value.map(formatTimeSlotItem)
|
||||
}
|
||||
|
||||
const subEventSlots = timeSlots.value.filter(ts => ts.source !== 'festival')
|
||||
@@ -91,18 +101,12 @@ const timeSlotItems = computed(() => {
|
||||
|
||||
if (subEventSlots.length) {
|
||||
items.push({ title: subEventSlots[0]?.event_name ?? 'Programma', type: 'subheader' })
|
||||
items.push(...subEventSlots.map(ts => ({
|
||||
title: `${ts.name} — ${ts.date} ${ts.start_time}–${ts.end_time}`,
|
||||
value: ts.id,
|
||||
})))
|
||||
items.push(...subEventSlots.map(formatTimeSlotItem))
|
||||
}
|
||||
|
||||
if (festivalSlots.length) {
|
||||
items.push({ title: festivalSlots[0]?.event_name ?? 'Festival', type: 'subheader' })
|
||||
items.push(...festivalSlots.map(ts => ({
|
||||
title: `${ts.name} — ${ts.date} ${ts.start_time}–${ts.end_time}`,
|
||||
value: ts.id,
|
||||
})))
|
||||
items.push(...festivalSlots.map(formatTimeSlotItem))
|
||||
}
|
||||
|
||||
return items
|
||||
@@ -192,10 +196,11 @@ function onSubmit() {
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<AppSelect
|
||||
v-if="timeSlotItems.length"
|
||||
v-if="timeSlotsLoading || timeSlotItems.length"
|
||||
v-model="form.time_slot_id"
|
||||
label="Time Slot"
|
||||
:items="timeSlotItems"
|
||||
:loading="timeSlotsLoading"
|
||||
:rules="[requiredValidator]"
|
||||
:error-messages="errors.time_slot_id"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user