mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-28 16:02:47 +00:00
feat: home list settings menu with sort, library and user-folder toggles (#11928)
* feat: restore home sort order and library script toggle in a list settings menu Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: hide other users' folders from the list settings, remember open folders and list settings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d98a26b1c8
commit
73f655db98
@@ -27,12 +27,13 @@
|
||||
Code2,
|
||||
LayoutDashboard,
|
||||
MoreHorizontal,
|
||||
Settings,
|
||||
Tag
|
||||
} from 'lucide-svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import CreateActionsMenu from './CreateActionsMenu.svelte'
|
||||
import ContentSearchInner from '$lib/components/ContentSearchInner.svelte'
|
||||
import type { IconType } from '$lib/utils'
|
||||
import type { IconType, Item as MenuItem } from '$lib/utils'
|
||||
|
||||
import { HOME_COLUMN_WIDTH, HOME_SEARCH_SHOW_FLOW, HOME_SEARCH_PLACEHOLDER } from '$lib/consts'
|
||||
|
||||
@@ -50,12 +51,13 @@
|
||||
import { clampRows } from './clampRows'
|
||||
import { setUnruledList } from '../common/table/unruledList'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import { isOwner } from '$lib/utils'
|
||||
import { getLocalSetting, isOwner, storeLocalSetting } from '$lib/utils'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import Drawer from '../common/drawer/Drawer.svelte'
|
||||
import HighlightCode from '../HighlightCode.svelte'
|
||||
import DrawerContent from '../common/drawer/DrawerContent.svelte'
|
||||
import TreeViewRoot from './TreeViewRoot.svelte'
|
||||
import { TreeOpenMemory } from './treeViewState.svelte'
|
||||
import { effectivePath, type ItemType } from './treeViewUtils'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
||||
@@ -167,7 +169,17 @@
|
||||
}
|
||||
|
||||
// Single URL-synced source of truth for the searchbar-driven filters.
|
||||
let filterValues = useUrlSyncedFilterInstance(untrack(() => searchFilterSchema))
|
||||
// The library and user-folder choices outlive the URL: a link that sets one still wins,
|
||||
// but a bare visit restores the last one made in this browser.
|
||||
const HIDE_LIBRARY_SETTING_NAME = 'homeHideLibrary'
|
||||
const ONLY_USER_FOLDERS_SETTING_NAME = 'homeOnlyUserFolders'
|
||||
let filterValues = useUrlSyncedFilterInstance(
|
||||
untrack(() => searchFilterSchema),
|
||||
{
|
||||
include_library: getLocalSetting(HIDE_LIBRARY_SETTING_NAME) == 'true' ? false : undefined,
|
||||
only_user_folders: getLocalSetting(ONLY_USER_FOLDERS_SETTING_NAME) == 'true' || undefined
|
||||
}
|
||||
)
|
||||
|
||||
// Derived views the rest of the data layer reads. The merged-endpoint reload effect
|
||||
// depends on these (search, kind, archived, library, user-folder scope), so changing a
|
||||
@@ -179,7 +191,18 @@
|
||||
let runnableKinds = $derived(itemKind === 'all' ? undefined : itemKind)
|
||||
let archived = $derived(!!filterValues.val.archived)
|
||||
let includeWithoutMain = $derived((filterValues.val.include_library ?? true) as boolean)
|
||||
let filterUserFolders = $derived(!!filterValues.val.only_user_folders)
|
||||
// Gated on the role so a choice remembered from an admin session can't hide other users'
|
||||
// spaces for a role that is never offered the toggle.
|
||||
let filterUserFolders = $derived(!!filterValues.val.only_user_folders && !!filterUserFoldersType)
|
||||
$effect(() => {
|
||||
storeLocalSetting(HIDE_LIBRARY_SETTING_NAME, includeWithoutMain ? undefined : 'true')
|
||||
})
|
||||
$effect(() => {
|
||||
storeLocalSetting(
|
||||
ONLY_USER_FOLDERS_SETTING_NAME,
|
||||
filterValues.val.only_user_folders ? 'true' : undefined
|
||||
)
|
||||
})
|
||||
|
||||
// Content search is a distinct mode: its results come from ContentSearchInner
|
||||
// (which carries only path + content), so the row-list filters can't
|
||||
@@ -374,14 +397,13 @@
|
||||
}
|
||||
const gen = ++loadGen
|
||||
// Snapshot the cursor now: a later request must not consume a cursor minted
|
||||
// by a filter that has since changed.
|
||||
// by an order/filter that has since changed.
|
||||
const cursor = reset ? undefined : serverCursor
|
||||
let res: { items: RunnableItem[]; next_cursor?: string }
|
||||
try {
|
||||
res = await ScriptService.listRunnables({
|
||||
workspace: ws,
|
||||
orderBy: 'updated',
|
||||
orderDesc: true,
|
||||
...sortToParams(sortOrder),
|
||||
showArchived: archived ? true : undefined,
|
||||
includeWithoutMain: includeWithoutMain ? true : undefined,
|
||||
kinds: runnableKinds,
|
||||
@@ -601,8 +623,7 @@
|
||||
try {
|
||||
res = await ScriptService.listRunnables({
|
||||
workspace: ws,
|
||||
orderBy: 'updated',
|
||||
orderDesc: true,
|
||||
...sortToParams(sortOrder),
|
||||
showArchived: archived ? true : undefined,
|
||||
includeWithoutMain: includeWithoutMain ? true : undefined,
|
||||
kinds: runnableKinds,
|
||||
@@ -752,15 +773,75 @@
|
||||
|
||||
const cmp = new Intl.Collator('en').compare
|
||||
|
||||
// Preserve the endpoint's exact order (most recently updated first, starred pinned on top
|
||||
// of the first page) rather than re-deriving it on the client: each row carries its server
|
||||
// fetch ordinal (`ord`), which already reflects the (path, kind) tiebreaks, full-precision
|
||||
// timestamps and the database collation. Sorting by it can never disagree with the server,
|
||||
// so a later page never jumps above shown rows on "load more".
|
||||
// The order is applied server-side so it holds across the whole workspace, not just the
|
||||
// loaded pages. Starred items are pinned on top of the first page.
|
||||
type SortOrder = 'updated_desc' | 'updated_asc' | 'name_asc' | 'name_desc'
|
||||
const SORT_SETTING_NAME = 'homeSort'
|
||||
const sortOptions: { value: SortOrder; label: string }[] = [
|
||||
{ value: 'updated_desc', label: 'Recently updated' },
|
||||
{ value: 'updated_asc', label: 'Oldest updated' },
|
||||
{ value: 'name_asc', label: 'Name (A-Z)' },
|
||||
{ value: 'name_desc', label: 'Name (Z-A)' }
|
||||
]
|
||||
let sortOrder = $state<SortOrder>(
|
||||
sortOptions.find((o) => o.value === getLocalSetting(SORT_SETTING_NAME))?.value ?? 'updated_desc'
|
||||
)
|
||||
function setSortOrder(o: SortOrder) {
|
||||
sortOrder = o
|
||||
storeLocalSetting(SORT_SETTING_NAME, o === 'updated_desc' ? undefined : o)
|
||||
}
|
||||
function sortToParams(o: SortOrder): { orderBy: 'updated' | 'name'; orderDesc: boolean } {
|
||||
return {
|
||||
orderBy: o.startsWith('name') ? 'name' : 'updated',
|
||||
orderDesc: o.endsWith('desc')
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve the endpoint's exact order rather than re-deriving it on the client: each row
|
||||
// carries its server fetch ordinal (`ord`), which already reflects the chosen order, the
|
||||
// (path, kind) tiebreaks, full-precision timestamps and the database collation. Sorting by
|
||||
// it can never disagree with the server, so a later page never jumps above shown rows on
|
||||
// "load more".
|
||||
function compareItems(a: { ord?: number }, b: { ord?: number }): number {
|
||||
return (a.ord ?? 0) - (b.ord ?? 0)
|
||||
}
|
||||
|
||||
let listSettingsItems: MenuItem[] = $derived([
|
||||
{
|
||||
displayName: 'Show library scripts',
|
||||
description: 'Scripts without a main function',
|
||||
toggle: includeWithoutMain,
|
||||
hide: !($userStore && $roleCapabilities.code),
|
||||
action: () => {
|
||||
// On by default, so showing them again clears the key rather than leaving a chip.
|
||||
if (includeWithoutMain) filterValues.val.include_library = false
|
||||
else delete filterValues.val.include_library
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
filterUserFoldersType === 'only f/*' ? 'Hide user folders' : "Hide other users' folders",
|
||||
description:
|
||||
filterUserFoldersType === 'only f/*'
|
||||
? 'Only f/*'
|
||||
: `Only u/${$userStore?.username} and f/*`,
|
||||
toggle: filterUserFolders,
|
||||
hide: !filterUserFoldersType,
|
||||
action: () => {
|
||||
if (filterUserFolders) delete filterValues.val.only_user_folders
|
||||
else filterValues.val.only_user_folders = true
|
||||
}
|
||||
},
|
||||
...sortOptions.map((o, i) => ({
|
||||
displayName: o.label,
|
||||
selected: o.value === sortOrder,
|
||||
separatorTop: i === 0,
|
||||
disabled: filter !== '',
|
||||
tooltip: filter !== '' ? 'Search results are ranked by relevance' : undefined,
|
||||
action: () => setSortOrder(o.value)
|
||||
}))
|
||||
])
|
||||
|
||||
const opts: uFuzzy.Options = {
|
||||
sort: (info, haystack, needle) => {
|
||||
let {
|
||||
@@ -850,6 +931,12 @@
|
||||
}
|
||||
|
||||
let collapseAll = $state(true)
|
||||
let treeOpenMemory = $derived(new TreeOpenMemory(`homeTreeOpen:${$workspaceStore}`))
|
||||
function toggleCollapseAll() {
|
||||
// Collapsing all is also how to forget the folders reopened on every visit.
|
||||
if (!collapseAll) treeOpenMemory.clear()
|
||||
collapseAll = !collapseAll
|
||||
}
|
||||
// Human-readable list of the filters currently narrowing the list. Empty means no
|
||||
// filter is active, so an empty result then means the workspace itself has no items
|
||||
// (NoItemFound shows the welcome message); otherwise the filters are just too narrow
|
||||
@@ -1160,9 +1247,9 @@
|
||||
let treeGlobalHasMore = $derived(!treeLazyMode && !searching ? hasMoreServer : false)
|
||||
$effect(() => {
|
||||
if ($userStore && $workspaceStore) {
|
||||
;[archived, includeWithoutMain, searching, ownerFilter, itemKind]
|
||||
// reloadItems (not a bare loadRunnables) so an in-place change — archive, library,
|
||||
// kind — reloads the global stream AND re-fetches the currently-open owners
|
||||
;[archived, includeWithoutMain, sortOrder, searching, ownerFilter, itemKind]
|
||||
// reloadItems (not a bare loadRunnables) so an in-place change — sort, archive,
|
||||
// library, kind — reloads the global stream AND re-fetches the currently-open owners
|
||||
// under the new filter, keeping folders expanded (file-explorer style)
|
||||
// rather than collapsing them. Mode changes (owner/search) flip treeLazyMode, so
|
||||
// reloadItems skips the re-fetch and the {#key} remount handles the reset.
|
||||
@@ -1579,9 +1666,21 @@
|
||||
variant="default"
|
||||
iconOnly
|
||||
title={collapseAll ? 'Expand all' : 'Collapse all'}
|
||||
on:click={() => (collapseAll = !collapseAll)}
|
||||
on:click={toggleCollapseAll}
|
||||
startIcon={{ icon: collapseAll ? ChevronsUpDown : ChevronsDownUp }}
|
||||
/>
|
||||
<DropdownV2 items={listSettingsItems} placement="bottom-end" fixedHeight={false}>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
iconOnly
|
||||
title="List settings"
|
||||
startIcon={{ icon: Settings }}
|
||||
/>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
<!-- Same gate the old create actions used: hidden from operators and in workspaces
|
||||
whose direct-deploy protection cleared showEditButtons (NoDirectDeployAlert), since
|
||||
@@ -1713,6 +1812,8 @@
|
||||
items={treeSource}
|
||||
{collapseAll}
|
||||
sortCompare={compareItems}
|
||||
groupDesc={sortOrder === 'name_desc'}
|
||||
openMemory={treeOpenMemory}
|
||||
hasMoreServer={treeGlobalHasMore}
|
||||
onLoadMore={fetchMoreServer}
|
||||
pipelineFolders={visiblePipelineFolders}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { pluralize } from '$lib/utils'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { inUnruledList, rowRule } from '../common/table/unruledList'
|
||||
import type { TreeOpenMemory } from './treeViewState.svelte'
|
||||
|
||||
interface Props {
|
||||
item: ItemType | FolderItem | UserItem
|
||||
@@ -52,6 +53,9 @@
|
||||
// Renders a leaf in place of the home page's `<Item>`, for lists whose rows are not
|
||||
// scripts/flows/apps. Receives the leaf and its visual depth.
|
||||
leaf?: Snippet<[ItemType, number]>
|
||||
// Remembers which nodes the user opened by hand, so they reopen on the next visit
|
||||
// while expand/collapse-all is in its collapsed state.
|
||||
openMemory?: TreeOpenMemory
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -70,7 +74,8 @@
|
||||
parentPrefix,
|
||||
ancestorHasMore = false,
|
||||
indent = 0,
|
||||
leaf
|
||||
leaf,
|
||||
openMemory
|
||||
}: Props = $props()
|
||||
|
||||
const unruled = inUnruledList()
|
||||
@@ -208,7 +213,8 @@
|
||||
ownerKey != undefined &&
|
||||
ownerLoad != undefined &&
|
||||
rootIndex >= EXPAND_ALL_LOAD_LIMIT
|
||||
const shouldOpen = expandAll && !cappedOwner
|
||||
const remembered = !expandAll && nodePrefix != undefined && !!openMemory?.isOpen(nodePrefix)
|
||||
const shouldOpen = (expandAll && !cappedOwner) || remembered
|
||||
opened = shouldOpen
|
||||
if (ownerKey == undefined) return
|
||||
if (shouldOpen) {
|
||||
@@ -244,6 +250,7 @@
|
||||
if (now - lastToggle < 300) return
|
||||
lastToggle = now
|
||||
opened = !opened
|
||||
if (nodePrefix != undefined) openMemory?.setOpen(nodePrefix, opened)
|
||||
if (opened) {
|
||||
// A top-level owner loads on expand. A subfolder's rows come from an ancestor's
|
||||
// pages, so opening one must not fire a request per subfolder — its own "Load
|
||||
@@ -313,6 +320,7 @@
|
||||
{onCollapseOwner}
|
||||
parentPrefix={nodePrefix}
|
||||
ancestorHasMore={nodeHasMore}
|
||||
{openMemory}
|
||||
on:scriptChanged
|
||||
on:flowChanged
|
||||
on:appChanged
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import TreeOwnerRow from './TreeOwnerRow.svelte'
|
||||
import { inUnruledList, rowRule } from '../common/table/unruledList'
|
||||
import { getLocalSetting, pluralize, storeLocalSetting } from '$lib/utils'
|
||||
import type { TreeOpenMemory } from './treeViewState.svelte'
|
||||
|
||||
interface Props {
|
||||
collapseAll: boolean
|
||||
@@ -50,6 +51,8 @@
|
||||
groupOtherUsers?: boolean
|
||||
// See TreeView's `leaf`.
|
||||
leaf?: Snippet<[ItemType, number]>
|
||||
// See TreeView's `openMemory`.
|
||||
openMemory?: TreeOpenMemory
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -72,7 +75,8 @@
|
||||
showEditButton = true,
|
||||
framed = true,
|
||||
groupOtherUsers = false,
|
||||
leaf
|
||||
leaf,
|
||||
openMemory
|
||||
}: Props = $props()
|
||||
|
||||
const unruled = inUnruledList()
|
||||
@@ -271,6 +275,7 @@
|
||||
{showCode}
|
||||
{showEditButton}
|
||||
{leaf}
|
||||
{openMemory}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -20,3 +20,39 @@ export class TreeViewState {
|
||||
storeLocalSetting(this.#settingName, value ? 'true' : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
/** The tree nodes a user opened by hand, by full path prefix, persisted under `settingName`. */
|
||||
export class TreeOpenMemory {
|
||||
// Bounds what a long-lived browser accumulates; the oldest openings drop first.
|
||||
static readonly MAX = 200
|
||||
#settingName: string
|
||||
#open: string[]
|
||||
|
||||
constructor(settingName: string) {
|
||||
this.#settingName = settingName
|
||||
let stored: unknown
|
||||
try {
|
||||
stored = JSON.parse(getLocalSetting(settingName) ?? '[]')
|
||||
} catch {}
|
||||
this.#open = Array.isArray(stored) ? stored.filter((k) => typeof k === 'string') : []
|
||||
}
|
||||
|
||||
isOpen(prefix: string): boolean {
|
||||
return this.#open.includes(prefix)
|
||||
}
|
||||
|
||||
setOpen(prefix: string, open: boolean) {
|
||||
this.#open = this.#open.filter((k) => k !== prefix)
|
||||
if (open) this.#open = [...this.#open, prefix].slice(-TreeOpenMemory.MAX)
|
||||
this.#save()
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.#open = []
|
||||
this.#save()
|
||||
}
|
||||
|
||||
#save() {
|
||||
storeLocalSetting(this.#settingName, this.#open.length ? JSON.stringify(this.#open) : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user