feat(frontend): add treeview (#2542)

* feat(frontend): add treeview

* feat(frontend): fix size

* feat(frontend): add treeview for users

* feat(frontend): fix build + remove console.og

* feat(frontend): fix actions

* feat(frontend): add icon for fake folder

* feat(frontend): fix add sort
This commit is contained in:
Faton Ramadani
2023-11-02 18:51:39 +01:00
committed by GitHub
parent 4711a45de1
commit fbed35c309
9 changed files with 427 additions and 129 deletions
@@ -34,6 +34,7 @@
export let moveDrawer: MoveDrawer
export let deploymentDrawer: DeployWorkspaceDrawer
export let deleteConfirmedCallback: (() => void) | undefined
export let depth: number = 0
let {
summary,
@@ -77,6 +78,7 @@
{starred}
on:change
canFavorite={!draft_only}
{depth}
>
<svelte:fragment slot="badges">
{#if execution_mode == 'anonymous'}
@@ -35,6 +35,7 @@
export let deleteConfirmedCallback: (() => void) | undefined
export let deploymentDrawer: DeployWorkspaceDrawer
export let errorHandlerMuted: boolean
export let depth: number = 0
let { summary, path, extra_perms, canWrite, workspace_id, archived, draft_only, has_draft } = flow
@@ -78,6 +79,7 @@
{errorHandlerMuted}
on:change
canFavorite={!draft_only}
{depth}
>
<svelte:fragment slot="badges">
{#if archived}
@@ -22,6 +22,7 @@
export let moveDrawer: MoveDrawer
export let deleteConfirmedCallback: (() => void) | undefined
export let deploymentDrawer: DeployWorkspaceDrawer
export let depth: number = 0
let updateAppDrawer: Drawer
@@ -60,6 +61,7 @@
{starred}
on:change
canFavorite={true}
{depth}
>
<svelte:fragment slot="badges">
<SharedBadge {canWrite} extraPerms={extra_perms} />
@@ -3,6 +3,7 @@
import { createEventDispatcher } from 'svelte'
import RowIcon from './RowIcon.svelte'
import { BellOff } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
export let marked: string | undefined
export let starred: boolean
@@ -16,11 +17,15 @@
export let path: string
export let href: string
export let workspaceId: string
export let depth: number = 0
</script>
<div
class="hover:bg-surface-hover w-full inline-flex items-center gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b px-4 py-2.5 border-b last:border-b-0"
class={twMerge(
'hover:bg-surface-hover w-full inline-flex items-center gap-4 first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b px-4 py-2.5 border-b last:border-b-0',
depth > 0 ? '!rounded-none' : ''
)}
style={depth > 0 ? `padding-left: ${depth * 32}px;` : ''}
>
<a {href} class="min-w-0 grow hover:underline decoration-gray-400 inline-flex items-center gap-4">
<div class="shrink">
@@ -37,6 +37,7 @@
export let deploymentDrawer: DeployWorkspaceDrawer
export let deleteConfirmedCallback: (() => void) | undefined
export let errorHandlerMuted: boolean
export let depth: number = 0
let {
summary,
@@ -96,6 +97,7 @@
workspaceId={$workspaceStore ?? ''}
on:change
canFavorite={!draft_only}
{depth}
>
<svelte:fragment slot="badges">
{#if lock_error_logs}
@@ -0,0 +1,127 @@
<script lang="ts">
import AppRow from '../common/table/AppRow.svelte'
import FlowRow from '../common/table/FlowRow.svelte'
import RawAppRow from '../common/table/RawAppRow.svelte'
import ScriptRow from '../common/table/ScriptRow.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { Alert, Badge } from '$lib/components/common'
import DeployWorkspaceDrawer from '../DeployWorkspaceDrawer.svelte'
import MoveDrawer from '../MoveDrawer.svelte'
import ShareModal from '../ShareModal.svelte'
import { createEventDispatcher } from 'svelte'
export let item
export let depth: number = 0
const dispatch = createEventDispatcher()
let deleteConfirmedCallback: (() => void) | undefined = undefined
let shareModal: ShareModal
let moveDrawer: MoveDrawer
let deploymentDrawer: DeployWorkspaceDrawer
</script>
{#key item.summary}
{#key item.starred}
{#key item.has_draft}
{#if item.type == 'script'}
<ScriptRow
bind:deleteConfirmedCallback
starred={item.starred ?? false}
marked={item.marked}
on:change={() => dispatch('scriptChanged')}
script={item}
errorHandlerMuted={item.ws_error_handler_muted === undefined ||
item.ws_error_handler_muted === null
? false
: item.ws_error_handler_muted}
{shareModal}
{moveDrawer}
{deploymentDrawer}
{depth}
/>
{:else if item.type == 'flow'}
<FlowRow
bind:deleteConfirmedCallback
starred={item.starred ?? false}
marked={item.marked}
on:change={() => dispatch('flowChanged')}
flow={item}
errorHandlerMuted={item.ws_error_handler_muted === undefined ||
item.ws_error_handler_muted === null
? false
: item.ws_error_handler_muted}
{shareModal}
{moveDrawer}
{deploymentDrawer}
{depth}
/>
{:else if item.type == 'app'}
<AppRow
bind:deleteConfirmedCallback
starred={item.starred ?? false}
marked={item.marked}
on:change={() => dispatch('appChanged')}
app={item}
{moveDrawer}
{shareModal}
{deploymentDrawer}
{depth}
/>
{:else if item.type == 'raw_app'}
<RawAppRow
bind:deleteConfirmedCallback
starred={item.starred ?? false}
marked={item.marked}
on:change={() => dispatch('rawAppChanged')}
app={item}
{moveDrawer}
{shareModal}
{deploymentDrawer}
{depth}
/>
{/if}
{/key}
{/key}
{/key}
<ConfirmationModal
open={Boolean(deleteConfirmedCallback)}
title="Remove"
confirmationText="Remove"
on:canceled={() => {
deleteConfirmedCallback = undefined
}}
on:confirmed={() => {
if (deleteConfirmedCallback) {
deleteConfirmedCallback()
}
deleteConfirmedCallback = undefined
}}
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to remove it?</span>
<Alert type="info" title="Bypass confirmation">
<div>
You can press
<Badge color="dark-gray">SHIFT</Badge>
while removing to bypass confirmation.
</div>
</Alert>
</div>
</ConfirmationModal>
<ShareModal
bind:this={shareModal}
on:change={() => {
dispatch('reload')
}}
/>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<MoveDrawer
bind:this={moveDrawer}
on:update={() => {
dispatch('reload')
}}
/>
+40 -127
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, Badge, Button, Skeleton } from '$lib/components/common'
import ShareModal from '$lib/components/ShareModal.svelte'
import { Button, Skeleton } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import {
AppService,
@@ -16,30 +15,25 @@
import { userStore, workspaceStore } from '$lib/stores'
import type uFuzzy from '@leeoniya/ufuzzy'
import { Code2, LayoutDashboard, SearchCode } from 'lucide-svelte'
import TreeView from './TreeView.svelte'
export let filter = ''
export let subtab: 'flow' | 'script' | 'app' = 'script'
import AppRow from '$lib/components/common/table/AppRow.svelte'
import FlowRow from '$lib/components/common/table/FlowRow.svelte'
import ScriptRow from '$lib/components/common/table/ScriptRow.svelte'
import { HOME_SEARCH_SHOW_FLOW, HOME_SEARCH_PLACEHOLDER } from '$lib/consts'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import MoveDrawer from '../MoveDrawer.svelte'
import SearchItems from '../SearchItems.svelte'
import ListFilters from './ListFilters.svelte'
import NoItemFound from './NoItemFound.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import FlowIcon from './FlowIcon.svelte'
import RawAppRow from '../common/table/RawAppRow.svelte'
import { canWrite } from '$lib/utils'
import { page } from '$app/stores'
import { setQuery } from '$lib/navigation'
import DeployWorkspaceDrawer from '../DeployWorkspaceDrawer.svelte'
import ContentSearch from '../ContentSearch.svelte'
import { groupItems } from './treeViewUtils'
import Item from './Item.svelte'
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
canWrite: boolean
@@ -64,16 +58,10 @@
let itemKind = ($page.url.searchParams.get('kind') as 'script' | 'flow' | 'app' | 'all') ?? 'all'
let shareModal: ShareModal
let moveDrawer: MoveDrawer
let deploymentDrawer: DeployWorkspaceDrawer
let loading = true
let nbDisplayed = 30
export let deleteConfirmedCallback: (() => void) | undefined = undefined
async function loadScripts(): Promise<void> {
const loadedScripts = await ScriptService.listScripts({
workspace: $workspaceStore!,
@@ -255,6 +243,7 @@
$: items && resetScroll()
let archived = false
let treeView = false
let contentSearch: ContentSearch
</script>
@@ -267,27 +256,6 @@
{opts}
/>
<ShareModal
bind:this={shareModal}
on:change={() => {
loadScripts()
loadApps()
loadFlows()
loadRawApps()
}}
/>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<MoveDrawer
bind:this={moveDrawer}
on:update={() => {
loadScripts()
loadApps()
loadFlows()
loadRawApps()
}}
/>
<ContentSearch bind:this={contentSearch} />
<CenteredPage>
<div class="flex flex-wrap gap-2 items-center justify-between w-full mt-2">
@@ -371,8 +339,9 @@
<div class="mt-10" />
{/if}
{#if !loading}
<div class="flex w-full flex-row-reverse">
<div class="flex w-full flex-row-reverse gap-2">
<Toggle size="xs" bind:checked={archived} options={{ right: 'Show archived' }} />
<Toggle size="xs" bind:checked={treeView} options={{ right: 'Tree view' }} />
</div>
{/if}
</div>
@@ -387,69 +356,39 @@
<NoItemFound />
{:else}
<div class="border rounded-md">
<!-- <VirtualList {items} let:item bind:start bind:end> -->
{#each (items ?? []).slice(0, nbDisplayed) as item (item.type + '/' + item.path)}
{#key item.summary}
{#key item.starred}
{#key item.has_draft}
{#if item.type == 'script'}
<ScriptRow
bind:deleteConfirmedCallback
starred={item.starred ?? false}
marked={item.marked}
on:change={loadScripts}
script={item}
errorHandlerMuted={item.ws_error_handler_muted === undefined ||
item.ws_error_handler_muted === null
? false
: item.ws_error_handler_muted}
{shareModal}
{moveDrawer}
{deploymentDrawer}
/>
{:else if item.type == 'flow'}
<FlowRow
bind:deleteConfirmedCallback
starred={item.starred ?? false}
marked={item.marked}
on:change={loadFlows}
flow={item}
errorHandlerMuted={item.ws_error_handler_muted === undefined ||
item.ws_error_handler_muted === null
? false
: item.ws_error_handler_muted}
{shareModal}
{moveDrawer}
{deploymentDrawer}
/>
{:else if item.type == 'app'}
<AppRow
bind:deleteConfirmedCallback
starred={item.starred ?? false}
marked={item.marked}
on:change={loadApps}
app={item}
{moveDrawer}
{shareModal}
{deploymentDrawer}
/>
{:else if item.type == 'raw_app'}
<RawAppRow
bind:deleteConfirmedCallback
starred={item.starred ?? false}
marked={item.marked}
on:change={loadRawApps}
app={item}
{moveDrawer}
{shareModal}
{deploymentDrawer}
/>
{/if}
{/key}
{/key}
{/key}
{/each}
<!-- </VirtualList> -->
{#if treeView}
{#each (groupItems(items) ?? []).slice(0, nbDisplayed) as item}
<TreeView
{item}
on:scriptChanged={loadScripts}
on:flowChanged={loadFlows}
on:appChanged={loadApps}
on:rawAppChanged={loadRawApps}
on:reload={() => {
loadScripts()
loadFlows()
loadApps()
loadRawApps()
}}
/>
{/each}
{:else}
{#each (items ?? []).slice(0, nbDisplayed) as item (item.type + '/' + item.path)}
<Item
{item}
on:scriptChanged={loadScripts}
on:flowChanged={loadFlows}
on:appChanged={loadApps}
on:rawAppChanged={loadRawApps}
on:reload={() => {
loadScripts()
loadFlows()
loadApps()
loadRawApps()
}}
/>
{/each}
{/if}
</div>
{#if items && items?.length > 30}
<span class="text-xs"
@@ -460,29 +399,3 @@
{/if}
</div>
</CenteredPage>
<ConfirmationModal
open={Boolean(deleteConfirmedCallback)}
title="Remove"
confirmationText="Remove"
on:canceled={() => {
deleteConfirmedCallback = undefined
}}
on:confirmed={() => {
if (deleteConfirmedCallback) {
deleteConfirmedCallback()
}
deleteConfirmedCallback = undefined
}}
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to remove it?</span>
<Alert type="info" title="Bypass confirmation">
<div>
You can press
<Badge color="dark-gray">SHIFT</Badge>
while removing to bypass confirmation.
</div>
</Alert>
</div>
</ConfirmationModal>
@@ -0,0 +1,115 @@
<script lang="ts">
import { ChevronDown, ChevronUp, Folder, FolderTree, User } from 'lucide-svelte'
import Item from './Item.svelte'
import type { FolderItem, ItemType, UserItem } from './treeViewUtils'
import { twMerge } from 'tailwind-merge'
import { slide } from 'svelte/transition'
import { pluralize } from '$lib/utils'
export let item: ItemType | FolderItem | UserItem | undefined
export let depth: number = 0
const isFolder = (i: any): i is FolderItem => i && 'folderName' in i
const isUser = (i: any): i is UserItem => i && 'username' in i
let opened: boolean = true
</script>
{#if item}
{#if isFolder(item)}
<div>
<div class={twMerge('px-4 py-2 border-b w-full flex flex-row items-center justify-between')}>
<div
class={twMerge('flex flex-row items-center gap-4 text-sm font-semibold')}
style={depth > 0 ? `padding-left: ${depth * 16}px;` : ''}
>
<div
class=" rounded-md p-1 flex justify-center items-center border bg-gray-50 border-gray-200 dark:bg-transparent dark:border-gray-900"
>
{#if depth === 0}
<Folder size={20} color="#aaa" />
{:else}
<FolderTree size={20} color="#aaa" />
{/if}
</div>
<div>
{#if depth === 0}f/{/if}{item.folderName}
<div class="text-2xs font-normal text-secondary">
({pluralize(item.items.length, ' item')})
</div>
</div>
</div>
<button on:click={() => (opened = !opened)}>
{#if opened}
<ChevronUp size={20} />
{:else}
<ChevronDown size={20} />
{/if}
</button>
</div>
{#if opened}
<div transition:slide>
{#each item.items as subItem}
<svelte:self
item={subItem}
on:scriptChanged
on:flowChanged
on:appChanged
on:rawAppChanged
on:reload
depth={depth + 1}
/>
{/each}
</div>
{/if}
</div>
{:else if isUser(item)}
<div>
<div class={twMerge('px-4 py-2 border-b w-full flex flex-row items-center justify-between')}>
<div
class={twMerge('flex flex-row items-center gap-4 text-sm font-semibold')}
style={depth > 0 ? `padding-left: ${depth * 16}px;` : ''}
>
<div
class=" rounded-md p-1 flex justify-center items-center border bg-gray-50 border-gray-200 dark:bg-transparent dark:border-gray-900"
>
<User size={20} color="#aaa" />
</div>
<div>
u/{item.username}
<div class="text-2xs font-normal text-secondary"
>({pluralize(item.items.length, ' item')})</div
>
</div>
</div>
<button on:click={() => (opened = !opened)}>
{#if opened}
<ChevronUp size={20} />
{:else}
<ChevronDown size={20} />
{/if}
</button>
</div>
{#if opened}
<div transition:slide>
{#each item.items as subItem}
<svelte:self
item={subItem}
on:scriptChanged
on:flowChanged
on:appChanged
on:rawAppChanged
on:reload
depth={depth + 1}
/>
{/each}
</div>
{/if}
</div>
{:else}
<Item {item} on:scriptChanged on:flowChanged on:appChanged on:rawAppChanged on:reload {depth} />
{/if}
{/if}
@@ -0,0 +1,130 @@
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
has_draft?: boolean
}
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)[]
}
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
}
})
}
export function groupItems(items: ItemType[] | undefined): (ItemType | FolderItem | UserItem)[] {
if (!items) {
return []
}
const root: (ItemType | FolderItem | UserItem)[] = []
items.forEach((item) => {
const pathSplit = item.path.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))
}
})
root.sort((a, b) => {
if ('username' in a && 'folderName' in b) {
return -1
}
if ('folderName' in a && 'username' in b) {
return 1
}
return 0
})
sortGroup(root)
return root
}
function sortGroup(group: (ItemType | FolderItem | UserItem)[]) {
group.forEach((item) => {
if ('items' in item) {
item.items.sort((a, b) => {
if (isItemType(a) && isItemType(b)) {
if (a.starred && !b.starred) return -1
if (!a.starred && b.starred) return 1
return getModifiedAt(b) - getModifiedAt(a)
}
return 0
})
sortGroup(item.items)
}
})
}
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
}