feat(18.1-01): task 4 flat reorder in the admin SPA
- ReorderView reads flat from the reorder data; ReorderBranch takes a flat prop and shows only move up and move down - ArrowRight and ArrowLeft do nothing, an inside drop lands after the row (tree.ts flatZone, flatMove) and flatten sends parent_id null - vitest flat cases; docs describe the flat page; rebuilt dist
This commit is contained in:
@@ -1,26 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
import { ChevronDown, ChevronUp, GripVertical, IndentDecrease, IndentIncrease } from '@lucide/vue'
|
||||
import { t } from '../../app/i18n'
|
||||
import { REORDER_PAGE } from './context'
|
||||
import type { Move, TreeNode } from './tree'
|
||||
import { flatMove, type Move, type TreeNode } from './tree'
|
||||
|
||||
// One level of the reorder tree (UI-SPEC "Tree"): a list item per node with
|
||||
// its row (drag handle, name, move up, move down, outdent, indent) and its
|
||||
// children's list inside it. The page owns the tree and every move.
|
||||
defineProps<{ nodes: TreeNode[]; depth: number }>()
|
||||
// children's list inside it. The page owns the tree and every move. A flat
|
||||
// reorder (D-17) shows only move up and move down.
|
||||
const props = withDefaults(defineProps<{ nodes: TreeNode[]; depth: number; flat?: boolean }>(), { flat: false })
|
||||
|
||||
const page = inject(REORDER_PAGE)!
|
||||
|
||||
const iconButton =
|
||||
'inline-flex size-8 shrink-0 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text disabled:cursor-not-allowed disabled:opacity-40'
|
||||
|
||||
const moves: Array<{ move: Move; icon: typeof ChevronUp; label: string; action: string }> = [
|
||||
const allMoves: Array<{ move: Move; icon: typeof ChevronUp; label: string; action: string }> = [
|
||||
{ move: 'up', icon: ChevronUp, label: 'backend::lang.reorder.move_up', action: 'reorder-up' },
|
||||
{ move: 'down', icon: ChevronDown, label: 'backend::lang.reorder.move_down', action: 'reorder-down' },
|
||||
{ move: 'outdent', icon: IndentDecrease, label: 'backend::lang.reorder.outdent', action: 'reorder-outdent' },
|
||||
{ move: 'indent', icon: IndentIncrease, label: 'backend::lang.reorder.indent', action: 'reorder-indent' },
|
||||
]
|
||||
const moves = computed(() => allMoves.filter((item) => flatMove(item.move, props.flat)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -80,7 +82,7 @@ const moves: Array<{ move: Move; icon: typeof ChevronUp; label: string; action:
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ReorderBranch v-if="node.children.length > 0" :nodes="node.children" :depth="depth + 1" />
|
||||
<ReorderBranch v-if="node.children.length > 0" :nodes="node.children" :depth="depth + 1" :flat="flat" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
@@ -58,12 +58,15 @@ export function buildTree(nodes: readonly AdminReorderNode[]): TreeNode[] {
|
||||
return roots
|
||||
}
|
||||
|
||||
/** The tree as sent: pre-order, sort_order counting from 1, parent null at the top. */
|
||||
export function flatten(tree: readonly TreeNode[]): FlatNode[] {
|
||||
/**
|
||||
* The tree as sent: pre-order, sort_order counting from 1, parent null at the
|
||||
* top. A flat reorder (D-17) sends parent null on every node.
|
||||
*/
|
||||
export function flatten(tree: readonly TreeNode[], flat = false): FlatNode[] {
|
||||
const out: FlatNode[] = []
|
||||
const walk = (list: readonly TreeNode[], parent: number | null) => {
|
||||
for (const node of list) {
|
||||
out.push({ id: node.id, parent_id: parent, sort_order: out.length + 1 })
|
||||
out.push({ id: node.id, parent_id: flat ? null : parent, sort_order: out.length + 1 })
|
||||
walk(node.children, node.id)
|
||||
}
|
||||
}
|
||||
@@ -150,6 +153,16 @@ export function applyMove(tree: TreeNode[], id: number, move: Move): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** The drop zone a flat reorder (D-17) uses: it never nests, so inside lands after the row. */
|
||||
export function flatZone(zone: Zone, flat: boolean): Zone {
|
||||
return flat && zone === 'inside' ? 'after' : zone
|
||||
}
|
||||
|
||||
/** The moves a flat reorder offers: siblings only, never indent or outdent. */
|
||||
export function flatMove(move: Move, flat: boolean): boolean {
|
||||
return !flat || move === 'up' || move === 'down'
|
||||
}
|
||||
|
||||
/** Whether node `dragged` may land on `target` in zone: never in its own subtree. */
|
||||
export function canDrop(tree: TreeNode[], dragged: number, target: number): boolean {
|
||||
const at = locate(tree, dragged)
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
canDrop,
|
||||
canMove,
|
||||
cloneTree,
|
||||
flatMove,
|
||||
flatZone,
|
||||
flatten,
|
||||
locate,
|
||||
buildTree,
|
||||
@@ -36,7 +38,10 @@ import { showToast } from '../state/useToasts'
|
||||
// than the screen. A failed save restores the last saved tree unless a later
|
||||
// save (which carries the whole tree) is waiting; a 409 (the records changed
|
||||
// meanwhile) reloads it. A response that arrives after the page reloaded or
|
||||
// switched partition leaves the screen alone.
|
||||
// switched partition leaves the screen alone. A flat reorder (D-17, the
|
||||
// data's `flat`) is a plain ordered list: no indent, outdent or inside drop,
|
||||
// ArrowRight and ArrowLeft do nothing, and every node is sent with
|
||||
// parent_id null.
|
||||
const SAVE_DEBOUNCE_MS = 400
|
||||
const SAVED_SHOWN_MS = 2000
|
||||
|
||||
@@ -56,6 +61,7 @@ type Status = 'loading' | 'ready' | 'failed' | 'forbidden'
|
||||
const status = ref<Status>('loading')
|
||||
const title = ref('')
|
||||
const partition = ref<AdminReorderPartition | null>(null)
|
||||
const flat = ref(false)
|
||||
const tree = ref<TreeNode[]>([])
|
||||
// The tree as last saved (or loaded): a failed save goes back to it.
|
||||
let savedTree: TreeNode[] = []
|
||||
@@ -99,6 +105,7 @@ async function load(): Promise<void> {
|
||||
}
|
||||
title.value = data.data.title
|
||||
partition.value = (data.data.partition as AdminReorderPartition | null) ?? null
|
||||
flat.value = data.data.flat === true
|
||||
tree.value = buildTree(data.data.nodes ?? [])
|
||||
savedTree = cloneTree(tree.value)
|
||||
status.value = 'ready'
|
||||
@@ -149,7 +156,7 @@ function failed(job: SaveJob): void {
|
||||
|
||||
async function send(job: SaveJob): Promise<void> {
|
||||
inFlight = true
|
||||
const body = { nodes: flatten(job.sent), partition: job.partition } as unknown as AdminReorderRequest
|
||||
const body = { nodes: flatten(job.sent, flat.value), partition: job.partition } as unknown as AdminReorderRequest
|
||||
savedShown.value = false
|
||||
saving.value++
|
||||
try {
|
||||
@@ -239,6 +246,9 @@ async function moved(id: number, kind: 'moved' | 'nested' | 'unnested'): Promise
|
||||
}
|
||||
|
||||
function move(id: number, kind: Move): void {
|
||||
if (!flatMove(kind, flat.value)) {
|
||||
return
|
||||
}
|
||||
const next = cloneTree(tree.value)
|
||||
if (!applyMove(next, id, kind)) {
|
||||
return
|
||||
@@ -251,7 +261,7 @@ const KEY_MOVES: Record<string, Move> = { ArrowUp: 'up', ArrowDown: 'down', Arro
|
||||
|
||||
function onHandleKeydown(event: KeyboardEvent, id: number): void {
|
||||
const kind = KEY_MOVES[event.key]
|
||||
if (kind) {
|
||||
if (kind && flatMove(kind, flat.value)) {
|
||||
event.preventDefault()
|
||||
move(id, kind)
|
||||
}
|
||||
@@ -288,7 +298,7 @@ function onDragOver(event: DragEvent, id: number): void {
|
||||
const box = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const offset = event.clientY - box.top
|
||||
const zone: Zone = offset < box.height / 3 ? 'before' : offset > (box.height * 2) / 3 ? 'after' : 'inside'
|
||||
dropTarget.value = { id, zone }
|
||||
dropTarget.value = { id, zone: flatZone(zone, flat.value) }
|
||||
}
|
||||
|
||||
function onDragLeave(id: number): void {
|
||||
@@ -324,7 +334,7 @@ provide(REORDER_PAGE, {
|
||||
dropTarget,
|
||||
blocked,
|
||||
nameOf,
|
||||
canMove: (id: number, kind: Move) => canMove(tree.value, id, kind),
|
||||
canMove: (id: number, kind: Move) => flatMove(kind, flat.value) && canMove(tree.value, id, kind),
|
||||
move,
|
||||
onHandleKeydown,
|
||||
onDragStart,
|
||||
@@ -437,7 +447,14 @@ void load()
|
||||
<p class="font-semibold">{{ t('backend::lang.reorder.empty') }}</p>
|
||||
<p class="max-w-[420px] text-[13px] text-muted">{{ t('backend::lang.reorder.empty_hint') }}</p>
|
||||
</div>
|
||||
<ReorderBranch v-else :nodes="tree" :depth="0" :aria-label="t('backend::lang.reorder.tree')" data-reorder-tree />
|
||||
<ReorderBranch
|
||||
v-else
|
||||
:nodes="tree"
|
||||
:depth="0"
|
||||
:flat="flat"
|
||||
:aria-label="t('backend::lang.reorder.tree')"
|
||||
data-reorder-tree
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="sr-only" aria-live="polite" data-reorder-live>{{ announcement }}</p>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref } from 'vue'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import ReorderBranch from '../../src/components/reorder/ReorderBranch.vue'
|
||||
import { REORDER_PAGE, type ReorderPage } from '../../src/components/reorder/context'
|
||||
import { applyDrop, applyMove, buildTree, canDrop, canMove, flatten, type TreeNode } from '../../src/components/reorder/tree'
|
||||
import { applyDrop, applyMove, buildTree, canDrop, canMove, flatMove, flatZone, flatten, type TreeNode } from '../../src/components/reorder/tree'
|
||||
import { setSettings } from '../../src/state/useSettings'
|
||||
import ReorderView from '../../src/views/ReorderView.vue'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
@@ -465,6 +465,90 @@ describe('reorder tree rules', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Phase 18.1 smoke specs for the flat (WinterCMS Sortable) reorder (D-17):
|
||||
// the data's `flat` turns the page into a plain ordered list.
|
||||
function flatTree(): Reply {
|
||||
return {
|
||||
body: {
|
||||
data: {
|
||||
title: 'Reorder links',
|
||||
partition: null,
|
||||
flat: true,
|
||||
nodes: [
|
||||
{ id: 1, name: 'Alpha', parent_id: null, sort_order: 1 },
|
||||
{ id: 2, name: 'Beta', parent_id: null, sort_order: 2 },
|
||||
{ id: 3, name: 'Gamma', parent_id: null, sort_order: 3 },
|
||||
],
|
||||
},
|
||||
meta: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('ReorderView flat', () => {
|
||||
it('offers no indent or outdent, ignores ArrowRight, drops a middle drag after the row and sends parent_id null', async () => {
|
||||
let body: unknown = null
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/reorder',
|
||||
{
|
||||
[`GET ${REORDER}`]: flatTree(),
|
||||
[`POST ${REORDER}`]: async (request) => {
|
||||
body = await request.json()
|
||||
return flatTree()
|
||||
},
|
||||
},
|
||||
{ attach: true },
|
||||
)
|
||||
expect(shape(wrapper)).toBe('Alpha Beta Gamma')
|
||||
expect(wrapper.find('[data-action="reorder-indent"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="reorder-outdent"]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('[data-action="reorder-up"]')).toHaveLength(3)
|
||||
expect(wrapper.findAll('[data-action="reorder-down"]')).toHaveLength(3)
|
||||
|
||||
keydown(handle(wrapper, 2).element, 'ArrowRight')
|
||||
keydown(handle(wrapper, 2).element, 'ArrowLeft')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Alpha Beta Gamma')
|
||||
|
||||
// A drag of Alpha onto the middle third of Beta's row lands after Beta.
|
||||
const row = wrapper.find('[data-reorder-node="2"]')
|
||||
vi.spyOn(row.element, 'getBoundingClientRect').mockReturnValue({ top: 0, height: 30, bottom: 30, left: 0, right: 100, width: 100, x: 0, y: 0, toJSON: () => ({}) } as DOMRect)
|
||||
await handle(wrapper, 1).trigger('dragstart')
|
||||
await row.trigger('dragover', { clientY: 15 })
|
||||
expect(row.find('[data-reorder-line]').exists()).toBe(true)
|
||||
await row.trigger('drop')
|
||||
await flushPromises()
|
||||
expect(shape(wrapper)).toBe('Beta Alpha Gamma')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1)
|
||||
})
|
||||
expect(body).toEqual({
|
||||
partition: null,
|
||||
nodes: [
|
||||
{ id: 2, parent_id: null, sort_order: 1 },
|
||||
{ id: 1, parent_id: null, sort_order: 2 },
|
||||
{ id: 3, parent_id: null, sort_order: 3 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('maps the inside zone to after, keeps sibling moves and flattens with null parents', () => {
|
||||
expect(flatZone('inside', true)).toBe('after')
|
||||
expect(flatZone('inside', false)).toBe('inside')
|
||||
expect(flatZone('before', true)).toBe('before')
|
||||
expect(flatMove('indent', true)).toBe(false)
|
||||
expect(flatMove('outdent', true)).toBe(false)
|
||||
expect(flatMove('up', true)).toBe(true)
|
||||
expect(flatMove('indent', false)).toBe(true)
|
||||
const nested: TreeNode[] = [{ id: 1, name: 'A', children: [{ id: 2, name: 'B', children: [] }] }]
|
||||
expect(flatten(nested, true)).toEqual([
|
||||
{ id: 1, parent_id: null, sort_order: 1 },
|
||||
{ id: 2, parent_id: null, sort_order: 2 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// The reorder modules imported by path (T-10-25): the page the route renders,
|
||||
// its tree level and the injection key they share.
|
||||
describe('reorder modules', () => {
|
||||
|
||||
@@ -241,6 +241,8 @@ The reorder is flat when the file names no `parentColumn` and the controller's r
|
||||
|
||||
The list schema's `reorder` and the data of both reorder routes carry `flat: true`; a tree reorder omits the key, so its payloads are unchanged. Every node of a flat reorder has `parent_id: null`. A save writes only the sort column, without running the model's save hooks. A node whose `parent_id` is not null is a 422 `validation_failed` on `nodes` with `reorder.flat_parent`; the duplicate-id 422 and the 409 `reorder_stale` set rule are the same as on a tree.
|
||||
|
||||
In the admin, a flat reorder page is a plain ordered list: every row has the drag handle and the move up and move down buttons, but no outdent or indent buttons; ArrowUp and ArrowDown on a handle move the row, ArrowRight and ArrowLeft do nothing, and a row dragged onto the middle of another lands after it. Saving works as on a tree, with `parent_id: null` on every node.
|
||||
|
||||
## Remembered list state
|
||||
|
||||
The admin keeps a list's search, sort, filters, page and page size in the URL, so a filtered list can be bookmarked or shared. It also remembers the last of these per list in the browser's local storage. When an administrator opens a list without any of these parameters in the URL (from the navigation, a breadcrumb or a form's back, save or delete), the admin restores the remembered query. A URL that carries list parameters wins and replaces the remembered query.
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
modules/boardwalk/dist/index.html
vendored
2
modules/boardwalk/dist/index.html
vendored
@@ -7,7 +7,7 @@
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<link rel="icon" href="./favicon.ico" sizes="48x48" />
|
||||
<title>SummerCMS</title>
|
||||
<script type="module" crossorigin src="./assets/index-CpHJmq2q.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-Bu2-b1tB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CS4sv1WG.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user