fix(frontend): improve workspace page (#7502)

* nit

* Improve workspace view by showing a tree

* implement search for workspaces

* Add collapse expand button

* improve unarchive button

* nit

* move search

* nit

* add max h

* Add keyboard navigation

* clean code

* Show admin workspaces with other workspaces

* Update frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* nit

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
Guilhem
2026-01-06 18:06:42 +00:00
committed by GitHub
parent 63c3dd2688
commit 69b44f3b68
11 changed files with 821 additions and 164 deletions

View File

@@ -33,7 +33,7 @@
{title}
</h1>
{#if subtitle}
<p class="text-sm font-semibold text-emphasis text-center mt-2">
<p class="text-xs font-normal text-primary text-center mt-2">
{subtitle}
</p>
{/if}

View File

@@ -50,7 +50,7 @@
</script>
<Drawer bind:this={drawer} size="900px" on:close={removeHash}>
<DrawerContent title="User Settings" on:close={closeDrawer}>
<DrawerContent title="User settings" on:close={closeDrawer}>
<div class="flex flex-col gap-6 pb-8">
{#if scopes == undefined}
<div

View File

@@ -221,7 +221,7 @@ export namespace ButtonType {
// New unified sizing system
export const UnifiedSizingClasses: Record<ButtonType.UnifiedSize, string> = {
xs: 'px-1',
xs: 'px-2',
sm: 'px-2', // Regular horizontal padding
md: 'px-4',
lg: 'px-6'

View File

@@ -15,12 +15,12 @@
import { userStore, workspaceStore } from '$lib/stores'
import type uFuzzy from '@leeoniya/ufuzzy'
import {
ChevronsDownUp,
ChevronsUpDown,
Code2,
FoldVertical,
LayoutDashboard,
ListFilterPlus,
SearchCode,
UnfoldVertical
SearchCode
} from 'lucide-svelte'
import { HOME_SEARCH_SHOW_FLOW, HOME_SEARCH_PLACEHOLDER } from '$lib/consts'
@@ -502,11 +502,11 @@
<Toggle size="xs" bind:checked={treeView} options={{ right: 'Tree view' }} />
{#if treeView}
<Button
size="xs2"
variant="default"
unifiedSize="sm"
variant="subtle"
on:click={() => (collapseAll = !collapseAll)}
startIcon={{
icon: collapseAll ? UnfoldVertical : FoldVertical
icon: collapseAll ? ChevronsUpDown : ChevronsDownUp
}}
>
{#if collapseAll}

View File

@@ -11,7 +11,8 @@
} from '$lib/stores'
import { Building, Plus, Settings, GitFork } from 'lucide-svelte'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import { Menu, MenuItem, Tooltip } from '$lib/components/meltComponents'
import { Menu, MenuItem } from '$lib/components/meltComponents'
import WorkspaceIcon from '$lib/components/workspace/WorkspaceIcon.svelte'
import { goto } from '$lib/navigation'
import { base } from '$lib/base'
import { page } from '$app/stores'
@@ -78,7 +79,7 @@
}
// Group workspaces into parent-child hierarchy using Svelte 5 derived and the new utility
const groupedWorkspaces = $derived(() => {
const groupedWorkspaces = $derived.by(() => {
if (!$userWorkspaces) return []
return buildWorkspaceHierarchy($userWorkspaces)
})
@@ -87,28 +88,6 @@
'text-primary flex flex-row gap-2 px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
</script>
{#snippet workspaceIcon(
workspaceColor: string | undefined,
isForked: boolean,
parentName: string | undefined
)}
{@const iconColor = getContrastTextColor(workspaceColor)}
<div style="background-color: {workspaceColor}" class="rounded-full p-1.5 center-center">
{#if isForked}
<Tooltip>
{#snippet text()}
{#if isForked && parentName}
Fork of {parentName}
{/if}
{/snippet}
<GitFork size={14} class="flex-shrink-0" style="color: {iconColor}" />
</Tooltip>
{:else}
<Building size={14} style="color: {iconColor}" />
{/if}
</div>
{/snippet}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
{@const forkedWorkspace = getForkedWorkspace($workspaceStore ?? '')}
@@ -143,7 +122,7 @@
{#snippet children({ item })}
<div class="divide-y" role="none">
<div class="py-1">
{#each groupedWorkspaces() as { workspace, depth, isForked, parentName }}
{#each groupedWorkspaces as { workspace, depth, isForked, parentName }}
{@const isSelected = $workspaceStore === workspace.id}
<MenuItem
class={twMerge(
@@ -164,7 +143,7 @@
>
<div class="flex items-center justify-between min-w-0 w-full">
<div class="flex items-center gap-2 min-w-0" style:padding-left={`${depth * 16}px`}>
{@render workspaceIcon(workspace.color, isForked, parentName)}
<WorkspaceIcon workspaceColor={workspace.color} {isForked} {parentName} />
<div class="min-w-0 flex-1">
<div
class={twMerge(

View File

@@ -0,0 +1,226 @@
<script lang="ts">
import { GitFork, ChevronUp, ArchiveRestore } from 'lucide-svelte'
import { slide } from 'svelte/transition'
import { Badge, Button } from '$lib/components/common'
import type { UserWorkspace } from '$lib/stores'
import { superadmin } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { pluralize } from '$lib/utils'
import WorkspaceIcon from './WorkspaceIcon.svelte'
import WorkspaceCard from './WorkspaceCard.svelte'
import { twMerge } from 'tailwind-merge'
interface ExtendedWorkspace extends UserWorkspace {
_children?: ExtendedWorkspace[]
marked?: string
}
interface Props {
workspace: UserWorkspace & { marked?: string }
isForked?: boolean
depth?: number
children?: ExtendedWorkspace[]
isExpanded?: boolean
expansionStates?: Record<string, boolean>
onEnterWorkspace: (workspaceId: string) => Promise<void>
onUnarchive?: (workspaceId: string) => Promise<void>
onToggleExpand?: (workspaceId: string) => void
selectedWorkspaceId?: string | null
onMouseEnter?: (workspaceId: string) => void
onMouseClick?: () => void
onKeyboardNavigation?: () => void
}
let {
workspace,
isForked = false,
depth = 0,
children = [],
isExpanded = false,
expansionStates = {},
onEnterWorkspace,
onUnarchive,
onToggleExpand,
selectedWorkspaceId,
onMouseEnter,
onMouseClick,
onKeyboardNavigation
}: Props = $props()
const paddingLeft = depth * 24
const isSelected = $derived(selectedWorkspaceId === workspace.id)
// Helper functions
function isWorkspaceArchived(workspace: UserWorkspace): boolean {
return workspace['deleted'] === true
}
function isWorkspaceDisabled(workspace: UserWorkspace): boolean {
return workspace.disabled === true
}
async function handleUnarchive() {
if (onUnarchive) {
await WorkspaceService.unarchiveWorkspace({ workspace: workspace.id })
await onUnarchive(workspace.id)
}
}
</script>
<div class="block pb-2" style:padding-left={`${paddingLeft}px`}>
<div
class={twMerge(
'border border-border-light rounded-md overflow-hidden transition-all duration-150',
isSelected ? 'bg-surface-hover' : 'bg-surface-tertiary'
)}
data-workspace-id={workspace.id}
>
<!-- Main workspace card - clickable to enter workspace -->
<div
class="px-4 py-2 hover:bg-surface-hover transition-colors w-full"
class:rounded-lg={children.length === 0}
class:rounded-b-none={children.length > 0}
class:opacity-60={isWorkspaceDisabled(workspace)}
class:cursor-not-allowed={isWorkspaceDisabled(workspace)}
class:cursor-pointer={!isWorkspaceDisabled(workspace)}
role="button"
tabindex="0"
onclick={async () => {
onMouseClick?.()
if (!isWorkspaceDisabled(workspace)) {
await onEnterWorkspace(workspace.id)
}
}}
onkeydown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && !isWorkspaceDisabled(workspace)) {
e.preventDefault()
onKeyboardNavigation?.()
onEnterWorkspace(workspace.id)
}
}}
onmouseenter={() => onMouseEnter?.(workspace.id)}
>
<div class="flex flex-row items-center justify-between">
<div class="flex flex-row items-center gap-3 flex-1 min-w-0">
<div class="flex flex-row items-center gap-2 flex-1 min-w-0">
<div class="flex-shrink-0">
<WorkspaceIcon
workspaceColor={workspace.color}
{isForked}
parentName={workspace.parent_workspace_id ?? undefined}
size={12}
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-row items-center gap-2 flex-wrap">
<span class="text-xs font-semibold text-primary truncate">
{#if workspace.marked}
{@html workspace.marked}
{:else}
{workspace.name}
{/if}
</span>
<span class="text-secondary text-xs">-</span>
{#if workspace.id === 'admins'}
<Badge color="blue">{workspace.id}</Badge>
{:else}
<span class="font-mono text-2xs text-secondary truncate">
{workspace.id}
</span>
{/if}
</div>
<div class="text-xs text-secondary">
as <span class="font-mono">{workspace.username}</span>
{#if isWorkspaceArchived(workspace)}
<span class="text-red-500 ml-1">(archived)</span>
{#if $superadmin && onUnarchive}
<Button
size="xs2"
variant="default"
btnClasses="ml-1"
propagateEvent={false}
onClick={handleUnarchive}
startIcon={{ icon: ArchiveRestore }}
>
Unarchive
</Button>
{/if}
{/if}
{#if isWorkspaceDisabled(workspace)}
<span class="text-red-500 ml-1">(user disabled in this workspace)</span>
{/if}
{#if workspace.id === 'admins'}
<span class="text-accent ml-1">Used to manage your Windmill instance</span>
{/if}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Forks section - clickable to expand -->
{#if children.length > 0}
<div
class="border-t border-border-light px-4 py-1.5 hover:bg-surface-hover transition-colors cursor-pointer"
role="button"
tabindex="0"
onclick={() => {
onMouseClick?.()
onToggleExpand?.(workspace.id)
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onKeyboardNavigation?.()
onToggleExpand?.(workspace.id)
}
}}
onmouseenter={() => onMouseEnter?.(workspace.id)}
>
<div class="flex flex-row items-center justify-between">
<div class="flex flex-row items-center gap-2 pl-2">
<GitFork size={10} class="text-primary" />
<span class="text-2xs text-primary">
{pluralize(children.length, 'fork', 'forks')}
</span>
</div>
<div class="flex items-center">
<ChevronUp
size={16}
class={twMerge(
'text-secondary transition-transform duration-150',
isExpanded ? 'transform rotate-180' : ''
)}
/>
</div>
</div>
</div>
{/if}
</div>
<!-- Expanded forks -->
{#if children.length > 0 && isExpanded}
<div class="mt-2 ml-6" transition:slide={{ duration: 150 }}>
{#each children as child (child.id)}
<WorkspaceCard
workspace={child}
isForked={true}
depth={depth + 1}
children={child._children || []}
isExpanded={expansionStates[child.id] ?? false}
{expansionStates}
{onEnterWorkspace}
{onUnarchive}
{onToggleExpand}
{selectedWorkspaceId}
{onMouseEnter}
{onMouseClick}
{onKeyboardNavigation}
/>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { Building, GitFork } from 'lucide-svelte'
import { Tooltip } from '$lib/components/meltComponents'
import { getContrastTextColor } from '$lib/utils'
interface Props {
workspaceColor?: string
isForked?: boolean
parentName?: string
size?: number
}
let { workspaceColor, isForked = false, parentName, size = 14 }: Props = $props()
const iconColor = $derived(getContrastTextColor(workspaceColor))
</script>
<div style="background-color: {workspaceColor}" class="rounded-full p-1.5 center-center">
{#if isForked}
<Tooltip>
{#snippet text()}
{#if isForked && parentName}
Fork of {parentName}
{/if}
{/snippet}
<GitFork {size} class="flex-shrink-0" style="color: {iconColor}" />
</Tooltip>
{:else}
<Building {size} style="color: {iconColor}" />
{/if}
</div>

View File

@@ -0,0 +1,412 @@
<script lang="ts">
import { Building2 } from 'lucide-svelte'
import { SvelteMap } from 'svelte/reactivity'
import WorkspaceCard from './WorkspaceCard.svelte'
import SearchItems from '$lib/components/SearchItems.svelte'
import type { UserWorkspace } from '$lib/stores'
interface ExtendedWorkspace extends UserWorkspace {
_children?: ExtendedWorkspace[]
marked?: string
}
interface Props {
workspaces: UserWorkspace[]
onEnterWorkspace: (workspaceId: string) => Promise<void>
onUnarchive?: (workspaceId: string) => Promise<void>
searchFilter?: string
onExpandCollapseAll?: () => void
allExpanded?: boolean
hasForks?: boolean
}
let {
workspaces,
onEnterWorkspace,
onUnarchive,
searchFilter = $bindable(''),
onExpandCollapseAll = $bindable(),
allExpanded = $bindable(false),
hasForks = $bindable(false)
}: Props = $props()
// State for manually toggled expansion status
let manualExpansionStates = $state<Record<string, boolean>>({})
let filteredWorkspaces: (UserWorkspace & { marked?: string })[] | undefined = $state()
// Keyboard navigation state
let selectedWorkspaceId = $state<string | null>(null)
let isKeyboardNavigation = $state(false)
let scrollContainer: HTMLElement
// Computed expansion states that include auto-expansion for search results
let expansionStates = $derived.by(() => {
if (!searchFilter || !filteredWorkspaces || !workspaces) {
return manualExpansionStates
}
const matchedWorkspaceIds = new Set(filteredWorkspaces.map((w) => w.id))
const autoExpanded: Record<string, boolean> = {}
// Build children map for descendant checking
const childrenMap = new Map<string, string[]>()
workspaces.forEach((workspace) => {
if (workspace.parent_workspace_id) {
if (!childrenMap.has(workspace.parent_workspace_id)) {
childrenMap.set(workspace.parent_workspace_id, [])
}
childrenMap.get(workspace.parent_workspace_id)!.push(workspace.id)
}
})
// Function to check if workspace has matching descendants (not itself)
function hasMatchingDescendant(workspaceId: string): boolean {
const children = childrenMap.get(workspaceId) || []
return children.some(
(childId) => matchedWorkspaceIds.has(childId) || hasMatchingDescendant(childId)
)
}
// Auto-expand workspaces only if they have matching descendants
// (not if the workspace itself matches - user can manually expand to see children)
workspaces.forEach((workspace) => {
if (hasMatchingDescendant(workspace.id)) {
autoExpanded[workspace.id] = true
}
})
// Combine manual and auto-expanded states
return { ...manualExpansionStates, ...autoExpanded }
})
// Build nested hierarchy correctly - always use full workspace list for hierarchy
let rootWorkspaces = $derived.by(() => {
if (!workspaces) return []
// Create a map of children for each parent workspace using ALL workspaces
const childrenMap = new SvelteMap<string, ExtendedWorkspace[]>()
// Build children mapping - this correctly handles nested relationships
workspaces.forEach((workspace) => {
if (workspace.parent_workspace_id) {
if (!childrenMap.has(workspace.parent_workspace_id)) {
childrenMap.set(workspace.parent_workspace_id, [])
}
// Find marked version from filtered results if available
const filteredWorkspace = filteredWorkspaces?.find((fw) => fw.id === workspace.id)
const extendedWorkspace = {
...workspace,
marked: filteredWorkspace?.marked
} as ExtendedWorkspace
childrenMap.get(workspace.parent_workspace_id)!.push(extendedWorkspace)
}
})
// Get IDs of workspaces that match the search
const matchedWorkspaceIds = new Set(filteredWorkspaces?.map((w) => w.id) || [])
// Function to check if a workspace or its descendants match the search
function hasMatchingDescendant(workspaceId: string): boolean {
if (matchedWorkspaceIds.has(workspaceId)) return true
const children = childrenMap.get(workspaceId) || []
return children.some((child) => hasMatchingDescendant(child.id))
}
// Recursive function to build full nested hierarchy
// parentMatched: if true, include all children regardless of search match
function buildWorkspaceWithChildren(
workspace: UserWorkspace,
parentMatched: boolean = false
): ExtendedWorkspace {
const directChildren = childrenMap.get(workspace.id) || []
const thisWorkspaceMatches = matchedWorkspaceIds.has(workspace.id)
// If this workspace or a parent matches, show all children
// Otherwise, only show children that match or have matching descendants
const visibleChildren =
searchFilter && !parentMatched && !thisWorkspaceMatches
? directChildren.filter((child) => hasMatchingDescendant(child.id))
: directChildren
const childrenWithNestedStructure = visibleChildren.map((child) =>
buildWorkspaceWithChildren(child, parentMatched || thisWorkspaceMatches)
)
// Find marked version from filtered results if available
const filteredWorkspace = filteredWorkspaces?.find((fw) => fw.id === workspace.id)
return {
...workspace,
marked: filteredWorkspace?.marked,
_children: childrenWithNestedStructure
}
}
// Return only root workspaces - filter based on search if active
const rootCandidates = workspaces.filter(
(workspace) =>
!workspace.parent_workspace_id ||
!workspaces.find((w) => w.id === workspace.parent_workspace_id)
)
const visibleRoots = searchFilter
? rootCandidates.filter((workspace) => hasMatchingDescendant(workspace.id))
: rootCandidates
return visibleRoots
.map((workspace) => buildWorkspaceWithChildren(workspace))
.sort((a, b) => {
// Admin workspace always goes first
if (a.id === 'admins') return -1
if (b.id === 'admins') return 1
// Then sort alphabetically by name
return a.name.localeCompare(b.name)
})
})
function handleToggleExpand(workspaceId: string) {
const currentState = expansionStates[workspaceId] ?? false
manualExpansionStates = { ...manualExpansionStates, [workspaceId]: !currentState }
}
// Get IDs of workspaces that have children (can be expanded)
let workspacesWithChildren = $derived.by(() => {
if (!workspaces) return []
const parentIds = new Set(
workspaces.filter((w) => w.parent_workspace_id).map((w) => w.parent_workspace_id)
)
return workspaces.filter((w) => parentIds.has(w.id)).map((w) => w.id)
})
// Check if all expandable workspaces are currently expanded
let allExpandedInternal = $derived(
workspacesWithChildren.length > 0 &&
workspacesWithChildren.every((id) => expansionStates[id] === true)
)
// Sync internal state to bindable props
$effect(() => {
allExpanded = allExpandedInternal
})
$effect(() => {
hasForks = workspacesWithChildren.length > 0
})
export function handleExpandCollapseAll() {
const newState = !allExpandedInternal
const newExpansionStates: Record<string, boolean> = {}
workspacesWithChildren.forEach((id) => {
newExpansionStates[id] = newState
})
manualExpansionStates = newExpansionStates
}
// Generate flattened navigation order for keyboard navigation
const flatNavigationOrder = $derived.by(() => {
const result: string[] = []
function addWorkspaceAndChildren(workspace: ExtendedWorkspace) {
result.push(workspace.id)
if (workspace._children && expansionStates[workspace.id]) {
workspace._children.forEach((child) => addWorkspaceAndChildren(child))
}
}
rootWorkspaces.forEach((workspace) => addWorkspaceAndChildren(workspace))
return result
})
// Keyboard navigation handlers
function handleKeyDown(event: KeyboardEvent) {
// Allow navigation keys even when search input has focus
const navigationKeys = [
'ArrowDown',
'ArrowUp',
'Home',
'End',
'ArrowLeft',
'ArrowRight',
'Enter',
' ',
'Escape'
]
const activeElement = document.activeElement
// Skip navigation only if user is typing in textarea or non-search inputs
if (
activeElement?.tagName === 'TEXTAREA' ||
(activeElement?.tagName === 'INPUT' && !navigationKeys.includes(event.key))
) {
return
}
// Enable keyboard navigation on arrow keys
if (['ArrowDown', 'ArrowUp', 'Home', 'End', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
enableKeyboardNavigation()
}
// Only handle navigation if keyboard navigation is active
if (!isKeyboardNavigation) {
return
}
if (flatNavigationOrder.length === 0) return
const currentIndex = selectedWorkspaceId ? flatNavigationOrder.indexOf(selectedWorkspaceId) : -1
switch (event.key) {
case 'ArrowDown': {
event.preventDefault()
const nextIndex = currentIndex < flatNavigationOrder.length - 1 ? currentIndex + 1 : 0
selectedWorkspaceId = flatNavigationOrder[nextIndex]
break
}
case 'ArrowUp': {
event.preventDefault()
const prevIndex = currentIndex > 0 ? currentIndex - 1 : flatNavigationOrder.length - 1
selectedWorkspaceId = flatNavigationOrder[prevIndex]
break
}
case 'Home': {
event.preventDefault()
selectedWorkspaceId = flatNavigationOrder[0]
break
}
case 'End': {
event.preventDefault()
selectedWorkspaceId = flatNavigationOrder[flatNavigationOrder.length - 1]
break
}
case 'ArrowRight': {
if (selectedWorkspaceId && workspacesWithChildren.includes(selectedWorkspaceId)) {
event.preventDefault()
if (!expansionStates[selectedWorkspaceId]) {
handleToggleExpand(selectedWorkspaceId)
}
}
break
}
case 'ArrowLeft': {
if (selectedWorkspaceId && workspacesWithChildren.includes(selectedWorkspaceId)) {
event.preventDefault()
if (expansionStates[selectedWorkspaceId]) {
handleToggleExpand(selectedWorkspaceId)
}
}
break
}
case 'Enter':
case ' ': {
if (selectedWorkspaceId) {
event.preventDefault()
const workspace = workspaces.find((w) => w.id === selectedWorkspaceId)
if (workspace && !workspace.disabled) {
onEnterWorkspace(selectedWorkspaceId)
}
}
break
}
case 'Escape': {
selectedWorkspaceId = null
isKeyboardNavigation = false
break
}
}
}
// Reset selection when workspaces change or search filter changes
$effect(() => {
if (rootWorkspaces.length === 0) {
selectedWorkspaceId = null
} else if (selectedWorkspaceId && !flatNavigationOrder.includes(selectedWorkspaceId)) {
// If currently selected workspace is no longer visible, reset to first visible
selectedWorkspaceId = flatNavigationOrder[0] || null
}
})
// Enable keyboard navigation when user starts navigating
function enableKeyboardNavigation() {
if (!isKeyboardNavigation && rootWorkspaces.length > 0) {
isKeyboardNavigation = true
if (!selectedWorkspaceId) {
selectedWorkspaceId = flatNavigationOrder[0] || null
}
}
}
// Handle mouse interactions - disable keyboard mode when mouse is used
function handleMouseEnter(workspaceId: string) {
if (isKeyboardNavigation) {
selectedWorkspaceId = workspaceId
}
}
function handleMouseClick() {
isKeyboardNavigation = false
selectedWorkspaceId = null
}
// Scroll selected workspace into view
function scrollToSelectedWorkspace() {
if (!selectedWorkspaceId || !scrollContainer) return
// Find the workspace card element by data attribute
const selectedElement = scrollContainer.querySelector(
`[data-workspace-id="${selectedWorkspaceId}"]`
)
if (selectedElement) {
selectedElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
}
}
// Auto-scroll when selection changes
$effect(() => {
if (selectedWorkspaceId && isKeyboardNavigation) {
scrollToSelectedWorkspace()
}
})
</script>
<svelte:window onkeydown={handleKeyDown} />
<!-- Search Items Component for fuzzy search with highlighting -->
<SearchItems
filter={searchFilter}
items={workspaces}
bind:filteredItems={filteredWorkspaces}
f={(workspace) => workspace.name + ' (' + workspace.id + ')'}
/>
<div class="space-y-4 max-h-[50vh] overflow-auto" bind:this={scrollContainer}>
<!-- Workspace Tree -->
<div class="space-y-2">
{#each rootWorkspaces as workspace (workspace.id)}
<WorkspaceCard
{workspace}
children={workspace._children || []}
isExpanded={expansionStates[workspace.id] ?? false}
{expansionStates}
{onEnterWorkspace}
{onUnarchive}
onToggleExpand={handleToggleExpand}
{selectedWorkspaceId}
onMouseEnter={handleMouseEnter}
onMouseClick={handleMouseClick}
onKeyboardNavigation={enableKeyboardNavigation}
/>
{/each}
{#if rootWorkspaces.length === 0}
<div class="text-center py-8">
<Building2 size={48} class="text-secondary mx-auto mb-3" />
<p class="text-sm text-secondary">
{searchFilter ? 'No workspaces match your search' : 'No workspaces available'}
</p>
</div>
{/if}
</div>
</div>

View File

@@ -801,7 +801,7 @@ export function pluralize(quantity: number, word: string, customPlural?: string)
if (quantity == 1) {
return `${quantity} ${word}`
} else if (customPlural) {
return `${quantity} ${customPlural}}`
return `${quantity} ${customPlural}`
} else {
return `${quantity} ${word}s`
}

View File

@@ -80,16 +80,16 @@
</label>
{/if}
<div class="flex flex-row justify-between pt-4 gap-x-1">
<Button variant="default" size="sm" href="{base}/user/workspaces"
<Button variant="default" unifiedSize="md" href="{base}/user/workspaces"
>&leftarrow; Back to workspaces</Button
>
<button
<Button
disabled={checking || (!automateUsernameCreation && (errorUsername != '' || !username))}
class="place-items-end bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-2 border rounded"
type="button"
on:click={acceptInvite}
variant="accent"
unifiedSize="md"
onClick={acceptInvite}
>
Accept invite
</button>
</Button>
</div>
</CenteredModal>

View File

@@ -21,12 +21,13 @@
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { USER_SETTINGS_HASH } from '$lib/components/sidebar/settings'
import { switchWorkspace } from '$lib/storeUtils'
import { Crown, GitFork, Settings } from 'lucide-svelte'
import { GitFork, Settings, User, Search, ChevronsDownUp, ChevronsUpDown } from 'lucide-svelte'
import { isCloudHosted } from '$lib/cloud'
import { emptyString } from '$lib/utils'
import { getUserExt } from '$lib/user'
import { refreshSuperadmin } from '$lib/refreshUser'
import { buildWorkspaceHierarchy } from '$lib/utils/workspaceHierarchy'
import WorkspaceTreeView from '$lib/components/workspace/WorkspaceTreeView.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import type { UserWorkspace } from '$lib/stores'
let invites: WorkspaceInvite[] = []
@@ -34,6 +35,12 @@
let workspaces: UserWorkspace[] | undefined = undefined
let showAllForks: boolean = false
// Workspace tree controls
let workspaceSearchFilter = ''
let workspaceAllExpanded = false
let workspaceHasForks = false
let workspaceTreeView: WorkspaceTreeView | undefined = undefined
let userSettings: UserSettings
let superadminSettings: SuperadminSettings
@@ -84,20 +91,8 @@
}
$: list_all_as_super_admin != undefined && $userWorkspaces && handleListWorkspaces()
$: adminsInstance = workspaces?.find((x) => x.id == 'admins') || $superadmin
// Complete workspace hierarchy with all forks
$: forkedWorkspacesHierarchy = (() => {
if (!workspaces) return []
// Filter out admin workspace
const nonAdminWorkspaces = workspaces.filter((x) => x.id !== 'admins')
return buildWorkspaceHierarchy(nonAdminWorkspaces)
})()
$: groupedNonAdminWorkspaces = forkedWorkspacesHierarchy
$: noWorkspaces = $superadmin && groupedNonAdminWorkspaces.length == 0
$: allWorkspaces = workspaces || []
$: noWorkspaces = $superadmin && allWorkspaces.length == 0
async function getCreateWorkspaceRequireSuperadmin() {
const r = await fetch(base + '/api/workspaces/create_workspace_require_superadmin')
@@ -123,6 +118,20 @@
async function speakFriendAndEnterWorkspace(workspaceId: string) {
loading = true
// Special handling for admins workspace
if (workspaceId === 'admins') {
workspaceStore.set('admins')
if (rd?.startsWith('http')) {
window.location.href = rd
return
}
await goto(rd ?? '/')
loading = false
return
}
// Regular workspace handling
workspaceStore.set(undefined)
workspaceStore.set(workspaceId)
$userStore = await getUserExt($workspaceStore!)
@@ -154,6 +163,10 @@
}
loading = false
}
function workspaceExpandCollapseAll() {
workspaceTreeView?.handleExpandCollapseAll()
}
</script>
{#if $superadmin}
@@ -161,38 +174,47 @@
{/if}
<CenteredModal title="Select a workspace" subtitle="Logged in as {$usersWorkspaceStore?.email}">
<div class="flex flex-row items-center gap-2 justify-between">
<h2 class="mb-4 inline-flex gap-2 text-sm font-semibold text-emphasis">
<div class="flex flex-row items-center gap-2 justify-between mb-4">
<h2 class="inline-flex gap-2 text-sm font-semibold text-emphasis flex-shrink-0">
Workspaces{#if loading}<WindmillIcon spin="fast" />{/if}
</h2>
{#if $superadmin}
<div class="flex flex-row-reverse pb-2">
<Toggle
bind:checked={list_all_as_super_admin}
options={{ right: 'List all workspaces as superadmin' }}
/>
{#if allWorkspaces.length > 1}
<div class="flex gap-2 items-center">
<div class="relative text-primary flex-1 max-w-48">
<TextInput
inputProps={{
placeholder: 'Search workspaces...'
}}
size="sm"
bind:value={workspaceSearchFilter}
class="!pr-8"
/>
<Search size={14} class="text-secondary absolute right-2 top-0 mt-2" />
</div>
{#if workspaceHasForks}
<Button
onClick={() => workspaceExpandCollapseAll?.()}
title={workspaceAllExpanded ? 'Collapse all' : 'Expand all'}
startIcon={{ icon: workspaceAllExpanded ? ChevronsDownUp : ChevronsUpDown }}
size="xs2"
variant="default"
>
{workspaceAllExpanded ? 'Collapse' : 'Expand'}
</Button>
{/if}
</div>
{/if}
</div>
{#if adminsInstance}
<Button
btnClasses="w-full mt-2 mb-4 truncate bg-surface-tertiary hover:bg-surface-secondary"
size="sm"
on:click={async () => {
workspaceStore.set('admins')
loading = true
if (rd?.startsWith('http')) {
window.location.href = rd
return
}
await goto(rd ?? '/')
loading = false
}}
variant="default"
>Manage Windmill on the superadmins workspace
</Button>
{#if $superadmin}
<div class="flex justify-end mb-2">
<Toggle
bind:checked={list_all_as_super_admin}
options={{ right: 'List all workspaces as superadmin' }}
size="xs"
/>
</div>
{/if}
{#if workspaces && $usersWorkspaceStore}
@@ -202,58 +224,25 @@
create your own{/if}
workspace.
</p>
{:else}
<WorkspaceTreeView
workspaces={allWorkspaces}
onEnterWorkspace={speakFriendAndEnterWorkspace}
onUnarchive={async (_workspaceId) => {
if (list_all_as_super_admin) {
loadWorkspacesAsAdmin()
} else {
loadWorkspaces()
}
}}
bind:searchFilter={workspaceSearchFilter}
bind:allExpanded={workspaceAllExpanded}
bind:hasForks={workspaceHasForks}
bind:this={workspaceTreeView}
/>
{/if}
{#each groupedNonAdminWorkspaces as { workspace, depth, isForked } (workspace.id)}
<label class="block pb-2" style:padding-left={`${depth * 24}px`}>
<Button
variant="default"
btnClasses="bg-surface-tertiary hover:bg-surface-secondary"
disabled={workspace.disabled}
on:click={async () => {
if (!workspace.disabled) {
speakFriendAndEnterWorkspace(workspace.id)
}
}}
>
{#if isForked}
<GitFork size={12} class="text-primary mr-2 flex-shrink-0" />
{/if}
<span class="flex-1 items-center">
{#if workspace.color}
<div
class="inline-block w-4 h-4 mr-2 rounded-full border"
style="background-color: {workspace.color}"
></div>
{/if}
<span class="font-mono text-secondary">{workspace.id}</span> -
<span class:text-secondary={isForked}>{workspace.name}</span>
as
<span class="font-mono" class:text-secondary={isForked}>{workspace.username}</span>
{#if workspace['deleted']}
<span class="text-red-500"> (archived)</span>
{/if}
{#if workspace.disabled}
<span class="text-red-500"> (user disabled in this workspace)</span>
{/if}
</span>
</Button>
{#if $superadmin && workspace['deleted']}
<Button
size="xs"
btnClasses="w-full mt-1"
variant="default"
on:click={async () => {
await WorkspaceService.unarchiveWorkspace({ workspace: workspace.id })
loadWorkspacesAsAdmin()
}}
>
Unarchive {workspace.id}
</Button>
{/if}
</label>
{/each}
{:else}
{#each new Array(3) as _}
{#each new Array(3) as _, i (i)}
<Skeleton layout={[[2], 0.5]} />
{/each}
{/if}
@@ -261,10 +250,11 @@
{#if createWorkspace}
<div class="flex flex-row-reverse pt-4">
<Button
size="sm"
unifiedSize="sm"
btnClasses={noWorkspaces ? 'animate-bounce hover:animate-none' : ''}
href="{base}/user/create_workspace{rd ? `?rd=${encodeURIComponent(rd)}` : ''}"
variant={noWorkspaces ? 'accent' : 'default'}
wrapperClasses="w-full"
>+&nbsp;Create a new workspace
</Button>
</div>
@@ -272,10 +262,19 @@
{@const nonForkInvites = invites.filter((invite) => invite.parent_workspace_id == undefined)}
<h2 class="mt-6 mb-4 text-sm font-semibold text-emphasis">Invites to join a Workspace</h2>
<div class="flex flex-row items-center justify-between mt-8">
<h2 class="text-sm font-semibold text-emphasis">Invites to join a Workspace</h2>
{#if workspaces}
<Toggle size="xs" bind:checked={showAllForks} options={{ right: 'Show workspace forks' }} />
{/if}
</div>
<div class="mt-4"></div>
{#if nonForkInvites.length == 0}
<p class="text-xs text-secondary mt-2"> You don't have new invites at the moment. </p>
<p class="text-xs text-secondary"> You don't have new invites at the moment. </p>
{/if}
{#each nonForkInvites as invite}
<div
class="w-full mx-auto py-1 px-2 rounded-md border border-border-light
@@ -318,20 +317,19 @@
</div>
{/each}
{#if workspaces}
<div class="flex flex-row pt-6 pb-2">
<Toggle size="xs" bind:checked={showAllForks} options={{ right: 'Show workspace forks' }} />
</div>
{/if}
{#if showAllForks}
{@const allWorkspacesList = workspaces || []}
{@const filteredInvites = invites.filter((invite) => invite.parent_workspace_id)}
<h2 class="mb-4 text-sm font-semibold text-emphasis">Forks of the workspaces you're in</h2>
<div class="mt-4"></div>
{#if filteredInvites.length == 0}
<p class="text-xs text-secondary mt-2"> There isn't anything here </p>
<p class="text-xs text-secondary"
>There are no invites to join the forks of any workspace you're in.</p
>
{:else}
<span class="mb-2 text-xs font-normal text-secondary">Forks of the workspaces you're in</span>
{/if}
{#each filteredInvites as invite}
{@const inviteWorkspace = allWorkspacesList.find((w) => w.id === invite.workspace_id)}
<div
@@ -357,18 +355,21 @@
{/if}
</div>
<div class="flex justify-end items-center flex-col sm:flex-row gap-1">
<a
class="font-semibold text-xs p-1"
<Button
variant="accent"
unifiedSize="xs"
href="{base}/user/accept_invite?workspace={encodeURIComponent(invite.workspace_id)}{rd
? `&rd=${encodeURIComponent(rd)}`
: ''}"
>
Accept
</a>
</Button>
<button
class="text-red-700 font-semibold text-xs p-1"
on:click={async () => {
<Button
variant="subtle"
unifiedSize="xs"
destructive
onClick={async () => {
await UserService.declineInvite({
requestBody: { workspace_id: invite.workspace_id }
})
@@ -377,7 +378,7 @@
}}
>
Decline
</button>
</Button>
</div>
</div>
{/each}
@@ -387,25 +388,33 @@
{#if $superadmin}
<Button
variant="default"
size="sm"
unifiedSize="md"
on:click={superadminSettings.openDrawer}
startIcon={{ icon: Crown }}
startIcon={{ icon: Settings }}
dropdownItems={[
{
label: 'User settings',
onClick: () => userSettings.openDrawer(),
icon: User
}
]}
>
Superadmin settings
Instance settings
</Button>
{:else}
<Button
variant="default"
unifiedSize="md"
onClick={() => userSettings.openDrawer()}
startIcon={{ icon: Settings }}
>
User settings
</Button>
{/if}
<Button
variant="default"
size="sm"
on:click={() => userSettings.openDrawer()}
startIcon={{ icon: Settings }}
>
User settings
</Button>
<Button
variant="default"
size="sm"
variant="accent"
unifiedSize="md"
on:click={async () => {
logout()
}}