Skip to content

Refactor CalendarView in zmscitizenview: Extract ListView into separate component #1342

@coderabbitai

Description

@coderabbitai

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

  1. Single Responsibility: Each component focuses on one specific concern
  2. Maintainability: Easier to understand and modify individual components
  3. Testability: Components can be unit tested in isolation
  4. Reusability: ListView can be used elsewhere in the application
  5. Performance: Better code splitting and lazy loading
  6. Code Organization: Logic separated into focused composables

Migration Strategy

  1. Phase 1: Extract ListView component with current accordion logic
  2. Phase 2: Extract ViewToggle, ProviderSelection sub-components
  3. Phase 3: Simplify CalendarView to focus only on calendar functionality
  4. Phase 4: Create composables for shared state management
  5. 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 requesttypescriptEverything TypescriptvuejsEverything vuejs

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions