feat(10-08): skeleton tables + BreadcrumbBar + EmptyState in AdminAuditView and AdminUsersView

- AdminAuditView: add BreadcrumbBar (Audit Log, showRoot=false), replace loading spinner with 8-row skeleton tbody, replace empty div with EmptyState (icon=clipboardList) + Clear filters CTA
- AdminUsersView: add BreadcrumbBar (Users, showRoot=false), replace loading spinner with 5-row x 6-col skeleton tbody
- Promote CTA test to use real EmptyState (no stub) so slot content renders
- All 8 skeleton tests pass GREEN
This commit is contained in:
curo1305
2026-06-15 20:25:05 +02:00
parent ec5fd23ec2
commit 8e360f4f21
3 changed files with 74 additions and 48 deletions
+47 -34
View File
@@ -1,5 +1,7 @@
<template> <template>
<div> <div>
<BreadcrumbBar :segments="[{ label: 'Audit Log' }]" :show-root="false" class="mb-4" />
<div class="flex flex-wrap gap-3 mb-4 items-end"> <div class="flex flex-wrap gap-3 mb-4 items-end">
<div> <div>
<label class="block text-xs font-semibold text-gray-500 mb-1">From</label> <label class="block text-xs font-semibold text-gray-500 mb-1">From</label>
@@ -75,20 +77,9 @@
<p v-if="exportError" class="text-xs text-red-600 self-center">{{ exportError }}</p> <p v-if="exportError" class="text-xs text-red-600 self-center">{{ exportError }}</p>
</div> </div>
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-8 text-center"> <p v-if="fetchError" class="text-xs text-red-600 mt-1">{{ fetchError }}</p>
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
Loading audit log
</div>
</div>
<p v-else-if="fetchError" class="text-xs text-red-600 mt-1">{{ fetchError }}</p> <div v-if="!fetchError" class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div v-else-if="entries.length === 0" class="text-center py-12 text-gray-400 text-sm">
No audit log entries match the selected filters.
</div>
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table class="w-full text-sm border-collapse"> <table class="w-full text-sm border-collapse">
<thead> <thead>
<tr class="bg-gray-50 border-b border-gray-200"> <tr class="bg-gray-50 border-b border-gray-200">
@@ -100,33 +91,53 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <template v-if="loading">
v-for="entry in entries" <tr v-for="n in 8" :key="`sk-${n}`" class="border-b border-gray-100">
:key="entry.id" <td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-32"></div></td>
class="border-b border-gray-100 hover:bg-gray-50 transition-colors" <td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-20"></div></td>
> <td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-36"></div></td>
<td class="px-4 py-3 font-mono text-xs text-gray-500">{{ formatTimestamp(entry.created_at) }}</td> <td class="px-4 py-3"><div class="h-4 bg-gray-100 rounded-full animate-pulse w-16"></div></td>
<td class="px-4 py-3 text-sm text-gray-700">{{ entry.user_handle || entry.user_id || '—' }}</td> <td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-24"></div></td>
<td class="px-4 py-3 text-sm text-gray-500"> </tr>
<span v-if="entry.user_email">{{ entry.user_email }}</span> </template>
<span v-else-if="entry.metadata_?.attempted_email_hash" class="text-amber-600 font-mono text-xs">hash:{{ entry.metadata_.attempted_email_hash }} <span class="text-xs not-italic">(attempted)</span></span> <tr v-else-if="entries.length === 0">
<span v-else></span> <td colspan="5" class="px-0 py-0">
<EmptyState icon="clipboardList" headline="No entries found" subtext="Try adjusting your filters or date range.">
<template #cta>
<button @click="clearFilters" class="mt-3 text-sm text-indigo-600 hover:underline">Clear filters</button>
</template>
</EmptyState>
</td> </td>
<td class="px-4 py-3">
<span
class="text-xs px-2 py-1 rounded-full font-medium"
:class="actionTypeClass(entry.event_type)"
>
{{ entry.event_type }}
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-700 font-mono text-xs">{{ entry.ip_address || '—' }}</td>
</tr> </tr>
<template v-else>
<tr
v-for="entry in entries"
:key="entry.id"
class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
>
<td class="px-4 py-3 font-mono text-xs text-gray-500">{{ formatTimestamp(entry.created_at) }}</td>
<td class="px-4 py-3 text-sm text-gray-700">{{ entry.user_handle || entry.user_id || '—' }}</td>
<td class="px-4 py-3 text-sm text-gray-500">
<span v-if="entry.user_email">{{ entry.user_email }}</span>
<span v-else-if="entry.metadata_?.attempted_email_hash" class="text-amber-600 font-mono text-xs">hash:{{ entry.metadata_.attempted_email_hash }} <span class="text-xs not-italic">(attempted)</span></span>
<span v-else></span>
</td>
<td class="px-4 py-3">
<span
class="text-xs px-2 py-1 rounded-full font-medium"
:class="actionTypeClass(entry.event_type)"
>
{{ entry.event_type }}
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-700 font-mono text-xs">{{ entry.ip_address || '—' }}</td>
</tr>
</template>
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="!loading && entries.length > 0" class="flex items-center justify-between mt-4"> <div v-if="!loading && !fetchError && entries.length > 0" class="flex items-center justify-between mt-4">
<button <button
@click="prevPage" @click="prevPage"
:disabled="page <= 1" :disabled="page <= 1"
@@ -187,6 +198,8 @@
<script setup> <script setup>
import { ref, reactive, onMounted, computed } from 'vue' import { ref, reactive, onMounted, computed } from 'vue'
import * as api from '../../api/client.js' import * as api from '../../api/client.js'
import BreadcrumbBar from '../../components/ui/BreadcrumbBar.vue'
import EmptyState from '../../components/ui/EmptyState.vue'
const entries = ref([]) const entries = ref([])
const total = ref(0) const total = ref(0)
+22 -13
View File
@@ -1,5 +1,7 @@
<template> <template>
<div> <div>
<BreadcrumbBar :segments="[{ label: 'Users' }]" :show-root="false" class="mb-4" />
<div v-if="showCreateForm" class="bg-white border border-gray-200 rounded-xl p-6 mb-4"> <div v-if="showCreateForm" class="bg-white border border-gray-200 rounded-xl p-6 mb-4">
<h3 class="text-sm font-semibold text-gray-900 mb-4">Create user</h3> <h3 class="text-sm font-semibold text-gray-900 mb-4">Create user</h3>
<div class="space-y-3"> <div class="space-y-3">
@@ -90,19 +92,7 @@
</button> </button>
</div> </div>
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-8 text-center"> <div class="bg-white rounded-xl border border-gray-200 overflow-hidden divide-y divide-gray-200">
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
Loading users
</div>
</div>
<div v-else-if="users.length === 0" class="bg-white rounded-xl border border-gray-200 p-12 text-center">
<h3 class="text-sm font-semibold text-gray-900 mb-1">No users yet</h3>
<p class="text-sm text-gray-500">Create the first user account to get started.</p>
</div>
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden divide-y divide-gray-200">
<table class="w-full"> <table class="w-full">
<thead> <thead>
<tr class="bg-gray-50 text-left"> <tr class="bg-gray-50 text-left">
@@ -115,6 +105,23 @@
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200"> <tbody class="divide-y divide-gray-200">
<template v-if="loading">
<tr v-for="n in 5" :key="`sk-${n}`" class="border-b border-gray-100">
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-36"></div></td>
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-20"></div></td>
<td class="px-4 py-3"><div class="h-4 bg-gray-100 rounded-full animate-pulse w-12"></div></td>
<td class="px-4 py-3"><div class="h-4 bg-gray-100 rounded-full animate-pulse w-16"></div></td>
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-24"></div></td>
<td class="px-4 py-3"><div class="h-3 bg-gray-100 rounded animate-pulse w-28"></div></td>
</tr>
</template>
<tr v-else-if="users.length === 0">
<td colspan="6" class="px-8 py-12 text-center">
<h3 class="text-sm font-semibold text-gray-900 mb-1">No users yet</h3>
<p class="text-sm text-gray-500">Create the first user account to get started.</p>
</td>
</tr>
<template v-else>
<tr <tr
v-for="user in users" v-for="user in users"
:key="user.id" :key="user.id"
@@ -252,6 +259,7 @@
</div> </div>
</td> </td>
</tr> </tr>
</template>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -264,6 +272,7 @@
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { formatDate } from '../../utils/formatters.js' import { formatDate } from '../../utils/formatters.js'
import * as api from '../../api/client.js' import * as api from '../../api/client.js'
import BreadcrumbBar from '../../components/ui/BreadcrumbBar.vue'
const users = ref([]) const users = ref([])
const loading = ref(false) const loading = ref(false)
@@ -79,7 +79,11 @@ describe('UX-01 (audit empty): EmptyState renders when entries is empty after lo
it('EmptyState includes a Clear filters CTA button', async () => { it('EmptyState includes a Clear filters CTA button', async () => {
const { adminListAuditLog } = await import('../../../api/client.js') const { adminListAuditLog } = await import('../../../api/client.js')
adminListAuditLog.mockResolvedValue({ items: [], total: 0 }) adminListAuditLog.mockResolvedValue({ items: [], total: 0 })
const wrapper = await mountAudit() // Mount with EmptyState NOT stubbed so slot content renders
const { default: AdminAuditView } = await import('../AdminAuditView.vue')
const wrapper = mount(AdminAuditView, {
global: { stubs: { BreadcrumbBar: true, AppIcon: true }, plugins: [createPinia()] },
})
await new Promise(r => setTimeout(r, 50)) await new Promise(r => setTimeout(r, 50))
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Clear filters') expect(wrapper.text()).toContain('Clear filters')