@stacksjs/defaults 0.75.231 → 0.75.236
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-dashboard/SKILL.md +2 -2
- package/ai/skills/stacks-database/SKILL.md +1 -1
- package/ai/skills/stacks-deploy/SKILL.md +3 -2
- package/ai/skills/stacks-migrations/SKILL.md +2 -2
- package/ai/skills/stacks-native/CATALOG.md +10 -10
- package/ai/skills/stacks-realtime/SKILL.md +4 -2
- package/ai/skills/stacks-storage/SKILL.md +43 -0
- package/ai/skills/stacks-testing/SKILL.md +38 -10
- package/app/Actions/Monitoring/error-provider.ts +129 -12
- package/app/Models/Error.ts +21 -0
- package/functions/dashboard-form.test.ts +41 -0
- package/functions/dashboard-form.ts +30 -0
- package/functions/dashboard-list.test.ts +228 -0
- package/functions/dashboard-list.ts +257 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/project/storage/framework/types/browser-composables.d.ts +56 -0
- package/resources/components/Dashboard/Auth/AuthCard.stx +29 -0
- package/resources/components/Dashboard/Auth/ForgotPassword.stx +65 -79
- package/resources/components/Dashboard/Auth/Login.stx +73 -82
- package/resources/components/Dashboard/Auth/Register.stx +121 -135
- package/resources/components/Dashboard/Auth/ResetPassword.stx +88 -102
- package/resources/components/Dashboard/Buddy/BuddyAssistant.stx +11 -15
- package/resources/components/Dashboard/Commerce/CommerceCategoriesDashboard.stx +10 -27
- package/resources/components/Dashboard/Commerce/CommerceCategoryDialog.stx +2 -4
- package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +2 -4
- package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +10 -27
- package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +2 -4
- package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +10 -27
- package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +2 -4
- package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +10 -27
- package/resources/components/Dashboard/Commerce/CommerceManufacturerDialog.stx +2 -4
- package/resources/components/Dashboard/Commerce/CommerceManufacturersDashboard.stx +13 -24
- package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +2 -4
- package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +10 -27
- package/resources/components/Dashboard/Commerce/CommerceOverviewDashboard.stx +17 -21
- package/resources/components/Dashboard/Commerce/CommercePaymentsDashboard.stx +7 -19
- package/resources/components/Dashboard/Commerce/CommercePosDashboard.stx +4 -16
- package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +2 -4
- package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +10 -27
- package/resources/components/Dashboard/Commerce/CommerceReviewEditDialog.stx +2 -4
- package/resources/components/Dashboard/Commerce/CommerceReviewsDashboard.stx +7 -18
- package/resources/components/Dashboard/Commerce/CommerceTaxDialog.stx +2 -4
- package/resources/components/Dashboard/Commerce/CommerceTaxesDashboard.stx +13 -24
- package/resources/components/Dashboard/Commerce/CommerceUnitDialog.stx +2 -4
- package/resources/components/Dashboard/Commerce/CommerceUnitsDashboard.stx +13 -24
- package/resources/components/Dashboard/Commerce/CommerceVariantDialog.stx +2 -4
- package/resources/components/Dashboard/Commerce/CommerceVariantsDashboard.stx +7 -18
- package/resources/components/Dashboard/Commerce/Delivery/CouriersDashboard.stx +52 -105
- package/resources/components/Dashboard/Commerce/Delivery/DeliveryOverviewDashboard.stx +12 -16
- package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesDashboard.stx +53 -105
- package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveriesDashboard.stx +7 -13
- package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysDashboard.stx +53 -105
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsDashboard.stx +7 -13
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +51 -103
- package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesDashboard.stx +52 -104
- package/resources/components/Dashboard/Commerce/PrintDeviceDialog.stx +2 -4
- package/resources/components/Dashboard/Commerce/PrintDevicesDashboard.stx +10 -27
- package/resources/components/Dashboard/Commerce/PrintLogsDashboard.stx +7 -19
- package/resources/components/Dashboard/Commerce/ProductWaitlistDashboard.stx +12 -16
- package/resources/components/Dashboard/Commerce/ProductWaitlistEntryDialog.stx +2 -4
- package/resources/components/Dashboard/Commerce/RestaurantWaitlistDashboard.stx +12 -16
- package/resources/components/Dashboard/Commerce/RestaurantWaitlistEntryDialog.stx +2 -4
- package/resources/components/Dashboard/Deployments/DeploymentList.stx +17 -21
- package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +8 -19
- package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +8 -19
- package/resources/components/Dashboard/Marketing/MarketingListsDashboard.stx +11 -22
- package/resources/components/Dashboard/Marketing/SocialPostsDashboard.stx +8 -19
- package/resources/components/Dashboard/Queue/QueueDashboard.stx +12 -16
- package/resources/components/Dashboard/Realtime/RealtimeDashboard.stx +6 -10
- package/resources/components/Dashboard/Releases/ReleaseDashboard.stx +6 -10
- package/resources/components/Dashboard/UI/MetricCards.stx +32 -0
- package/resources/components/Dashboard/UI/PageHeader.stx +25 -66
- package/resources/components/Dashboard/UI/Table.stx +79 -6
- package/resources/plugins/preloader.ts +9 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { pageControls, useRecordsDashboard } from './dashboard-list'
|
|
3
|
+
|
|
4
|
+
/** The smallest thing with the two members `pageControls` reads. */
|
|
5
|
+
function pageSignal(initial: number) {
|
|
6
|
+
let value = initial
|
|
7
|
+
const signal = (() => value) as { (): number, set: (next: number) => void }
|
|
8
|
+
signal.set = (next: number) => { value = next }
|
|
9
|
+
return signal
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('pageControls', () => {
|
|
13
|
+
it('moves a page back and forward', () => {
|
|
14
|
+
const page = pageSignal(2)
|
|
15
|
+
const { previousPage, nextPage } = pageControls(page, () => 5)
|
|
16
|
+
previousPage()
|
|
17
|
+
expect(page()).toBe(1)
|
|
18
|
+
nextPage()
|
|
19
|
+
nextPage()
|
|
20
|
+
expect(page()).toBe(3)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('will not go below the first page', () => {
|
|
24
|
+
const page = pageSignal(1)
|
|
25
|
+
const { previousPage } = pageControls(page, () => 5)
|
|
26
|
+
previousPage()
|
|
27
|
+
previousPage()
|
|
28
|
+
expect(page()).toBe(1)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('will not go past the last page', () => {
|
|
32
|
+
const page = pageSignal(5)
|
|
33
|
+
const { nextPage } = pageControls(page, () => 5)
|
|
34
|
+
nextPage()
|
|
35
|
+
expect(page()).toBe(5)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('reads the last page at click time, not when it was wired up', () => {
|
|
39
|
+
// A filter that shrinks the result set has to shrink the range too.
|
|
40
|
+
const page = pageSignal(1)
|
|
41
|
+
let last = 9
|
|
42
|
+
const { nextPage } = pageControls(page, () => last)
|
|
43
|
+
last = 2
|
|
44
|
+
nextPage()
|
|
45
|
+
nextPage()
|
|
46
|
+
nextPage()
|
|
47
|
+
expect(page()).toBe(2)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('clamps an out-of-range page back into the range', () => {
|
|
51
|
+
// The guard style the Marketing dashboards used could not do this: a page
|
|
52
|
+
// already past the end stayed there.
|
|
53
|
+
const page = pageSignal(40)
|
|
54
|
+
const { nextPage, previousPage } = pageControls(page, () => 3)
|
|
55
|
+
nextPage()
|
|
56
|
+
expect(page()).toBe(3)
|
|
57
|
+
page.set(40)
|
|
58
|
+
previousPage()
|
|
59
|
+
expect(page()).toBe(39)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('survives an empty result set, where the last page is zero', () => {
|
|
63
|
+
const page = pageSignal(1)
|
|
64
|
+
const { nextPage } = pageControls(page, () => 0)
|
|
65
|
+
nextPage()
|
|
66
|
+
expect(page()).toBe(1)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
interface Row { id: number, name: string }
|
|
71
|
+
|
|
72
|
+
const byName = (rows: Row[], query: string) => rows.filter(r => r.name.toLowerCase().includes(query))
|
|
73
|
+
|
|
74
|
+
function seeded(count: number, perPage = 10) {
|
|
75
|
+
const d = useRecordsDashboard<Row>({ filter: byName, perPage })
|
|
76
|
+
d.records.set(Array.from({ length: count }, (_, i) => ({ id: i + 1, name: `row ${i + 1}` })))
|
|
77
|
+
return d
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('useRecordsDashboard', () => {
|
|
81
|
+
it('starts loading, empty, on the first page', () => {
|
|
82
|
+
const d = useRecordsDashboard<Row>({ filter: byName })
|
|
83
|
+
expect(d.loading()).toBe(true)
|
|
84
|
+
expect(d.records()).toEqual([])
|
|
85
|
+
expect(d.page()).toBe(1)
|
|
86
|
+
expect(d.pageCount()).toBe(1)
|
|
87
|
+
expect(d.paginatedRecords()).toEqual([])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('pages through the records', () => {
|
|
91
|
+
const d = seeded(25)
|
|
92
|
+
expect(d.pageCount()).toBe(3)
|
|
93
|
+
expect(d.paginatedRecords().map(r => r.id)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
|
|
94
|
+
d.nextPage()
|
|
95
|
+
expect(d.paginatedRecords()[0]!.id).toBe(11)
|
|
96
|
+
d.page.set(3)
|
|
97
|
+
expect(d.paginatedRecords()).toHaveLength(5)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('searching returns to the first page, because page 3 of a new result set is meaningless', () => {
|
|
101
|
+
const d = seeded(25)
|
|
102
|
+
d.page.set(3)
|
|
103
|
+
d.handleSearch('row 1')
|
|
104
|
+
expect(d.page()).toBe(1)
|
|
105
|
+
// row 1, 10-19
|
|
106
|
+
expect(d.filteredRecords()).toHaveLength(11)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('an edit replaces in place and does not move the page', () => {
|
|
110
|
+
const d = seeded(25)
|
|
111
|
+
d.page.set(2)
|
|
112
|
+
const target = d.records()[4]!
|
|
113
|
+
d.upsert({ id: target.id, name: 'renamed' }, target)
|
|
114
|
+
expect(d.records()[4]!.name).toBe('renamed')
|
|
115
|
+
expect(d.records()).toHaveLength(25)
|
|
116
|
+
expect(d.page()).toBe(2)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('a create appends and lands on the page the new row is on', () => {
|
|
120
|
+
const d = seeded(20)
|
|
121
|
+
expect(d.pageCount()).toBe(2)
|
|
122
|
+
d.upsert({ id: 99, name: 'new' }, null)
|
|
123
|
+
expect(d.records()).toHaveLength(21)
|
|
124
|
+
expect(d.page()).toBe(3)
|
|
125
|
+
expect(d.paginatedRecords()).toEqual([{ id: 99, name: 'new' }])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('removing the last row on the last page pulls the page back', () => {
|
|
129
|
+
const d = seeded(11)
|
|
130
|
+
d.page.set(2)
|
|
131
|
+
expect(d.paginatedRecords()).toHaveLength(1)
|
|
132
|
+
d.remove(d.records()[10]!)
|
|
133
|
+
expect(d.page()).toBe(1)
|
|
134
|
+
expect(d.records()).toHaveLength(10)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('removing elsewhere leaves the page alone', () => {
|
|
138
|
+
const d = seeded(25)
|
|
139
|
+
d.page.set(2)
|
|
140
|
+
d.remove(d.records()[0]!)
|
|
141
|
+
expect(d.page()).toBe(2)
|
|
142
|
+
expect(d.records()).toHaveLength(24)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('clampPage pulls a page back into range after a filter shrinks the list', () => {
|
|
146
|
+
const d = seeded(25)
|
|
147
|
+
d.page.set(3)
|
|
148
|
+
d.search.set('row 7')
|
|
149
|
+
d.clampPage()
|
|
150
|
+
expect(d.page()).toBe(1)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('the editor opens for a create and for an edit, and remembers which', () => {
|
|
154
|
+
const d = seeded(3)
|
|
155
|
+
d.openCreate()
|
|
156
|
+
expect(d.editorOpen()).toBe(true)
|
|
157
|
+
expect(d.editorMode()).toBe('create')
|
|
158
|
+
expect(d.editing()).toBeNull()
|
|
159
|
+
|
|
160
|
+
const row = d.records()[1]!
|
|
161
|
+
d.openEdit(row)
|
|
162
|
+
expect(d.editorMode()).toBe('edit')
|
|
163
|
+
expect(d.editing()).toBe(row)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('will not close the editor mid-save', () => {
|
|
167
|
+
// Otherwise a slow PATCH can land on a dialog the user already dismissed.
|
|
168
|
+
const d = seeded(3)
|
|
169
|
+
d.openEdit(d.records()[0]!)
|
|
170
|
+
d.editorError.set('something went wrong')
|
|
171
|
+
d.saving.set(true)
|
|
172
|
+
d.closeEditor()
|
|
173
|
+
expect(d.editorOpen()).toBe(true)
|
|
174
|
+
expect(d.editorError()).toBe('something went wrong')
|
|
175
|
+
|
|
176
|
+
d.saving.set(false)
|
|
177
|
+
d.closeEditor()
|
|
178
|
+
expect(d.editorOpen()).toBe(false)
|
|
179
|
+
expect(d.editing()).toBeNull()
|
|
180
|
+
expect(d.editorError()).toBe('')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('will not close the delete confirmation mid-delete', () => {
|
|
184
|
+
const d = seeded(3)
|
|
185
|
+
d.requestDelete(d.records()[0]!)
|
|
186
|
+
d.deleteBusy.set(true)
|
|
187
|
+
d.closeDelete()
|
|
188
|
+
expect(d.deleting()).not.toBeNull()
|
|
189
|
+
|
|
190
|
+
d.deleteBusy.set(false)
|
|
191
|
+
d.closeDelete()
|
|
192
|
+
expect(d.deleting()).toBeNull()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('honours a custom identity instead of id', () => {
|
|
196
|
+
const d = useRecordsDashboard<{ sku: string }>({
|
|
197
|
+
filter: rows => rows,
|
|
198
|
+
sameRecord: (a, b) => a.sku === b.sku,
|
|
199
|
+
})
|
|
200
|
+
d.records.set([{ sku: 'a' }, { sku: 'b' }])
|
|
201
|
+
d.remove({ sku: 'a' })
|
|
202
|
+
expect(d.records()).toEqual([{ sku: 'b' }])
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('useRecordsDashboard goToPage', () => {
|
|
207
|
+
it('jumps to a page', () => {
|
|
208
|
+
const d = seeded(25)
|
|
209
|
+
d.goToPage(3)
|
|
210
|
+
expect(d.page()).toBe(3)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('clamps a page that does not exist', () => {
|
|
214
|
+
const d = seeded(25)
|
|
215
|
+
d.goToPage(99)
|
|
216
|
+
expect(d.page()).toBe(3)
|
|
217
|
+
d.goToPage(0)
|
|
218
|
+
expect(d.page()).toBe(1)
|
|
219
|
+
d.goToPage(-4)
|
|
220
|
+
expect(d.page()).toBe(1)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('survives a non-number from a component that emitted one', () => {
|
|
224
|
+
const d = seeded(25)
|
|
225
|
+
d.goToPage(Number.NaN)
|
|
226
|
+
expect(d.page()).toBe(1)
|
|
227
|
+
})
|
|
228
|
+
})
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type { Signal } from '@stacksjs/stx'
|
|
2
|
+
import { state } from '@stacksjs/stx'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared pieces of the dashboard's list screens.
|
|
6
|
+
*
|
|
7
|
+
* Seventeen list dashboards each wrote their own `previousPage` and
|
|
8
|
+
* `nextPage` (stacksjs/stacks#2918), in four different styles. Twelve of them
|
|
9
|
+
* mean the same thing - move a page number, keep it between one and the last
|
|
10
|
+
* page - and this is that, once.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The shape `state()` returns, narrowed to what paging needs. */
|
|
14
|
+
interface PageSignal {
|
|
15
|
+
(): number
|
|
16
|
+
set: (value: number) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PageControls {
|
|
20
|
+
/** Back one page, never past the first. */
|
|
21
|
+
previousPage: () => void
|
|
22
|
+
/** Forward one page, never past the last. */
|
|
23
|
+
nextPage: () => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Previous and next for a page number bounded by a last page.
|
|
28
|
+
*
|
|
29
|
+
* `lastPage` is an accessor rather than a number because every call site
|
|
30
|
+
* derives it, and reading it at click time is what keeps the clamp correct
|
|
31
|
+
* when a filter shrinks the result set under the reader's feet.
|
|
32
|
+
*
|
|
33
|
+
* Clamps rather than guards. The twelve call sites split between
|
|
34
|
+
* `page.set(Math.max(1, page() - 1))` and `if (page() > 1) page.set(page() - 1)`,
|
|
35
|
+
* which do the same thing from a user's point of view; clamping is the one
|
|
36
|
+
* that stays correct if a page number is ever set out of range by something
|
|
37
|
+
* other than these two functions.
|
|
38
|
+
*
|
|
39
|
+
* Not for the dashboards that page server-side. `Jobs/JobHistory` reloads
|
|
40
|
+
* inside its `nextPage`, and a navigation that triggers a fetch is a
|
|
41
|
+
* different thing wearing the same name.
|
|
42
|
+
*/
|
|
43
|
+
export function pageControls(page: PageSignal, lastPage: () => number): PageControls {
|
|
44
|
+
return {
|
|
45
|
+
previousPage: () => page.set(Math.max(1, page() - 1)),
|
|
46
|
+
nextPage: () => page.set(Math.min(Math.max(1, lastPage()), page() + 1)),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** How a list screen decides what the current search matches. */
|
|
51
|
+
export interface RecordsDashboardOptions<T> {
|
|
52
|
+
/**
|
|
53
|
+
* Rows matching `query`, which arrives trimmed and lowercased.
|
|
54
|
+
*
|
|
55
|
+
* The caller's, because the fields worth searching are the only part of a
|
|
56
|
+
* list dashboard that is genuinely about its own records.
|
|
57
|
+
*/
|
|
58
|
+
filter: (records: T[], query: string) => T[]
|
|
59
|
+
/** Rows per page. Default 10, which is what all five callers used. */
|
|
60
|
+
perPage?: number
|
|
61
|
+
/**
|
|
62
|
+
* Whether two rows are the same row. Defaults to matching `id`, which is
|
|
63
|
+
* what every current caller means.
|
|
64
|
+
*/
|
|
65
|
+
sameRecord?: (a: T, b: T) => boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RecordsDashboard<T> {
|
|
69
|
+
records: Signal<T[]>
|
|
70
|
+
loading: Signal<boolean>
|
|
71
|
+
loadError: Signal<string>
|
|
72
|
+
search: Signal<string>
|
|
73
|
+
page: Signal<number>
|
|
74
|
+
perPage: Signal<number>
|
|
75
|
+
editorOpen: Signal<boolean>
|
|
76
|
+
editorMode: Signal<'create' | 'edit'>
|
|
77
|
+
editing: Signal<T | null>
|
|
78
|
+
saving: Signal<boolean>
|
|
79
|
+
editorError: Signal<string>
|
|
80
|
+
deleting: Signal<T | null>
|
|
81
|
+
deleteBusy: Signal<boolean>
|
|
82
|
+
deleteError: Signal<string>
|
|
83
|
+
filteredRecords: () => T[]
|
|
84
|
+
paginatedRecords: () => T[]
|
|
85
|
+
pageCount: () => number
|
|
86
|
+
previousPage: () => void
|
|
87
|
+
nextPage: () => void
|
|
88
|
+
/**
|
|
89
|
+
* Jump to a page, clamped into range.
|
|
90
|
+
*
|
|
91
|
+
* The five callers each wrote a bare `page.set(value)` and trusted the
|
|
92
|
+
* pagination component to emit only pages that exist. Clamping costs
|
|
93
|
+
* nothing when that is true and keeps the list rendering when it is not.
|
|
94
|
+
*/
|
|
95
|
+
goToPage: (value: number) => void
|
|
96
|
+
/** Set the search and return to the first page, which is the only sane page to land on. */
|
|
97
|
+
handleSearch: (value: string) => void
|
|
98
|
+
openCreate: () => void
|
|
99
|
+
openEdit: (record: T) => void
|
|
100
|
+
/** Refuses while a save is in flight, so a half-written row cannot be abandoned by accident. */
|
|
101
|
+
closeEditor: () => void
|
|
102
|
+
requestDelete: (record: T) => void
|
|
103
|
+
closeDelete: () => void
|
|
104
|
+
/**
|
|
105
|
+
* Put a saved row into the list.
|
|
106
|
+
*
|
|
107
|
+
* `replacing` is the row the editor was opened on, or null for a create.
|
|
108
|
+
* Passed rather than inferred so the behaviour matches what the five
|
|
109
|
+
* callers did: an edit replaces in place and a create appends and jumps to
|
|
110
|
+
* the page the new row landed on.
|
|
111
|
+
*/
|
|
112
|
+
upsert: (record: T, replacing: T | null) => void
|
|
113
|
+
/** Drop a row and pull the page back if that emptied the last one. */
|
|
114
|
+
remove: (record: T) => void
|
|
115
|
+
/** Pull `page` back into range after the row count changes under it. */
|
|
116
|
+
clampPage: () => void
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The state and lifecycle every list screen in the dashboard repeats.
|
|
121
|
+
*
|
|
122
|
+
* Five Delivery dashboards carried byte-identical copies of fourteen signals,
|
|
123
|
+
* `handleSearch`, `openCreate`, `openEdit`, `closeEditor`, `requestDelete`,
|
|
124
|
+
* `closeDelete`, and the page arithmetic around saving and deleting
|
|
125
|
+
* (stacksjs/stacks#2918). Five copies of an off-by-one is four chances to be
|
|
126
|
+
* right in four places and wrong in the fifth.
|
|
127
|
+
*
|
|
128
|
+
* Deliberately not here: fetching, toasts, and the search predicate. Those
|
|
129
|
+
* differ per screen, and a composable that owned them would be a framework
|
|
130
|
+
* each caller had to fight. The caller writes its own `load`, `save` and
|
|
131
|
+
* `delete`, and calls `upsert`, `remove` and `clampPage` from inside them.
|
|
132
|
+
*/
|
|
133
|
+
export function useRecordsDashboard<T>(options: RecordsDashboardOptions<T>): RecordsDashboard<T> {
|
|
134
|
+
const sameRecord = options.sameRecord
|
|
135
|
+
?? ((a: T, b: T) => (a as { id?: unknown }).id === (b as { id?: unknown }).id)
|
|
136
|
+
|
|
137
|
+
const records = state<T[]>([])
|
|
138
|
+
const loading = state(true)
|
|
139
|
+
const loadError = state('')
|
|
140
|
+
const search = state('')
|
|
141
|
+
const page = state(1)
|
|
142
|
+
const perPage = state(options.perPage ?? 10)
|
|
143
|
+
const editorOpen = state(false)
|
|
144
|
+
const editorMode = state<'create' | 'edit'>('create')
|
|
145
|
+
const editing = state<T | null>(null)
|
|
146
|
+
const saving = state(false)
|
|
147
|
+
const editorError = state('')
|
|
148
|
+
const deleting = state<T | null>(null)
|
|
149
|
+
const deleteBusy = state(false)
|
|
150
|
+
const deleteError = state('')
|
|
151
|
+
|
|
152
|
+
const filteredRecords = (): T[] => {
|
|
153
|
+
const query = search().trim().toLowerCase()
|
|
154
|
+
return query ? options.filter(records(), query) : records()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const pageCount = (): number => Math.max(1, Math.ceil(filteredRecords().length / perPage()))
|
|
158
|
+
|
|
159
|
+
const paginatedRecords = (): T[] => {
|
|
160
|
+
const start = (page() - 1) * perPage()
|
|
161
|
+
return filteredRecords().slice(start, start + perPage())
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const clampPage = (): void => {
|
|
165
|
+
const lastPage = pageCount()
|
|
166
|
+
if (page() > lastPage)
|
|
167
|
+
page.set(lastPage)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const { previousPage, nextPage } = pageControls(page, pageCount)
|
|
171
|
+
|
|
172
|
+
const goToPage = (value: number): void => {
|
|
173
|
+
page.set(Math.min(Math.max(1, Math.trunc(value) || 1), pageCount()))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
records,
|
|
178
|
+
loading,
|
|
179
|
+
loadError,
|
|
180
|
+
search,
|
|
181
|
+
page,
|
|
182
|
+
perPage,
|
|
183
|
+
editorOpen,
|
|
184
|
+
editorMode,
|
|
185
|
+
editing,
|
|
186
|
+
saving,
|
|
187
|
+
editorError,
|
|
188
|
+
deleting,
|
|
189
|
+
deleteBusy,
|
|
190
|
+
deleteError,
|
|
191
|
+
filteredRecords,
|
|
192
|
+
paginatedRecords,
|
|
193
|
+
pageCount,
|
|
194
|
+
previousPage,
|
|
195
|
+
nextPage,
|
|
196
|
+
goToPage,
|
|
197
|
+
|
|
198
|
+
handleSearch(value: string): void {
|
|
199
|
+
search.set(value)
|
|
200
|
+
page.set(1)
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
openCreate(): void {
|
|
204
|
+
editing.set(null)
|
|
205
|
+
editorMode.set('create')
|
|
206
|
+
editorError.set('')
|
|
207
|
+
editorOpen.set(true)
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
openEdit(record: T): void {
|
|
211
|
+
editing.set(record)
|
|
212
|
+
editorMode.set('edit')
|
|
213
|
+
editorError.set('')
|
|
214
|
+
editorOpen.set(true)
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
closeEditor(): void {
|
|
218
|
+
if (saving())
|
|
219
|
+
return
|
|
220
|
+
editorOpen.set(false)
|
|
221
|
+
editing.set(null)
|
|
222
|
+
editorError.set('')
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
requestDelete(record: T): void {
|
|
226
|
+
deleting.set(record)
|
|
227
|
+
deleteError.set('')
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
closeDelete(): void {
|
|
231
|
+
if (deleteBusy())
|
|
232
|
+
return
|
|
233
|
+
deleting.set(null)
|
|
234
|
+
deleteError.set('')
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
upsert(record: T, replacing: T | null): void {
|
|
238
|
+
if (replacing) {
|
|
239
|
+
records.set(records().map(existing => sameRecord(existing, replacing) ? record : existing))
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
const next = [...records(), record]
|
|
243
|
+
records.set(next)
|
|
244
|
+
// Land on the page the new row is actually on, not the last page of the
|
|
245
|
+
// filtered view: a create while a search is active would otherwise jump
|
|
246
|
+
// somewhere the row is not.
|
|
247
|
+
page.set(Math.max(1, Math.ceil(next.length / perPage())))
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
remove(record: T): void {
|
|
251
|
+
records.set(records().filter(existing => !sameRecord(existing, record)))
|
|
252
|
+
clampPage()
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
clampPage,
|
|
256
|
+
}
|
|
257
|
+
}
|
package/ide/vscode/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"publisher": "Stacks",
|
|
3
3
|
"name": "vscode-stacks",
|
|
4
4
|
"displayName": "Stacks",
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.236",
|
|
6
6
|
"description": "Stacks, built into VS Code: stx templates, pickier linting and formatting, encrypted env files, the dev server preview and every buddy command. No other extensions required.",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"funding": "https://github.com/sponsors/chrisbbreuer",
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.236",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@iconify-json/f7": "^1.2.2",
|
|
46
46
|
"@iconify-json/hugeicons": "^1.2.35",
|
|
47
|
-
"@stacksjs/mobile": "0.75.
|
|
47
|
+
"@stacksjs/mobile": "0.75.236",
|
|
48
48
|
"@stacksjs/sanitizer": "^0.2.534",
|
|
49
49
|
"@xterm/addon-fit": "^0.11.0",
|
|
50
50
|
"@xterm/xterm": "^6.0.0",
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Hand-written, like `request-context.d.ts` beside it, and handed to
|
|
2
|
+
// `stx typecheck` with `--lib` by the `typecheck:views` script.
|
|
3
|
+
//
|
|
4
|
+
// What a `<script client>` block can reach from stx's DEMAND-INLINE
|
|
5
|
+
// composables (stacksjs/stacks#2918 follow-up). These are not ordinary
|
|
6
|
+
// auto-imports: stx scans a client script for the names it calls and inlines
|
|
7
|
+
// the implementation of just those, so they exist at runtime without an
|
|
8
|
+
// import and are invisible to any checker that is only given the module
|
|
9
|
+
// declarations. `node_modules/@stacksjs/stx/dist/composables/*.d.ts` declares
|
|
10
|
+
// them as module exports, which is the wrong shape for a global, so nothing
|
|
11
|
+
// typed them here.
|
|
12
|
+
//
|
|
13
|
+
// That is how `MembershipCard.stx` turned `typecheck:views` red while working
|
|
14
|
+
// correctly in a browser: it calls `useMouse({ target, resetOnLeave })` and
|
|
15
|
+
// subscribes, which is exactly the demand-inlined API, while the only
|
|
16
|
+
// `useMouse` a checker could see was the unrelated no-argument one in
|
|
17
|
+
// `browser-composables.d.ts`.
|
|
18
|
+
//
|
|
19
|
+
// Mirrors `dist/composables/use-mouse.d.ts` rather than inventing a shape. If
|
|
20
|
+
// stx changes it, this drifts, and the symptom is a type error on code that
|
|
21
|
+
// runs - so correct it here against that file rather than loosening it.
|
|
22
|
+
|
|
23
|
+
export {}
|
|
24
|
+
|
|
25
|
+
declare global {
|
|
26
|
+
interface StxMouseState {
|
|
27
|
+
x: number
|
|
28
|
+
y: number
|
|
29
|
+
pageX: number
|
|
30
|
+
pageY: number
|
|
31
|
+
clientX: number
|
|
32
|
+
clientY: number
|
|
33
|
+
movementX: number
|
|
34
|
+
movementY: number
|
|
35
|
+
buttons: number
|
|
36
|
+
isPressed: boolean
|
|
37
|
+
isInside: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface StxMouseOptions {
|
|
41
|
+
target?: Window | HTMLElement | null
|
|
42
|
+
touch?: boolean
|
|
43
|
+
resetOnLeave?: boolean
|
|
44
|
+
type?: 'page' | 'client' | 'screen' | 'movement'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface StxMouseRef {
|
|
48
|
+
get: () => StxMouseState
|
|
49
|
+
/** Returns its own unsubscribe, which is what callers store. */
|
|
50
|
+
subscribe: (fn: (state: StxMouseState) => void) => () => void
|
|
51
|
+
stop: () => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reactive mouse or pointer position, globally or within a target. */
|
|
55
|
+
function useMouse(options?: StxMouseOptions): StxMouseRef
|
|
56
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
/**
|
|
3
|
+
* The centred card the signed-out auth pages sit in.
|
|
4
|
+
*
|
|
5
|
+
* Login, Register, ForgotPassword and ResetPassword each carried this
|
|
6
|
+
* markup (stacksjs/stacks#2917): the same full-height centring, the same
|
|
7
|
+
* 24rem column, the same logo tile, and the same heading pair. They differ
|
|
8
|
+
* only in what goes below it, which is the slot.
|
|
9
|
+
*
|
|
10
|
+
* Four pages the user moves between in one flow is the worst place for a
|
|
11
|
+
* shell to drift, because the drift is visible as a jump between steps.
|
|
12
|
+
*/
|
|
13
|
+
const title = useReactiveProp('title', '')
|
|
14
|
+
const subtitle = useReactiveProp('subtitle', '')
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<div class="flex items-center justify-center px-4 py-12 min-h-screen">
|
|
18
|
+
<div class="max-w-sm w-full">
|
|
19
|
+
<div class="mb-8 text-center">
|
|
20
|
+
<div class="inline-flex items-center justify-center mb-4 h-12 w-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl shadow-blue-500/25 shadow-lg">
|
|
21
|
+
<span class="h-7 w-7 text-white i-hugeicons-cube"></span>
|
|
22
|
+
</div>
|
|
23
|
+
<h1 class="font-semibold text-2xl text-neutral-900 dark:text-white">{{ title() }}</h1>
|
|
24
|
+
<p class="mt-2 text-neutral-500 text-sm dark:text-neutral-400">{{ subtitle() }}</p>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<slot />
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|