mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-22 00:03:35 +00:00
* feat: add tree view to the schedules page Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep schedule job previews visible inside tree folders Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep job preview loading while hovered and close it on scroll Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep schedules outside u/ and f/ in the tree view Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: add tree view to the trigger list pages (#11400) * feat: add tree view to the trigger list pages Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor: let a TreeViewState own the tree view setting Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: drop the doubled bottom border at the end of a tree Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
192 lines
5.7 KiB
TypeScript
192 lines
5.7 KiB
TypeScript
import type { ListableApp, Script, Flow, ListableRawApp } from '$lib/gen'
|
|
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
|
|
canWrite: boolean
|
|
marked?: string
|
|
type?: U
|
|
time?: number
|
|
starred?: boolean
|
|
// Server fetch ordinal (see ItemsList) — the tree sorts leaves by it to preserve
|
|
// the endpoint's order rather than re-deriving it.
|
|
ord?: number
|
|
}
|
|
|
|
type TableScript = TableItem<Script, 'script'>
|
|
type TableFlow = TableItem<Flow, 'flow'>
|
|
type TableApp = TableItem<ListableApp, 'app'>
|
|
type TableRawApp = TableItem<ListableRawApp, 'raw_app'>
|
|
|
|
export type ItemType = TableScript | TableFlow | TableApp | TableRawApp
|
|
|
|
export interface FolderItem {
|
|
folderName: string
|
|
items: (ItemType | FolderItem)[]
|
|
}
|
|
|
|
export type UserItem = {
|
|
username: string
|
|
items: (ItemType | FolderItem)[]
|
|
}
|
|
|
|
/**
|
|
* Where an item belongs: its owner, folder, and the name it is filtered and searched by.
|
|
* A draft-only item is parked at a generated `u/<you>/draft_<uuid>` but names the path it
|
|
* will deploy to, and that is what the row shows and what the server lists, filters and
|
|
* counts it under — so every categorization has to follow it. `path` stays the storage
|
|
* identity that the editor link and the row key resolve.
|
|
*/
|
|
export function effectivePath(item: {
|
|
path: string
|
|
draft_only?: boolean | null
|
|
draft_path?: string | null
|
|
}): string {
|
|
return (item.draft_only && item.draft_path) || item.path
|
|
}
|
|
|
|
/** Rows loaded under a node, counting nested subfolders' rows rather than the subfolder
|
|
* as one child — what "N items are on screen here" means to someone reading the tree. */
|
|
export function countLeaves(node: FolderItem | UserItem): number {
|
|
let n = 0
|
|
for (const child of node.items) n += 'items' in child ? countLeaves(child) : 1
|
|
return n
|
|
}
|
|
|
|
function insertItemInFolder(
|
|
root: (ItemType | FolderItem | UserItem)[],
|
|
item: ItemType,
|
|
path: string[]
|
|
) {
|
|
let currentLevel = root
|
|
|
|
path.forEach((folderName, index) => {
|
|
if (index === path.length - 1) {
|
|
currentLevel.push(item)
|
|
} else {
|
|
let folder = currentLevel.find((f) => 'folderName' in f && f.folderName === folderName) as
|
|
| FolderItem
|
|
| undefined
|
|
|
|
if (!folder) {
|
|
folder = { folderName: folderName, items: [] }
|
|
currentLevel.push(folder)
|
|
}
|
|
currentLevel = folder.items
|
|
}
|
|
})
|
|
}
|
|
|
|
// Default leaf ordering when the caller doesn't impose one: starred first, then
|
|
// most recently modified. Folders/users always sort alphabetically regardless.
|
|
const defaultLeafCompare = (a: ItemType, b: ItemType): number => {
|
|
if (a.starred && !b.starred) return -1
|
|
if (!a.starred && b.starred) return 1
|
|
return getModifiedAt(b) - getModifiedAt(a)
|
|
}
|
|
|
|
export function groupItems(
|
|
items: ItemType[] | undefined,
|
|
leafCompare: (a: ItemType, b: ItemType) => number = defaultLeafCompare,
|
|
// Folders/users have only a name, so the sort key is always name; `groupDesc`
|
|
// flips its direction (Z-A) to follow a name-descending sort, like a file explorer
|
|
// reordering folders when you reverse the name sort. Time sorts pass false (no
|
|
// folder timestamp to order by, so folders stay alphabetical).
|
|
groupDesc: boolean = false
|
|
): (ItemType | FolderItem | UserItem)[] {
|
|
if (!items) {
|
|
return []
|
|
}
|
|
|
|
const root: (ItemType | FolderItem | UserItem)[] = []
|
|
|
|
items.forEach((item) => {
|
|
const pathSplit = effectivePath(item).split('/')
|
|
if (pathSplit[0] === 'u') {
|
|
const username = pathSplit[1]
|
|
let userItem = root.find((f): f is UserItem => 'username' in f && f.username === username) as
|
|
| UserItem
|
|
| undefined
|
|
|
|
if (!userItem) {
|
|
userItem = { username, items: [] }
|
|
root.push(userItem)
|
|
}
|
|
|
|
if (pathSplit.length > 2) {
|
|
insertItemInFolder(userItem.items, item, pathSplit.slice(2))
|
|
} else {
|
|
userItem.items.push(item)
|
|
}
|
|
} else if (pathSplit[0] === 'f') {
|
|
insertItemInFolder(root, item, pathSplit.slice(1))
|
|
} else {
|
|
// Any other owner (e.g. a schedule at `g/<group>/...`) has no node kind, so it
|
|
// stays a top-level leaf rather than vanishing from the tree.
|
|
root.push(item)
|
|
}
|
|
})
|
|
|
|
const dir = groupDesc ? -1 : 1
|
|
// Users, then folders, then ungrouped leaves regardless of direction; only the name
|
|
// comparison within users and folders follows `groupDesc`.
|
|
const rank = (n: ItemType | FolderItem | UserItem) =>
|
|
'username' in n ? 0 : 'folderName' in n ? 1 : 2
|
|
root.sort((a, b) => {
|
|
const r = rank(a) - rank(b)
|
|
if (r !== 0) return r
|
|
if ('username' in a && 'username' in b) return dir * a.username.localeCompare(b.username)
|
|
if ('folderName' in a && 'folderName' in b)
|
|
return dir * a.folderName.localeCompare(b.folderName)
|
|
return 0
|
|
})
|
|
|
|
sortGroup(root, leafCompare, dir)
|
|
|
|
return root
|
|
}
|
|
|
|
function sortGroup(
|
|
group: (ItemType | FolderItem | UserItem)[],
|
|
leafCompare: (a: ItemType, b: ItemType) => number,
|
|
dir: number = 1
|
|
) {
|
|
group.forEach((item) => {
|
|
if ('items' in item) {
|
|
item.items.sort((a, b) => {
|
|
// Nested subfolders sort before leaves and follow the group direction.
|
|
if ('folderName' in a && 'folderName' in b) {
|
|
return dir * a.folderName.localeCompare(b.folderName)
|
|
}
|
|
if ('folderName' in a) {
|
|
return -1
|
|
}
|
|
if ('folderName' in b) {
|
|
return 1
|
|
}
|
|
if (isItemType(a) && isItemType(b)) {
|
|
return leafCompare(a, b)
|
|
}
|
|
return 0
|
|
})
|
|
|
|
sortGroup(item.items, leafCompare, dir)
|
|
}
|
|
})
|
|
}
|
|
|
|
function isItemType(item: ItemType | FolderItem | UserItem): item is ItemType {
|
|
return 'type' in item
|
|
}
|
|
|
|
function getModifiedAt(item: ItemType): number {
|
|
if (item.type === 'app') {
|
|
return new Date(item.edited_at).getTime() || 0
|
|
} else if (item.type === 'script') {
|
|
return new Date(item.created_at).getTime() || 0
|
|
} else if (item.type === 'flow') {
|
|
return new Date(item.edited_at).getTime() || 0
|
|
} else if (item.type === 'raw_app') {
|
|
return new Date(item.edited_at).getTime() || 0
|
|
}
|
|
|
|
return 0
|
|
}
|