-
Notifications
You must be signed in to change notification settings - Fork 2
Open
Labels
enhancementNew feature or requestNew feature or requesttypescriptEverything TypescriptEverything TypescriptvuejsEverything vuejsEverything vuejs
Description
Problem Description
The current CalendarView.vue
component is a monolithic 1000+ line file that handles both calendar and list view functionality. This violates the single responsibility principle and makes the component difficult to maintain, test, and understand.
Current Implementation Analysis
Looking at zmscitizenview/src/components/Appointment/CalendarView.vue
, the component currently:
- Uses
isListView
ref to toggle between calendar and list views - Contains complex accordion logic for list display
- Handles time slot selection in both calendar and list modes
- Mixes calendar rendering with appointment list rendering logic
- Has over 1000 lines of template, script, and style code
Current Toggle Implementation
<div class="m-toggle-switch" @click="toggleView">
<span>{{ t("calendarView") }}</span>
<span>{{ t("listView") }}</span>
</div>
<!-- Calendar View -->
<div v-if="!isListView" class="m-component">
<muc-calendar />
</div>
<!-- List View -->
<div v-if="isListView" class="m-content">
<div class="m-accordion">
<!-- Complex accordion logic for appointment lists -->
</div>
</div>
Proposed Refactoring Solution
1. Extract ListView Component
AppointmentListView.vue (New Component)
<template>
<div class="appointment-list-view">
<div class="m-content">
<h3 tabindex="0">{{ t('availableTimes') }}</h3>
</div>
<div class="m-component m-component-accordion">
<div class="m-component__body">
<div class="m-accordion" id="appointmentListAccordion">
<template v-for="(day, index) in availableDays" :key="day.dateString">
<div>
<h3 class="m-accordion__section-header" :id="'listHeading-' + index">
<button
class="m-accordion__section-button"
type="button"
@click="onDayAccordionSelect(day, index)"
:aria-expanded="index === openAccordionIndex"
>
{{ day.label }}
<svg aria-hidden="true" class="icon">
<use :xlink:href="accordionIcon(index)"></use>
</svg>
</button>
</h3>
<section
class="m-accordion__section-content collapse"
:class="{ show: index === openAccordionIndex }"
:id="'listContent-' + index"
>
<AppointmentSlots
v-if="day.appointmentsCount > appointmentsThreshold"
:hour-rows="day.hourRows"
:selected-providers="selectedProviders"
:is-loading="isLoadingAppointments && index === openAccordionIndex"
@time-slot-selected="handleTimeSlotSelection"
/>
<AppointmentSlots
v-else
:day-part-rows="day.dayPartRows"
:selected-providers="selectedProviders"
:is-loading="isLoadingAppointments && index === openAccordionIndex"
@time-slot-selected="handleTimeSlotSelection"
/>
</section>
</div>
</template>
</div>
</div>
</div>
<muc-button
v-if="canLoadMore"
@click="loadMoreDays"
icon="chevron-down"
icon-animated
>
{{ t('loadMore') }}
</muc-button>
</div>
</template>
<script setup lang="ts">
import { ref, computed, defineProps, defineEmits } from 'vue'
import type { AccordionDay } from '@/types/AccordionDay'
interface Props {
availableDays: AccordionDay[]
selectedProviders: Record<string, boolean>
appointmentsThreshold: number
isLoadingAppointments: boolean
canLoadMore: boolean
t: (key: string) => string
}
interface Emits {
(e: 'time-slot-selected', officeId: number, time: number): void
(e: 'day-selected', day: AccordionDay): void
(e: 'load-more'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const openAccordionIndex = ref(0)
const onDayAccordionSelect = (day: AccordionDay, index: number) => {
if (openAccordionIndex.value === index) {
openAccordionIndex.value = -1
} else {
openAccordionIndex.value = index
emit('day-selected', day)
}
}
const handleTimeSlotSelection = (officeId: number, time: number) => {
emit('time-slot-selected', officeId, time)
}
const accordionIcon = (index: number) => {
return index === openAccordionIndex.value ? '#icon-chevron-up' : '#icon-chevron-down'
}
</script>
AppointmentSlots.vue (New Sub-Component)
<template>
<div class="appointment-slots">
<div v-if="isLoading" class="loading-container">
<div class="spinner"></div>
</div>
<template v-else>
<!-- Hourly View Slots -->
<template v-if="hourRows" v-for="hourRow in hourRows" :key="hourRow.hour">
<div v-if="selectedProviders[hourRow.officeId]" class="time-slot-section">
<div class="location-title" v-if="showLocationTitle">
<svg aria-hidden="true" class="icon icon--before">
<use xlink:href="#icon-map-pin"></use>
</svg>
{{ getOfficeName(hourRow.officeId) }}
</div>
<div class="wrapper">
<p class="centered-text nowrap">{{ hourRow.hour }}:00‑{{ hourRow.hour }}:59</p>
<div class="grid">
<div v-for="time in hourRow.times" :key="time" class="grid-item">
<muc-button
class="timeslot"
:variant="isSlotSelected(hourRow.officeId, time) ? 'primary' : 'secondary'"
@click="selectTimeSlot(hourRow.officeId, time)"
>
{{ formatTime(time) }}
</muc-button>
</div>
</div>
</div>
</div>
</template>
<!-- Day Part View Slots -->
<template v-if="dayPartRows" v-for="partRow in dayPartRows" :key="partRow.part">
<div v-if="selectedProviders[partRow.officeId]" class="time-slot-section">
<div class="location-title" v-if="showLocationTitle">
<svg aria-hidden="true" class="icon icon--before">
<use xlink:href="#icon-map-pin"></use>
</svg>
{{ getOfficeName(partRow.officeId) }}
</div>
<div class="wrapper">
<p class="centered-text nowrap">{{ t(partRow.part) }}</p>
<div class="grid">
<div v-for="time in partRow.times" :key="time" class="grid-item">
<muc-button
class="timeslot"
:variant="isSlotSelected(partRow.officeId, time) ? 'primary' : 'secondary'"
@click="selectTimeSlot(partRow.officeId, time)"
>
{{ formatTime(time) }}
</muc-button>
</div>
</div>
</div>
</div>
</template>
</template>
</div>
</template>
<script setup lang="ts">
// Implementation for time slot rendering logic
</script>
2. Refactor CalendarView Component
CalendarView.vue (Simplified)
<template>
<div class="calendar-view">
<!-- Provider Selection (keep existing logic) -->
<ProviderSelection
v-if="providersWithAppointments?.length > 1"
:providers="providersWithAppointments"
:selected-providers="selectedProviders"
:error-message="providerSelectionError"
@update:selected-providers="selectedProviders = $event"
/>
<!-- View Toggle -->
<div class="view-header">
<h2 tabindex="0">{{ t('time') }}</h2>
<ViewToggle
:is-list-view="isListView"
@toggle-view="toggleView"
:t="t"
/>
</div>
<!-- Calendar Display -->
<AppointmentCalendar
v-if="!isListView"
:selected-day="selectedDay"
:allowed-dates="allowedDates"
:min-date="minDate"
:max-date="maxDate"
:view-month="viewMonth"
@day-selected="handleDaySelection"
/>
<!-- List Display -->
<AppointmentListView
v-if="isListView"
:available-days="firstFiveAvailableDays"
:selected-providers="selectedProviders"
:appointments-threshold="APPOINTMENTS_THRESHOLD_FOR_HOURLY_VIEW"
:is-loading-appointments="isLoadingAppointments"
:can-load-more="firstFiveAvailableDays.length < availableDays.length"
:t="t"
@time-slot-selected="handleTimeSlotSelection"
@day-selected="handleDayAccordionSelect"
@load-more="loadMoreDays"
/>
<!-- Time Slots for Calendar View -->
<CalendarTimeSlots
v-if="!isListView && selectedDay"
:selected-day="selectedDay"
:time-slots-by-office="timeSlotsInHoursByOffice"
:selected-providers="selectedProviders"
:appointments-threshold="APPOINTMENTS_THRESHOLD_FOR_HOURLY_VIEW"
@time-slot-selected="handleTimeSlotSelection"
/>
<!-- Appointment Summary (keep existing) -->
<AppointmentSummary
v-if="selectedProvider && selectedDay && selectedTimeslot !== 0"
:selected-provider="selectedProvider"
:selected-day="selectedDay"
:selected-timeslot="selectedTimeslot"
:estimated-duration="estimatedDuration()"
:t="t"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import AppointmentListView from './AppointmentListView.vue'
import AppointmentCalendar from './AppointmentCalendar.vue'
import ViewToggle from './ViewToggle.vue'
// ... other imports
// Simplified component with extracted logic
const isListView = ref(false)
const toggleView = () => {
isListView.value = !isListView.value
}
// Move complex logic to composables
const {
selectedDay,
selectedProviders,
availableDays,
// ... other calendar state
} = useCalendarState()
const {
timeSlotsInHoursByOffice,
handleTimeSlotSelection,
// ... other appointment logic
} = useAppointmentSlots()
</script>
Benefits of This Refactoring
- Single Responsibility: Each component focuses on one specific concern
- Maintainability: Easier to understand and modify individual components
- Testability: Components can be unit tested in isolation
- Reusability: ListView can be used elsewhere in the application
- Performance: Better code splitting and lazy loading
- Code Organization: Logic separated into focused composables
Migration Strategy
- Phase 1: Extract ListView component with current accordion logic
- Phase 2: Extract ViewToggle, ProviderSelection sub-components
- Phase 3: Simplify CalendarView to focus only on calendar functionality
- Phase 4: Create composables for shared state management
- Phase 5: Update tests and ensure full functionality preservation
Acceptance Criteria
- AppointmentListView.vue component created with accordion functionality
- CalendarView.vue simplified to focus on calendar display
- ViewToggle component extracted for reusability
- All existing functionality preserved (provider selection, time slot selection, etc.)
- Current toggle behavior maintained (
isListView
state management) - Unit tests created for all new components
- No breaking changes to parent component API
- Performance maintained or improved
- Accessibility features preserved (ARIA labels, keyboard navigation)
References
- Current file:
zmscitizenview/src/components/Appointment/CalendarView.vue
(1000+ lines) - Related to comprehensive unit testing initiative
- Supports better component testing strategy
Metadata
Metadata
Assignees
Labels
enhancementNew feature or requestNew feature or requesttypescriptEverything TypescriptEverything TypescriptvuejsEverything vuejsEverything vuejs