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:
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<BreadcrumbBar :segments="[{ label: 'Audit Log' }]" :show-root="false" class="mb-4" />
|
||||
|
||||
<div class="flex flex-wrap gap-3 mb-4 items-end">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
||||
<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-if="fetchError" class="text-xs text-red-600 mt-1">{{ fetchError }}</p>
|
||||
|
||||
<p v-else-if="fetchError" class="text-xs text-red-600 mt-1">{{ fetchError }}</p>
|
||||
|
||||
<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">
|
||||
<div v-if="!fetchError" class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 border-b border-gray-200">
|
||||
@@ -100,33 +91,53 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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>
|
||||
<template v-if="loading">
|
||||
<tr v-for="n in 8" :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-32"></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-3 bg-gray-100 rounded animate-pulse w-36"></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>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="entries.length === 0">
|
||||
<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 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 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>
|
||||
</table>
|
||||
</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
|
||||
@click="prevPage"
|
||||
:disabled="page <= 1"
|
||||
@@ -187,6 +198,8 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
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 total = ref(0)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<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">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-4">Create user</h3>
|
||||
<div class="space-y-3">
|
||||
@@ -90,19 +92,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
||||
<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">
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden divide-y divide-gray-200">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-left">
|
||||
@@ -115,6 +105,23 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<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
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
@@ -252,6 +259,7 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -264,6 +272,7 @@
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { formatDate } from '../../utils/formatters.js'
|
||||
import * as api from '../../api/client.js'
|
||||
import BreadcrumbBar from '../../components/ui/BreadcrumbBar.vue'
|
||||
|
||||
const users = ref([])
|
||||
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 () => {
|
||||
const { adminListAuditLog } = await import('../../../api/client.js')
|
||||
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 wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).toContain('Clear filters')
|
||||
|
||||
Reference in New Issue
Block a user