Skip to content

Commit 635b3ee

Browse files
committed
Tests: Add admin e2e drag-reorder coverage to nested-list scenario
- Add `order` int property and `ordered` scope to Item - Declare `scope: 'ordered'` on Widget.items so eager loads sort - Schema sets `draggable: true` and `orderKey: 'order'` on items - New drag-reorder.ts asserts DOM swap, save, reload, DB order - Split index.spec.ts into barrel + crud.ts, mirrors assets layout
1 parent 82ece6d commit 635b3ee

6 files changed

Lines changed: 150 additions & 74 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { test, expect, Widget } from '../fixtures.js'
2+
import {
3+
DitoListView,
4+
DitoForm,
5+
DitoNestedList
6+
} from '../../../utils/pages.js'
7+
8+
test.describe('nested-list', () => {
9+
test('add items, save, reload, delete one, reload', async ({
10+
page,
11+
url
12+
}) => {
13+
// Seed a widget with no items initially.
14+
const widget = await Widget.query().insert({ name: 'Widget A' })
15+
16+
const list = new DitoListView(page, url, 'Widget')
17+
const form = new DitoForm(page)
18+
const items = new DitoNestedList(page, 'Items')
19+
20+
await list.navigate('/widgets')
21+
await list.list.edit('Widget A')
22+
23+
// Add two items via the nested list. Each Add click appends a new
24+
// row whose inline form contains a Label field. Fill by row index
25+
// to disambiguate from the previously-added row.
26+
await items.add()
27+
await items.rows
28+
.nth(0)
29+
.getByLabel('Label', { exact: true })
30+
.fill('first')
31+
await items.add()
32+
await items.rows
33+
.nth(1)
34+
.getByLabel('Label', { exact: true })
35+
.fill('second')
36+
37+
await form.save()
38+
39+
// Reload via the list and confirm the items rehydrated with the
40+
// typed labels.
41+
await list.navigate('/widgets')
42+
await list.list.edit('Widget A')
43+
await expect(items.rows).toHaveCount(2)
44+
await expect(items.rows.nth(0).getByLabel('Label')).toHaveValue('first')
45+
await expect(items.rows.nth(1).getByLabel('Label')).toHaveValue('second')
46+
47+
// Confirm DB state matches.
48+
const persisted = await Widget.query()
49+
.findById(widget.$id() as number)
50+
.withGraphFetched('items')
51+
const persistedLabels = (persisted?.items ?? [])
52+
.map(i => i.label)
53+
.sort()
54+
expect(persistedLabels).toEqual(['first', 'second'])
55+
56+
// Delete the first item, save, reload, assert one remains with the
57+
// surviving label.
58+
await items.delete(0)
59+
await form.save()
60+
await list.navigate('/widgets')
61+
await list.list.edit('Widget A')
62+
await expect(items.rows).toHaveCount(1)
63+
await expect(items.rows.nth(0).getByLabel('Label')).toHaveValue('second')
64+
65+
const remaining = await Widget.query()
66+
.findById(widget.$id() as number)
67+
.withGraphFetched('items')
68+
const remainingLabels = (remaining?.items ?? []).map(i => i.label)
69+
expect(remainingLabels).toEqual(['second'])
70+
})
71+
})
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { test, expect, Widget } from '../fixtures.js'
2+
import {
3+
DitoListView,
4+
DitoForm,
5+
DitoNestedList
6+
} from '../../../utils/pages.js'
7+
8+
test.describe('drag reorder', () => {
9+
test('drag persists across reload and to the database', async ({
10+
page,
11+
url
12+
}) => {
13+
// Seed a widget with two items in a known order. `insertGraph`
14+
// walks the relation and inserts the children in one round-trip.
15+
const widget = await Widget.query().insertGraph({
16+
name: 'Widget',
17+
items: [
18+
{ label: 'A', order: 0 },
19+
{ label: 'B', order: 1 }
20+
]
21+
})
22+
23+
const list = new DitoListView(page, url, 'Widget')
24+
const form = new DitoForm(page)
25+
const items = new DitoNestedList(page, 'Items')
26+
27+
await list.navigate('/widgets')
28+
await list.list.edit('Widget')
29+
30+
// Sanity: items load in seeded order.
31+
await expect(items.rows).toHaveCount(2)
32+
await expect(items.rows.nth(0).getByLabel('Label')).toHaveValue('A')
33+
await expect(items.rows.nth(1).getByLabel('Label')).toHaveValue('B')
34+
35+
// Adjacent swap. dragRow uses SortableJS's fallback drag, which
36+
// reliably swaps neighbours but skips swaps on multi-step drags.
37+
await items.dragRow(0, 1)
38+
await expect(items.rows.nth(0).getByLabel('Label')).toHaveValue('B')
39+
await expect(items.rows.nth(1).getByLabel('Label')).toHaveValue('A')
40+
41+
await form.save()
42+
43+
// Reload via the list and confirm the order persisted through the
44+
// controller's `^withItems` scope, which uses the `ordered` modifier.
45+
await list.navigate('/widgets')
46+
await list.list.edit('Widget')
47+
await expect(items.rows.nth(0).getByLabel('Label')).toHaveValue('B')
48+
await expect(items.rows.nth(1).getByLabel('Label')).toHaveValue('A')
49+
50+
// DB shape: SortableMixin reassigns `order` to match array index, so
51+
// labels in `order` ASC must be [B, A]. The `ordered` scope is auto-
52+
// applied because Widget.relations.items declares it.
53+
const persisted = await Widget.query()
54+
.findById(widget.$id() as number)
55+
.withGraphFetched('items')
56+
expect((persisted?.items ?? []).map(i => i.label)).toEqual(['B', 'A'])
57+
})
58+
})
Lines changed: 2 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,2 @@
1-
import { test, expect, Widget } from '../fixtures.js'
2-
import {
3-
DitoListView,
4-
DitoForm,
5-
DitoNestedList
6-
} from '../../../utils/pages.js'
7-
8-
test.describe('nested-list', () => {
9-
test('add items, save, reload, delete one, reload', async ({
10-
page,
11-
url
12-
}) => {
13-
// Seed a widget with no items initially.
14-
const widget = await Widget.query().insert({ name: 'Widget A' })
15-
16-
const list = new DitoListView(page, url, 'Widget')
17-
const form = new DitoForm(page)
18-
const items = new DitoNestedList(page, 'Items')
19-
20-
await list.navigate('/widgets')
21-
await list.list.edit('Widget A')
22-
23-
// Add two items via the nested list. Each Add click appends a new
24-
// row whose inline form contains a Label field. Fill by row index
25-
// to disambiguate from the previously-added row.
26-
await items.add()
27-
await items.rows
28-
.nth(0)
29-
.getByLabel('Label', { exact: true })
30-
.fill('first')
31-
await items.add()
32-
await items.rows
33-
.nth(1)
34-
.getByLabel('Label', { exact: true })
35-
.fill('second')
36-
37-
await form.save()
38-
39-
// Reload via the list and confirm the items rehydrated with the
40-
// typed labels.
41-
await list.navigate('/widgets')
42-
await list.list.edit('Widget A')
43-
await expect(items.rows).toHaveCount(2)
44-
await expect(items.rows.nth(0).getByLabel('Label')).toHaveValue('first')
45-
await expect(items.rows.nth(1).getByLabel('Label')).toHaveValue('second')
46-
47-
// Confirm DB state matches.
48-
const persisted = await Widget.query()
49-
.findById(widget.$id() as number)
50-
.withGraphFetched('items')
51-
const persistedLabels = (persisted?.items ?? [])
52-
.map(i => i.label)
53-
.sort()
54-
expect(persistedLabels).toEqual(['first', 'second'])
55-
56-
// Delete the first item, save, reload, assert one remains with the
57-
// surviving label.
58-
await items.delete(0)
59-
await form.save()
60-
await list.navigate('/widgets')
61-
await list.list.edit('Widget A')
62-
await expect(items.rows).toHaveCount(1)
63-
await expect(items.rows.nth(0).getByLabel('Label')).toHaveValue('second')
64-
65-
const remaining = await Widget.query()
66-
.findById(widget.$id() as number)
67-
.withGraphFetched('items')
68-
const remainingLabels = (remaining?.items ?? []).map(i => i.label)
69-
expect(remainingLabels).toEqual(['second'])
70-
})
71-
})
1+
import './crud.js'
2+
import './drag-reorder.js'

tests/e2e/nested-list/app/views/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ export const widgets = createWidgetView<Widget>(
1010
items: {
1111
type: 'list',
1212
label: 'Items',
13+
orderKey: 'order',
1314
inlined: true,
1415
creatable: true,
1516
deletable: true,
17+
draggable: true,
1618
form: {
1719
type: 'form',
1820
components: {
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { ModelProperties } from '@ditojs/server'
22
import { Model } from '@ditojs/server'
3+
import type { QueryBuilder } from 'objection'
34

45
export interface Item {
56
id: number
67
widgetId: number
78
label: string
9+
order: number | null
810
}
911

1012
export class Item extends Model {
@@ -13,6 +15,12 @@ export class Item extends Model {
1315
// it on inserted children from the parent relation — the request body
1416
// doesn't carry it.
1517
widgetId: { type: 'integer', index: true, nullable: true },
16-
label: { type: 'string', required: true }
18+
label: { type: 'string', required: true },
19+
order: { type: 'integer', nullable: true }
20+
}
21+
22+
static override scopes = {
23+
ordered: (query: QueryBuilder<Item>) =>
24+
query.orderBy('order').orderBy('id')
1725
}
1826
}

tests/e2e/nested-list/models/Widget.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,20 @@ export class Widget extends Model {
2323
// graph save inserts/updates/deletes child rows with their data
2424
// (label, etc.) rather than treating incoming items as relate-by-id
2525
// references.
26-
owner: true
26+
owner: true,
27+
// Dito-specific: auto-applies `Item.scopes.ordered` whenever this
28+
// relation is fetched, so eager loads come back in `order` ASC
29+
// without callers having to remember the modifier name.
30+
scope: 'ordered'
2731
}
2832
}
2933

3034
static override scopes = {
3135
// Eager-load items so the admin form re-hydrates the nested list on
3236
// edit reload. Applied as `^withItems` on the Widgets controller in
33-
// fixtures.ts so every query goes through this scope.
37+
// fixtures.ts so every query goes through this scope. The relation
38+
// itself defines `scope: 'ordered'`, so items come back ordered
39+
// automatically — no `(ordered)` modifier suffix needed here.
3440
withItems: (query: QueryBuilder<Widget>) =>
3541
query.withGraphFetched('items')
3642
}

0 commit comments

Comments
 (0)