feat: allow manual saved view ordering
This commit is contained in:
81
src/utils/viewOrdering.test.ts
Normal file
81
src/utils/viewOrdering.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ViewFile } from '../types'
|
||||
import {
|
||||
buildViewOrderUpdates,
|
||||
canMoveView,
|
||||
moveView,
|
||||
nextViewOrder,
|
||||
orderViewsByFilename,
|
||||
} from './viewOrdering'
|
||||
|
||||
function makeView(filename: string, name: string, order: number | null = null): ViewFile {
|
||||
return {
|
||||
filename,
|
||||
definition: {
|
||||
name,
|
||||
icon: null,
|
||||
color: null,
|
||||
order,
|
||||
sort: null,
|
||||
filters: { all: [] },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('viewOrdering', () => {
|
||||
const views = [
|
||||
makeView('alpha.yml', 'Alpha', 0),
|
||||
makeView('beta.yml', 'Beta', 1),
|
||||
makeView('gamma.yml', 'Gamma', 2),
|
||||
]
|
||||
|
||||
it('moves a view up or down without mutating the input order', () => {
|
||||
expect(moveView(views, 'beta.yml', 'up')?.map((view) => view.filename)).toEqual([
|
||||
'beta.yml',
|
||||
'alpha.yml',
|
||||
'gamma.yml',
|
||||
])
|
||||
expect(moveView(views, 'beta.yml', 'down')?.map((view) => view.filename)).toEqual([
|
||||
'alpha.yml',
|
||||
'gamma.yml',
|
||||
'beta.yml',
|
||||
])
|
||||
expect(views.map((view) => view.filename)).toEqual(['alpha.yml', 'beta.yml', 'gamma.yml'])
|
||||
})
|
||||
|
||||
it('blocks moves outside the list bounds', () => {
|
||||
expect(canMoveView(views, 'alpha.yml', 'up')).toBe(false)
|
||||
expect(canMoveView(views, 'gamma.yml', 'down')).toBe(false)
|
||||
expect(moveView(views, 'missing.yml', 'up')).toBeNull()
|
||||
})
|
||||
|
||||
it('orders views by a complete filename list', () => {
|
||||
expect(orderViewsByFilename(views, ['gamma.yml', 'alpha.yml', 'beta.yml'])?.map((view) => view.filename)).toEqual([
|
||||
'gamma.yml',
|
||||
'alpha.yml',
|
||||
'beta.yml',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects incomplete, duplicate, or unknown filename orders', () => {
|
||||
expect(orderViewsByFilename(views, ['alpha.yml', 'beta.yml'])).toBeNull()
|
||||
expect(orderViewsByFilename(views, ['alpha.yml', 'alpha.yml', 'gamma.yml'])).toBeNull()
|
||||
expect(orderViewsByFilename(views, ['alpha.yml', 'beta.yml', 'missing.yml'])).toBeNull()
|
||||
})
|
||||
|
||||
it('builds dense order updates while preserving view definitions', () => {
|
||||
expect(buildViewOrderUpdates([views[2], views[0]]).map(({ filename, definition }) => ({
|
||||
filename,
|
||||
name: definition.name,
|
||||
order: definition.order,
|
||||
}))).toEqual([
|
||||
{ filename: 'gamma.yml', name: 'Gamma', order: 0 },
|
||||
{ filename: 'alpha.yml', name: 'Alpha', order: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('places new views after the highest explicit order', () => {
|
||||
expect(nextViewOrder(views)).toBe(3)
|
||||
expect(nextViewOrder([makeView('untitled.yml', 'Untitled')])).toBe(1)
|
||||
})
|
||||
})
|
||||
62
src/utils/viewOrdering.ts
Normal file
62
src/utils/viewOrdering.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { ViewDefinition, ViewFile } from '../types'
|
||||
|
||||
export type ViewMoveDirection = 'up' | 'down'
|
||||
export type ViewMoveHandler = (filename: string, direction: ViewMoveDirection) => Promise<void> | void
|
||||
export type ViewReorderHandler = (orderedFilenames: string[]) => Promise<void> | void
|
||||
|
||||
export interface ViewOrderUpdate {
|
||||
filename: string
|
||||
definition: ViewDefinition
|
||||
}
|
||||
|
||||
function orderedViewIndex(views: ViewFile[], filename: string): number {
|
||||
return views.findIndex((view) => view.filename === filename)
|
||||
}
|
||||
|
||||
function destinationIndex(index: number, direction: ViewMoveDirection): number {
|
||||
return direction === 'up' ? index - 1 : index + 1
|
||||
}
|
||||
|
||||
export function canMoveView(views: ViewFile[], filename: string, direction: ViewMoveDirection): boolean {
|
||||
const index = orderedViewIndex(views, filename)
|
||||
if (index === -1) return false
|
||||
const nextIndex = destinationIndex(index, direction)
|
||||
return nextIndex >= 0 && nextIndex < views.length
|
||||
}
|
||||
|
||||
export function moveView(views: ViewFile[], filename: string, direction: ViewMoveDirection): ViewFile[] | null {
|
||||
if (!canMoveView(views, filename, direction)) return null
|
||||
|
||||
const index = orderedViewIndex(views, filename)
|
||||
const nextIndex = destinationIndex(index, direction)
|
||||
const reordered = [...views]
|
||||
const [view] = reordered.splice(index, 1)
|
||||
reordered.splice(nextIndex, 0, view)
|
||||
return reordered
|
||||
}
|
||||
|
||||
export function orderViewsByFilename(views: ViewFile[], orderedFilenames: string[]): ViewFile[] | null {
|
||||
if (views.length !== orderedFilenames.length) return null
|
||||
if (new Set(orderedFilenames).size !== orderedFilenames.length) return null
|
||||
|
||||
const viewByFilename = new Map(views.map((view) => [view.filename, view]))
|
||||
const ordered = orderedFilenames.map((filename) => viewByFilename.get(filename))
|
||||
if (ordered.some((view) => view === undefined)) return null
|
||||
|
||||
return ordered as ViewFile[]
|
||||
}
|
||||
|
||||
export function buildViewOrderUpdates(views: ViewFile[]): ViewOrderUpdate[] {
|
||||
return views.map((view, order) => ({
|
||||
filename: view.filename,
|
||||
definition: { ...view.definition, order },
|
||||
}))
|
||||
}
|
||||
|
||||
export function nextViewOrder(views: ViewFile[]): number {
|
||||
const explicitOrders = views
|
||||
.map((view) => view.definition.order)
|
||||
.filter((order): order is number => typeof order === 'number' && Number.isFinite(order))
|
||||
|
||||
return explicitOrders.length > 0 ? Math.max(...explicitOrders) + 1 : views.length
|
||||
}
|
||||
Reference in New Issue
Block a user