@stacksjs/defaults 0.75.279 → 0.75.280

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/ide/vscode/package.json +1 -1
  2. package/package.json +2 -2
  3. package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +28 -30
  4. package/resources/components/Dashboard/Commerce/CommerceCategoriesDashboard.stx +8 -28
  5. package/resources/components/Dashboard/Commerce/CommerceCategoriesTable.stx +72 -29
  6. package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +8 -29
  7. package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +69 -49
  8. package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +8 -27
  9. package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +70 -48
  10. package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +8 -29
  11. package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +72 -82
  12. package/resources/components/Dashboard/Commerce/CommerceManufacturersDashboard.stx +1 -1
  13. package/resources/components/Dashboard/Commerce/CommerceManufacturersTable.stx +3 -0
  14. package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +8 -28
  15. package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +76 -49
  16. package/resources/components/Dashboard/Commerce/CommercePaymentsDashboard.stx +1 -1
  17. package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +3 -0
  18. package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +8 -28
  19. package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +81 -34
  20. package/resources/components/Dashboard/Commerce/CommerceReviewsDashboard.stx +11 -37
  21. package/resources/components/Dashboard/Commerce/CommerceReviewsTable.stx +106 -77
  22. package/resources/components/Dashboard/Commerce/CommerceTaxesDashboard.stx +1 -1
  23. package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +3 -0
  24. package/resources/components/Dashboard/Commerce/CommerceUnitsDashboard.stx +1 -1
  25. package/resources/components/Dashboard/Commerce/CommerceUnitsTable.stx +3 -0
  26. package/resources/components/Dashboard/Commerce/CommerceVariantsDashboard.stx +1 -1
  27. package/resources/components/Dashboard/Commerce/CommerceVariantsTable.stx +3 -0
  28. package/resources/components/Dashboard/Commerce/Delivery/CouriersDashboard.stx +1 -11
  29. package/resources/components/Dashboard/Commerce/Delivery/CouriersTable.stx +55 -82
  30. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesDashboard.stx +1 -12
  31. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesTable.stx +57 -86
  32. package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveriesDashboard.stx +1 -24
  33. package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveryTable.stx +62 -80
  34. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysDashboard.stx +1 -12
  35. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysTable.stx +58 -99
  36. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsDashboard.stx +1 -23
  37. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +51 -86
  38. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +1 -12
  39. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +58 -71
  40. package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesDashboard.stx +1 -12
  41. package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesTable.stx +85 -110
  42. package/resources/components/Dashboard/Commerce/PrintDevicesDashboard.stx +8 -28
  43. package/resources/components/Dashboard/Commerce/PrintDevicesTable.stx +62 -57
  44. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +7 -24
  45. package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +63 -59
  46. package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +7 -25
  47. package/resources/components/Dashboard/Marketing/CampaignsTable.stx +80 -57
  48. package/resources/components/Dashboard/Marketing/MarketingListsDashboard.stx +7 -27
  49. package/resources/components/Dashboard/Marketing/MarketingListsTable.stx +66 -71
  50. package/resources/components/Dashboard/Marketing/SocialPostsDashboard.stx +7 -25
  51. package/resources/components/Dashboard/Marketing/SocialPostsTable.stx +75 -62
  52. package/resources/components/Dashboard/Monitoring/ErrorDashboard.stx +9 -50
  53. package/resources/components/Dashboard/Monitoring/ErrorTable.stx +68 -97
  54. package/resources/components/Dashboard/Notifications/NotificationDeliveries.stx +22 -44
  55. package/resources/components/Dashboard/Notifications/NotificationDeliveryHistory.stx +13 -4
  56. package/resources/components/Dashboard/Notifications/NotificationDeliveryTable.stx +74 -86
  57. package/resources/components/Dashboard/UI/Table.stx +41 -2
@@ -6,7 +6,6 @@ import type {
6
6
  } from '../../../../app/Actions/Dashboard/Marketing/social-post-records'
7
7
  import { dashboardApi } from '../../../../functions/dashboard-api'
8
8
  import { pushToast } from '../../../../functions/toasts'
9
- import { pageControls } from '../../../../functions/dashboard-list'
10
9
 
11
10
  interface SocialPostIndexResponse {
12
11
  records: SocialPostRecord[]
@@ -35,8 +34,6 @@ const statusFilter = state('all')
35
34
  const platformFilter = state('all')
36
35
  const authorFilter = state('all')
37
36
  const sort = state('newest')
38
- const page = state(1)
39
- const perPage = 10
40
37
  const dialogOpen = state(false)
41
38
  const dialogMode = state<'add' | 'edit'>('add')
42
39
  const editingRecord = state<SocialPostRecord | null>(null)
@@ -70,24 +67,14 @@ function filteredRecords(): SocialPostRecord[] {
70
67
  })
71
68
  }
72
69
 
73
- function visibleRecords(): SocialPostRecord[] {
74
- const start = (page() - 1) * perPage
75
- return filteredRecords().slice(start, start + perPage)
70
+ /**
71
+ * What the table is showing: a change returns it to the first page, as this
72
+ * page's own pager did before the table took over paging.
73
+ */
74
+ function viewKey(): string {
75
+ return JSON.stringify([search(), statusFilter(), platformFilter(), authorFilter(), sort()])
76
76
  }
77
77
 
78
- function totalPages(): number {
79
- return Math.max(1, Math.ceil(filteredRecords().length / perPage))
80
- }
81
-
82
- effect(() => {
83
- search()
84
- statusFilter()
85
- platformFilter()
86
- authorFilter()
87
- sort()
88
- page.set(1)
89
- })
90
-
91
78
  async function loadPosts(): Promise<void> {
92
79
  loading.set(true)
93
80
  loadError.set('')
@@ -98,8 +85,6 @@ async function loadPosts(): Promise<void> {
98
85
  records.set(data.records)
99
86
  users.set(data.users)
100
87
  summary.set(data.summary)
101
- if (page() > totalPages())
102
- page.set(totalPages())
103
88
  }
104
89
  catch (error) {
105
90
  loadError.set(error instanceof Error ? error.message : String(error))
@@ -187,8 +172,6 @@ async function deletePost(): Promise<void> {
187
172
  }
188
173
  }
189
174
 
190
- const { previousPage, nextPage } = pageControls(page, totalPages)
191
-
192
175
  onMount(() => {
193
176
  void loadPosts()
194
177
  })
@@ -220,8 +203,7 @@ onMount(() => {
220
203
  <label><span class="sr-only">Filter owner</span><select x-model="authorFilter" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-800 border border-gray-300 rounded-md dark:border-neutral-700"><option value="all">All owners</option><option value="">Unassigned</option><template :for="user in users()" :key="user.id"><option :value="user.id">{{ user.name }}</option></template></select></label>
221
204
  <label><span class="sr-only">Sort social posts</span><select x-model="sort" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-800 border border-gray-300 rounded-md dark:border-neutral-700"><option value="newest">Newest</option><option value="scheduled">Latest schedule</option><option value="reach">Most reach</option><option value="engagement">Most engagement</option></select></label>
222
205
  </div>
223
- <SocialPostsTable :records="visibleRecords()" :loading="loading()" @edit="openEdit" @delete="openDelete" />
224
- <div :if="filteredRecords().length > perPage" class="flex items-center justify-between"><p class="text-gray-500 text-sm dark:text-neutral-400">Page {{ page() }} of {{ totalPages() }}</p><div class="flex gap-2"><Button :disabled="page() === 1" variant="secondary" @click="previousPage()">Previous</Button><Button :disabled="page() === totalPages()" variant="secondary" @click="nextPage()">Next</Button></div></div>
206
+ <SocialPostsTable :sort-reset-key="sort()" :page-reset-key="viewKey()" :records="filteredRecords()" :loading="loading()" @edit="openEdit" @delete="openDelete" />
225
207
  </section>
226
208
  </div>
227
209
 
@@ -1,79 +1,92 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { SocialPostRecord } from '../../../../app/Actions/Dashboard/Marketing/social-post-records'
5
+ import { parseDashboardDate } from '../../../../functions/dashboard-dates'
3
6
 
4
7
  const records = useReactiveProp('records', [] as SocialPostRecord[])
5
8
  const loading = useReactiveProp('loading', false)
9
+ /** The parent's sort select: a change hands the order back from a header sort. */
10
+ const sortResetKey = useReactiveProp<unknown>('sortResetKey', '')
11
+ /** The parent's search and filters: a change returns to the first page. */
12
+ const pageResetKey = useReactiveProp<unknown>('pageResetKey', '')
6
13
  const emit = defineEmits()
7
14
 
8
15
  function formatDate(value: string): string {
9
16
  if (!value)
10
17
  return 'Not set'
11
- const timestamp = new Date(value.replace(' ', 'T')).getTime()
12
- return Number.isFinite(timestamp)
13
- ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp)
14
- : value
15
- }
16
-
17
- function statusClass(status: string): string {
18
- if (status === 'published')
19
- return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300'
20
- if (status === 'scheduled')
21
- return 'bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300'
22
- if (status === 'failed')
23
- return 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300'
24
- return 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300'
18
+ const date = parseDashboardDate(value)
19
+ return Number.isNaN(date.getTime())
20
+ ? value
21
+ : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
25
22
  }
26
23
 
27
24
  function displayPlatform(value: string): string {
28
25
  return value.charAt(0).toUpperCase() + value.slice(1)
29
26
  }
27
+
28
+ /** The post cell is slotted: an image link and an external id appear only when present. */
29
+ const COLUMNS = [
30
+ { key: 'content', label: 'Post', slotted: true },
31
+ { key: 'platformLabel', label: 'Platform', sortable: true, class: 'font-medium text-gray-700 whitespace-nowrap dark:text-neutral-200' },
32
+ { key: 'authorName', label: 'Owner', sortable: true, subtitleKey: 'authorEmail' },
33
+ { key: 'timingLabel', label: 'Timing', subtitleKey: 'timingNote', class: 'whitespace-nowrap' },
34
+ { key: 'reach', label: 'Performance', type: 'number' as const, sortable: true, subtitleKey: 'engagementLabel', class: 'whitespace-nowrap' },
35
+ {
36
+ key: 'status',
37
+ label: 'Status',
38
+ type: 'badge' as const,
39
+ sortable: true,
40
+ badgeClasses: {
41
+ published: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300',
42
+ scheduled: 'bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300',
43
+ failed: 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300',
44
+ draft: 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300',
45
+ },
46
+ },
47
+ ]
48
+
49
+ const ROW_ACTIONS = [
50
+ { action: 'edit', label: 'Edit social post', icon: 'i-hugeicons-edit-01' },
51
+ { action: 'delete', label: 'Delete social post', icon: 'i-hugeicons-delete-02', class: 'text-red-500' },
52
+ ]
53
+
54
+ const rows = derived(() => records().map(record => ({
55
+ ...record,
56
+ platformLabel: displayPlatform(record.platform),
57
+ timingLabel: record.status === 'published' ? formatDate(record.publishedAt) : formatDate(record.scheduledAt),
58
+ timingNote: record.status === 'published' ? 'Published' : record.status === 'scheduled' ? 'Scheduled' : `Created ${formatDate(record.createdAt)}`,
59
+ engagementLabel: `${record.engagement.toLocaleString()} interactions`,
60
+ })))
61
+
62
+ function handleRowAction(payload: { action: string, row: SocialPostRecord }): void {
63
+ emit(payload.action, payload.row)
64
+ }
30
65
  </script>
31
66
 
32
- <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
33
- <div :if="loading() && records().length === 0" class="flex items-center justify-center py-16 text-gray-500 text-sm dark:text-neutral-400"><span class="mr-2 h-5 w-5 animate-spin i-hugeicons-loading-03"></span>Loading social posts</div>
34
- <div :else-if="records().length === 0" class="px-6 py-16 text-center">
35
- <span class="inline-block h-8 w-8 text-gray-400 i-hugeicons-share-01"></span>
36
- <p class="mt-3 font-medium text-gray-900 text-sm dark:text-white">No social posts found</p>
37
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Adjust the filters or add a SocialPost record.</p>
38
- </div>
39
- <div :else class="overflow-x-auto">
40
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
41
- <thead class="bg-gray-50 dark:bg-neutral-900/50">
42
- <tr>
43
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Post</th>
44
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Platform</th>
45
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Owner</th>
46
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Timing</th>
47
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Performance</th>
48
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Status</th>
49
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-right text-xs dark:text-neutral-300"><span class="sr-only">Actions</span></th>
50
- </tr>
51
- </thead>
52
- <tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
53
- <template :for="record in records()" :key="record.id">
54
- <tr class="align-top">
55
- <td class="px-4 py-4 max-w-md">
56
- <p class="text-gray-900 text-sm dark:text-white">{{ record.content }}</p>
57
- <p :if="record.imageUrl" class="mt-1 max-w-xs text-blue-600 text-xs truncate dark:text-blue-400">{{ record.imageUrl }}</p>
58
- <p :if="record.externalId" class="mt-1 font-mono text-gray-500 text-xs dark:text-neutral-400">{{ record.externalId }}</p>
59
- </td>
60
- <td class="px-4 py-4 font-medium text-gray-700 text-sm whitespace-nowrap dark:text-neutral-200">{{ displayPlatform(record.platform) }}</td>
61
- <td class="px-4 py-4 text-gray-600 text-sm dark:text-neutral-300"><p>{{ record.authorName }}</p><p :if="record.authorEmail" class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ record.authorEmail }}</p></td>
62
- <td class="px-4 py-4 text-gray-600 text-sm whitespace-nowrap dark:text-neutral-300">
63
- <p>{{ record.status === 'published' ? formatDate(record.publishedAt) : formatDate(record.scheduledAt) }}</p>
64
- <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ record.status === 'published' ? 'Published' : record.status === 'scheduled' ? 'Scheduled' : 'Created ' + formatDate(record.createdAt) }}</p>
65
- </td>
66
- <td class="px-4 py-4 text-gray-600 text-sm whitespace-nowrap dark:text-neutral-300"><p>{{ record.reach.toLocaleString() }} reach</p><p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ record.engagement.toLocaleString() }} interactions</p></td>
67
- <td class="px-4 py-4"><span :class="'inline-flex px-2 py-1 font-medium text-xs rounded-full ' + statusClass(record.status)">{{ record.status }}</span></td>
68
- <td class="px-4 py-4">
69
- <div class="flex gap-1 justify-end">
70
- <Button variant="ghost" size="sm" iconOnly :ariaLabel="'Edit social post ' + record.id" @click="emit('edit', record)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-edit-01"></span></Button>
71
- <Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete social post ' + record.id" @click="emit('delete', record)"><span class="block h-4 w-4 text-red-500 i-hugeicons-delete-02"></span></Button>
72
- </div>
73
- </td>
74
- </tr>
75
- </template>
76
- </tbody>
77
- </table>
78
- </div>
79
- </div>
67
+ {{--
68
+ No `searchable` or `filterable`: the parent dashboard owns the search box
69
+ and the filter selects. Table pages the whole filtered list the parent passes.
70
+ --}}
71
+ <Table
72
+ :sort-reset-key="sortResetKey()"
73
+ :page-reset-key="pageResetKey()"
74
+ :columns="COLUMNS"
75
+ :data="rows()"
76
+ :loading="loading()"
77
+ :row-actions="ROW_ACTIONS"
78
+ action-label-key="id"
79
+ paginated
80
+ :page-size="10"
81
+ empty-title="No social posts found"
82
+ empty-description="Adjust the filters or add a SocialPost record."
83
+ @row-action="handleRowAction"
84
+ >
85
+ <template #cell="{ row }">
86
+ <div class="max-w-md">
87
+ <p class="text-gray-900 text-sm dark:text-white" x-text="row.content"></p>
88
+ <p :if="row.imageUrl" class="mt-1 max-w-xs text-blue-600 text-xs truncate dark:text-blue-400" x-text="row.imageUrl"></p>
89
+ <p :if="row.externalId" class="mt-1 font-mono text-gray-500 text-xs dark:text-neutral-400" x-text="row.externalId"></p>
90
+ </div>
91
+ </template>
92
+ </Table>
@@ -29,39 +29,33 @@
29
29
  <h2 class="font-semibold text-lg text-neutral-900 dark:text-white">Issues</h2>
30
30
  <div class="flex flex-col gap-3 sm:flex-row">
31
31
  <div class="w-full sm:w-64">
32
- <Input v-model:value="searchQuery" ariaLabel="Search errors" placeholder="Search errors..." size="sm" @input="resetFilterPage">
32
+ <Input v-model:value="searchQuery" ariaLabel="Search errors" placeholder="Search errors..." size="sm">
33
33
  <template #iconLeft>
34
34
  <div class="h-4 w-4 i-hugeicons-search-01"></div>
35
35
  </template>
36
36
  </Input>
37
37
  </div>
38
38
  <div class="w-full sm:w-40">
39
- <Select v-model:value="statusFilter" :options="statusOptions" ariaLabel="Error status" placeholder="" size="sm" @change="resetFilterPage" />
39
+ <Select v-model:value="statusFilter" :options="statusOptions" ariaLabel="Error status" placeholder="" size="sm" />
40
40
  </div>
41
41
  <div class="w-full sm:w-40">
42
- <Select v-model:value="typeFilter" :options="typeOptions" ariaLabel="Error type" placeholder="" size="sm" @change="resetFilterPage" />
42
+ <Select v-model:value="typeFilter" :options="typeOptions" ariaLabel="Error type" placeholder="" size="sm" />
43
43
  </div>
44
44
  </div>
45
45
  </div>
46
46
  </template>
47
47
 
48
48
  <ErrorTable
49
- :errors="paginatedErrors"
49
+ :errors="filteredErrors"
50
+ :pageResetKey="viewKey"
50
51
  :emptyTitle="emptyTitle"
51
52
  :emptyDescription="emptyDescription"
52
- :sortColumn="sortBy"
53
- :sortDirection="sortOrder"
54
- @sort="handleSort"
55
53
  @row-click="viewError"
56
54
  @resolve="handleResolveError"
57
55
  @ignore="handleIgnoreError"
58
56
  @unresolve="handleUnresolveError"
59
57
  @delete="openDeleteModal"
60
58
  />
61
-
62
- <template #footer>
63
- <Pagination :currentPage="currentPage" :totalPages="totalPages" :totalItems="totalFiltered" :itemsPerPage="itemsPerPage" showTotalItems @page="setCurrentPage" />
64
- </template>
65
59
  </Card>
66
60
 
67
61
  <Modal :isOpen="isDetailModalOpen()" :title="detailTitle" size="lg" @close="closeDetailModal">
@@ -171,10 +165,6 @@ const { groupedErrors, errorStats, fetchGroupedErrors, fetchErrorStats, resolveE
171
165
  const searchQuery = state('')
172
166
  const statusFilter = state('')
173
167
  const typeFilter = state('')
174
- const sortBy = state('last_seen')
175
- const sortOrder = state<'asc' | 'desc'>('desc')
176
- const currentPage = state(1)
177
- const itemsPerPage = state(10)
178
168
 
179
169
  const selectedError = state<ErrorGroup | null>(null)
180
170
  const isDetailModalOpen = state(false)
@@ -216,8 +206,6 @@ const filteredErrors = derived(() => {
216
206
  const q = searchQuery().toLowerCase()
217
207
  const status = statusFilter()
218
208
  const type = typeFilter()
219
- const sb = sortBy()
220
- const so = sortOrder()
221
209
  return list
222
210
  .filter((error) => {
223
211
  const matchesSearch = !q
@@ -227,38 +215,19 @@ const filteredErrors = derived(() => {
227
215
  const matchesType = !type || error.type === type
228
216
  return matchesSearch && matchesStatus && matchesType
229
217
  })
230
- .sort((a, b) => {
231
- let comparison = 0
232
- if (sb === 'type')
233
- comparison = a.type.localeCompare(b.type)
234
- else if (sb === 'count')
235
- comparison = a.count - b.count
236
- else if (sb === 'last_seen')
237
- comparison = new Date(a.last_seen).getTime() - new Date(b.last_seen).getTime()
238
- else if (sb === 'first_seen')
239
- comparison = new Date(a.first_seen).getTime() - new Date(b.first_seen).getTime()
240
- return so === 'asc' ? comparison : -comparison
241
- })
218
+ // Most recently seen first: the order a header sort in the table falls back to.
219
+ .sort((a, b) => new Date(b.last_seen).getTime() - new Date(a.last_seen).getTime())
242
220
  })
243
221
 
244
- const paginatedErrors = derived(() => {
245
- const start = (currentPage() - 1) * itemsPerPage()
246
- return filteredErrors().slice(start, start + itemsPerPage())
247
- })
222
+ /** What the table is showing: a change to the search or a filter returns it to the first page. */
223
+ const viewKey = derived(() => JSON.stringify([searchQuery(), statusFilter(), typeFilter()]))
248
224
 
249
- const totalFiltered = derived(() => filteredErrors().length)
250
- const totalPages = derived(() => Math.max(1, Math.ceil(filteredErrors().length / itemsPerPage())))
251
225
 
252
226
  const hasFilters = derived(() => searchQuery() !== '' || (statusFilter() ?? '') !== '' || (typeFilter() ?? '') !== '')
253
227
  const emptyTitle = derived(() => hasFilters() ? 'No matching errors' : 'No errors found')
254
228
  const emptyDescription = derived(() => hasFilters() ? 'Try adjusting your filters' : 'Your application is running smoothly!')
255
229
 
256
230
  const hasSelectedError = derived(() => selectedError() !== null)
257
-
258
- function setCurrentPage(page: number | CustomEvent<number>): void {
259
- const nextPage = typeof page === 'number' ? page : page.detail
260
- currentPage.set(Math.min(Math.max(1, nextPage), totalPages()))
261
- }
262
231
  const selectedIsUnresolved = derived(() => selectedError()?.status === 'unresolved')
263
232
  const selectedIsResolvedOrIgnored = derived(() => {
264
233
  const s = selectedError()?.status
@@ -281,16 +250,6 @@ function eventValue<T>(value: T | CustomEvent<T>): T {
281
250
  return value instanceof CustomEvent ? value.detail : value
282
251
  }
283
252
 
284
- function resetFilterPage(): void {
285
- currentPage.set(1)
286
- }
287
-
288
- function handleSort(value: { column: string, direction: 'asc' | 'desc' } | CustomEvent<{ column: string, direction: 'asc' | 'desc' }>): void {
289
- const sort = eventValue(value)
290
- sortBy.set(sort.column)
291
- sortOrder.set(sort.direction)
292
- }
293
-
294
253
  function viewError(value: ErrorGroup | CustomEvent<ErrorGroup>): void {
295
254
  const error = eventValue(value)
296
255
  selectedError.set(error)
@@ -1,3 +1,5 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  interface ErrorGroup {
3
5
  id?: string | number
@@ -10,36 +12,13 @@ interface ErrorGroup {
10
12
  stack?: string | null
11
13
  }
12
14
 
13
- interface SortPayload {
14
- column: 'type' | 'count' | 'last_seen'
15
- direction: 'asc' | 'desc'
16
- }
17
-
18
15
  const errors = useReactiveProp('errors', [])
19
16
  const emptyTitle = useReactiveProp('emptyTitle', 'No errors found')
20
17
  const emptyDescription = useReactiveProp('emptyDescription', 'Your application is running smoothly!')
21
- const sortColumn = useReactiveProp('sortColumn', 'last_seen')
22
- const sortDirection = useReactiveProp('sortDirection', 'desc')
18
+ /** The parent's search and filters: a change returns to the first page. */
19
+ const pageResetKey = useReactiveProp<unknown>('pageResetKey', '')
23
20
  const emit = defineEmits()
24
21
 
25
- function rows(): ErrorGroup[] {
26
- return errors() as ErrorGroup[]
27
- }
28
-
29
- function nextSort(column: SortPayload['column']): void {
30
- const direction = sortColumn() === column && sortDirection() === 'desc' ? 'asc' : 'desc'
31
- emit('sort', { column, direction })
32
- }
33
-
34
- function statusClass(status: string): string {
35
- const classes: Record<string, string> = {
36
- unresolved: 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/40 border-red-200 dark:border-red-900',
37
- resolved: 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/40 border-green-200 dark:border-green-900',
38
- ignored: 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40 border-amber-200 dark:border-amber-900',
39
- }
40
- return classes[status] || 'text-neutral-700 dark:text-neutral-300 bg-neutral-50 dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700'
41
- }
42
-
43
22
  function formatRelativeTime(value: string): string {
44
23
  if (!value)
45
24
  return 'Not recorded'
@@ -58,77 +37,69 @@ function formatRelativeTime(value: string): string {
58
37
  const days = Math.floor(hours / 24)
59
38
  return days < 7 ? `${days}d ago` : date.toLocaleDateString()
60
39
  }
61
- </script>
62
40
 
63
- <div class="overflow-hidden border border-stone-200/80 rounded-xl dark:border-neutral-700">
64
- <div :if="rows().length === 0" class="flex flex-col items-center justify-center px-4 py-12 text-center">
65
- <span class="block mb-3 h-7 w-7 text-neutral-400 i-hugeicons-inbox"></span>
66
- <p class="font-medium text-neutral-700 text-sm dark:text-neutral-200">{{ emptyTitle() }}</p>
67
- <p class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">{{ emptyDescription() }}</p>
68
- </div>
41
+ /**
42
+ * Last seen reads relative to now and sorts by the timestamp behind it, so
43
+ * it is slotted rather than a datetime column. The parent's order, most
44
+ * recently seen first, is what a header sort falls back to.
45
+ */
46
+ const COLUMNS = [
47
+ { key: 'type', label: 'Type', type: 'badge' as const, sortable: true },
48
+ { key: 'message', label: 'Message', class: 'max-w-lg truncate text-neutral-700 dark:text-neutral-200' },
49
+ { key: 'count', label: 'Count', type: 'number' as const, sortable: true, align: 'center' as const, class: 'font-semibold' },
50
+ {
51
+ key: 'status',
52
+ label: 'Status',
53
+ type: 'badge' as const,
54
+ sortable: true,
55
+ align: 'center' as const,
56
+ badgeClasses: {
57
+ unresolved: 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/40',
58
+ resolved: 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/40',
59
+ ignored: 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40',
60
+ },
61
+ },
62
+ { key: 'last_seen', label: 'Last seen', sortable: true, align: 'right' as const, slotted: true },
63
+ ]
64
+
65
+ /** Resolve and ignore an open error; reopen one that is resolved or ignored. */
66
+ const ROW_ACTIONS = [
67
+ { action: 'view', label: 'View details of', icon: 'i-hugeicons-view' },
68
+ { action: 'resolve', label: 'Resolve', icon: 'i-hugeicons-checkmark-circle-02', class: 'text-green-500', when: { key: 'open', equals: true } },
69
+ { action: 'ignore', label: 'Ignore', icon: 'i-hugeicons-view-off', class: 'text-amber-500', when: { key: 'open', equals: true } },
70
+ { action: 'unresolve', label: 'Unresolve', icon: 'i-hugeicons-refresh', class: 'text-neutral-500', when: { key: 'open', equals: false } },
71
+ { action: 'delete', label: 'Delete', icon: 'i-hugeicons-delete-02', class: 'text-red-500' },
72
+ ]
73
+
74
+ const rows = derived(() => (errors() as ErrorGroup[]).map((error, index) => ({
75
+ ...error,
76
+ rowId: error.id ?? `${error.type}:${error.message}:${index}`,
77
+ open: error.status === 'unresolved',
78
+ lastSeenLabel: formatRelativeTime(error.last_seen),
79
+ // The message, not the type: several groups share a type, and a name read out
80
+ // for every row of one type would not say which row it acts on.
81
+ actionName: `${error.type}: ${error.message}`,
82
+ })))
83
+
84
+ function handleRowAction(payload: { action: string, row: ErrorGroup }): void {
85
+ emit(payload.action === 'view' ? 'row-click' : payload.action, payload.row)
86
+ }
87
+ </script>
69
88
 
70
- <div :if="rows().length > 0" class="overflow-x-auto">
71
- <table class="w-full">
72
- <thead class="bg-stone-50/80 dark:bg-neutral-800/50">
73
- <tr>
74
- <th scope="col" class="pl-5 pr-4 py-3.5 font-semibold text-left text-stone-500 text-xs tracking-wider uppercase dark:text-neutral-400">
75
- <button type="button" class="inline-flex gap-1.5 items-center hover:text-stone-700 dark:hover:text-neutral-200" @click="nextSort('type')">
76
- Type
77
- <span class="h-3.5 w-3.5 opacity-40 i-hugeicons-arrow-up-down"></span>
78
- </button>
79
- </th>
80
- <th scope="col" class="px-4 py-3.5 font-semibold text-left text-stone-500 text-xs tracking-wider uppercase dark:text-neutral-400">Message</th>
81
- <th scope="col" class="px-4 py-3.5 font-semibold text-center text-stone-500 text-xs tracking-wider uppercase dark:text-neutral-400">
82
- <button type="button" class="inline-flex gap-1.5 items-center hover:text-stone-700 dark:hover:text-neutral-200" @click="nextSort('count')">
83
- Events
84
- <span class="h-3.5 w-3.5 opacity-40 i-hugeicons-arrow-up-down"></span>
85
- </button>
86
- </th>
87
- <th scope="col" class="px-4 py-3.5 font-semibold text-center text-stone-500 text-xs tracking-wider uppercase dark:text-neutral-400">Status</th>
88
- <th scope="col" class="px-4 py-3.5 font-semibold text-right text-stone-500 text-xs tracking-wider uppercase dark:text-neutral-400">
89
- <button type="button" class="inline-flex gap-1.5 items-center hover:text-stone-700 dark:hover:text-neutral-200" @click="nextSort('last_seen')">
90
- Last seen
91
- <span class="h-3.5 w-3.5 opacity-40 i-hugeicons-arrow-up-down"></span>
92
- </button>
93
- </th>
94
- <th scope="col" class="px-4 py-3.5 w-36"><span class="sr-only">Actions</span></th>
95
- </tr>
96
- </thead>
97
- <tbody class="divide-stone-100 divide-y dark:divide-neutral-700">
98
- <template :for="error in rows()">
99
- <tr class="hover:bg-stone-50/50 dark:hover:bg-neutral-800/50">
100
- <td class="pl-5 pr-4 py-3.5">
101
- <span class="inline-flex items-center px-2 py-0.5 font-medium text-neutral-700 text-xs dark:text-neutral-200 bg-neutral-100 dark:bg-neutral-700 rounded">{{ error.type }}</span>
102
- </td>
103
- <td class="px-4 py-3.5 max-w-lg">
104
- <button type="button" class="block w-full text-left text-neutral-700 text-sm truncate dark:text-neutral-200 hover:text-blue-600 dark:hover:text-blue-400" @click="emit('row-click', error)">{{ error.message }}</button>
105
- </td>
106
- <td class="px-4 py-3.5 text-center">
107
- <span class="inline-flex items-center justify-center px-2 py-0.5 min-w-[2rem] font-semibold text-neutral-700 text-xs dark:text-neutral-200 bg-neutral-100 dark:bg-neutral-700 rounded-full">{{ error.count }}</span>
108
- </td>
109
- <td class="px-4 py-3.5 text-center">
110
- <span :class="'inline-flex items-center px-2 py-0.5 font-medium text-xs border rounded-full ' + statusClass(error.status)">{{ error.status }}</span>
111
- </td>
112
- <td class="px-4 py-3.5 text-neutral-500 text-right text-sm whitespace-nowrap dark:text-neutral-400">{{ formatRelativeTime(error.last_seen) }}</td>
113
- <td class="px-4 py-3.5">
114
- <div class="flex gap-1 items-center justify-end">
115
- <Button :if="error.status === 'unresolved'" variant="success" size="sm" iconOnly ariaLabel="Resolve error" @click="emit('resolve', error)">
116
- <span class="block h-4 w-4 i-hugeicons-checkmark-circle-02"></span>
117
- </Button>
118
- <Button :if="error.status === 'unresolved'" variant="warning" size="sm" iconOnly ariaLabel="Ignore error" @click="emit('ignore', error)">
119
- <span class="block h-4 w-4 i-hugeicons-view-off"></span>
120
- </Button>
121
- <Button :if="error.status !== 'unresolved'" variant="secondary" size="sm" iconOnly ariaLabel="Unresolve error" @click="emit('unresolve', error)">
122
- <span class="block h-4 w-4 i-hugeicons-refresh"></span>
123
- </Button>
124
- <Button variant="danger" size="sm" iconOnly ariaLabel="Delete error" @click="emit('delete', error)">
125
- <span class="block h-4 w-4 i-hugeicons-delete-02"></span>
126
- </Button>
127
- </div>
128
- </td>
129
- </tr>
130
- </template>
131
- </tbody>
132
- </table>
133
- </div>
134
- </div>
89
+ <Table
90
+ :page-reset-key="pageResetKey()"
91
+ :columns="COLUMNS"
92
+ :data="rows()"
93
+ :row-actions="ROW_ACTIONS"
94
+ action-label-key="actionName"
95
+ row-key="rowId"
96
+ paginated
97
+ :page-size="10"
98
+ :empty-title="emptyTitle()"
99
+ :empty-description="emptyDescription()"
100
+ @row-action="handleRowAction"
101
+ >
102
+ <template #cell="{ row }">
103
+ <span class="text-neutral-500 text-sm whitespace-nowrap dark:text-neutral-400" x-text="row.lastSeenLabel"></span>
104
+ </template>
105
+ </Table>