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>
|
<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)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user