mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-27 08:02:39 +00:00
feat: manage and create folders from the home tree view (#11943)
* feat: manage and create folders from the home tree view * fix: drop empty folders under a kind filter, gate manage on visibility * fix: show the tree in a folders-only workspace, record created folder * fix: address review nits on the home folder menu * fix: record created folder ownership, offer folder in empty-state menu
This commit is contained in:
@@ -16,8 +16,10 @@
|
||||
Workflow,
|
||||
Import,
|
||||
Store,
|
||||
PanelLeftClose
|
||||
PanelLeftClose,
|
||||
Folder
|
||||
} from 'lucide-svelte'
|
||||
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import DbtIcon from '$lib/components/icons/DbtIcon.svelte'
|
||||
import { PythonIcon, TypeScriptIcon } from '$lib/components/common/languageIcons'
|
||||
@@ -48,9 +50,17 @@
|
||||
* serve both this menu and the empty state's own link.
|
||||
*/
|
||||
onImportHubProject?: () => void
|
||||
/** Opens the host's folder drawer. The entry is offered only when this is set. */
|
||||
onCreateFolder?: () => void
|
||||
}
|
||||
|
||||
let { trigger, triggerElement, source = 'toolbar', onImportHubProject }: Props = $props()
|
||||
let {
|
||||
trigger,
|
||||
triggerElement,
|
||||
source = 'toolbar',
|
||||
onImportHubProject,
|
||||
onCreateFolder
|
||||
}: Props = $props()
|
||||
|
||||
type Variant = {
|
||||
label: string
|
||||
@@ -713,8 +723,26 @@
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- bottom import section: one entry whose submenu imports any artifact -->
|
||||
<!-- bottom section: a folder, then one entry whose submenu imports any artifact -->
|
||||
<div class="mx-1 my-1 border-t border-gray-200 dark:border-gray-700"></div>
|
||||
{#if onCreateFolder}
|
||||
<!-- Focus also arrives on hover: melt focuses the highlighted item. -->
|
||||
<MenuItem
|
||||
{item}
|
||||
class="w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
|
||||
onFocusIn={() => highlightOption(undefined)}
|
||||
onClick={() => onCreateFolder()}
|
||||
>
|
||||
<div
|
||||
class="w-6 h-6 rounded-md flex items-center justify-center shrink-0 bg-gray-100 dark:bg-gray-700"
|
||||
>
|
||||
<Folder size={14} class="text-gray-600 dark:text-gray-300" />
|
||||
</div>
|
||||
<span class="text-xs font-medium text-primary flex-1 min-w-0 whitespace-nowrap">
|
||||
Folder
|
||||
</span>
|
||||
</MenuItem>
|
||||
{/if}
|
||||
<button
|
||||
use:melt={$importSubTrigger}
|
||||
class="w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
|
||||
|
||||
@@ -57,6 +57,8 @@
|
||||
import HighlightCode from '../HighlightCode.svelte'
|
||||
import DrawerContent from '../common/drawer/DrawerContent.svelte'
|
||||
import TreeViewRoot from './TreeViewRoot.svelte'
|
||||
import FolderEditorDrawer from '../FolderEditorDrawer.svelte'
|
||||
import { isDemoWorkspaceRestricted } from '$lib/cloud'
|
||||
import Item from './Item.svelte'
|
||||
import PipelineRow from '../common/table/PipelineRow.svelte'
|
||||
import { TreeOpenMemory } from './treeViewState.svelte'
|
||||
@@ -933,6 +935,7 @@
|
||||
)
|
||||
})
|
||||
let viewCodeDrawer: Drawer | undefined = $state()
|
||||
let folderEditorDrawer: FolderEditorDrawer | undefined = $state()
|
||||
let viewCodeTitle: string | undefined = $state()
|
||||
let script: Script | undefined = $state()
|
||||
async function showCode(path: string, summary: string) {
|
||||
@@ -1048,6 +1051,13 @@
|
||||
let treeLazyMode = $derived(
|
||||
!flatList && !searching && ownerFilter == undefined && labelFilter == undefined
|
||||
)
|
||||
// Unfiltered, the tree lists every folder, empty ones too, so a workspace holding only
|
||||
// folders is not empty: its first folder is created from here and managed from its row.
|
||||
// A role without the Folders page gets no folder menu, so empty rows would be noise.
|
||||
let keepEmptyFolders = $derived(itemKind === 'all' && $roleCapabilities.visibility.folders)
|
||||
let treeShowsFolders = $derived(
|
||||
treeLazyMode && !archived && keepEmptyFolders && (folderNamesRes.current?.length ?? 0) > 0
|
||||
)
|
||||
// How many runnables each owner (`f/<folder>` / `u/<user>`) holds for this user,
|
||||
// in one request. It labels every tree node up front — a lazy owner's own count is
|
||||
// unknown until it's expanded — and lets both the tree and the owner chips drop the
|
||||
@@ -1199,6 +1209,11 @@
|
||||
let canCreateHere = $derived(
|
||||
(editRights.roleCanAuthor('flow') || editRights.roleCanAuthor('raw_app')) && showEditButtons
|
||||
)
|
||||
// The Folders page's own rule for its "New folder" button.
|
||||
let canCreateFolder = $derived(
|
||||
$roleCapabilities.visibility.folders &&
|
||||
!isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
|
||||
)
|
||||
|
||||
// The workspace itself holds nothing — no filter is narrowing the list away. It stays
|
||||
// false until the first load resolves: a skeleton already means "loading", and the
|
||||
@@ -1212,6 +1227,7 @@
|
||||
filteredItems != undefined &&
|
||||
filteredItems.length === 0 &&
|
||||
visiblePipelineFolders.size === 0 &&
|
||||
!treeShowsFolders &&
|
||||
!hasMoreServer
|
||||
)
|
||||
/**
|
||||
@@ -1870,6 +1886,21 @@
|
||||
{opts}
|
||||
/>
|
||||
|
||||
<!-- Permission changes can hide or reveal rows for this user, so a save reloads them. -->
|
||||
<FolderEditorDrawer
|
||||
bind:this={folderEditorDrawer}
|
||||
onSaved={async (name, created) => {
|
||||
if (created) {
|
||||
// Folder pickers and the tree's folder menu derive write access from these, which
|
||||
// the server only resends on the next whoami. The creator is the folder's owner.
|
||||
$userStore?.folders.push(name)
|
||||
$userStore?.folders_owners.push(name)
|
||||
await folderNamesRes.refetch()
|
||||
}
|
||||
await reloadItemsAndCounts()
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
bind:this={viewCodeDrawer}
|
||||
on:close={() => {
|
||||
@@ -1990,6 +2021,7 @@
|
||||
onImportHubProject={$disableHubStore || !editRights.roleCanAuthor('script')
|
||||
? undefined
|
||||
: () => (hubPickerOpen = true)}
|
||||
onCreateFolder={canCreateFolder ? () => folderEditorDrawer?.initNew() : undefined}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -2033,7 +2065,7 @@
|
||||
have scrolled away. -->
|
||||
<div class="sticky z-10 border-b mx-5" style:top="{toolbarHeight}px"></div>
|
||||
{/if}
|
||||
{#if filteredItems?.length == 0 && !workspaceEmpty}
|
||||
{#if filteredItems?.length == 0 && !workspaceEmpty && !treeShowsFolders}
|
||||
<div class="mt-10"></div>
|
||||
{/if}
|
||||
<!-- Inset so a row's hover fill clears the card's border; the controls above take the same
|
||||
@@ -2060,7 +2092,7 @@
|
||||
<Skeleton layout={[[4], 0.5]} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filteredItems.length === 0 && (filter !== '' || visiblePipelineFolders.size === 0)}
|
||||
{:else if filteredItems.length === 0 && (filter !== '' || visiblePipelineFolders.size === 0) && !treeShowsFolders}
|
||||
<div class="px-4 pb-3">
|
||||
<!-- Pipelines aren't part of the text filter, so only fall through to show
|
||||
them (list rows / injected tree folders) when not actively searching;
|
||||
@@ -2077,6 +2109,7 @@
|
||||
<WorkspaceEmptyState
|
||||
archivedOnly={archivedProbe?.hasArchived === true}
|
||||
canCreate={canCreateHere}
|
||||
onCreateFolder={canCreateFolder ? () => folderEditorDrawer?.initNew() : undefined}
|
||||
onPick={(project) => (hubPick = project)}
|
||||
onShowArchived={() =>
|
||||
(filterValues.val = { ...filterValues.val, archived: true })}
|
||||
@@ -2173,6 +2206,10 @@
|
||||
{showCode}
|
||||
showEditButton={showEditButtons}
|
||||
framed={false}
|
||||
onManageFolder={$roleCapabilities.visibility.folders
|
||||
? (name) => folderEditorDrawer?.initEdit(name)
|
||||
: undefined}
|
||||
{keepEmptyFolders}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
@@ -18,9 +18,20 @@
|
||||
toggleLabel?: string
|
||||
/** The line under the name, usually its item count. */
|
||||
detail: Snippet
|
||||
/** Controls rendered next to the name. Clicks on them do not toggle the row. */
|
||||
actions?: Snippet
|
||||
}
|
||||
|
||||
let { icon: Icon, name, opened, onToggle, indent = 0, toggleLabel, detail }: Props = $props()
|
||||
let {
|
||||
icon: Icon,
|
||||
name,
|
||||
opened,
|
||||
onToggle,
|
||||
indent = 0,
|
||||
toggleLabel,
|
||||
detail,
|
||||
actions
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
@@ -29,7 +40,7 @@
|
||||
onclick={onToggle}
|
||||
class="px-4 py-2 {rowRule(
|
||||
unruled
|
||||
)} w-full flex flex-row items-center justify-between cursor-pointer hover:bg-surface-hover"
|
||||
)} group/row w-full flex flex-row items-center justify-between cursor-pointer hover:bg-surface-hover"
|
||||
>
|
||||
<div
|
||||
class="flex flex-row items-center gap-4"
|
||||
@@ -37,7 +48,22 @@
|
||||
>
|
||||
<Icon size={16} class="text-secondary shrink-0" />
|
||||
<div>
|
||||
<span class="whitespace-nowrap text-xs text-emphasis font-semibold">{name}</span>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<span class="whitespace-nowrap text-xs text-emphasis font-semibold">{name}</span>
|
||||
{#if actions}
|
||||
<!-- Shown for the hovered row, a tabbed-into one, or one whose menu is open.
|
||||
`-my-0.5` keeps the 20px trigger from making this line taller than the
|
||||
name alone, so rows with and without actions line up. -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="-my-0.5 opacity-0 transition-opacity duration-100 group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100 has-[[aria-expanded=true]]:opacity-100"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{@render actions()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-2xs font-normal text-secondary whitespace-nowrap">
|
||||
{@render detail()}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { createEventDispatcher, onDestroy, untrack, type Snippet } from 'svelte'
|
||||
import ResizeTransitionWrapper from '$lib/components/common/ResizeTransitionWrapper.svelte'
|
||||
|
||||
import { Folder, FolderTree, User } from 'lucide-svelte'
|
||||
import { Eye, Folder, FolderTree, Pen, User } from 'lucide-svelte'
|
||||
import { userStore } from '$lib/stores'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import TreeOwnerRow from './TreeOwnerRow.svelte'
|
||||
import PipelineRow from '../common/table/PipelineRow.svelte'
|
||||
import Item from './Item.svelte'
|
||||
@@ -56,6 +58,9 @@
|
||||
// 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
|
||||
// Adds a "Manage folder" menu ("View folder" for a non-owner) to top-level folder rows.
|
||||
// Subfolders are path segments, not folders with permissions of their own, so get none.
|
||||
onManageFolder?: (folderName: string) => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -75,7 +80,8 @@
|
||||
ancestorHasMore = false,
|
||||
indent = 0,
|
||||
leaf,
|
||||
openMemory
|
||||
openMemory,
|
||||
onManageFolder
|
||||
}: Props = $props()
|
||||
|
||||
const unruled = inUnruledList()
|
||||
@@ -270,6 +276,29 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet manageFolderMenu()}
|
||||
{#if isFolder(item)}
|
||||
{@const folderName = item.folderName}
|
||||
<!-- The Folders page's write rule. A non-owner's drawer opens read-only. -->
|
||||
{@const canWrite =
|
||||
$userStore?.is_admin ||
|
||||
$userStore?.is_super_admin ||
|
||||
$userStore?.folders_owners.includes(folderName)}
|
||||
<Dropdown
|
||||
size="xs"
|
||||
fixedHeight={false}
|
||||
placement="bottom-start"
|
||||
items={[
|
||||
{
|
||||
displayName: canWrite ? 'Manage folder' : 'View folder',
|
||||
icon: canWrite ? Pen : Eye,
|
||||
action: () => onManageFolder?.(folderName)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if isFolder(item) || isUser(item)}
|
||||
<div>
|
||||
<TreeOwnerRow
|
||||
@@ -278,6 +307,7 @@
|
||||
{opened}
|
||||
onToggle={toggleOwner}
|
||||
indent={visualDepth * 16}
|
||||
actions={onManageFolder && depth === 0 && isFolder(item) ? manageFolderMenu : undefined}
|
||||
>
|
||||
{#snippet detail()}
|
||||
{#if ownerCount != undefined}
|
||||
@@ -331,6 +361,7 @@
|
||||
depth={depth + 1}
|
||||
{indent}
|
||||
{leaf}
|
||||
{onManageFolder}
|
||||
/>
|
||||
{/each}
|
||||
{#if effectiveMax < item.items.length}
|
||||
|
||||
@@ -53,6 +53,11 @@
|
||||
leaf?: Snippet<[ItemType, number]>
|
||||
// See TreeView's `openMemory`.
|
||||
openMemory?: TreeOpenMemory
|
||||
// See TreeView's `onManageFolder`.
|
||||
onManageFolder?: (folderName: string) => void
|
||||
// Inject folders `ownerCounts` calls empty, as "0 items". Off under a kind filter,
|
||||
// where those counts are per kind and every folder lacking that kind would show.
|
||||
keepEmptyFolders?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -76,7 +81,9 @@
|
||||
framed = true,
|
||||
groupOtherUsers = false,
|
||||
leaf,
|
||||
openMemory
|
||||
openMemory,
|
||||
onManageFolder,
|
||||
keepEmptyFolders = false
|
||||
}: Props = $props()
|
||||
|
||||
const unruled = inUnruledList()
|
||||
@@ -127,15 +134,20 @@
|
||||
.filter((g) => 'folderName' in g)
|
||||
.map((g) => (g as { folderName: string }).folderName)
|
||||
)
|
||||
// Once counts are in, an owner holding nothing the user can see is dropped
|
||||
// rather than injected — a workspace's folder list is mostly noise in the
|
||||
// tree otherwise. Pipeline folders are exempt: their entry is the pipeline
|
||||
// itself, whose member scripts are folded out of the runnable count.
|
||||
// Once counts are in, a user space holding nothing the user can see is dropped
|
||||
// rather than injected — the member list is mostly noise in the tree otherwise.
|
||||
// Empty folders are dropped too unless `keepEmptyFolders`. Pipeline folders are
|
||||
// exempt: their entry is the pipeline, whose scripts the count folds out.
|
||||
const isEmptyOwner = (prefix: string) => ownerCounts != undefined && !ownerCounts[prefix]
|
||||
const missingFolders: { folderName: string; items: [] }[] = []
|
||||
for (const folderName of [...(pipelineFolders ?? []), ...allFolders]) {
|
||||
if (presentFolders.has(folderName)) continue
|
||||
if (!pipelineFolders?.has(folderName) && isEmptyOwner(`f/${folderName}`)) continue
|
||||
if (
|
||||
!keepEmptyFolders &&
|
||||
!pipelineFolders?.has(folderName) &&
|
||||
isEmptyOwner(`f/${folderName}`)
|
||||
)
|
||||
continue
|
||||
presentFolders.add(folderName)
|
||||
missingFolders.push({ folderName, items: [] })
|
||||
}
|
||||
@@ -276,6 +288,7 @@
|
||||
{showEditButton}
|
||||
{leaf}
|
||||
{openMemory}
|
||||
{onManageFolder}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -26,9 +26,17 @@
|
||||
* take — the archived link stays, since reading archived items is not a write.
|
||||
*/
|
||||
canCreate?: boolean
|
||||
/** See CreateActionsMenu's `onCreateFolder`. */
|
||||
onCreateFolder?: () => void
|
||||
}
|
||||
|
||||
let { onPick, archivedOnly = false, onShowArchived, canCreate = true }: Props = $props()
|
||||
let {
|
||||
onPick,
|
||||
archivedOnly = false,
|
||||
onShowArchived,
|
||||
canCreate = true,
|
||||
onCreateFolder
|
||||
}: Props = $props()
|
||||
|
||||
// Row opacities: the list fading out of existence. Static on purpose — motion is what
|
||||
// makes a skeleton mean "loading", and this state means "empty".
|
||||
@@ -136,7 +144,7 @@
|
||||
</Popover>
|
||||
or
|
||||
{/if}
|
||||
<CreateActionsMenu source="empty_state" triggerElement={newLinkEl}>
|
||||
<CreateActionsMenu source="empty_state" triggerElement={newLinkEl} {onCreateFolder}>
|
||||
{#snippet trigger()}
|
||||
<!-- The full stop rides inside the snippet: across a component boundary Svelte
|
||||
keeps the markup whitespace, which would leave a gap before it. -->
|
||||
|
||||
Reference in New Issue
Block a user