Files
windmill/frontend/src/lib/components/workspaceTree.test.ts
T
Guilhem 568dbbee85 fix(frontend): show friendly draft path for draft-only items in pickers (#10136)
* fix(frontend): show friendly draft path for draft-only items in pickers

* fix(frontend): dedupe current draft item and scope tab picker by friendly path

* fix(frontend): key live draft picker entries by storage path

* fix(frontend): fall back to the current leaf when the picker highlight key vanishes

* fix(frontend): remount session tab picker when the friendly scope arrives

* fix(frontend): stamp staged tab path for deployed items with undeployed renames

* fix(frontend): expose staged flow/raw-app renames through the live draft registration
2026-07-16 14:56:57 +02:00

534 lines
19 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { buildWorkspaceTree, legacyScopeToPath, relativizeWorkspacePath } from './workspaceTree'
import {
dirKey,
kindKey,
leafKeyFor,
type WorkspaceItem,
type WorkspaceItemKind
} from './workspacePicker'
import type { DrillBranch, DrillLeaf, DrillNode } from './drillPicker'
const item = (
kind: WorkspaceItemKind,
path: string,
summary?: string,
raw_app?: boolean
): WorkspaceItem => ({ kind, path, summary: summary ?? '', raw_app })
const isBranch = <L>(n: DrillNode<L> | undefined): n is DrillBranch<L> => !!n && n.type === 'branch'
const isLeaf = <L>(n: DrillNode<L> | undefined): n is DrillLeaf<L> => !!n && n.type === 'leaf'
const childKeys = <L>(b: DrillBranch<L>) => b.children.map((c) => c.key)
const findBranch = <L>(nodes: DrillNode<L>[], key: string): DrillBranch<L> => {
const n = nodes.find((x) => x.key === key)
if (!isBranch(n)) throw new Error(`expected branch ${key} in [${nodes.map((x) => x.key)}]`)
return n
}
describe('buildWorkspaceTree', () => {
describe('shape', () => {
it('returns an empty tree when kinds is empty', () => {
expect(buildWorkspaceTree({ loaded: {}, kinds: [], loadingKind: {} })).toEqual([])
})
it('multi-kind: prepends an All branch then per-kind branches', () => {
const tree = buildWorkspaceTree({
loaded: {
flow: [item('flow', 'f/demo/a')],
script: [item('script', 'f/demo/b')]
},
kinds: ['flow', 'script'],
loadingKind: {}
})
expect(tree.map((n) => n.key)).toEqual([kindKey('all'), kindKey('flow'), kindKey('script')])
})
it('All branch is omitFromSearch and labeled "All"', () => {
const tree = buildWorkspaceTree({
loaded: { flow: [item('flow', 'f/demo/a')], script: [] },
kinds: ['flow', 'script'],
loadingKind: {}
})
const all = findBranch(tree, kindKey('all'))
expect(all.omitFromSearch).toBe(true)
expect(all.label).toBe('All')
})
it('per-kind branches are searchGroup anchors', () => {
const tree = buildWorkspaceTree({
loaded: { flow: [item('flow', 'f/demo/a')], script: [] },
kinds: ['flow', 'script'],
loadingKind: {}
})
const flow = findBranch(tree, kindKey('flow'))
expect(flow.searchGroup).toBe(true)
})
it("single-kind: returns that kind branch's children directly (no kind-level)", () => {
const tree = buildWorkspaceTree({
loaded: { flow: [item('flow', 'f/demo/a'), item('flow', 'u/alice/b')] },
kinds: ['flow'],
loadingKind: {}
})
// At the top we should see the scope dirs (u/alice, f/demo) directly,
// not a single 'kind:flow' branch wrapping them.
expect(tree.every((n) => isBranch(n) && n.key.startsWith('dir:flow:'))).toBe(true)
// u-scopes come before f-scopes
expect(tree.map((n) => n.key)).toEqual([dirKey('flow', 'u/alice'), dirKey('flow', 'f/demo')])
})
})
describe('loading state', () => {
it('per-kind branch is loading=true when loaded[k] is undefined and loadingKind[k] is true', () => {
const tree = buildWorkspaceTree({
loaded: {},
kinds: ['flow', 'script'],
loadingKind: { flow: true }
})
const flow = findBranch(tree, kindKey('flow'))
expect(flow.loading).toBe(true)
})
it('per-kind branch is not loading once loaded[k] is set, even mid-refetch', () => {
const tree = buildWorkspaceTree({
loaded: { flow: [] },
kinds: ['flow', 'script'],
loadingKind: { flow: true }
})
const flow = findBranch(tree, kindKey('flow'))
expect(flow.loading).toBeFalsy()
})
it('All branch is loading when any kind is loading', () => {
const tree = buildWorkspaceTree({
loaded: { flow: [] },
kinds: ['flow', 'script'],
loadingKind: { script: true }
})
const all = findBranch(tree, kindKey('all'))
expect(all.loading).toBe(true)
})
})
describe('dir forest', () => {
it('groups leaves under their scope, then nested folders', () => {
const tree = buildWorkspaceTree({
loaded: {
flow: [
item('flow', 'f/demo/a'),
item('flow', 'f/demo/sub/b'),
item('flow', 'f/demo/sub/c'),
item('flow', 'u/alice/d')
]
},
kinds: ['flow'],
loadingKind: {}
})
// Top-level: u/alice (user scope), f/demo (folder scope)
expect(tree.map((n) => n.key)).toEqual([dirKey('flow', 'u/alice'), dirKey('flow', 'f/demo')])
const demo = findBranch(tree, dirKey('flow', 'f/demo'))
// Children: nested folder `sub` first, then leaf `a`
expect(childKeys(demo)).toEqual([
dirKey('flow', 'f/demo/sub'),
leafKeyFor('flow', 'f/demo/a')
])
const sub = findBranch(demo.children, dirKey('flow', 'f/demo/sub'))
expect(childKeys(sub)).toEqual([
leafKeyFor('flow', 'f/demo/sub/b'),
leafKeyFor('flow', 'f/demo/sub/c')
])
})
it('skips items with paths shorter than 3 segments', () => {
const tree = buildWorkspaceTree({
loaded: { flow: [item('flow', 'f/demo'), item('flow', 'f/demo/a')] },
kinds: ['flow'],
loadingKind: {}
})
const demo = findBranch(tree, dirKey('flow', 'f/demo'))
expect(childKeys(demo)).toEqual([leafKeyFor('flow', 'f/demo/a')])
})
})
describe('leaf shape', () => {
it('uses summary as label and path as secondary when summary is present', () => {
const tree = buildWorkspaceTree({
loaded: { flow: [item('flow', 'f/demo/a', 'Hello')] },
kinds: ['flow'],
loadingKind: {}
})
const demo = findBranch(tree, dirKey('flow', 'f/demo'))
const leaf = demo.children[0]
if (!isLeaf(leaf)) throw new Error('expected leaf')
expect(leaf.label).toBe('Hello')
expect(leaf.secondary).toBe('f/demo/a')
})
it('falls back to path as label when summary is empty', () => {
const tree = buildWorkspaceTree({
loaded: { flow: [item('flow', 'f/demo/a')] },
kinds: ['flow'],
loadingKind: {}
})
const demo = findBranch(tree, dirKey('flow', 'f/demo'))
const leaf = demo.children[0]
if (!isLeaf(leaf)) throw new Error('expected leaf')
expect(leaf.label).toBe('f/demo/a')
expect(leaf.secondary).toBeUndefined()
})
it('labels and groups a draft-only item by its friendly draftPath, keyed by storage path', () => {
const draft = { ...item('app', 'u/admin/draft_abc123'), draftPath: 'f/marketing/dashboard' }
const tree = buildWorkspaceTree({
loaded: { app: [draft] },
kinds: ['app'],
loadingKind: {}
})
// Grouped under the friendly folder, not u/admin.
expect(tree.map((n) => n.key)).toEqual([dirKey('app', 'f/marketing')])
const marketing = findBranch(tree, dirKey('app', 'f/marketing'))
const leaf = marketing.children[0]
if (!isLeaf(leaf)) throw new Error('expected leaf')
// Displayed by the friendly path; keyed (and navigated) by storage path.
expect(leaf.label).toBe('f/marketing/dashboard')
expect(leaf.key).toBe(leafKeyFor('app', 'u/admin/draft_abc123'))
expect(leaf.data.path).toBe('u/admin/draft_abc123')
})
it('uses the friendly draftPath as secondary when a summary is present', () => {
const draft = {
...item('script', 'u/admin/draft_xyz', 'My Script'),
draftPath: 'u/admin/my_script'
}
const tree = buildWorkspaceTree({
loaded: { script: [draft] },
kinds: ['script'],
loadingKind: {}
})
const admin = findBranch(tree, dirKey('script', 'u/admin'))
const leaf = admin.children[0]
if (!isLeaf(leaf)) throw new Error('expected leaf')
expect(leaf.label).toBe('My Script')
expect(leaf.secondary).toBe('u/admin/my_script')
})
it('marks the currentItem leaf with current=true', () => {
const tree = buildWorkspaceTree({
loaded: { flow: [item('flow', 'f/demo/a'), item('flow', 'f/demo/b')] },
kinds: ['flow'],
loadingKind: {},
currentItem: item('flow', 'f/demo/a')
})
const demo = findBranch(tree, dirKey('flow', 'f/demo'))
const [a, b] = demo.children
if (!isLeaf(a) || !isLeaf(b)) throw new Error('expected leaves')
expect(a.current).toBe(true)
expect(b.current).toBeFalsy()
})
})
describe('withCurrent: rename suppression', () => {
it('injects currentItem at its live path when not already in the list', () => {
const tree = buildWorkspaceTree({
loaded: { flow: [] },
kinds: ['flow'],
loadingKind: {},
currentItem: { ...item('flow', 'f/demo/new'), summary: 'My Flow' }
})
const demo = findBranch(tree, dirKey('flow', 'f/demo'))
expect(demo.children.map((c) => c.key)).toEqual([leafKeyFor('flow', 'f/demo/new')])
})
it('drops the savedPath entry during a mid-rename so only the live one shows', () => {
const tree = buildWorkspaceTree({
loaded: { flow: [item('flow', 'f/demo/old', 'My Flow')] },
kinds: ['flow'],
loadingKind: {},
currentItem: { ...item('flow', 'f/demo/new', 'My Flow'), savedPath: 'f/demo/old' }
})
const demo = findBranch(tree, dirKey('flow', 'f/demo'))
const paths = demo.children.map((c) => c.key)
expect(paths).toContain(leafKeyFor('flow', 'f/demo/new'))
expect(paths).not.toContain(leafKeyFor('flow', 'f/demo/old'))
})
it('does not duplicate a draft-only row whose friendly draftPath is the current live path', () => {
// Editor open on a renamed draft-only script: currentItem.path is the
// friendly path while listScripts returns the storage-path row carrying
// the same friendly path as draftPath. One leaf, marked current.
const loadedDraft = {
...item('script', 'u/admin/draft_abc'),
draftPath: 'u/admin/my_script'
}
const tree = buildWorkspaceTree({
loaded: { script: [loadedDraft] },
kinds: ['script'],
loadingKind: {},
currentItem: item('script', 'u/admin/my_script')
})
const admin = findBranch(tree, dirKey('script', 'u/admin'))
expect(admin.children.map((c) => c.key)).toEqual([leafKeyFor('script', 'u/admin/draft_abc')])
const leaf = admin.children[0]
if (!isLeaf(leaf)) throw new Error('expected leaf')
expect(leaf.current).toBe(true)
})
it('drops the storage-path row via draftPath during a mid-rename', () => {
// Renaming a draft-only item: savedPath is the old friendly path, which
// the loaded row only knows as its draftPath. The stale row must go so
// only the live (typed) entry shows.
const loadedDraft = {
...item('script', 'u/admin/draft_abc'),
draftPath: 'u/admin/old_name'
}
const tree = buildWorkspaceTree({
loaded: { script: [loadedDraft] },
kinds: ['script'],
loadingKind: {},
currentItem: { ...item('script', 'u/admin/new_name'), savedPath: 'u/admin/old_name' }
})
const admin = findBranch(tree, dirKey('script', 'u/admin'))
expect(admin.children.map((c) => c.key)).toEqual([leafKeyFor('script', 'u/admin/new_name')])
})
it('does not re-inject when the live entry already exists in loaded', () => {
const tree = buildWorkspaceTree({
loaded: { flow: [item('flow', 'f/demo/a', 'Original')] },
kinds: ['flow'],
loadingKind: {},
currentItem: item('flow', 'f/demo/a', 'Original')
})
const demo = findBranch(tree, dirKey('flow', 'f/demo'))
expect(demo.children.length).toBe(1)
})
it('passes other-kind items through untouched', () => {
const tree = buildWorkspaceTree({
loaded: { flow: [item('flow', 'f/demo/a')], script: [item('script', 'f/demo/b')] },
kinds: ['flow', 'script'],
loadingKind: {},
currentItem: { ...item('flow', 'f/demo/new'), savedPath: 'f/demo/old' }
})
const script = findBranch(tree, kindKey('script'))
const demo = findBranch(script.children, dirKey('script', 'f/demo'))
expect(demo.children.map((c) => c.key)).toEqual([leafKeyFor('script', 'f/demo/b')])
})
})
describe('extraItemsByKind (drafts)', () => {
it('merges extras alongside loaded items', () => {
const tree = buildWorkspaceTree({
loaded: { flow: [item('flow', 'f/demo/a')] },
kinds: ['flow'],
loadingKind: {},
extraItemsByKind: { flow: [item('flow', 'f/demo/draft')] }
})
const demo = findBranch(tree, dirKey('flow', 'f/demo'))
expect(demo.children.map((c) => c.key).sort()).toEqual(
[leafKeyFor('flow', 'f/demo/a'), leafKeyFor('flow', 'f/demo/draft')].sort()
)
})
it('drops extras whose path collides with a loaded item (loaded wins)', () => {
const tree = buildWorkspaceTree({
loaded: { flow: [item('flow', 'f/demo/a', 'Backend summary')] },
kinds: ['flow'],
loadingKind: {},
extraItemsByKind: { flow: [item('flow', 'f/demo/a', 'Draft summary')] }
})
const demo = findBranch(tree, dirKey('flow', 'f/demo'))
expect(demo.children.length).toBe(1)
const leaf = demo.children[0]
if (!isLeaf(leaf)) throw new Error('expected leaf')
expect(leaf.label).toBe('Backend summary')
})
it('extras flow into the cross-kind All branch too', () => {
const tree = buildWorkspaceTree({
loaded: { flow: [], script: [item('script', 'f/demo/b')] },
kinds: ['flow', 'script'],
loadingKind: {},
extraItemsByKind: { flow: [item('flow', 'f/demo/draft')] }
})
const all = findBranch(tree, kindKey('all'))
const demo = findBranch(all.children, dirKey('all', 'f/demo'))
const keys = demo.children.map((c) => c.key)
expect(keys).toContain(leafKeyFor('flow', 'f/demo/draft'))
expect(keys).toContain(leafKeyFor('script', 'f/demo/b'))
})
it('drops a live-cell extra at the friendly path when a loaded row carries it as draftPath', () => {
// listApps returns the draft-only row at its storage path with the
// friendly path in draftPath; the live editor cell surfaces the same
// draft as an extra keyed by the friendly path. One leaf, not two.
const loadedDraft = { ...item('app', 'u/admin/draft_abc'), draftPath: 'u/admin/dashboard' }
const tree = buildWorkspaceTree({
loaded: { app: [loadedDraft] },
kinds: ['app'],
loadingKind: {},
extraItemsByKind: { app: [item('app', 'u/admin/dashboard')] }
})
const admin = findBranch(tree, dirKey('app', 'u/admin'))
expect(admin.children.map((c) => c.key)).toEqual([leafKeyFor('app', 'u/admin/draft_abc')])
})
it('folds a mid-rename live extra into the stale loaded row: one storage-keyed leaf under the typed folder', () => {
// Session picker while a rename's autosave is pending: listApps still
// carries the pre-rename friendly path, the live cell extra (re-keyed to
// the storage path by the picker) carries the typed one, and the tab's
// currentItem is the storage path. The typed name must win, on a single
// leaf that navigates via the storage path — never the display path.
const staleLoaded = { ...item('app', 'u/admin/draft_abc'), draftPath: 'u/admin/old_name' }
const liveExtra = { ...item('app', 'u/admin/draft_abc'), draftPath: 'f/marketing/new_name' }
const tree = buildWorkspaceTree({
loaded: { app: [staleLoaded] },
kinds: ['app'],
loadingKind: {},
extraItemsByKind: { app: [liveExtra] },
currentItem: item('app', 'u/admin/draft_abc')
})
expect(tree.map((n) => n.key)).toEqual([dirKey('app', 'f/marketing')])
const marketing = findBranch(tree, dirKey('app', 'f/marketing'))
expect(marketing.children.map((c) => c.key)).toEqual([leafKeyFor('app', 'u/admin/draft_abc')])
const leaf = marketing.children[0]
if (!isLeaf(leaf)) throw new Error('expected leaf')
expect(leaf.current).toBe(true)
expect(leaf.data.path).toBe('u/admin/draft_abc')
expect(leaf.label).toBe('f/marketing/new_name')
})
it('is a no-op when extras are absent or empty', () => {
const noOpts = buildWorkspaceTree({
loaded: { flow: [item('flow', 'f/demo/a')] },
kinds: ['flow'],
loadingKind: {}
})
const emptyExtras = buildWorkspaceTree({
loaded: { flow: [item('flow', 'f/demo/a')] },
kinds: ['flow'],
loadingKind: {},
extraItemsByKind: { flow: [] }
})
expect(JSON.stringify(noOpts)).toEqual(JSON.stringify(emptyExtras))
})
})
describe('flat layout', () => {
it('roots the cross-kind scope dirs directly (no All / kind branches)', () => {
const tree = buildWorkspaceTree({
loaded: {
flow: [item('flow', 'f/demo/a'), item('flow', 'u/alice/b')],
script: [item('script', 'f/demo/c')]
},
kinds: ['flow', 'script'],
loadingKind: {},
layout: 'flat'
})
// u-scopes before f-scopes, keyed under the 'all' namespace.
expect(tree.map((n) => n.key)).toEqual([dirKey('all', 'u/alice'), dirKey('all', 'f/demo')])
})
it('mixes every kind inside the same scope dir', () => {
const tree = buildWorkspaceTree({
loaded: {
flow: [item('flow', 'f/demo/a')],
script: [item('script', 'f/demo/b')]
},
kinds: ['flow', 'script'],
loadingKind: {},
layout: 'flat'
})
const demo = findBranch(tree, dirKey('all', 'f/demo'))
expect(childKeys(demo)).toEqual([
leafKeyFor('flow', 'f/demo/a'),
leafKeyFor('script', 'f/demo/b')
])
})
it('applies extras and currentItem like the by-kind layout', () => {
const tree = buildWorkspaceTree({
loaded: { flow: [item('flow', 'f/demo/a')], script: [] },
kinds: ['flow', 'script'],
loadingKind: {},
extraItemsByKind: { script: [item('script', 'f/demo/draft')] },
currentItem: item('flow', 'f/demo/a'),
layout: 'flat'
})
const demo = findBranch(tree, dirKey('all', 'f/demo'))
const leaves = demo.children.filter(isLeaf)
expect(leaves.map((l) => l.key)).toEqual([
leafKeyFor('flow', 'f/demo/a'),
leafKeyFor('script', 'f/demo/draft')
])
expect(leaves[0].current).toBe(true)
})
})
})
describe('legacyScopeToPath', () => {
it('returns [] for undefined scope', () => {
expect(legacyScopeToPath(undefined, ['flow', 'script'])).toEqual([])
})
it('multi-kind: returns [kindKey] for a kind-only scope', () => {
expect(legacyScopeToPath({ kind: 'flow' }, ['flow', 'script'])).toEqual([kindKey('flow')])
})
it('multi-kind: returns [kindKey, dirKey] for a kind+dir scope', () => {
expect(legacyScopeToPath({ kind: 'flow', dir: 'f/demo' }, ['flow', 'script'])).toEqual([
kindKey('flow'),
dirKey('flow', 'f/demo')
])
})
it('multi-kind: handles `all` as a kind', () => {
expect(legacyScopeToPath({ kind: 'all', dir: 'f/demo' }, ['flow', 'script'])).toEqual([
kindKey('all'),
dirKey('all', 'f/demo')
])
})
it('single-kind: returns [] for a kind-only scope (no kind level in tree)', () => {
expect(legacyScopeToPath({ kind: 'flow' }, ['flow'])).toEqual([])
})
it('single-kind: returns [dirKey] for a kind+dir scope', () => {
expect(legacyScopeToPath({ kind: 'flow', dir: 'f/demo' }, ['flow'])).toEqual([
dirKey('flow', 'f/demo')
])
})
it('flat: returns [dirKey under all] for a dir scope, ignoring the kind', () => {
expect(legacyScopeToPath({ kind: 'flow', dir: 'f/demo' }, ['flow', 'script'], 'flat')).toEqual([
dirKey('all', 'f/demo')
])
})
it('flat: returns [] without a dir', () => {
expect(legacyScopeToPath({ kind: 'all' }, ['flow', 'script'], 'flat')).toEqual([])
})
})
describe('relativizeWorkspacePath', () => {
it('returns the absolute path when scope has no dir segment', () => {
expect(relativizeWorkspacePath('f/demo/a', [])).toBe('f/demo/a')
expect(relativizeWorkspacePath('f/demo/a', [kindKey('flow')])).toBe('f/demo/a')
})
it('shortens to the path relative to the deepest dir scope', () => {
const scope = [kindKey('flow'), dirKey('flow', 'f/demo')]
expect(relativizeWorkspacePath('f/demo/a', scope)).toBe('a')
})
it('uses the DEEPEST dir scope when there are nested ones', () => {
const scope = [kindKey('flow'), dirKey('flow', 'f/demo'), dirKey('flow', 'f/demo/sub')]
expect(relativizeWorkspacePath('f/demo/sub/b', scope)).toBe('b')
})
it('falls back to absolute path when the leaf is not under the dir scope', () => {
const scope = [kindKey('flow'), dirKey('flow', 'f/demo')]
expect(relativizeWorkspacePath('f/other/a', scope)).toBe('f/other/a')
})
})