All files / src/components/ui EntityListState.vue

100% Statements 21/21
40% Branches 2/5
100% Functions 0/0
100% Lines 21/21

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80                                        1x                                                 1x     1x 10x 10x 10x   10x   1x 30x 30x 30x 30x 30x 30x 30x 30x 30x             30x 30x   30x     30x    
<script setup lang="ts">
/**
 * Unified loading / empty / content wrapper for entity-list pages.
 *
 * Three states, one component:
 *   * ``isLoading`` → centered ``Loader2`` spinner + optional text
 *   * ``isEmpty``   → centered icon + empty message + optional action
 *   * otherwise     → the default slot (the entity list itself)
 *
 * Why a component: every main page had its own variant — text-only
 * loading vs. spinner-only vs. spinner-with-text, icon-with-text vs.
 * just-text empty states, different paddings. Centralising the three
 * shapes here gives the whole app a consistent feel without each
 * page rebuilding the same div-with-Tailwind block.
 *
 * Order of precedence: ``isLoading`` wins over ``isEmpty``. That
 * matters because while data is being fetched, ``items.length === 0``
 * is also true, and you don't want to flash the "empty" CTA before
 * the spinner has had a chance to appear.
 */
import { Loader2 } from 'lucide-vue-next'
import type { FunctionalComponent } from 'vue'
 
defineProps<{
  /** Whether the page is currently fetching its first batch of data.
   *  Wins over ``isEmpty`` — see component docs. */
  isLoading?: boolean
  /** Whether the list of entities is empty AFTER loading has settled.
   *  Callers usually express this as
   *  ``!store.isLoading && store.items.length === 0``. */
  isEmpty?: boolean
  /** Lucide icon component for the empty state (e.g. ``Inbox``,
   *  ``GraduationCap``). Falls back to no icon — the empty message
   *  alone is still rendered. */
  icon?: FunctionalComponent
  /** Visible text for the empty state. */
  emptyMessage?: string
  /** Optional text shown next to the loading spinner. Leave undefined
   *  for pages where the title is self-explanatory and a second
   *  "Lade…" line would just be noise. */
  loadingMessage?: string
}>()
</script>
 
<template>
  <!-- Loading: spinner + optional one-line label. ``py-12`` matches
       the empty-state vertical air so the page doesn't jump between
       states. -->
  <div v-if="isLoading" class="flex flex-col items-center justify-center py-12 gap-3 text-gray-500">
    <Loader2 :size="32" class="animate-spin text-primary" />
    <p v-if="loadingMessage" class="text-sm">{{ loadingMessage }}</p>
  </div>
 
  <!-- Empty: icon + message + optional CTA. Icon is muted so the
       primary visual is still the message + action. -->
  <div v-else-if="isEmpty" class="flex flex-col items-center justify-center py-12 text-center">
    <component
      v-if="icon"
      :is="icon"
      :size="64"
      class="text-gray-300 mb-4"
      aria-hidden="true"
    />
    <p v-if="emptyMessage" class="text-gray-500 mb-4">{{ emptyMessage }}</p>
    <!--
      Action slot — callers commonly drop a ``<BaseButton>`` here that
      mirrors the page-header create button (so an empty page has a
      visible call-to-action without the user having to find the
      header button). Slot stays optional; many empty states are
      view-only (e.g. approvals, when nothing is pending).
    -->
    <slot name="empty-action" />
  </div>
 
  <!-- Default slot: the actual list rendering. Callers wrap their
       grid/table here. We don't impose a wrapper — keeps it flexible
       (grid, table, accordion all work). -->
  <slot v-else />
</template>