@lovett/ui 0.1.0 → 0.2.3
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/dist/{chunk-RBYWGBQ2.js → chunk-GP7BKVZC.js} +8 -5
- package/dist/chunk-GP7BKVZC.js.map +1 -0
- package/dist/index.d.ts +316 -17
- package/dist/index.js +96 -23
- package/dist/index.js.map +1 -1
- package/dist/{rich-composer-impl-5NO443A6.js → rich-composer-impl-F5PQVFZT.js} +3 -3
- package/dist/{rich-composer-impl-5NO443A6.js.map → rich-composer-impl-F5PQVFZT.js.map} +1 -1
- package/dist/styles.css +53 -1
- package/dist/theme-v2.css +26 -0
- package/dist/tokens.css +13 -0
- package/package.json +2 -2
- package/src/__tests__/clip-reserve.test.ts +431 -0
- package/src/__tests__/display-store.test.tsx +120 -21
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/detail/__tests__/activity-pane.test.tsx +218 -1
- package/src/detail/activity-pane.tsx +117 -3
- package/src/display-store.tsx +61 -2
- package/src/index.ts +7 -0
- package/src/sortable.tsx +230 -25
- package/src/styles.css +53 -1
- package/src/theme-v2.css +26 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +17 -0
- package/src/thread/__tests__/thread.test.tsx +268 -0
- package/src/thread/attachments.tsx +1 -1
- package/src/thread/comment.tsx +104 -6
- package/src/thread/composer.tsx +4 -1
- package/src/thread/reactions.tsx +2 -1
- package/src/thread/types.ts +109 -0
- package/src/tokens.css +13 -0
- package/dist/chunk-RBYWGBQ2.js.map +0 -1
|
@@ -10,11 +10,18 @@
|
|
|
10
10
|
* 2. PERSISTENCE IS PER SCOPE and VALIDATED. A key from another scope never
|
|
11
11
|
* leaks; a hand-edited value that is the wrong type or outside the
|
|
12
12
|
* allow-list falls back to the default instead of reaching a component.
|
|
13
|
+
*
|
|
14
|
+
* And one contract that is not an inversion but a PIN. `prefix` moves the key
|
|
15
|
+
* for a host that is not this workspace, and the default has to stay
|
|
16
|
+
* byte-identical: these keys hold operator preferences already on disk, so a
|
|
17
|
+
* changed default would not migrate them, it would lose them. The default is
|
|
18
|
+
* asserted as a literal, and asserted again through a value written by hand at
|
|
19
|
+
* the key an older build wrote.
|
|
13
20
|
*/
|
|
14
21
|
import { describe, expect, it, beforeEach } from 'vitest'
|
|
15
22
|
import { render, screen } from '@testing-library/react'
|
|
16
23
|
import userEvent from '@testing-library/user-event'
|
|
17
|
-
import { createDisplayStore } from '../display-store'
|
|
24
|
+
import { createDisplayStore, type DisplayStore } from '../display-store'
|
|
18
25
|
|
|
19
26
|
type Settings = {
|
|
20
27
|
readonly grouping: 'column' | 'assignee'
|
|
@@ -40,28 +47,52 @@ const store = createDisplayStore<Settings>(
|
|
|
40
47
|
},
|
|
41
48
|
)
|
|
42
49
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
50
|
+
/**
|
|
51
|
+
* The same store under a different PREFIX, and identical in every other way —
|
|
52
|
+
* same name, same defaults, same allow-list. The pair is what proves the
|
|
53
|
+
* option moves the key and touches nothing else: if the prefix were dropped
|
|
54
|
+
* these two would be one store sharing one key.
|
|
55
|
+
*/
|
|
56
|
+
const prefixedStore = createDisplayStore<Settings>(
|
|
57
|
+
'test',
|
|
58
|
+
DEFAULTS,
|
|
59
|
+
{ grouping: ['column', 'assignee'] },
|
|
60
|
+
undefined,
|
|
61
|
+
{ prefix: 'lightwork' },
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A panel per store. `useDisplay` reaches the nearest provider, so a second
|
|
66
|
+
* store needs a component that calls the second store's hook — the panel is
|
|
67
|
+
* built from the store rather than closing over one.
|
|
68
|
+
*/
|
|
69
|
+
function panelFor(target: DisplayStore<Settings>) {
|
|
70
|
+
return function Panel({ name }: { name: string }) {
|
|
71
|
+
const { settings, set, reset, isDefault } = target.useDisplay()
|
|
72
|
+
return (
|
|
73
|
+
<div>
|
|
74
|
+
<span data-testid={`${name}-grouping`}>{settings.grouping}</span>
|
|
75
|
+
<span data-testid={`${name}-empty`}>{String(settings.showEmpty)}</span>
|
|
76
|
+
<span data-testid={`${name}-default`}>{String(isDefault)}</span>
|
|
77
|
+
<button type="button" onClick={() => set('grouping', 'assignee')}>
|
|
78
|
+
{`${name}: group by assignee`}
|
|
79
|
+
</button>
|
|
80
|
+
<button type="button" onClick={() => set('showEmpty', false)}>
|
|
81
|
+
{`${name}: hide empty`}
|
|
82
|
+
</button>
|
|
83
|
+
<span data-testid={`${name}-size`}>{String(settings.panelSize)}</span>
|
|
84
|
+
<button type="button" onClick={() => set('panelSize', 820)}>
|
|
85
|
+
{`${name}: widen`}
|
|
86
|
+
</button>
|
|
87
|
+
<button type="button" onClick={reset}>{`${name}: reset`}</button>
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
63
91
|
}
|
|
64
92
|
|
|
93
|
+
const Panel = panelFor(store)
|
|
94
|
+
const PrefixedPanel = panelFor(prefixedStore)
|
|
95
|
+
|
|
65
96
|
describe('createDisplayStore', () => {
|
|
66
97
|
beforeEach(() => window.localStorage.clear())
|
|
67
98
|
|
|
@@ -69,6 +100,74 @@ describe('createDisplayStore', () => {
|
|
|
69
100
|
expect(store.storageKey('brn_1')).toBe('workspace:test:brn_1:display')
|
|
70
101
|
})
|
|
71
102
|
|
|
103
|
+
it('refuses an empty prefix rather than silently sharing the default namespace', () => {
|
|
104
|
+
// Both quiet alternatives are worse than a throw: defaulting hands a caller
|
|
105
|
+
// who asked for isolation the shared namespace, and accepting produces
|
|
106
|
+
// `:name:scope:display`, which collides with every other empty-prefix
|
|
107
|
+
// caller — the cross-product the option exists to prevent.
|
|
108
|
+
expect(() => createDisplayStore('board', { a: 1 }, undefined, undefined, { prefix: '' })).toThrow(
|
|
109
|
+
/prefix.*cannot be empty/i,
|
|
110
|
+
)
|
|
111
|
+
// And the default path is untouched by the guard.
|
|
112
|
+
expect(createDisplayStore('board', { a: 1 }).storageKey('ws_1')).toBe('workspace:board:ws_1:display')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('reads a key written before the prefix option existed', () => {
|
|
116
|
+
// The key is spelled out rather than taken from `storageKey`, because what
|
|
117
|
+
// is under test is that the two still agree. A store built without the
|
|
118
|
+
// option has to find exactly what the build before it left behind.
|
|
119
|
+
window.localStorage.setItem(
|
|
120
|
+
'workspace:test:brn_old:display',
|
|
121
|
+
JSON.stringify({ grouping: 'assignee', showEmpty: false, panelSize: 700 }),
|
|
122
|
+
)
|
|
123
|
+
render(
|
|
124
|
+
<store.Provider scope="brn_old">
|
|
125
|
+
<Panel name="old" />
|
|
126
|
+
</store.Provider>,
|
|
127
|
+
)
|
|
128
|
+
expect(screen.getByTestId('old-grouping')).toHaveTextContent('assignee')
|
|
129
|
+
expect(screen.getByTestId('old-empty')).toHaveTextContent('false')
|
|
130
|
+
expect(screen.getByTestId('old-size')).toHaveTextContent('700')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('takes a prefix, and the prefix is the only segment it changes', () => {
|
|
134
|
+
expect(prefixedStore.storageKey('brn_1')).toBe('lightwork:test:brn_1:display')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('a prefixed store writes its own key and never the unprefixed one', async () => {
|
|
138
|
+
const user = userEvent.setup()
|
|
139
|
+
render(
|
|
140
|
+
<prefixedStore.Provider scope="brn_p">
|
|
141
|
+
<PrefixedPanel name="p" />
|
|
142
|
+
</prefixedStore.Provider>,
|
|
143
|
+
)
|
|
144
|
+
await user.click(screen.getByRole('button', { name: 'p: group by assignee' }))
|
|
145
|
+
expect(window.localStorage.getItem('lightwork:test:brn_p:display')).toContain(
|
|
146
|
+
'assignee',
|
|
147
|
+
)
|
|
148
|
+
// The point of the option: another product on the same origin, using the
|
|
149
|
+
// same primitive under the same name, is not writing over this workspace.
|
|
150
|
+
expect(window.localStorage.getItem('workspace:test:brn_p:display')).toBeNull()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('two prefixes over one name and one scope do not see each other', async () => {
|
|
154
|
+
const user = userEvent.setup()
|
|
155
|
+
const { unmount } = render(
|
|
156
|
+
<store.Provider scope="brn_shared">
|
|
157
|
+
<Panel name="host" />
|
|
158
|
+
</store.Provider>,
|
|
159
|
+
)
|
|
160
|
+
await user.click(screen.getByRole('button', { name: 'host: group by assignee' }))
|
|
161
|
+
unmount()
|
|
162
|
+
|
|
163
|
+
render(
|
|
164
|
+
<prefixedStore.Provider scope="brn_shared">
|
|
165
|
+
<PrefixedPanel name="guest" />
|
|
166
|
+
</prefixedStore.Provider>,
|
|
167
|
+
)
|
|
168
|
+
expect(screen.getByTestId('guest-grouping')).toHaveTextContent('column')
|
|
169
|
+
})
|
|
170
|
+
|
|
72
171
|
it('persists a change and reads it back on the next mount', async () => {
|
|
73
172
|
const user = userEvent.setup()
|
|
74
173
|
const { unmount } = render(
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MultiSortableList` — the two drag defects, pinned.
|
|
3
|
+
*
|
|
4
|
+
* Both were reported from a browser and neither was visible to any gate, which
|
|
5
|
+
* is the reason this file exists rather than a note in a README:
|
|
6
|
+
*
|
|
7
|
+
* 1. dropping a card in another lane flashed it back to its origin for a
|
|
8
|
+
* frame — "like an invisible or ghost presence" — because the drop
|
|
9
|
+
* cleared the preview and rendered from `containers`, which the consumer
|
|
10
|
+
* had not patched yet.
|
|
11
|
+
* 2. the drop target was resolved from the CARD's corners, so a tall card
|
|
12
|
+
* had to be dragged bodily into a lane before that lane would catch it.
|
|
13
|
+
* The pointer was not consulted.
|
|
14
|
+
*
|
|
15
|
+
* The collision suite is a pure-function suite: a `CollisionDetection` takes
|
|
16
|
+
* rects and coordinates and returns collisions, so the geometry that jsdom
|
|
17
|
+
* cannot produce can simply be handed to it. It asserts the new answer and,
|
|
18
|
+
* on the identical geometry, the one `closestCorners` gives — so the defect is
|
|
19
|
+
* pinned next to the fix, and a revert to either fails here.
|
|
20
|
+
*
|
|
21
|
+
* The preview suite drives a real keyboard drag. That works — barely, and only
|
|
22
|
+
* with the geometry mock — because dnd-kit measures the DragOverlay's single
|
|
23
|
+
* child (`getMeasurableNode`), which the harness gives a `data-rect` like every
|
|
24
|
+
* other node. A pointer drag is still not reproducible here: the PointerSensor
|
|
25
|
+
* needs layout that jsdom does not have. The keyboard path exercises the same
|
|
26
|
+
* `handleDragOver` / `handleDragEnd` the pointer path does, which is the code
|
|
27
|
+
* under test, and it has the side benefit of proving keyboard dragging still
|
|
28
|
+
* works at all after the collision change.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
|
|
32
|
+
import { act, render, screen } from '@testing-library/react'
|
|
33
|
+
import { closestCorners, type CollisionDetection } from '@dnd-kit/core'
|
|
34
|
+
import {
|
|
35
|
+
MultiSortableList,
|
|
36
|
+
PREVIEW_SETTLE_MS,
|
|
37
|
+
SortableDropZone,
|
|
38
|
+
SortableItem,
|
|
39
|
+
pointerFirstCollision,
|
|
40
|
+
} from '../sortable'
|
|
41
|
+
import type { MultiSortableMove, SortableContainers } from '../sortable'
|
|
42
|
+
import { mockGeometry, setViewport } from './helpers/geometry'
|
|
43
|
+
|
|
44
|
+
/* ── collision detection ─────────────────────────────────────────────────── */
|
|
45
|
+
|
|
46
|
+
type CollisionArgs = Parameters<CollisionDetection>[0]
|
|
47
|
+
type ClientRect = CollisionArgs['collisionRect']
|
|
48
|
+
type Droppable = CollisionArgs['droppableContainers'][number]
|
|
49
|
+
|
|
50
|
+
const rect = (left: number, top: number, width: number, height: number): ClientRect => ({
|
|
51
|
+
left,
|
|
52
|
+
top,
|
|
53
|
+
width,
|
|
54
|
+
height,
|
|
55
|
+
right: left + width,
|
|
56
|
+
bottom: top + height,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const droppable = (id: string, r: ClientRect): Droppable => ({
|
|
60
|
+
id,
|
|
61
|
+
key: id,
|
|
62
|
+
disabled: false,
|
|
63
|
+
data: { current: undefined },
|
|
64
|
+
node: { current: null },
|
|
65
|
+
rect: { current: r },
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A two-lane board. `build-1` is 600px tall — the variable-height card the
|
|
70
|
+
* whole collision argument is about.
|
|
71
|
+
*/
|
|
72
|
+
const LANE_PLANNING = rect(0, 0, 300, 800)
|
|
73
|
+
const LANE_BUILD = rect(320, 0, 300, 800)
|
|
74
|
+
const CARD_PLAN_1 = rect(0, 0, 300, 120)
|
|
75
|
+
const CARD_BUILD_1 = rect(320, 100, 300, 600)
|
|
76
|
+
|
|
77
|
+
const LANE_OF: Record<string, string> = {
|
|
78
|
+
planning: 'planning',
|
|
79
|
+
build: 'build',
|
|
80
|
+
'plan-1': 'planning',
|
|
81
|
+
'build-1': 'build',
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Dragged left by 100px: 80px of the card has crossed into Planning and 200px
|
|
86
|
+
* is still over Build. Under any card-measuring strategy Build still wins,
|
|
87
|
+
* which is the defect — the user is pointing at Planning.
|
|
88
|
+
*/
|
|
89
|
+
const DRAGGED = rect(220, 100, 300, 600)
|
|
90
|
+
|
|
91
|
+
function args(pointerCoordinates: { x: number; y: number } | null): CollisionArgs {
|
|
92
|
+
const rects: Array<[string, ClientRect]> = [
|
|
93
|
+
['planning', LANE_PLANNING],
|
|
94
|
+
['build', LANE_BUILD],
|
|
95
|
+
['plan-1', CARD_PLAN_1],
|
|
96
|
+
['build-1', CARD_BUILD_1],
|
|
97
|
+
]
|
|
98
|
+
return {
|
|
99
|
+
active: {
|
|
100
|
+
id: 'build-1',
|
|
101
|
+
data: { current: undefined },
|
|
102
|
+
rect: { current: { initial: CARD_BUILD_1, translated: DRAGGED } },
|
|
103
|
+
},
|
|
104
|
+
collisionRect: DRAGGED,
|
|
105
|
+
droppableRects: new Map(rects),
|
|
106
|
+
droppableContainers: rects.map(([id, r]) => droppable(id, r)),
|
|
107
|
+
pointerCoordinates,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What `MultiSortableList` does with the winner: resolve it to a lane. */
|
|
112
|
+
function laneFor(strategy: CollisionDetection, at: { x: number; y: number } | null): string {
|
|
113
|
+
const winner = strategy(args(at))[0]
|
|
114
|
+
return winner ? (LANE_OF[String(winner.id)] ?? 'none') : 'none'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe('pointerFirstCollision', () => {
|
|
118
|
+
it('catches the card in the lane the POINTER is over', () => {
|
|
119
|
+
// Cursor at x=260: inside Planning, and below plan-1, so the lane itself.
|
|
120
|
+
expect(laneFor(pointerFirstCollision, { x: 260, y: 400 })).toBe('planning')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('is a real change: closestCorners answers the source lane on the same geometry', () => {
|
|
124
|
+
// The defect, kept next to the fix. If this ever agrees with the case
|
|
125
|
+
// above, the geometry has stopped exercising anything.
|
|
126
|
+
expect(laneFor(closestCorners, { x: 260, y: 400 })).toBe('build')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('resolves to a card, not the lane, when the pointer is over one', () => {
|
|
130
|
+
// The card is what carries the destination INDEX, so a pointer over a card
|
|
131
|
+
// has to win over the lane containing it or every cross-lane drop appends.
|
|
132
|
+
const winner = pointerFirstCollision(args({ x: 150, y: 60 }))[0]
|
|
133
|
+
expect(winner?.id).toBe('plan-1')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('falls back to closestCorners when there is no pointer — the keyboard drag', () => {
|
|
137
|
+
// dnd-kit derives pointerCoordinates from the activator event's clientX/Y,
|
|
138
|
+
// and a KeyboardEvent has neither. A pointer-only strategy returns nothing
|
|
139
|
+
// here, `over` stays null for the whole drag, and cross-lane keyboard
|
|
140
|
+
// movement dies silently.
|
|
141
|
+
const byFallback = pointerFirstCollision(args(null))
|
|
142
|
+
expect(byFallback.length).toBeGreaterThan(0)
|
|
143
|
+
expect(byFallback.map((c) => c.id)).toEqual(closestCorners(args(null)).map((c) => c.id))
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('falls back when the pointer is over no droppable — the board margins', () => {
|
|
147
|
+
// x=900 is past the right edge of every lane. Dropping there still has to
|
|
148
|
+
// resolve somewhere rather than reading as a drop outside the board.
|
|
149
|
+
expect(laneFor(pointerFirstCollision, { x: 900, y: 400 })).toBe('build')
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
/* ── the preview, across a drop ──────────────────────────────────────────── */
|
|
154
|
+
|
|
155
|
+
const ORDER = ['planning', 'build'] as const
|
|
156
|
+
|
|
157
|
+
const RECTS: Record<string, string> = {
|
|
158
|
+
planning: '0,0,300,800',
|
|
159
|
+
build: '320,0,300,800',
|
|
160
|
+
'plan-1': '0,0,300,120',
|
|
161
|
+
'build-1': '320,0,300,600',
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const START: SortableContainers = { planning: ['plan-1'], build: ['build-1'] }
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* dnd-kit's KeyboardSensor adds its document keydown listener inside a
|
|
168
|
+
* `setTimeout`, so every key after the activating one has to wait a macrotask
|
|
169
|
+
* or it lands before the sensor is listening.
|
|
170
|
+
*/
|
|
171
|
+
const flush = async () => {
|
|
172
|
+
await act(async () => {
|
|
173
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const press = async (el: HTMLElement, code: string, key: string) => {
|
|
178
|
+
await act(async () => {
|
|
179
|
+
el.dispatchEvent(new KeyboardEvent('keydown', { key, code, bubbles: true }))
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `containers` is a PROP, so each test decides for itself whether, when, and
|
|
185
|
+
* with what the consumer answers a move — which is the variable under test.
|
|
186
|
+
* A `rerender` is the consumer patching its cache.
|
|
187
|
+
*/
|
|
188
|
+
function Harness({
|
|
189
|
+
containers,
|
|
190
|
+
onMove,
|
|
191
|
+
log,
|
|
192
|
+
}: {
|
|
193
|
+
containers: SortableContainers
|
|
194
|
+
onMove: (move: MultiSortableMove, next: SortableContainers) => void
|
|
195
|
+
log: SortableContainers[]
|
|
196
|
+
}) {
|
|
197
|
+
return (
|
|
198
|
+
<MultiSortableList
|
|
199
|
+
containers={containers}
|
|
200
|
+
containerOrder={ORDER}
|
|
201
|
+
onMove={onMove}
|
|
202
|
+
renderOverlay={(id) => <div data-rect={RECTS[id]}>overlay {id}</div>}
|
|
203
|
+
>
|
|
204
|
+
{(arrangement) => {
|
|
205
|
+
log.push(arrangement)
|
|
206
|
+
return (
|
|
207
|
+
<div>
|
|
208
|
+
{ORDER.map((laneId) => (
|
|
209
|
+
<SortableDropZone key={laneId} id={laneId} items={arrangement[laneId] ?? []}>
|
|
210
|
+
{({ setNodeRef }) => (
|
|
211
|
+
<div ref={setNodeRef} data-testid={`lane-${laneId}`} data-rect={RECTS[laneId]}>
|
|
212
|
+
{(arrangement[laneId] ?? []).map((itemId) => (
|
|
213
|
+
<SortableItem key={itemId} id={itemId}>
|
|
214
|
+
{({ setNodeRef: setItemRef, setActivatorNodeRef, dragListeners }) => (
|
|
215
|
+
<div ref={setItemRef} data-rect={RECTS[itemId]}>
|
|
216
|
+
<button type="button" ref={setActivatorNodeRef} {...dragListeners}>
|
|
217
|
+
drag {itemId}
|
|
218
|
+
</button>
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
</SortableItem>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</SortableDropZone>
|
|
226
|
+
))}
|
|
227
|
+
</div>
|
|
228
|
+
)
|
|
229
|
+
}}
|
|
230
|
+
</MultiSortableList>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** What the consumer's cache holds once it has accepted the move. */
|
|
235
|
+
const MOVED: SortableContainers = { planning: ['plan-1', 'build-1'], build: [] }
|
|
236
|
+
|
|
237
|
+
/** Pick up `build-1`, move it left into Planning, and stop before the drop. */
|
|
238
|
+
async function dragBuildCardIntoPlanning(): Promise<void> {
|
|
239
|
+
const handle = screen.getByRole('button', { name: /drag build-1/i })
|
|
240
|
+
handle.focus()
|
|
241
|
+
await press(handle, 'Space', ' ')
|
|
242
|
+
await flush()
|
|
243
|
+
// The card is re-rendered into the other lane as the preview updates, so the
|
|
244
|
+
// original handle node is detached. Subsequent keys go to the document, which
|
|
245
|
+
// is where the sensor listens anyway.
|
|
246
|
+
await press(document.body, 'ArrowLeft', 'ArrowLeft')
|
|
247
|
+
await flush()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const lanes = () => ({
|
|
251
|
+
planning: screen.getByTestId('lane-planning').textContent,
|
|
252
|
+
build: screen.getByTestId('lane-build').textContent,
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('MultiSortableList — the preview across a drop', () => {
|
|
256
|
+
let restore: () => void
|
|
257
|
+
|
|
258
|
+
beforeEach(() => {
|
|
259
|
+
restore = mockGeometry()
|
|
260
|
+
setViewport(1200, 900)
|
|
261
|
+
})
|
|
262
|
+
afterEach(() => restore())
|
|
263
|
+
|
|
264
|
+
it('never renders the pre-drag arrangement between the drop and the patch', async () => {
|
|
265
|
+
// The ghost, exactly: every render taken while `containers` is still stale
|
|
266
|
+
// — which is every render between the drop and a mutation's `onMutate` —
|
|
267
|
+
// used to put the card back in Build. This walks that window and asserts
|
|
268
|
+
// Build is empty in all of it.
|
|
269
|
+
const onMove = vi.fn()
|
|
270
|
+
const log: SortableContainers[] = []
|
|
271
|
+
const { rerender } = render(<Harness containers={START} onMove={onMove} log={log} />)
|
|
272
|
+
|
|
273
|
+
await dragBuildCardIntoPlanning()
|
|
274
|
+
|
|
275
|
+
const atDrop = log.length
|
|
276
|
+
await press(document.body, 'Space', ' ')
|
|
277
|
+
|
|
278
|
+
expect(onMove).toHaveBeenCalledWith(
|
|
279
|
+
{ itemId: 'build-1', fromContainerId: 'build', toContainerId: 'planning', toIndex: 1 },
|
|
280
|
+
MOVED,
|
|
281
|
+
)
|
|
282
|
+
const afterDrop = log.slice(atDrop)
|
|
283
|
+
// Non-empty, or the loop below asserts nothing: the drop DOES render.
|
|
284
|
+
expect(afterDrop.length).toBeGreaterThan(0)
|
|
285
|
+
for (const arrangement of afterDrop) {
|
|
286
|
+
expect(arrangement['build']).toEqual([])
|
|
287
|
+
}
|
|
288
|
+
expect(lanes().build).toBe('')
|
|
289
|
+
|
|
290
|
+
// The consumer's patch finally lands. Nothing moves.
|
|
291
|
+
rerender(<Harness containers={MOVED} onMove={onMove} log={log} />)
|
|
292
|
+
expect(lanes().planning).toContain('drag build-1')
|
|
293
|
+
expect(lanes().build).toBe('')
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('yields to `containers` the moment the consumer disagrees', async () => {
|
|
297
|
+
// A rejected move, or an optimistic write rolled back. The preview is a
|
|
298
|
+
// bridge, not a claim that outranks the consumer: a new `containers`
|
|
299
|
+
// supersedes it at once, well inside the settle deadline.
|
|
300
|
+
const onMove = vi.fn()
|
|
301
|
+
const { rerender } = render(<Harness containers={START} onMove={onMove} log={[]} />)
|
|
302
|
+
|
|
303
|
+
await dragBuildCardIntoPlanning()
|
|
304
|
+
await press(document.body, 'Space', ' ')
|
|
305
|
+
expect(lanes().build).toBe('')
|
|
306
|
+
|
|
307
|
+
rerender(
|
|
308
|
+
<Harness
|
|
309
|
+
containers={{ planning: ['plan-1'], build: ['build-1'] }}
|
|
310
|
+
onMove={onMove}
|
|
311
|
+
log={[]}
|
|
312
|
+
/>,
|
|
313
|
+
)
|
|
314
|
+
expect(lanes().build).toContain('drag build-1')
|
|
315
|
+
expect(lanes().planning).not.toContain('drag build-1')
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('does not resurrect the preview when `containers` returns to its old identity', async () => {
|
|
319
|
+
// The preview is held against a `containers` IDENTITY, and identities come
|
|
320
|
+
// back: a rolled-back optimistic write restores the previous object, not a
|
|
321
|
+
// copy of it. A preview still in state at that point would stop looking
|
|
322
|
+
// superseded and draw the move a second time, after the consumer undid it.
|
|
323
|
+
const onMove = vi.fn()
|
|
324
|
+
const { rerender } = render(<Harness containers={START} onMove={onMove} log={[]} />)
|
|
325
|
+
|
|
326
|
+
await dragBuildCardIntoPlanning()
|
|
327
|
+
await press(document.body, 'Space', ' ')
|
|
328
|
+
|
|
329
|
+
// Optimistic patch, then the write fails and the exact prior object is put
|
|
330
|
+
// back — `START` itself, which is the value the preview was held against.
|
|
331
|
+
rerender(<Harness containers={MOVED} onMove={onMove} log={[]} />)
|
|
332
|
+
rerender(<Harness containers={START} onMove={onMove} log={[]} />)
|
|
333
|
+
|
|
334
|
+
expect(lanes().build).toContain('drag build-1')
|
|
335
|
+
expect(lanes().planning).not.toContain('drag build-1')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('does not yank a still-held card back when the next drag starts', async () => {
|
|
339
|
+
// The fast user: drop one card, grab another before the first patch lands.
|
|
340
|
+
// A drag that rebased on `containers` would put the first card back in
|
|
341
|
+
// Build on pickup — the same ghost, one drag later.
|
|
342
|
+
const onMove = vi.fn()
|
|
343
|
+
render(<Harness containers={START} onMove={onMove} log={[]} />)
|
|
344
|
+
|
|
345
|
+
await dragBuildCardIntoPlanning()
|
|
346
|
+
await press(document.body, 'Space', ' ')
|
|
347
|
+
expect(lanes().planning).toContain('drag build-1')
|
|
348
|
+
|
|
349
|
+
const other = screen.getByRole('button', { name: /drag plan-1/i })
|
|
350
|
+
other.focus()
|
|
351
|
+
await press(other, 'Space', ' ')
|
|
352
|
+
await flush()
|
|
353
|
+
expect(lanes().planning).toContain('drag build-1')
|
|
354
|
+
expect(lanes().build).toBe('')
|
|
355
|
+
|
|
356
|
+
// And cancelling the second drag returns to the held arrangement, not to
|
|
357
|
+
// the `containers` the consumer still has not updated.
|
|
358
|
+
await press(document.body, 'Escape', 'Escape')
|
|
359
|
+
expect(lanes().planning).toContain('drag build-1')
|
|
360
|
+
expect(lanes().build).toBe('')
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('expires the preview when the consumer never patches at all', async () => {
|
|
364
|
+
// The cost of holding. A consumer that ignores `onMove` produces no new
|
|
365
|
+
// `containers`, so nothing supersedes the preview and the board would
|
|
366
|
+
// otherwise assert a move that never happened, indefinitely and silently.
|
|
367
|
+
const onMove = vi.fn()
|
|
368
|
+
render(<Harness containers={START} onMove={onMove} log={[]} />)
|
|
369
|
+
|
|
370
|
+
await dragBuildCardIntoPlanning()
|
|
371
|
+
await press(document.body, 'Space', ' ')
|
|
372
|
+
expect(onMove).toHaveBeenCalledTimes(1)
|
|
373
|
+
expect(lanes().planning).toContain('drag build-1')
|
|
374
|
+
|
|
375
|
+
await act(async () => {
|
|
376
|
+
await new Promise((resolve) => setTimeout(resolve, PREVIEW_SETTLE_MS + 50))
|
|
377
|
+
})
|
|
378
|
+
expect(lanes().build).toContain('drag build-1')
|
|
379
|
+
expect(lanes().planning).not.toContain('drag build-1')
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('returns the card on cancel, without a move', async () => {
|
|
383
|
+
const onMove = vi.fn()
|
|
384
|
+
render(<Harness containers={START} onMove={onMove} log={[]} />)
|
|
385
|
+
|
|
386
|
+
await dragBuildCardIntoPlanning()
|
|
387
|
+
expect(lanes().planning).toContain('drag build-1')
|
|
388
|
+
|
|
389
|
+
await press(document.body, 'Escape', 'Escape')
|
|
390
|
+
expect(onMove).not.toHaveBeenCalled()
|
|
391
|
+
expect(lanes().build).toContain('drag build-1')
|
|
392
|
+
expect(lanes().planning).not.toContain('drag build-1')
|
|
393
|
+
})
|
|
394
|
+
})
|