@kernhq/module-inventory 0.2.0 → 0.4.0
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/README.md +106 -25
- package/dist/contract/capabilities.d.ts +4 -4
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +51 -9
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/events.d.ts +43 -0
- package/dist/contract/events.d.ts.map +1 -1
- package/dist/contract/events.js +40 -0
- package/dist/contract/events.js.map +1 -1
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/models.d.ts +345 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +245 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/notifications.d.ts +24 -0
- package/dist/contract/notifications.d.ts.map +1 -0
- package/dist/contract/notifications.js +72 -0
- package/dist/contract/notifications.js.map +1 -0
- package/dist/contract/permissions.d.ts +52 -2
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +55 -2
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +1108 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +253 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/contract/settings.d.ts +2 -0
- package/dist/contract/settings.d.ts.map +1 -1
- package/dist/contract/settings.js +24 -4
- package/dist/contract/settings.js.map +1 -1
- package/dist/server/index.d.ts +8 -4
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +289 -5
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts +48 -0
- package/dist/server/jobs.d.ts.map +1 -0
- package/dist/server/jobs.js +358 -0
- package/dist/server/jobs.js.map +1 -0
- package/dist/server/router.d.ts +1392 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +385 -7
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +134 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +169 -4
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +69 -3
- package/dist/server/services/assets.d.ts.map +1 -1
- package/dist/server/services/assets.js +199 -31
- package/dist/server/services/assets.js.map +1 -1
- package/dist/server/services/attachments.d.ts +80 -0
- package/dist/server/services/attachments.d.ts.map +1 -0
- package/dist/server/services/attachments.js +182 -0
- package/dist/server/services/attachments.js.map +1 -0
- package/dist/server/services/audience.d.ts +15 -0
- package/dist/server/services/audience.d.ts.map +1 -0
- package/dist/server/services/audience.js +64 -0
- package/dist/server/services/audience.js.map +1 -0
- package/dist/server/services/categories.d.ts +157 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +309 -0
- package/dist/server/services/categories.js.map +1 -0
- package/dist/server/services/cursor.d.ts +68 -0
- package/dist/server/services/cursor.d.ts.map +1 -0
- package/dist/server/services/cursor.js +39 -0
- package/dist/server/services/cursor.js.map +1 -0
- package/dist/server/services/custody.d.ts +175 -0
- package/dist/server/services/custody.d.ts.map +1 -0
- package/dist/server/services/custody.js +367 -0
- package/dist/server/services/custody.js.map +1 -0
- package/dist/server/services/db-errors.d.ts +7 -0
- package/dist/server/services/db-errors.d.ts.map +1 -0
- package/dist/server/services/db-errors.js +32 -0
- package/dist/server/services/db-errors.js.map +1 -0
- package/dist/server/services/index.d.ts +16 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +25 -1
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/members.d.ts +27 -0
- package/dist/server/services/members.d.ts.map +1 -0
- package/dist/server/services/members.js +39 -0
- package/dist/server/services/members.js.map +1 -0
- package/dist/server/services/notify.d.ts +43 -1
- package/dist/server/services/notify.d.ts.map +1 -1
- package/dist/server/services/notify.js +47 -5
- package/dist/server/services/notify.js.map +1 -1
- package/dist/server/services/offboarding.d.ts +70 -0
- package/dist/server/services/offboarding.d.ts.map +1 -0
- package/dist/server/services/offboarding.js +116 -0
- package/dist/server/services/offboarding.js.map +1 -0
- package/dist/server/services/repairs.d.ts +204 -0
- package/dist/server/services/repairs.d.ts.map +1 -0
- package/dist/server/services/repairs.js +476 -0
- package/dist/server/services/repairs.js.map +1 -0
- package/dist/server/services/search.d.ts +85 -0
- package/dist/server/services/search.d.ts.map +1 -0
- package/dist/server/services/search.js +142 -0
- package/dist/server/services/search.js.map +1 -0
- package/dist/server/services/stats.d.ts +42 -0
- package/dist/server/services/stats.d.ts.map +1 -0
- package/dist/server/services/stats.js +80 -0
- package/dist/server/services/stats.js.map +1 -0
- package/dist/server/services/status.d.ts +102 -0
- package/dist/server/services/status.d.ts.map +1 -0
- package/dist/server/services/status.js +71 -0
- package/dist/server/services/status.js.map +1 -0
- package/migrations/0002_custody_and_categories.sql +23 -0
- package/migrations/0003_repairs.sql +23 -0
- package/migrations/0004_platform_surfaces.sql +51 -0
- package/migrations/0005_repair_dates.sql +35 -0
- package/migrations/0006_workspace_registry_read.sql +50 -0
- package/migrations/0007_history_sequence.sql +83 -0
- package/migrations/0008_category_order_unique.sql +71 -0
- package/migrations/meta/0002_snapshot.json +1054 -0
- package/migrations/meta/0003_snapshot.json +1070 -0
- package/migrations/meta/0004_snapshot.json +1130 -0
- package/migrations/meta/0005_snapshot.json +1135 -0
- package/migrations/meta/_journal.json +49 -0
- package/package.json +2 -1
- package/src/client/api-instance.ts +27 -2
- package/src/client/bidi.test.ts +148 -0
- package/src/client/bidi.ts +85 -0
- package/src/client/components/AssetDetailPanel.svelte +614 -0
- package/src/client/components/AssetFormDialog.svelte +71 -22
- package/src/client/components/AssetPhoto.svelte +178 -0
- package/src/client/components/AttachmentsSection.svelte +327 -0
- package/src/client/components/CustodyDialog.svelte +201 -0
- package/src/client/components/RepairDialog.svelte +271 -0
- package/src/client/components/RepairsSection.svelte +318 -0
- package/src/client/components/Timeline.svelte +347 -0
- package/src/client/components/TimelineText.svelte +124 -0
- package/src/client/core-api.ts +71 -0
- package/src/client/custody.test.ts +31 -0
- package/src/client/custody.ts +34 -0
- package/src/client/errors.test.ts +395 -0
- package/src/client/errors.ts +229 -0
- package/src/client/links.test.ts +74 -0
- package/src/client/links.ts +44 -0
- package/src/client/members.test.ts +132 -0
- package/src/client/members.ts +116 -0
- package/src/client/messages.test.ts +127 -0
- package/src/client/messages.ts +1106 -18
- package/src/client/mock.test.ts +465 -1
- package/src/client/mock.ts +1105 -79
- package/src/client/module.ts +73 -1
- package/src/client/pages/AssetsPage.svelte +317 -45
- package/src/client/permissions.ts +7 -0
- package/src/client/price.test.ts +151 -3
- package/src/client/price.ts +155 -11
- package/src/client/query.ts +36 -0
- package/src/client/reorder.test.ts +100 -0
- package/src/client/reorder.ts +79 -0
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/sequence.test.ts +248 -0
- package/src/client/sequence.ts +185 -0
- package/src/client/settings/CategoriesSettings.svelte +746 -0
- package/src/client/settings/GeneralSettings.svelte +0 -0
- package/src/client/status.ts +29 -0
- package/src/client/timeline.test.ts +175 -0
- package/src/client/timeline.ts +206 -0
- package/src/client/widgets/OverviewWidget.svelte +127 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +53 -9
- package/src/contract/events.ts +49 -0
- package/src/contract/index.ts +1 -0
- package/src/contract/models.ts +270 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +55 -2
- package/src/contract/router.ts +284 -1
- package/src/contract/settings.ts +24 -4
- package/src/module.test.ts +217 -4
- package/src/server/index.ts +311 -4
- package/src/server/inventory.int.test.ts +4109 -19
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +253 -2
- package/src/server/router.ts +486 -6
- package/src/server/schema.ts +171 -3
- package/src/server/services/assets.ts +214 -54
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +337 -0
- package/src/server/services/cursor.ts +104 -0
- package/src/server/services/custody.ts +471 -0
- package/src/server/services/db-errors.ts +42 -0
- package/src/server/services/index.ts +34 -1
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +52 -7
- package/src/server/services/offboarding.ts +150 -0
- package/src/server/services/repairs.ts +567 -0
- package/src/server/services/search.ts +166 -0
- package/src/server/services/stats.ts +88 -0
- package/src/server/services/status.test.ts +34 -0
- package/src/server/services/status.ts +143 -0
- package/src/client/settings/core-api.ts +0 -32
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a repair section can actually offer right now.
|
|
3
|
+
*
|
|
4
|
+
* Three verbs and not one, because the server has three procedures and refuses the wrong one with
|
|
5
|
+
* its own sentence: sending something that is already at a repairer is a mistake worth naming, and
|
|
6
|
+
* completing a repair that is already finished is a different one. A button that always produces a
|
|
7
|
+
* conflict is a door that will not open.
|
|
8
|
+
*
|
|
9
|
+
* A `.ts` file rather than a `const` inside the component, for the reason `custody.ts`, `price.ts`
|
|
10
|
+
* and `timeline.ts` are: this is a fact with a right answer, and a `.svelte` file drags a compiler
|
|
11
|
+
* behind it and cannot be unit-tested. It also has to be a plain module because the type is shared
|
|
12
|
+
* with the dialog — a type exported from a component's instance script is a prop, not a type.
|
|
13
|
+
*/
|
|
14
|
+
export type RepairAction = 'create' | 'edit' | 'complete'
|
|
15
|
+
|
|
16
|
+
export interface RepairState {
|
|
17
|
+
/** A repair for this asset with no return date — at most one exists, by unique index. */
|
|
18
|
+
open: boolean
|
|
19
|
+
/** The workspace has taken the item out of the register; the server refuses a new repair. */
|
|
20
|
+
archived: boolean
|
|
21
|
+
/** This person holds `inventory.repair.manage`. */
|
|
22
|
+
may: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What to offer, in the order the controls appear.
|
|
27
|
+
*
|
|
28
|
+
* Empty for somebody without the permission — hide what a person may never do — and empty for an
|
|
29
|
+
* archived item with nothing open, where the section says *why* in one sentence instead. An
|
|
30
|
+
* archived item whose repair is still open keeps `complete` and `edit`: archiving is refused while
|
|
31
|
+
* a repair is open, so the only way to be in that state is to have archived it first and opened the
|
|
32
|
+
* repair never — but if the data ever says otherwise, the way out has to stay reachable.
|
|
33
|
+
*/
|
|
34
|
+
export function repairActions(state: RepairState): RepairAction[] {
|
|
35
|
+
if (!state.may) return []
|
|
36
|
+
if (state.open) return ['complete', 'edit']
|
|
37
|
+
return state.archived ? [] : ['create']
|
|
38
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
consider,
|
|
4
|
+
DRAG_STOPPED,
|
|
5
|
+
finalize,
|
|
6
|
+
move,
|
|
7
|
+
refused,
|
|
8
|
+
reseed,
|
|
9
|
+
type Sequence,
|
|
10
|
+
saved,
|
|
11
|
+
seed,
|
|
12
|
+
start,
|
|
13
|
+
} from './sequence.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The order of events on the categories screen — which is where every defect in it has been.
|
|
17
|
+
*
|
|
18
|
+
* The arithmetic lives in `reorder.ts` and is tested beside it. Nothing here recalculates a list; it
|
|
19
|
+
* asserts *when* a list is adopted, sent, rolled back or spoken, because the four inputs — a drag, a
|
|
20
|
+
* keypress, a server answer and a refetch — arrive in an order nobody controls, and each of the four
|
|
21
|
+
* cases below shipped as a silent wrong answer.
|
|
22
|
+
*/
|
|
23
|
+
type Row = { id: string; name: string }
|
|
24
|
+
const list = (...ids: string[]): Row[] => ids.map((id) => ({ id, name: id.toUpperCase() }))
|
|
25
|
+
const ids = (items: readonly Row[]) => items.map((item) => item.id)
|
|
26
|
+
|
|
27
|
+
/** A pointer drag, start to finish, as the library fires it. */
|
|
28
|
+
function pointerDrag(seq: Sequence<Row>, to: readonly Row[], id: string) {
|
|
29
|
+
const considered = consider(seq, to, 'draggedOverIndex')
|
|
30
|
+
return finalize(considered.next, to, id)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('a keyboard drag ending', () => {
|
|
34
|
+
/**
|
|
35
|
+
* The proved defect: `svelte-dnd-action` ends a keyboard drag with a **`consider`**, not a
|
|
36
|
+
* `finalize`. Enter, Escape, a click elsewhere and the row disappearing all land on the same
|
|
37
|
+
* `handleDrop`, which dispatches `consider` with `dragStopped` and nothing after it.
|
|
38
|
+
*
|
|
39
|
+
* A handler that sets "dragging" on every consider and clears it only on finalize is therefore
|
|
40
|
+
* stuck true from the moment somebody uses the keyboard once — and the seeding effect below is
|
|
41
|
+
* skipped for the rest of the page's life, so the screen silently stops reflecting the database.
|
|
42
|
+
*/
|
|
43
|
+
it('leaves nothing in progress, so the list goes on updating', () => {
|
|
44
|
+
let seq = start(list('a', 'b', 'c'))
|
|
45
|
+
|
|
46
|
+
// Enter on a row: the library fires `consider` with `dragStarted`.
|
|
47
|
+
seq = consider(seq, list('a', 'b', 'c'), 'dragStarted').next
|
|
48
|
+
expect(seq.dragging, 'a drag really is in progress now').toBe(true)
|
|
49
|
+
|
|
50
|
+
// One arrow key: `finalize`, per press.
|
|
51
|
+
const step = finalize(seq, list('b', 'a', 'c'), 'a')
|
|
52
|
+
seq = step.next
|
|
53
|
+
expect(step.save, 'and it is worth sending').toEqual(['b', 'a', 'c'])
|
|
54
|
+
|
|
55
|
+
// Enter again, or Escape, or a click elsewhere. This is the last event of the whole gesture.
|
|
56
|
+
seq = consider(seq, list('b', 'a', 'c'), DRAG_STOPPED).next
|
|
57
|
+
expect(seq.dragging, 'the gesture is over').toBe(false)
|
|
58
|
+
|
|
59
|
+
// Which is the thing that actually matters: a later refetch is adopted rather than skipped.
|
|
60
|
+
seq = saved(seq, list('b', 'a', 'c')).next
|
|
61
|
+
seq = seed(seq, list('b', 'a', 'c', 'd'))
|
|
62
|
+
expect(ids(seq.rows), 'somebody added a category in another tab and the screen shows it').toEqual([
|
|
63
|
+
'b',
|
|
64
|
+
'a',
|
|
65
|
+
'c',
|
|
66
|
+
'd',
|
|
67
|
+
])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
/** A drag picked up and put straight down: two considers, no finalize at all, nothing sent. */
|
|
71
|
+
it('sends nothing when it is picked up and put down again', () => {
|
|
72
|
+
let seq = start(list('a', 'b', 'c'))
|
|
73
|
+
seq = consider(seq, list('a', 'b', 'c'), 'dragStarted').next
|
|
74
|
+
const step = consider(seq, list('a', 'b', 'c'), DRAG_STOPPED)
|
|
75
|
+
expect(step.save).toBeNull()
|
|
76
|
+
expect(step.next.dragging).toBe(false)
|
|
77
|
+
expect(ids(step.next.rows)).toEqual(['a', 'b', 'c'])
|
|
78
|
+
expect(
|
|
79
|
+
seed(step.next, list('c', 'b', 'a')).rows.map((r) => r.id),
|
|
80
|
+
'and seeding works',
|
|
81
|
+
).toEqual(['c', 'b', 'a'])
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('a refusal the reader can act on', () => {
|
|
86
|
+
/**
|
|
87
|
+
* The proved sequence, from the review:
|
|
88
|
+
*
|
|
89
|
+
* 3. refetch lands mid-save rows = abc live = abcd <- skipped
|
|
90
|
+
* 6. after the invalidation rows = abc live = abcd <- never re-runs
|
|
91
|
+
*
|
|
92
|
+
* The rollback restores the list the server has just refused, and the effect that would replace it
|
|
93
|
+
* is keyed on the query's data — which did not change between the skipped refetch and the one after
|
|
94
|
+
* the invalidation, because it was already the fresh value. So every retry sends the same stale list
|
|
95
|
+
* and earns the same refusal, under a message telling the reader to try again.
|
|
96
|
+
*/
|
|
97
|
+
it('re-seeds from what the server has, so the next attempt is a different one', () => {
|
|
98
|
+
let seq = start(list('a', 'b', 'c'))
|
|
99
|
+
|
|
100
|
+
// Somebody drags b in front of a. The write goes.
|
|
101
|
+
const step = pointerDrag(seq, list('b', 'a', 'c'), 'b')
|
|
102
|
+
seq = step.next
|
|
103
|
+
expect(step.save).toEqual(['b', 'a', 'c'])
|
|
104
|
+
|
|
105
|
+
// A refetch lands mid-save carrying a category another tab added. Skipped — correctly, because
|
|
106
|
+
// adopting it would undo the move being written at that moment.
|
|
107
|
+
const live = list('a', 'b', 'c', 'd')
|
|
108
|
+
seq = seed(seq, live)
|
|
109
|
+
expect(ids(seq.rows), 'the optimistic order is still on screen').toEqual(['b', 'a', 'c'])
|
|
110
|
+
|
|
111
|
+
// The server refuses: the list did not name `d`. Rolling back alone leaves `abc`.
|
|
112
|
+
expect(ids(refused(seq).rows), 'which is exactly the list that was just refused').toEqual(['a', 'b', 'c'])
|
|
113
|
+
|
|
114
|
+
// Re-seeding is what makes the next attempt a different one.
|
|
115
|
+
seq = reseed(live)
|
|
116
|
+
expect(ids(seq.rows), 'the workspace as it actually is').toEqual(['a', 'b', 'c', 'd'])
|
|
117
|
+
expect(seq.saving).toBe(false)
|
|
118
|
+
expect(seq.dragging).toBe(false)
|
|
119
|
+
expect(seq.pending).toBeNull()
|
|
120
|
+
|
|
121
|
+
// And the retry now names every live category, which is what the server was asking for.
|
|
122
|
+
const retry = pointerDrag(seq, list('b', 'a', 'c', 'd'), 'b')
|
|
123
|
+
expect(retry.save).toEqual(['b', 'a', 'c', 'd'])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('rolls back to the server, not to the screen, and forgets anything queued behind it', () => {
|
|
127
|
+
let seq = start(list('a', 'b', 'c'))
|
|
128
|
+
seq = move(seq, 'c', -1).next // acb, in flight
|
|
129
|
+
seq = move(seq, 'c', -1).next // cab, coalesced behind it
|
|
130
|
+
expect(ids(seq.rows)).toEqual(['c', 'a', 'b'])
|
|
131
|
+
expect(seq.pending).not.toBeNull()
|
|
132
|
+
|
|
133
|
+
const back = refused(seq)
|
|
134
|
+
expect(ids(back.rows), 'where the server is believed to be').toEqual(['a', 'b', 'c'])
|
|
135
|
+
expect(back.pending, 'the queued move was built on top of a write that never happened').toBeNull()
|
|
136
|
+
expect(back.saving).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('pressing an arrow key faster than the server answers', () => {
|
|
141
|
+
/**
|
|
142
|
+
* The proved defect: a second press while a save was in flight was **discarded** — the list snapped
|
|
143
|
+
* back to what was on screen and the row announced the position it had not left. Somebody pressing
|
|
144
|
+
* *move down* three times was told three times that nothing had moved, and two of the three presses
|
|
145
|
+
* vanished.
|
|
146
|
+
*
|
|
147
|
+
* Coalesced rather than queued: three presses are one extra request, not three, and a request per
|
|
148
|
+
* keypress would arrive out of order with each one describing a list the next contradicts.
|
|
149
|
+
*/
|
|
150
|
+
it('applies every press and announces where the row really is', () => {
|
|
151
|
+
let seq = start(list('a', 'b', 'c', 'd'))
|
|
152
|
+
|
|
153
|
+
const first = move(seq, 'd', -1)
|
|
154
|
+
seq = first.next
|
|
155
|
+
expect(first.save, 'the first press goes at once').toEqual(['a', 'b', 'd', 'c'])
|
|
156
|
+
expect(ids(first.announce!.list), 'and is announced in the list it produced').toEqual([
|
|
157
|
+
'a',
|
|
158
|
+
'b',
|
|
159
|
+
'd',
|
|
160
|
+
'c',
|
|
161
|
+
])
|
|
162
|
+
|
|
163
|
+
const second = move(seq, 'd', -1)
|
|
164
|
+
seq = second.next
|
|
165
|
+
expect(second.save, 'the second waits rather than racing the first').toBeNull()
|
|
166
|
+
expect(ids(seq.rows), 'but the row really moved on screen').toEqual(['a', 'd', 'b', 'c'])
|
|
167
|
+
expect(ids(second.announce!.list), 'so the sentence is true').toEqual(['a', 'd', 'b', 'c'])
|
|
168
|
+
|
|
169
|
+
const third = move(seq, 'd', -1)
|
|
170
|
+
seq = third.next
|
|
171
|
+
expect(third.save).toBeNull()
|
|
172
|
+
expect(ids(third.announce!.list)).toEqual(['d', 'a', 'b', 'c'])
|
|
173
|
+
|
|
174
|
+
// The first write answers. The three presses add up to one more request, not two.
|
|
175
|
+
const settled = saved(seq, list('a', 'b', 'd', 'c'))
|
|
176
|
+
seq = settled.next
|
|
177
|
+
expect(settled.save, 'everything pressed since, in one list').toEqual(['d', 'a', 'b', 'c'])
|
|
178
|
+
expect(seq.saving, 'still writing, so a fourth press coalesces too').toBe(true)
|
|
179
|
+
expect(ids(seq.rows), 'and the screen is never rewound past what was pressed').toEqual([
|
|
180
|
+
'd',
|
|
181
|
+
'a',
|
|
182
|
+
'b',
|
|
183
|
+
'c',
|
|
184
|
+
])
|
|
185
|
+
|
|
186
|
+
const done = saved(seq, list('d', 'a', 'b', 'c'))
|
|
187
|
+
expect(done.save, 'nothing left over').toBeNull()
|
|
188
|
+
expect(done.next.saving).toBe(false)
|
|
189
|
+
expect(done.next.pending).toBeNull()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('sends nothing more when the coalesced moves add up to where the server already is', () => {
|
|
193
|
+
let seq = start(list('a', 'b'))
|
|
194
|
+
seq = move(seq, 'a', 1).next // ba, in flight
|
|
195
|
+
seq = move(seq, 'a', -1).next // back to ab, coalesced
|
|
196
|
+
const settled = saved(seq, list('b', 'a'))
|
|
197
|
+
expect(settled.save, 'ab is not where the server is, so it does go').toEqual(['a', 'b'])
|
|
198
|
+
|
|
199
|
+
let other = start(list('a', 'b'))
|
|
200
|
+
other = move(other, 'a', 1).next
|
|
201
|
+
other = move(other, 'a', -1).next
|
|
202
|
+
other = move(other, 'a', 1).next
|
|
203
|
+
const same = saved(other, list('b', 'a'))
|
|
204
|
+
expect(same.save, 'and ba is exactly what was written, so nothing more goes').toBeNull()
|
|
205
|
+
expect(same.next.saving).toBe(false)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('says where a row is when it cannot move, rather than saying nothing', () => {
|
|
209
|
+
const seq = start(list('a', 'b', 'c'))
|
|
210
|
+
const step = move(seq, 'a', -1)
|
|
211
|
+
expect(step.save, 'the first row cannot go up, and that is not a write').toBeNull()
|
|
212
|
+
expect(step.announce, 'silence reads as a broken button').not.toBeNull()
|
|
213
|
+
expect(step.announce?.id).toBe('a')
|
|
214
|
+
expect(ids(step.announce!.list)).toEqual(['a', 'b', 'c'])
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
describe('seeding from the query', () => {
|
|
219
|
+
it('adopts what a refetch delivers when nothing is in progress', () => {
|
|
220
|
+
const seq = seed(start(list('a', 'b')), list('a', 'b', 'c'))
|
|
221
|
+
expect(ids(seq.rows)).toEqual(['a', 'b', 'c'])
|
|
222
|
+
expect(ids(seq.settled)).toEqual(['a', 'b', 'c'])
|
|
223
|
+
expect(ids(seq.shown)).toEqual(['a', 'b', 'c'])
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('leaves the list alone under a drag, so it does not move under the pointer', () => {
|
|
227
|
+
const dragging = consider(start(list('a', 'b', 'c')), list('b', 'a', 'c'), 'draggedOverIndex').next
|
|
228
|
+
expect(ids(seed(dragging, list('a', 'b', 'c', 'd')).rows)).toEqual(['b', 'a', 'c'])
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('leaves it alone under a save, so the screen never disagrees with the write in flight', () => {
|
|
232
|
+
const saving = move(start(list('a', 'b', 'c')), 'c', -1).next
|
|
233
|
+
expect(ids(seed(saving, list('a', 'b', 'c')).rows)).toEqual(['a', 'c', 'b'])
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
describe('a drag that ends where it started', () => {
|
|
238
|
+
it('costs no request, because the library fires finalize either way', () => {
|
|
239
|
+
const seq = start(list('a', 'b', 'c'))
|
|
240
|
+
const step = pointerDrag(seq, list('a', 'b', 'c'), 'b')
|
|
241
|
+
expect(
|
|
242
|
+
step.save,
|
|
243
|
+
'every open screen in the workspace would hear about a write that did nothing',
|
|
244
|
+
).toBeNull()
|
|
245
|
+
expect(step.next.dragging).toBe(false)
|
|
246
|
+
expect(step.announce?.id, 'and the row still says where it is').toBe('b')
|
|
247
|
+
})
|
|
248
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The categories screen's drag, its two arrow buttons and the one save behind both — as a value.
|
|
3
|
+
*
|
|
4
|
+
* In its own file for the reason `reorder.ts`, `custody.ts` and `price.ts` are: a `.svelte` file
|
|
5
|
+
* cannot be unit-tested here, so anything living inside one is only ever checked by reading it. This
|
|
6
|
+
* is not arithmetic, though. It is a small state machine with four inputs that arrive in an order
|
|
7
|
+
* nobody controls — a drag, a keypress, a server answer and a refetch — and every defect it has had
|
|
8
|
+
* was an *ordering*, not a calculation. Those are three assertions each rather than three careful
|
|
9
|
+
* reads.
|
|
10
|
+
*
|
|
11
|
+
* Three snapshots, and they are only equal when nothing is in flight:
|
|
12
|
+
*
|
|
13
|
+
* - `rows` is what the list renders.
|
|
14
|
+
* - `shown` is what the screen was showing before the gesture in progress, which while a save is
|
|
15
|
+
* in flight is the optimistic order and **not** what the server holds. A blocked move rolls back
|
|
16
|
+
* to this, so the move being written at that moment survives.
|
|
17
|
+
* - `settled` is what the **server** is believed to hold, and it is what a refusal rolls back to.
|
|
18
|
+
*
|
|
19
|
+
* Every transition is pure and returns the work the caller owes: a list to post, a row to speak
|
|
20
|
+
* about, or neither. The screen does the posting and the speaking; nothing here knows what a request
|
|
21
|
+
* or a live region is.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { moveBy, type Ordered, sameOrder } from './reorder.js'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The trigger `svelte-dnd-action` puts on the `consider` it fires when a **keyboard** drag ends.
|
|
28
|
+
*
|
|
29
|
+
* The library's two routes do not have the same shape, and reading only the pointer one is what left
|
|
30
|
+
* this screen permanently stale. A pointer drag is `consider`…`consider`…`finalize`. A keyboard drag
|
|
31
|
+
* is `consider` (`dragStarted`), then one `finalize` per arrow key, and then — on Enter, on Escape,
|
|
32
|
+
* on a click elsewhere, on the row being removed — a **`consider`** carrying this trigger. So the
|
|
33
|
+
* last event of a keyboard drag is a consider, and a handler that sets "dragging" on every consider
|
|
34
|
+
* and clears it only on finalize is stuck true from that moment on. Whatever the flag then guards is
|
|
35
|
+
* guarded for ever: here it was the seeding effect, so the screen quietly stopped showing what the
|
|
36
|
+
* database held, with no error anywhere.
|
|
37
|
+
*/
|
|
38
|
+
export const DRAG_STOPPED = 'dragStopped'
|
|
39
|
+
|
|
40
|
+
export interface Sequence<T extends Ordered> {
|
|
41
|
+
/** What the list renders. */
|
|
42
|
+
rows: readonly T[]
|
|
43
|
+
/** What the server is believed to hold — where a refusal rolls back to. */
|
|
44
|
+
settled: readonly T[]
|
|
45
|
+
/** What the screen showed before the gesture in progress. */
|
|
46
|
+
shown: readonly T[]
|
|
47
|
+
/** A drag is in progress, so a refetch must not pull the list out from under the pointer. */
|
|
48
|
+
dragging: boolean
|
|
49
|
+
/** A save is in flight. Set in the same tick as the gesture; a disabled button is a render late. */
|
|
50
|
+
saving: boolean
|
|
51
|
+
/** Moves made while that save was in flight, coalesced into the one list they add up to. */
|
|
52
|
+
pending: readonly T[] | null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A transition, and the work it leaves the screen to do. */
|
|
56
|
+
export interface Step<T extends Ordered> {
|
|
57
|
+
next: Sequence<T>
|
|
58
|
+
/** The ids to post, in order. Null when nothing needs writing. */
|
|
59
|
+
save: readonly string[] | null
|
|
60
|
+
/** The row to speak about and the list that now holds it. Null when there is nothing to say. */
|
|
61
|
+
announce: { id: string; list: readonly T[] } | null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function start<T extends Ordered>(rows: readonly T[] = []): Sequence<T> {
|
|
65
|
+
return { rows, settled: rows, shown: rows, dragging: false, saving: false, pending: null }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Adopt what a query just delivered — unless a gesture or a write is in the middle of something.
|
|
70
|
+
*
|
|
71
|
+
* Seeding under a drag pulls the list out from under the pointer, and seeding under a save replaces
|
|
72
|
+
* the optimistic order with data the write has not reached yet, so the screen disagrees with the
|
|
73
|
+
* change it is at that moment making.
|
|
74
|
+
*
|
|
75
|
+
* **A skipped seed is dropped, not queued, and that is only safe because of `reseed`.** A refetch
|
|
76
|
+
* landing mid-save is skipped here; on success the server's own answer is fresher than anything the
|
|
77
|
+
* query could hold, and on a refusal `reseed` takes what the server actually has. Without that second
|
|
78
|
+
* half this is the whole defect: skip one refetch, refuse the save, and the screen is left holding a
|
|
79
|
+
* list the server has already rejected once, with no route back to a fresh one.
|
|
80
|
+
*/
|
|
81
|
+
export function seed<T extends Ordered>(seq: Sequence<T>, live: readonly T[]): Sequence<T> {
|
|
82
|
+
if (seq.dragging || seq.saving) return seq
|
|
83
|
+
return { ...seq, rows: live, settled: live, shown: live }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Take what the server actually has, whatever this screen was in the middle of.
|
|
88
|
+
*
|
|
89
|
+
* What makes an `order_stale` refusal recoverable. The rollback alone is not enough and reads as
|
|
90
|
+
* though it were: it restores the same list the server has just refused, so every retry sends it
|
|
91
|
+
* again and is refused again, under a message telling the reader to try once more. The seeding effect
|
|
92
|
+
* cannot rescue it either — it is keyed on the query's data, and the refetch that follows the refusal
|
|
93
|
+
* returns the value it already skipped, which is not a change and so does not re-run anything.
|
|
94
|
+
*
|
|
95
|
+
* The same thing `start` does, and named separately because the call site is where it has to be
|
|
96
|
+
* obvious that the previous state is being thrown away on purpose rather than merged into.
|
|
97
|
+
*/
|
|
98
|
+
export function reseed<T extends Ordered>(live: readonly T[]): Sequence<T> {
|
|
99
|
+
return start(live)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The drag library considering a position — or, on `dragStopped`, telling us the keyboard drag ended.
|
|
104
|
+
*
|
|
105
|
+
* The trigger is the whole point. See `DRAG_STOPPED`.
|
|
106
|
+
*/
|
|
107
|
+
export function consider<T extends Ordered>(seq: Sequence<T>, items: readonly T[], trigger: string): Step<T> {
|
|
108
|
+
if (trigger === DRAG_STOPPED)
|
|
109
|
+
return { next: { ...seq, dragging: false, rows: seq.shown }, save: null, announce: null }
|
|
110
|
+
return { next: { ...seq, dragging: true, rows: items }, save: null, announce: null }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A drop, from either route: the arrangement is final, so decide whether it is worth sending. */
|
|
114
|
+
export function finalize<T extends Ordered>(seq: Sequence<T>, items: readonly T[], id: string): Step<T> {
|
|
115
|
+
return apply({ ...seq, dragging: false }, items, id)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** *Move up* or *move down* on one row, which must produce exactly what the same drag would. */
|
|
119
|
+
export function move<T extends Ordered>(seq: Sequence<T>, id: string, delta: number): Step<T> {
|
|
120
|
+
return apply(seq, moveBy(seq.rows, id, delta), id)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Take an arrangement, decide whether it is worth sending, and say where the row ended up.
|
|
125
|
+
*
|
|
126
|
+
* One function for both routes, because a drag and a keypress that produce the same list must produce
|
|
127
|
+
* the same request, the same optimistic update and the same sentence. Three outcomes:
|
|
128
|
+
*
|
|
129
|
+
* - **nothing actually moved**: a drag that ended where it started fires `finalize` exactly as a
|
|
130
|
+
* real one does, and *move up* on the first row is a button somebody will press. Neither is a
|
|
131
|
+
* write. Both still get an answer, because silence reads as a broken button.
|
|
132
|
+
* - **a save already in flight**: the move is applied on screen and **coalesced** into `pending`,
|
|
133
|
+
* which is posted the moment the current write answers. It used to be discarded — the list
|
|
134
|
+
* snapped back to `shown` and the row announced the position it had not left, so somebody
|
|
135
|
+
* pressing *move down* three times was told three times that nothing had moved, while the second
|
|
136
|
+
* and third presses vanished. A write per keypress is the other wrong answer: they arrive out of
|
|
137
|
+
* order and each one describes a list the next one contradicts.
|
|
138
|
+
* - **a real move with nothing in flight**: optimistic, announced at once, posted, and rolled back
|
|
139
|
+
* by `refused` if the server says no.
|
|
140
|
+
*
|
|
141
|
+
* Every one of the three announces, and every announcement is now true — which is the point of
|
|
142
|
+
* coalescing rather than dropping.
|
|
143
|
+
*/
|
|
144
|
+
function apply<T extends Ordered>(seq: Sequence<T>, next: readonly T[], id: string): Step<T> {
|
|
145
|
+
if (sameOrder(next, seq.shown))
|
|
146
|
+
return { next: { ...seq, rows: seq.shown }, save: null, announce: { id, list: seq.shown } }
|
|
147
|
+
|
|
148
|
+
const rows = [...next]
|
|
149
|
+
const moved = { ...seq, rows, shown: rows }
|
|
150
|
+
if (seq.saving) return { next: { ...moved, pending: rows }, save: null, announce: { id, list: rows } }
|
|
151
|
+
return { next: { ...moved, saving: true }, save: rows.map((row) => row.id), announce: { id, list: rows } }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The server wrote it, and answered the sequence it wrote.
|
|
156
|
+
*
|
|
157
|
+
* Nothing to guess and no flash: the optimistic list is replaced by the same list. When moves were
|
|
158
|
+
* coalesced while this one was in flight, they go now — and `rows` is deliberately *not* rewound to
|
|
159
|
+
* the server's answer first, because that answer is one move behind what the person is looking at.
|
|
160
|
+
* `saving` stays set across that hand-off, so a third move still coalesces rather than racing.
|
|
161
|
+
*/
|
|
162
|
+
export function saved<T extends Ordered>(seq: Sequence<T>, server: readonly T[]): Step<T> {
|
|
163
|
+
const queued = seq.pending
|
|
164
|
+
if (queued && !sameOrder(queued, server))
|
|
165
|
+
return {
|
|
166
|
+
next: { ...seq, settled: server, pending: null },
|
|
167
|
+
save: queued.map((row) => row.id),
|
|
168
|
+
announce: null,
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
next: { rows: server, settled: server, shown: server, dragging: false, saving: false, pending: null },
|
|
172
|
+
save: null,
|
|
173
|
+
announce: null,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The server refused it. Put the list back where the server is believed to be, and drop the queue.
|
|
179
|
+
*
|
|
180
|
+
* Anything coalesced behind the refused write was built on top of it, so it describes an arrangement
|
|
181
|
+
* that never existed. Sending it next would be refused for the same reason, or — worse — accepted.
|
|
182
|
+
*/
|
|
183
|
+
export function refused<T extends Ordered>(seq: Sequence<T>): Sequence<T> {
|
|
184
|
+
return { ...seq, rows: seq.settled, shown: seq.settled, dragging: false, saving: false, pending: null }
|
|
185
|
+
}
|