@stacksjs/defaults 0.75.279 → 0.75.281
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.
- package/ai/skills/stacks-database/SKILL.md +1 -3
- package/ai/skills/stacks-native/CATALOG.md +4 -4
- package/ide/vscode/package.json +1 -1
- package/package.json +3 -3
- package/project/bun-version +1 -1
- package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +28 -30
- package/resources/components/Dashboard/Commerce/CommerceCategoriesDashboard.stx +8 -28
- package/resources/components/Dashboard/Commerce/CommerceCategoriesTable.stx +72 -29
- package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +8 -29
- package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +69 -49
- package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +8 -27
- package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +70 -48
- package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +8 -29
- package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +72 -82
- package/resources/components/Dashboard/Commerce/CommerceManufacturersDashboard.stx +1 -1
- package/resources/components/Dashboard/Commerce/CommerceManufacturersTable.stx +3 -0
- package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +8 -28
- package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +76 -49
- package/resources/components/Dashboard/Commerce/CommercePaymentsDashboard.stx +1 -1
- package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +3 -0
- package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +8 -28
- package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +81 -34
- package/resources/components/Dashboard/Commerce/CommerceReviewsDashboard.stx +11 -37
- package/resources/components/Dashboard/Commerce/CommerceReviewsTable.stx +106 -77
- package/resources/components/Dashboard/Commerce/CommerceTaxesDashboard.stx +1 -1
- package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +3 -0
- package/resources/components/Dashboard/Commerce/CommerceUnitsDashboard.stx +1 -1
- package/resources/components/Dashboard/Commerce/CommerceUnitsTable.stx +3 -0
- package/resources/components/Dashboard/Commerce/CommerceVariantsDashboard.stx +1 -1
- package/resources/components/Dashboard/Commerce/CommerceVariantsTable.stx +3 -0
- package/resources/components/Dashboard/Commerce/Delivery/CouriersDashboard.stx +1 -11
- package/resources/components/Dashboard/Commerce/Delivery/CouriersTable.stx +55 -82
- package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesDashboard.stx +1 -12
- package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesTable.stx +57 -86
- package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveriesDashboard.stx +1 -24
- package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveryTable.stx +62 -80
- package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysDashboard.stx +1 -12
- package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysTable.stx +58 -99
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsDashboard.stx +1 -23
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +51 -86
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +1 -12
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +58 -71
- package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesDashboard.stx +1 -12
- package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesTable.stx +85 -110
- package/resources/components/Dashboard/Commerce/PrintDevicesDashboard.stx +8 -28
- package/resources/components/Dashboard/Commerce/PrintDevicesTable.stx +62 -57
- package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +7 -24
- package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +63 -59
- package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +7 -25
- package/resources/components/Dashboard/Marketing/CampaignsTable.stx +80 -57
- package/resources/components/Dashboard/Marketing/MarketingListsDashboard.stx +7 -27
- package/resources/components/Dashboard/Marketing/MarketingListsTable.stx +66 -71
- package/resources/components/Dashboard/Marketing/SocialPostsDashboard.stx +7 -25
- package/resources/components/Dashboard/Marketing/SocialPostsTable.stx +75 -62
- package/resources/components/Dashboard/Monitoring/ErrorDashboard.stx +9 -50
- package/resources/components/Dashboard/Monitoring/ErrorTable.stx +68 -97
- package/resources/components/Dashboard/Notifications/NotificationDeliveries.stx +22 -44
- package/resources/components/Dashboard/Notifications/NotificationDeliveryHistory.stx +13 -4
- package/resources/components/Dashboard/Notifications/NotificationDeliveryTable.stx +74 -86
- 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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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="
|
|
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
|
|
12
|
-
return Number.
|
|
13
|
-
?
|
|
14
|
-
:
|
|
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
|
-
|
|
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
|
-
<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"
|
|
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"
|
|
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"
|
|
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="
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
22
|
-
const
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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>
|