mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-12 00:06:14 +00:00
feat: tuck other users' spaces into a collapsible home tree row (#11073)
* feat: group other users' spaces under a collapsible row in the home tree Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013eAE5cpKPdtH9DNJm5TjcH * fix: page the other users group on its own and use a design-system toggle Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013eAE5cpKPdtH9DNJm5TjcH --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
63cb46d7bb
commit
d87f089288
@@ -1991,6 +1991,7 @@
|
||||
allUsers={treeInjectUsers}
|
||||
ownerCounts={!searching && labelFilter == undefined ? ownerCounts : undefined}
|
||||
selfUsername={$userStore?.username}
|
||||
groupOtherUsers={treeLazyMode}
|
||||
ownerLoad={treeLazyMode ? ownerLoad : undefined}
|
||||
onExpandOwner={treeLazyMode ? loadOwnerItems : undefined}
|
||||
onCollapseOwner={treeLazyMode ? collapseOwner : undefined}
|
||||
|
||||
@@ -32,8 +32,9 @@
|
||||
// `all` pages the prefix to the end in one call instead of fetching a single page.
|
||||
onExpandOwner?: (prefix: string, more?: boolean, opts?: { all?: boolean }) => void
|
||||
onCollapseOwner?: (prefix: string) => void
|
||||
// Position of this node among the rendered root nodes; "expand all" only
|
||||
// auto-loads the first EXPAND_ALL_LOAD_LIMIT of them (see the effect below).
|
||||
// This root owner's place in line for "expand all", which only auto-loads the first
|
||||
// EXPAND_ALL_LOAD_LIMIT (see the effect below). Not always its rendered position:
|
||||
// owners nested under a grouping row are ranked after the rest.
|
||||
rootIndex?: number
|
||||
showEditButton?: boolean
|
||||
// Path prefix of the parent node, so this one can name its own (`ownerLoad` and
|
||||
@@ -43,6 +44,10 @@
|
||||
// is grouped under this node is only part of it: counts render as "N+" and the
|
||||
// node offers to load the rest of itself.
|
||||
ancestorHasMore?: boolean
|
||||
// Visual nesting on top of `depth`. `depth` stays semantic (0 is a top-level owner
|
||||
// that loads lazily), so an owner shown inside a grouping row is indented through
|
||||
// this rather than by raising its depth.
|
||||
indent?: number
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -59,9 +64,12 @@
|
||||
rootIndex = 0,
|
||||
showEditButton = true,
|
||||
parentPrefix,
|
||||
ancestorHasMore = false
|
||||
ancestorHasMore = false,
|
||||
indent = 0
|
||||
}: Props = $props()
|
||||
|
||||
let visualDepth = $derived(depth + indent)
|
||||
|
||||
// Bounds the request burst from "expand all": however many root owners the tree
|
||||
// renders (its slice grows as you scroll), it fetches at most this many. Lazy owners
|
||||
// past the cap stay collapsed and load on a single click (see the effect).
|
||||
@@ -256,7 +264,7 @@
|
||||
>
|
||||
<div
|
||||
class={twMerge('flex flex-row items-center gap-4 text-sm font-semibold')}
|
||||
style={depth > 0 ? `padding-left: ${depth * 16}px;` : ''}
|
||||
style={visualDepth > 0 ? `padding-left: ${visualDepth * 16}px;` : ''}
|
||||
>
|
||||
<div class="flex justify-center items-center">
|
||||
{#if isUser(item)}
|
||||
@@ -310,7 +318,7 @@
|
||||
<a
|
||||
href="{base}/pipeline/{encodeURIComponent(item.folderName)}"
|
||||
class="flex items-center gap-4 px-4 py-3 border-b text-sm hover:bg-surface-hover transition-colors"
|
||||
style="padding-left: {(depth + 1) * 16}px;"
|
||||
style="padding-left: {(visualDepth + 1) * 16}px;"
|
||||
>
|
||||
<NetworkIcon size={16} class="text-emerald-600 dark:text-emerald-400" />
|
||||
<span class="text-xs font-medium text-emphasis">Pipeline</span>
|
||||
@@ -335,12 +343,13 @@
|
||||
{showCode}
|
||||
{showEditButton}
|
||||
depth={depth + 1}
|
||||
{indent}
|
||||
/>
|
||||
{/each}
|
||||
{#if effectiveMax < item.items.length}
|
||||
<div
|
||||
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
|
||||
style="padding-left: {(depth + 1) * 16}px;"
|
||||
style="padding-left: {(visualDepth + 1) * 16}px;"
|
||||
>
|
||||
<!-- Rows, not items: this slices the node's own entries, where a subfolder
|
||||
is one row standing for everything under it. -->
|
||||
@@ -377,7 +386,7 @@
|
||||
as rows still missing. -->
|
||||
<div
|
||||
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
|
||||
style="padding-left: {(depth + 1) * 16}px;"
|
||||
style="padding-left: {(visualDepth + 1) * 16}px;"
|
||||
>
|
||||
<span class="text-xs text-secondary">
|
||||
Showing {loadedHere}{ownerTotal != undefined ? ` of ${ownerTotal}` : ''} items in {nodePrefix}
|
||||
@@ -427,6 +436,6 @@
|
||||
on:appChanged
|
||||
on:rawAppChanged
|
||||
on:reload
|
||||
{depth}
|
||||
depth={visualDepth}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import TreeView from './TreeView.svelte'
|
||||
import { groupItems, type ItemType } from './treeViewUtils'
|
||||
import { countLeaves, groupItems, type ItemType, type UserItem } from './treeViewUtils'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { ChevronDown, ChevronUp, Users } from 'lucide-svelte'
|
||||
import { getLocalSetting, pluralize, storeLocalSetting } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
collapseAll: boolean
|
||||
@@ -38,6 +40,10 @@
|
||||
onExpandOwner?: (owner: string, more?: boolean, opts?: { all?: boolean }) => void
|
||||
onCollapseOwner?: (owner: string) => void
|
||||
showEditButton?: boolean
|
||||
// Tuck every user space but `selfUsername`'s into one collapsible "Other users" row
|
||||
// under it. Only for browsing: a search or filter must not hide its matches behind
|
||||
// a closed row.
|
||||
groupOtherUsers?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -57,7 +63,8 @@
|
||||
ownerLoad,
|
||||
onExpandOwner,
|
||||
onCollapseOwner,
|
||||
showEditButton = true
|
||||
showEditButton = true,
|
||||
groupOtherUsers = false
|
||||
}: Props = $props()
|
||||
|
||||
// How many root nodes render at once. A root node is a collapsed owner row that
|
||||
@@ -153,6 +160,61 @@
|
||||
})
|
||||
})
|
||||
|
||||
type RootNode = ReturnType<typeof groupItems>[number]
|
||||
// `loadRank` is what "expand all" caps its requests by (see TreeView's rootIndex).
|
||||
type RootRow = { kind: 'node'; node: RootNode; loadRank: number } | { kind: 'otherUsers' }
|
||||
|
||||
const OTHER_USERS_OPEN_SETTING_NAME = 'homeTreeOtherUsersOpen'
|
||||
let otherUsersOpen = $state(getLocalSetting(OTHER_USERS_OPEN_SETTING_NAME) == 'true')
|
||||
function toggleOtherUsers() {
|
||||
otherUsersOpen = !otherUsersOpen
|
||||
storeLocalSetting(OTHER_USERS_OPEN_SETTING_NAME, otherUsersOpen ? 'true' : undefined)
|
||||
}
|
||||
// The group pages its rows separately from the root slice. Drawing from `nbDisplayed`
|
||||
// instead, opening it would push folders past the slice and unmount them, dropping
|
||||
// whatever they had expanded.
|
||||
let nbOtherUsersDisplayed = $state(ROOT_PAGE)
|
||||
|
||||
function isOtherUser(node: RootNode): node is UserItem {
|
||||
return 'username' in node && node.username !== selfUsername
|
||||
}
|
||||
|
||||
let otherUsers: UserItem[] = $derived(
|
||||
groupOtherUsers && selfUsername != undefined && Array.isArray(groupedItems)
|
||||
? groupedItems.filter(isOtherUser)
|
||||
: []
|
||||
)
|
||||
let otherUsersItemCount = $derived(
|
||||
ownerCounts != undefined
|
||||
? otherUsers.reduce(
|
||||
(sum, u) => sum + Math.max(ownerCounts[`u/${u.username}`] ?? 0, countLeaves(u)),
|
||||
0
|
||||
)
|
||||
: undefined
|
||||
)
|
||||
|
||||
let rows: RootRow[] = $derived.by(() => {
|
||||
if (!Array.isArray(groupedItems)) return []
|
||||
if (otherUsers.length === 0) {
|
||||
return groupedItems.map((node, i): RootRow => ({ kind: 'node', node, loadRank: i }))
|
||||
}
|
||||
// `groupItems` orders users before folders, so with the others taken out the viewer's
|
||||
// own space is what leads.
|
||||
const main = groupedItems.filter((g) => !isOtherUser(g))
|
||||
const lead = main.filter((g) => 'username' in g)
|
||||
const rest = main.filter((g) => !('username' in g))
|
||||
return [
|
||||
...lead.map((node, i): RootRow => ({ kind: 'node', node, loadRank: i })),
|
||||
{ kind: 'otherUsers' },
|
||||
...rest.map((node, i): RootRow => ({ kind: 'node', node, loadRank: lead.length + i }))
|
||||
]
|
||||
})
|
||||
// Owner rows only, for the footer: the "Other users" row is neither a folder nor a user.
|
||||
let ownerRowCount = $derived(rows.filter((r) => r.kind === 'node').length)
|
||||
let shownOwnerRowCount = $derived(
|
||||
rows.slice(0, nbDisplayed).filter((r) => r.kind === 'node').length
|
||||
)
|
||||
|
||||
let footerEl: HTMLDivElement | undefined = $state()
|
||||
// Reveal the next slice of root nodes as the footer comes into view. Only the
|
||||
// client-side slice auto-grows — those nodes are already grouped and render
|
||||
@@ -162,10 +224,9 @@
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (!entries.some((e) => e.isIntersecting)) return
|
||||
const grouped = groupedItems
|
||||
if (!Array.isArray(grouped) || nbDisplayed >= grouped.length) return
|
||||
if (nbDisplayed >= rows.length) return
|
||||
if (nbDisplayed >= AUTO_REVEAL_LIMIT) return
|
||||
nbDisplayed = Math.min(nbDisplayed + ROOT_PAGE, grouped.length)
|
||||
nbDisplayed = Math.min(nbDisplayed + ROOT_PAGE, rows.length)
|
||||
// Revealing more doesn't change whether the footer intersects, so no further
|
||||
// callback would fire and scrolling would stall with rows left unrevealed.
|
||||
// Re-observing re-delivers the current intersection after the rows render.
|
||||
@@ -177,6 +238,29 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
{#snippet ownerNode(node: RootNode, loadRank: number, indent: number)}
|
||||
<TreeView
|
||||
rootIndex={loadRank}
|
||||
{indent}
|
||||
{isSearching}
|
||||
{collapseAll}
|
||||
item={node}
|
||||
{pipelineFolders}
|
||||
{ownerCounts}
|
||||
ancestorHasMore={hasMoreServer}
|
||||
{ownerLoad}
|
||||
{onExpandOwner}
|
||||
{onCollapseOwner}
|
||||
on:scriptChanged
|
||||
on:flowChanged
|
||||
on:appChanged
|
||||
on:rawAppChanged
|
||||
on:reload
|
||||
{showCode}
|
||||
{showEditButton}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#if groupedItems === 'loading'}
|
||||
<div class="flex flex-row items-center justify-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-gray-100"
|
||||
@@ -188,30 +272,70 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="border rounded-md bg-surface-tertiary">
|
||||
{#each groupedItems.slice(0, nbDisplayed) as item, rootIndex ('folderName' in item ? `f__${item.folderName}` : 'username' in item ? `u__${item.username}` : `i__${item.type}__${item.path}`)}
|
||||
{#if item}
|
||||
<TreeView
|
||||
{rootIndex}
|
||||
{isSearching}
|
||||
{collapseAll}
|
||||
{item}
|
||||
{pipelineFolders}
|
||||
{ownerCounts}
|
||||
ancestorHasMore={hasMoreServer}
|
||||
{ownerLoad}
|
||||
{onExpandOwner}
|
||||
{onCollapseOwner}
|
||||
on:scriptChanged
|
||||
on:flowChanged
|
||||
on:appChanged
|
||||
on:rawAppChanged
|
||||
on:reload
|
||||
{showCode}
|
||||
{showEditButton}
|
||||
/>
|
||||
{#each rows.slice(0, nbDisplayed) as row (row.kind === 'otherUsers' ? 'other_users' : 'folderName' in row.node ? `f__${row.node.folderName}` : 'username' in row.node ? `u__${row.node.username}` : `i__${row.node.type}__${row.node.path}`)}
|
||||
{#if row.kind === 'otherUsers'}
|
||||
<!-- Same shape as an owner row, so it reads as part of the tree. It only reveals
|
||||
the user rows under it: each still loads its own items when opened. -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
onclick={toggleOtherUsers}
|
||||
class="px-4 py-2 border-b w-full flex flex-row items-center justify-between cursor-pointer"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<Users size={16} class="text-secondary" />
|
||||
<div>
|
||||
<span class="whitespace-nowrap text-xs text-emphasis font-semibold">Other users</span>
|
||||
<div class="text-2xs font-normal text-secondary whitespace-nowrap">
|
||||
({pluralize(otherUsers.length, 'user')}{otherUsersItemCount != undefined
|
||||
? ` · ${pluralize(otherUsersItemCount, 'item')}`
|
||||
: ''})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
iconOnly
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: otherUsersOpen ? ChevronUp : ChevronDown }}
|
||||
title={otherUsersOpen ? 'Hide other users' : 'Show other users'}
|
||||
aria-label="Other users"
|
||||
aria-expanded={otherUsersOpen}
|
||||
onClick={toggleOtherUsers}
|
||||
/>
|
||||
</div>
|
||||
{#if otherUsersOpen}
|
||||
<!-- Ranked after every root owner, so opening this row can't push folders out
|
||||
of what "expand all" auto-loads. -->
|
||||
{#each otherUsers.slice(0, nbOtherUsersDisplayed) as user, i (user.username)}
|
||||
{@render ownerNode(user, ownerRowCount + i, 1)}
|
||||
{/each}
|
||||
{#if nbOtherUsersDisplayed < otherUsers.length}
|
||||
<div
|
||||
class="pl-8 pr-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
|
||||
>
|
||||
<span class="text-xs text-secondary">
|
||||
Showing {nbOtherUsersDisplayed} of {otherUsers.length} users
|
||||
</span>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
onClick={() =>
|
||||
(nbOtherUsersDisplayed = Math.min(
|
||||
nbOtherUsersDisplayed + ROOT_PAGE,
|
||||
otherUsers.length
|
||||
))}
|
||||
>
|
||||
Show more
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
{@render ownerNode(row.node, row.loadRank, 0)}
|
||||
{/if}
|
||||
{/each}
|
||||
{#if nbDisplayed < groupedItems.length || hasMoreServer}
|
||||
{#if nbDisplayed < rows.length || hasMoreServer}
|
||||
<!-- Last row of the tree's own frame, not a caption under it: what is missing
|
||||
has to read as part of the list to be noticed at all. -->
|
||||
<div
|
||||
@@ -219,8 +343,8 @@
|
||||
class="px-4 py-3 flex flex-row items-center justify-between gap-4 bg-surface-secondary"
|
||||
>
|
||||
<span class="text-xs text-secondary">
|
||||
{#if nbDisplayed < groupedItems.length}
|
||||
Showing {nbDisplayed} of {groupedItems.length} folders and users
|
||||
{#if nbDisplayed < rows.length}
|
||||
Showing {shownOwnerRowCount} of {ownerRowCount} folders and users
|
||||
{:else}
|
||||
<!-- Scoped to one owner: the tree groups the paged browse stream, so what
|
||||
is missing is items, not root nodes. -->
|
||||
@@ -231,12 +355,12 @@
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
on:click={() => {
|
||||
if (nbDisplayed < groupedItems.length)
|
||||
nbDisplayed = Math.min(nbDisplayed + ROOT_PAGE, groupedItems.length)
|
||||
if (nbDisplayed < rows.length)
|
||||
nbDisplayed = Math.min(nbDisplayed + ROOT_PAGE, rows.length)
|
||||
else onLoadMore?.()
|
||||
}}
|
||||
>
|
||||
{nbDisplayed < groupedItems.length ? 'Show more' : 'Load more'}
|
||||
{nbDisplayed < rows.length ? 'Show more' : 'Load more'}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user