feat: overhaul scripts and flows page

This commit is contained in:
Ruben Fiszel
2022-11-28 11:16:10 +01:00
parent fa2ae6d8fc
commit 66ff51ae5f
15 changed files with 476 additions and 591 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
</script>
<div
class="ml-2 {relative ? 'relative' : ''} {$$props.class}"
class="{relative ? 'relative' : ''} {$$props.class}"
use:clickOutside
on:click_outside={handleClickOutsideMenu}
>
+3 -1
View File
@@ -68,7 +68,9 @@
const workspaceScripts: { path: string; summary?: string }[] = await ScriptService.listScripts({
workspace: $workspaceStore ?? 'NO_W'
})
await loadHubScripts()
if (!$hubScripts) {
await loadHubScripts()
}
const hubScripts_ = $hubScripts ?? []
return workspaceScripts.concat(hubScripts_)
@@ -0,0 +1,36 @@
<script lang="ts">
import uFuzzy from '@leeoniya/ufuzzy'
export let filter: string = ''
export let items: any[]
export let f: (item: any) => string
export let filteredItems: (any & { marked: string })[]
let opts = {}
let uf = new uFuzzy(opts)
$: plaintextItems = items.map((item) => f(item))
$: plaintextItems && filter != undefined && setTimeout(() => filterItems(), 100)
function filterItems() {
if (filter.length == 0) {
filteredItems = items
return
}
// pre-filter
let idxs = uf.filter(plaintextItems, filter)
let info = uf.info(idxs, plaintextItems, filter)
let order = uf.sort(info, plaintextItems, filter)
let result: any[] = []
for (let i = 0; i < order.length; i++) {
let infoIdx = order[i]
result.push({
...items[info.idx[infoIdx]],
marked: uFuzzy.highlight(plaintextItems[info.idx[infoIdx]], info.ranges[infoIdx])
})
}
filteredItems = result
}
</script>
@@ -23,7 +23,7 @@ export namespace ButtonType {
export const SpacingClasses: Record<ButtonType.Size, string> = {
xs: 'px-3 py-1.5',
sm: 'px-3 py-1.5',
md: 'px-4 py-2',
md: 'px-3 py-1.5',
lg: 'px-4 py-2',
xl: 'px-4 py-2'
} as const
@@ -1,44 +1,36 @@
<script lang="ts">
import { hubScripts } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, onMount } from 'svelte'
import type { HubItem } from './model'
import Fuse from 'fuse.js'
import IconedResourceType from '$lib/components/IconedResourceType.svelte'
import { Badge } from '$lib/components/common'
import { Badge, Skeleton } from '$lib/components/common'
import SearchItems from '$lib/components/SearchItems.svelte'
import { loadHubScripts } from '$lib/utils'
export let kind: 'script' | 'trigger' | 'approval' | 'failure' = 'script'
let items: HubItem[] = []
$: items = ($hubScripts ?? []).filter((i) => i.kind === kind)
let filteredItems: HubItem[] | undefined = []
let itemsFilter = ''
let filteredItems: (HubItem & { marked?: string })[] = []
let filter = ''
let appFilter: string | undefined = undefined
const fuseOptions = {
includeScore: false,
keys: ['path', 'summay']
}
const fuse: Fuse<HubItem> = new Fuse(items, fuseOptions)
$: {
items =
$hubScripts?.filter(
(x) => x.kind == kind && (appFilter == undefined || x.app == appFilter)
) ?? []
fuse.setCollection(items)
}
$: filteredItems =
itemsFilter.length > 0 && items ? fuse.search(itemsFilter).map((value) => value.item) : items
$: apps = Array.from(new Set(filteredItems?.map((x) => x.app) ?? []))
$: apps = Array.from(new Set(filteredItems?.map((x) => x.app) ?? [])).sort()
const dispatch = createEventDispatcher()
onMount(() => {
if (!$hubScripts) {
loadHubScripts()
}
})
</script>
<SearchItems {filter} {items} bind:filteredItems f={(x) => x.summary} />
<div class="flex flex-col min-h-0">
<div class="w-12/12 pb-2">
<input type="text" placeholder="Search script" bind:value={itemsFilter} class="search-item" />
<div class="w-12/12 pb-2 flex flex-row mt-1 gap-1">
<input type="text" placeholder="Search Scripts" bind:value={filter} class="text-2xl grow" />
</div>
<div class="gap-2 w-full flex flex-wrap pb-2">
@@ -56,9 +48,12 @@
</Badge>
{/each}
</div>
{#if filteredItems}
<div class="overflow-auto">
<ul class="divide-y divide-gray-200">
<div class="overflow-auto">
<ul class="divide-y divide-gray-200">
{#if $hubScripts}
{#if filter.length > 0 && filteredItems.length == 0}
<p>No items found</p>
{/if}
{#each filteredItems as obj}
<li class="flex flex-row w-full">
<button
@@ -67,14 +62,24 @@
dispatch('pick', obj)
}}
>
<div class="mr-2 text-sm text-left truncate w-36 shrink-0">
<div class="mr-2 text-sm text-left truncate w-32 shrink-0">
<IconedResourceType after={true} silent={false} name={obj['app']} />
</div>
<div class="mr-2 text-left">{obj['summary'] ?? ''}</div>
<div class="mr-2 text-left">
{#if obj.marked}
{@html obj.marked ?? ''}
{:else}
{obj.summary ?? ''}
{/if}
</div>
</button>
</li>
{/each}
</ul>
</div>
{/if}
{:else}
{#each Array(10).fill(0) as sk}
<Skeleton layout={[[4], 0.5]} />
{/each}
{/if}
</ul>
</div>
</div>
@@ -5,18 +5,10 @@
import HatIcon from '../icons/HatIcon.svelte'
</script>
<div class="p-4 border border-gray-300 rounded-md bg-gray-50 overflow-auto">
<div class="flex items-center">
<h3 class="inline-flex items-center font-semibold">
<HatIcon class="w-6 h-6 mr-2" />
Getting started
</h3>
</div>
<div class="mt-2 mb-4 text-sm text-gray-700 ">
<div class=" overflow-auto">
<div class="mt-2 mb-4 text-sm text-gray-700 dark:text-gray-300">
Flows allow you to streamline complex processes and operations by chaining simple steps
together. Each Flow is composed of one or more steps. Create a new flow or find inspiration on
the Hub!
</div>
together. Each Flow is composed of one or more steps.
<div class="flex flex-row flex-wrap gap-y-2">
<Button href="/flows/add" color="dark" size="xs" btnClasses="mr-2" startIcon={{ icon: faPlus }}>
Create flow
@@ -5,13 +5,7 @@
import HatIcon from '../icons/HatIcon.svelte'
</script>
<div class="p-4 border border-gray-300 rounded-md bg-gray-50 dark:border-gray-600 dark:bg-gray-700">
<div class="flex items-center">
<h3 class="inline-flex items-center font-semibold">
<HatIcon class="w-6 h-6 mr-2" />
Getting started
</h3>
</div>
<div class="fle flex-row gap-2">
<div class="mt-2 mb-4 text-sm text-gray-700 dark:text-gray-300">
Connect to apps like Slack, Google Drive or Airtable using OAuth.
</div>
@@ -5,6 +5,7 @@
import { truncateHash } from '$lib/utils'
import { faPencil, faPlay } from '@fortawesome/free-solid-svg-icons'
import { Button, Badge } from '$lib/components/common'
import { LanguageIcon } from '../common/languageIcons'
export let script: Script
</script>
@@ -17,15 +18,9 @@
<div class="inline-flex justify-between w-full">
<div class="text-xs">{script.path}</div>
<Badge color="gray">
{truncateHash(script.hash)}
</Badge>
<div><LanguageIcon height={16} lang={script.language} /></div>
</div>
<div class="inline-flex space-x-1 w-full">
<Badge color="blue" capitalize>
{script.language}
</Badge>
{#if script.kind !== 'script'}
<Badge color="green" capitalize>
{script.kind}
@@ -5,16 +5,7 @@
import HatIcon from '../icons/HatIcon.svelte'
</script>
<div class="p-4 border border-gray-300 rounded-md bg-gray-50 overflow-auto">
<div class="flex items-center">
<h3 class="inline-flex items-center font-semibold">
<HatIcon class="w-6 h-6 mr-2" />
Getting started
</h3>
</div>
<div class="mt-2 mb-4 text-sm text-gray-700 ">
Create a new script or find inspiration on the Hub!
</div>
<div class="">
<div class="inline-flex flex-wrap gap-y-2">
<Button
href="/scripts/add"
@@ -40,7 +40,7 @@
]
const thirdMenuLinks = [
{ label: 'Documentation', href: 'https://docs.windmill.dev/docs/intro/', icon: faBookOpen },
{ label: 'Docs', href: 'https://docs.windmill.dev/docs/intro/', icon: faBookOpen },
{ label: 'Feedbacks', href: 'https://discord.gg/V7PM2YHsPB', icon: faDiscord },
{
label: 'Issues',
+2 -3
View File
@@ -59,9 +59,8 @@ export function displayDate(dateString: string | undefined): string {
if (date.toString() === 'Invalid Date') {
return ''
} else {
return `${date.getFullYear()}/${
date.getMonth() + 1
}/${date.getDate()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
return `${date.getFullYear()}/${date.getMonth() + 1
}/${date.getDate()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
}
}
+85
View File
@@ -0,0 +1,85 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'
import { Badge, Skeleton } from '$lib/components/common'
import SearchItems from '$lib/components/SearchItems.svelte'
import { loadHubFlows } from '$lib/utils'
type Item = { apps: string[]; summary: string }
let hubFlows: any[] | undefined = undefined
let filteredItems: (Item & { marked?: string })[] = []
let filter = ''
let appFilter: string | undefined = undefined
$: prefilteredItems = appFilter
? (hubFlows ?? []).filter((i) => i.apps.includes(appFilter))
: hubFlows ?? []
$: apps = Array.from(new Set(filteredItems?.flatMap((x) => x.apps) ?? [])).sort()
const dispatch = createEventDispatcher()
onMount(async () => {
hubFlows = await loadHubFlows()
})
</script>
<SearchItems
{filter}
items={prefilteredItems}
bind:filteredItems
f={(x) => x.summary + ' (' + x.apps.join(', ') + ')'}
/>
<div class="flex flex-col min-h-0">
<div class="w-12/12 pb-2 flex flex-row mt-1 gap-1">
<input type="text" placeholder="Search Flows" bind:value={filter} class="text-2xl grow" />
</div>
<div class="gap-2 w-full flex flex-wrap pb-2">
{#each apps as app}
<Badge
class="cursor-pointer hover:bg-gray-200"
on:click={() => {
appFilter = appFilter == app ? undefined : app
}}
capitalize
color={app === appFilter ? 'blue' : 'gray'}
>
{app}
{#if app === appFilter}&cross;{/if}
</Badge>
{/each}
</div>
<div class="overflow-auto">
<ul class="divide-y divide-gray-200">
{#if hubFlows}
{#if filter.length > 0 && filteredItems.length == 0}
<p>No items found</p>
{/if}
{#each filteredItems as obj}
<li class="flex flex-row w-full">
<button
class="py-4 px-1 gap-1 flex flex-row grow hover:bg-blue-50 bg-white transition-all"
on:click={() => {
dispatch('pick', obj)
}}
>
<div class="mr-2 text-sm text-left w-32 shrink-0">{obj.apps.join(', ')}</div>
<div class="mr-2 text-left">
{#if obj.marked}
{@html obj.marked ?? ''}
{:else}
{obj.summary ?? ''}
{/if}
</div>
</button>
</li>
{/each}
{:else}
{#each Array(10).fill(0) as sk}
<Skeleton layout={[[4], 0.5]} />
{/each}
{/if}
</ul>
</div>
</div>
+187 -307
View File
@@ -7,122 +7,59 @@
</script>
<script lang="ts">
import Fuse from 'fuse.js'
import { FlowService, type OpenFlow } from '$lib/gen'
import type { Flow } from '$lib/gen'
import { sendUserToast, groupBy, canWrite, loadHubFlows } from '$lib/utils'
import { sendUserToast, canWrite } from '$lib/utils'
import {
faArchive,
faBuilding,
faCalendarAlt,
faCodeFork,
faEdit,
faEye,
faGlobe,
faList,
faPlay,
faPlus,
faShare
} from '@fortawesome/free-solid-svg-icons'
import Dropdown from '$lib/components/Dropdown.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import { superadmin, userStore, workspaceStore } from '$lib/stores'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import TableCustom from '$lib/components/TableCustom.svelte'
import FlowViewer from '$lib/components/FlowViewer.svelte'
import { Button, Tabs, Tab, Skeleton } from '../lib/components/common'
import { Button, Tabs, Tab, Skeleton, Badge } from '../lib/components/common'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import CreateActions from '$lib/components/flows/CreateActions.svelte'
import Icon from 'svelte-awesome'
import SearchItems from '$lib/components/SearchItems.svelte'
import PickHubFlow from './PickHubFlow.svelte'
type Tab = 'all' | 'personal' | 'groups' | 'shared' | 'hub'
type Section = [string, FlowW[]]
type FlowW = Flow & { canWrite: boolean; tab: Tab }
type Tab = 'hub' | 'workspace'
type FlowW = Flow & { canWrite: boolean; marked?: string }
let flows: FlowW[] = []
let filteredFlows: FlowW[]
let hubFlows: any[] | undefined = undefined
let filteredHubFlows: any[]
let filteredFlows: FlowW[] = []
let flowFilter = ''
let groupedFlows: Section[] = []
let hubFilter = ''
let tab: Tab = 'all'
let tab: Tab = 'workspace'
let shareModal: ShareModal
let loading = {
general: true,
hub: true
}
const flowFuseOptions = {
includeScore: false,
keys: ['description', 'path', 'content', 'hash', 'summary']
}
const flowFuse: Fuse<FlowW> = new Fuse(flows, flowFuseOptions)
const flowHubFuse: Fuse<any> = new Fuse([], {
includeScore: false,
keys: ['summary']
})
$: filteredFlows =
flowFilter.length > 0 ? flowFuse.search(flowFilter).map((value) => value.item) : flows
$: filteredHubFlows =
hubFilter.length > 0 ? flowHubFuse.search(hubFilter).map((value) => value.item) : hubFlows ?? []
let flowViewer: Drawer
let flowViewerFlow: OpenFlow | undefined
let flowViewerFlow: { flow?: OpenFlow & { id?: number } } | undefined
$: {
let defaults: string[] = []
if (tab == 'all' || tab == 'personal') {
defaults = defaults.concat(`u/${$userStore?.username}`)
}
if (tab == 'all' || tab == 'groups') {
defaults = defaults.concat($userStore?.groups.map((x) => `g/${x}`) ?? [])
}
groupedFlows = groupBy(
filteredFlows,
(sc: Flow) => sc.path.split('/').slice(0, 2).join('/'),
defaults
)
}
function tabFromPath(path: string) {
let t: Tab = 'shared'
let path_prefix = path.split('/').slice(0, 2)
if (path_prefix[0] == 'u' && path_prefix[1] == $userStore?.username) {
t = 'personal'
} else if (path_prefix[0] == 'g' && $userStore?.groups.includes(path_prefix[1])) {
t = 'groups'
}
return t
}
let loading = true
async function loadFlows(): Promise<void> {
const allFlows = (await FlowService.listFlows({ workspace: $workspaceStore! })).map(
(x: Flow) => {
let t: Tab = tabFromPath(x.path)
return {
canWrite:
canWrite(x.path, x.extra_perms, $userStore) && x.workspace_id == $workspaceStore,
tab: t,
...x
}
flows = (await FlowService.listFlows({ workspace: $workspaceStore! })).map((x: Flow) => {
return {
canWrite: canWrite(x.path, x.extra_perms, $userStore) && x.workspace_id == $workspaceStore,
...x
}
)
flows = tab == 'all' ? allFlows : allFlows.filter((x) => x.tab == tab)
loading.general = false
flowFuse.setCollection(flows)
}
async function loadHubFlowsWFuse(): Promise<void> {
hubFlows = await loadHubFlows()
loading.hub = false
flowHubFuse.setCollection(hubFlows ?? [])
})
loading = false
}
async function archiveFlow(path: string): Promise<void> {
@@ -135,13 +72,22 @@
}
}
async function viewFlow(id: number): Promise<void> {
const hub = (await FlowService.getHubFlowById({ id: Number(id) })).flow
async function viewFlow(obj: { flow_id: number }): Promise<void> {
// console.log(obj)
const hub = await FlowService.getHubFlowById({ id: obj.flow_id })
flowViewerFlow = hub
flowViewer.openDrawer()
}
loadHubFlowsWFuse()
$: owners = Array.from(
new Set(filteredFlows?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
$: preFilteredFlows =
ownerFilter != undefined ? flows.filter((x) => x.path.startsWith(ownerFilter ?? '')) : flows
let ownerFilter: string | undefined = undefined
$: {
if ($workspaceStore && ($userStore || $superadmin)) {
@@ -150,13 +96,27 @@
}
</script>
<SearchItems
filter={flowFilter}
items={preFilteredFlows}
bind:filteredItems={filteredFlows}
f={(x) => x.summary + ' (' + x.path + ')'}
/>
<Drawer bind:this={flowViewer} size="900px">
<DrawerContent
title="Hub flow '{flowViewerFlow?.summary ?? ''}'"
on:close={flowViewer.closeDrawer}
>
{#if flowViewerFlow}
<FlowViewer flow={flowViewerFlow} />
<DrawerContent title="Hub flow" on:close={flowViewer.closeDrawer}>
<div slot="submission" class="flex flex-row gap-2 pr-2"
><Button
href="https://hub.windmill.dev/flows/{flowViewerFlow?.flow?.id}"
startIcon={{ icon: faGlobe }}
variant="border">View on the Hub</Button
><Button href="/flows/add?hub={flowViewerFlow?.flow?.id}" startIcon={{ icon: faCodeFork }}
>Fork</Button
></div
>
{#if flowViewerFlow?.flow}
<FlowViewer flow={flowViewerFlow.flow} />
{/if}
</DrawerContent>
</Drawer>
@@ -169,228 +129,148 @@
</PageHeader>
<Tabs bind:selected={tab}>
<Tab value="all">All</Tab>
<Tab value="hub">Hub</Tab>
<Tab value="personal">{`Personal space (${$userStore?.username})`}</Tab>
<Tab value="groups">Groups</Tab>
<Tab value="shared">Shared</Tab>
<Tab size="xl" value="workspace"><Icon data={faBuilding} class="mr-1" /> Workspace</Tab>
<Tab size="xl" value="hub"><Icon data={faGlobe} class="mr-1" /> Hub</Tab>
</Tabs>
<div class="mb-1" />
{#if tab != 'hub'}
<input type="text" placeholder="Search flows" bind:value={flowFilter} class="search-bar mt-2" />
{/if}
<div class="grid grid-cols-1 divide-y">
{#each tab == 'all' ? ['personal', 'groups', 'shared', 'hub'] : [tab] as sectionTab}
<div class="shadow p-4 my-2">
{#if sectionTab == 'personal'}
<h2>
<span class="text-lg xl:text-xl">Personal</span>
<span class="text-sm">
({`u/${$userStore?.username}`}) <Tooltip>
All flows owned by you (and visible only to you if you do not explicitly share them)
</Tooltip></span
>
</h2>
{:else if sectionTab == 'groups'}
<h2 class="text-lg xl:text-xl">
Groups <Tooltip>All flows being owned by groups that you are member of</Tooltip>
</h2>
{:else if sectionTab == 'shared'}
<h2 class="text-lg xl:text-xl">
Shared <Tooltip>All flows visible to you because they have been shared to you</Tooltip>
</h2>
{:else if sectionTab == 'hub'}
<h2 class="text-lg xl:text-xl">
Approved flows from the WindmillHub <Tooltip>
All approved Flow from the <a href="https://hub.windmill.dev">WindmillHub</a>.
Approved flows have been potentially contributed by the community but reviewed and
selected carefully by the Windmill team.
</Tooltip>
</h2>
<input
type="text"
placeholder="Search hub flows"
bind:value={hubFilter}
class="search-bar mt-2"
/>
<div class="relative mt-2">
{#if loading.hub}
<Skeleton
loading={loading.hub}
layout={[0.4, [3], 0.4, [3], 0.4, [3], 0.4, [3], 0.4, [3], 0.4, [3], 0.4, [3]]}
/>
{:else if hubFlows != undefined}
<TableCustom>
<tr slot="header-row">
<th>Apps</th>
<th>Summary</th>
<th />
</tr>
<tbody slot="body">
{#each filteredHubFlows ?? [] as { summary, apps, flow_id }}
<tr>
<td class="font-black">{apps.join(', ')}</td>
<td
><button class="text-left" on:click={() => viewFlow(flow_id)}
>{summary}</button
></td
>
<td class="whitespace-nowrap"
><button class="text-blue-500" on:click={() => viewFlow(flow_id)}
>view flow</button
>
|
<a target="_blank" href={`https://hub.windmill.dev/flows/${flow_id}`}
>hub's page
</a>
| <a class="font-bold" href={`/flows/add?hub=${flow_id}`}>fork</a>
</td>
</tr>
{/each}
</tbody>
</TableCustom>
{:else}
<span class="mt-2 text-sm text-red-400">
Hub not reachable. If your environment is air gapped, contact sales@windmill.dev to
setup a local mirror.
</span>
{/if}
</div>
{/if}
{#each groupedFlows.filter((x) => tabFromPath(x[0]) == sectionTab) as [section, flows]}
{#if sectionTab != 'personal'}
<div class="font-bold text-gray-700 mt-2 mb-2">
{section}
{#if section == 'g/all'}
<Tooltip
>'g/all' is the namespace for the group all. Every user is a member of all.
Everything in this namespace is visible by all users. At the opposite, 'u/myuser'
are private user namespaces.</Tooltip
>
{/if}
</div>
{/if}
{#if loading.general}
<div class="grid gap-4 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-3 mt-2">
{#each new Array(3) as _}
<Skeleton layout={[[7.6]]} />
{/each}
</div>
{:else if flows.length == 0 && sectionTab == 'personal'}
<p class="text-xs text-gray-600 italic mt-2">No flows yet</p>
{:else}
<div class="grid md:grid-cols-2 gap-4 sm:grid-cols-1 xl:grid-cols-3 mt-2">
{#each flows as { summary, path, extra_perms, canWrite }}
<a
class="border border-gray-400 p-4 rounded-sm shadow-sm space-y-2 hover:border-blue-600 text-gray-800 flex flex-col justify-between"
href="/flows/get/{path}"
>
<div class="px-6 overflow-auto ">
<div class="font-semibold text-gray-700">
{!summary || summary.length == 0 ? path : summary}
</div>
<p class="text-gray-700 text-xs">
<a class="text-gray-700 text-xs" href="/flows/get/{path}">{path} </a>
</p>
</div>
<div class="flex flex-row pl-6 pr-2 mt-2">
<div class="mr-3 w-full">
<SharedBadge {canWrite} extraPerms={extra_perms} />
</div>
<div class="flex flex-row-reverse w-full place space-x-1">
<div>
<Dropdown
dropdownItems={[
{
displayName: 'View flow',
icon: faEye,
href: `/flows/get/${path}`
},
{
displayName: 'Edit',
icon: faEdit,
href: `/flows/edit/${path}`,
disabled: !canWrite
},
{
displayName: 'Use as template/Fork',
icon: faCodeFork,
href: `/flows/add?template=${path}`
},
{
displayName: 'View runs',
icon: faList,
href: `/runs/${path}`
},
{
displayName: 'Schedule',
icon: faCalendarAlt,
href: `/schedule/add?path=${path}&isFlow=true`
},
{
displayName: 'Share',
icon: faShare,
action: () => {
shareModal.openDrawer(path)
},
disabled: !canWrite
},
{
displayName: 'Archive',
icon: faArchive,
action: () => {
path ? archiveFlow(path) : null
},
type: 'delete',
disabled: !canWrite
}
]}
/>
</div>
{#if canWrite}
<div>
<Button
variant="border"
size="xs"
href="/flows/edit/{path}"
endIcon={{ icon: faEdit }}
>
Edit
</Button>
</div>
{:else}
<div>
<Button
variant="border"
size="xs"
href="/flows/add?template={path}"
endIcon={{ icon: faCodeFork }}
>
Fork
</Button>
</div>
{/if}
<input type="text" placeholder="Search Flows" bind:value={flowFilter} class="text-2xl mt-2" />
<div>
<Button
variant="border"
size="xs"
href="/flows/run/{path}"
endIcon={{ icon: faPlay }}
>
Run
</Button>
</div>
</div>
<div class="gap-2 w-full flex flex-wrap pb-1 pt-2">
{#each owners as owner}
<Badge
class="cursor-pointer hover:bg-gray-200"
on:click={() => {
ownerFilter = ownerFilter == owner ? undefined : owner
}}
color={owner === ownerFilter ? 'blue' : 'gray'}
>
{owner}
{#if owner === ownerFilter}&cross;{/if}
</Badge>
{/each}
</div>
{/if}
{#if tab == 'workspace'}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 mt-2 w-full">
{#if !loading}
{#each filteredFlows as { summary, path, extra_perms, canWrite, marked }}
<a
class="border border-gray-400 p-2 rounded-sm shadow-sm hover:border-blue-600 text-gray-800 flex flex-row items-center justify-between"
href="/flows/get/{path}"
>
<div class="flex flex-col gap-1 w-full h-full">
<div class="font-semibold text-gray-700 truncate">
{#if marked}
{@html marked}
{:else}
{!summary || summary.length == 0 ? path : summary}
{/if}
</div>
<div class="flex flex-row justify-between w-full grow gap-2 items-start">
<div class="text-gray-700 text-xs flex flex-row flex-wrap gap-x-1 items-center"
>{path}
<SharedBadge {canWrite} extraPerms={extra_perms} />
</div>
<div class="flex flex-row-reverse place gap-x-2 pt-4">
<div>
<Dropdown
dropdownItems={[
{
displayName: 'View flow',
icon: faEye,
href: `/flows/get/${path}`
},
{
displayName: 'Edit',
icon: faEdit,
href: `/flows/edit/${path}`,
disabled: !canWrite
},
{
displayName: 'Use as template/Fork',
icon: faCodeFork,
href: `/flows/add?template=${path}`
},
{
displayName: 'View runs',
icon: faList,
href: `/runs/${path}`
},
{
displayName: 'Schedule',
icon: faCalendarAlt,
href: `/schedule/add?path=${path}&isFlow=true`
},
{
displayName: 'Share',
icon: faShare,
action: () => {
shareModal.openDrawer(path)
},
disabled: !canWrite
},
{
displayName: 'Archive',
icon: faArchive,
action: () => {
path ? archiveFlow(path) : null
},
type: 'delete',
disabled: !canWrite
}
]}
/>
</div>
</a>
{/each}
</div>
{/if}
<div>
<Button
color="dark"
size="xs"
href="/flows/run/{path}"
startIcon={{ icon: faPlay }}>Run</Button
>
</div>
{#if canWrite}
<div>
<Button
color="dark"
variant="border"
size="xs"
href="/flows/edit/{path}"
startIcon={{ icon: faEdit }}
>
Edit
</Button>
</div>
{:else}
<div>
<Button
color="dark"
variant="border"
size="xs"
href="/flows/add?template={path}"
startIcon={{ icon: faCodeFork }}
>
Fork
</Button>
</div>
{/if}
</div>
</div></div
></a
>
{/each}
</div>
{/each}
</div>
{:else}
{#each Array(10).fill(0) as sk}
<Skeleton layout={[[4]]} />
{/each}
{/if}
</div>
{:else}
<PickHubFlow on:pick={(e) => viewFlow(e.detail)} />
{/if}
</CenteredPage>
<ShareModal
+109 -208
View File
@@ -9,6 +9,7 @@
<script lang="ts">
import {
faArchive,
faBuilding,
faCalendarAlt,
faCodeFork,
faEdit,
@@ -18,108 +19,55 @@
faPlay,
faShare
} from '@fortawesome/free-solid-svg-icons'
import Fuse from 'fuse.js'
import type { Script } from '$lib/gen'
import { ScriptService } from '$lib/gen'
import { superadmin, userStore, workspaceStore, hubScripts } from '$lib/stores'
import {
canWrite,
getScriptByPath,
groupBy,
loadHubScripts,
sendUserToast,
truncateHash
} from '$lib/utils'
import { superadmin, userStore, workspaceStore } from '$lib/stores'
import { canWrite, getScriptByPath, sendUserToast } from '$lib/utils'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import Dropdown from '$lib/components/Dropdown.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import TableCustom from '$lib/components/TableCustom.svelte'
import { Button, Tabs, Tab, Badge, Skeleton, DrawerContent } from '$lib/components/common'
import CreateActions from '$lib/components/scripts/CreateActions.svelte'
import HighlightCode from '$lib/components/HighlightCode.svelte'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import PickHubScript from '$lib/components/flows/pickers/PickHubScript.svelte'
import type { HubItem } from '$lib/components/flows/pickers/model'
import Icon from 'svelte-awesome'
type Tab = 'all' | 'personal' | 'groups' | 'shared' | 'examples' | 'hub'
type Section = [string, ScriptW[]]
type ScriptW = Script & { canWrite: boolean; tab: Tab }
type Tab = 'workspace' | 'hub'
type ScriptW = Script & { canWrite: boolean; marked?: string }
let scripts: ScriptW[] = []
let filteredScripts: ScriptW[]
let scriptFilter = ''
let hubFilter = ''
let groupedScripts: Section[] = []
let communityScripts: Section[] = []
let preFilteredScripts: ScriptW[] = []
let filteredScripts: ScriptW[] = []
let filter = ''
let loading = true
let tab: Tab = 'all'
let tab: Tab = 'workspace'
let shareModal: ShareModal
const fuseOptions = {
includeScore: false,
keys: ['description', 'path', 'content', 'hash', 'summary']
}
const fuse: Fuse<ScriptW> = new Fuse(scripts, fuseOptions)
const hubScriptsFuse: Fuse<any> = new Fuse($hubScripts ?? [], {
includeScore: false,
keys: ['app', 'path', 'summary']
})
let codeViewer: Drawer
let codeViewerContent: string = ''
let codeViewerLanguage: 'deno' | 'python3' | 'go' | 'bash' = 'deno'
let codeViewerObj: HubItem | undefined = undefined
$: filteredScripts =
scriptFilter.length > 0 ? fuse.search(scriptFilter).map((value) => value.item) : scripts
$: {
let defaults: string[] = []
if (tab == 'all' || tab == 'personal') {
defaults = defaults.concat(`u/${$userStore?.username}`)
}
if (tab == 'all' || tab == 'groups') {
defaults = defaults.concat($userStore?.groups.map((x) => `g/${x}`) ?? [])
}
groupedScripts = groupBy(
filteredScripts.filter((x) => x.tab != 'examples'),
(sc: Script) => sc.path.split('/').slice(0, 2).join('/'),
defaults
)
communityScripts = [['examples', filteredScripts.filter((x) => x.tab == 'examples')]]
}
function tabFromPath(path: string) {
let t: Tab = 'shared'
let path_prefix = path.split('/').slice(0, 2)
if (path_prefix[0] == 'u' && path_prefix[1] == $userStore?.username) {
t = 'personal'
} else if (path_prefix[0] == 'g' && $userStore?.groups.includes(path_prefix[1])) {
t = 'groups'
}
return t
}
import SearchItems from '$lib/components/SearchItems.svelte'
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
async function loadScripts(): Promise<void> {
const allScripts = (
await ScriptService.listScripts({ workspace: $workspaceStore!, perPage: 300 })
).map((x: Script) => {
let t: Tab = x.workspace_id == $workspaceStore ? tabFromPath(x.path) : 'examples'
return {
canWrite: canWrite(x.path, x.extra_perms, $userStore) && x.workspace_id == $workspaceStore,
tab: t,
...x
scripts = (await ScriptService.listScripts({ workspace: $workspaceStore!, perPage: 300 })).map(
(x: Script) => {
return {
canWrite:
canWrite(x.path, x.extra_perms, $userStore) && x.workspace_id == $workspaceStore,
...x
}
}
})
scripts = tab == 'all' ? allScripts : allScripts.filter((x) => x.tab == tab)
)
loading = false
fuse.setCollection(scripts)
}
async function archiveScript(path: string): Promise<void> {
@@ -136,20 +84,29 @@
codeViewer.openDrawer()
}
async function loadHubScriptsWFuse(): Promise<void> {
await loadHubScripts()
hubScriptsFuse.setCollection($hubScripts ?? [])
}
$: owners = Array.from(
new Set(filteredScripts?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
loadHubScriptsWFuse()
$: preFilteredScripts =
ownerFilter != undefined ? scripts.filter((x) => x.path.startsWith(ownerFilter ?? '')) : scripts
let ownerFilter: string | undefined = undefined
$: {
if ($workspaceStore && ($userStore || $superadmin) && tab) {
if ($workspaceStore && ($userStore || $superadmin)) {
loadScripts()
}
}
</script>
<SearchItems
{filter}
items={preFilteredScripts}
bind:filteredItems={filteredScripts}
f={(x) => x.summary + ' (' + x.path + ')'}
/>
<Drawer bind:this={codeViewer} size="900px">
<DrawerContent title={codeViewerObj?.summary ?? ''} on:close={codeViewer.closeDrawer}>
<div slot="submission" class="flex flex-row gap-2 pr-2"
@@ -179,122 +136,58 @@
</PageHeader>
<Tabs bind:selected={tab}>
<Tab value="all">All</Tab>
<Tab value="hub">Hub</Tab>
<Tab value="personal">{`Personal space (${$userStore?.username})`}</Tab>
<Tab value="groups">Groups</Tab>
<Tab value="shared">Shared</Tab>
<Tab value="examples">Examples</Tab>
<Tab size="xl" value="workspace"><Icon data={faBuilding} class="mr-1" /> Workspace</Tab>
<Tab size="xl" value="hub"><Icon data={faGlobe} class="mr-1" /> Hub</Tab>
</Tabs>
{#if tab != 'hub'}
<input
type="text"
placeholder="Search scripts"
bind:value={scriptFilter}
class="search-bar mt-2"
/>
{/if}
<div class="mb-1" />
<div class="grid grid-cols-1 divide-y">
{#each tab == 'all' ? ['personal', 'groups', 'shared', 'examples', 'hub'] : [tab] as sectionTab}
<div class="shadow p-4 my-2">
{#if sectionTab == 'personal'}
<h2>
<span class="text-lg xl:text-xl">Personal</span>
<span class="text-sm">
({`u/${$userStore?.username}`}) <Tooltip>
All scripts owned by you (and visible only to you if you do not explicitly share
them)
</Tooltip>
</span>
</h2>
{:else if sectionTab == 'groups'}
<h2 class="text-lg xl:text-xl">
Groups <Tooltip>All scripts being owned by groups that you are member of</Tooltip>
</h2>
{:else if sectionTab == 'shared'}
<h2 class="text-lg xl:text-xl">
Shared <Tooltip>All scripts visible to you because they have been shared to you</Tooltip
>
</h2>
{:else if sectionTab == 'examples'}
<h2 class="text-lg xl:text-xl">
Public <Tooltip>
Template and examples shared across all workspaces of this instance. They are managed
from a special workspace called 'starter' that only superadmin can change.
</Tooltip>
</h2>
{:else if sectionTab == 'hub'}
<h2 class="text-lg xl:text-xl mb-4">
Approved scripts from the WindmillHub <Tooltip>
All approved Deno scripts from the <a href="https://hub.windmill.dev">WindmillHub</a>.
Approved scripts have been reviewed by the Windmill team and are safe to use in
production.
</Tooltip>
</h2>
{#if tab == 'workspace'}
<input type="text" placeholder="Search Scripts" bind:value={filter} class="text-2xl mt-2" />
<div class="flex flex-col max-h-screen">
{#if $hubScripts != undefined}
<PickHubScript on:pick={(e) => viewCode(e.detail)} />
{:else}
<span class="mt-2 text-sm">
Hub not reachable. If your environment is air gapped, contact sales@windmill.dev to
setup a local mirror.
</span>
{/if}
</div>
{/if}
{#each sectionTab == 'examples' ? communityScripts : groupedScripts.filter((x) => tabFromPath(x[0]) == sectionTab) as [section, scripts]}
{#if sectionTab != 'personal' && sectionTab != 'examples'}
<div class="font-bold text-gray-700 mt-2 mb-2">
{section}
{#if section == 'g/all'}
<Tooltip
>'g/all' is the namespace for the group all. Every user is a member of all.
Everything in this namespace is visible by all users. At the opposite, 'u/myuser'
are private user namespaces.</Tooltip
>
{/if}
</div>
{/if}
{#if loading}
<div class="grid gap-4 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-3 mt-2">
{#each new Array(3) as _}
<Skeleton layout={[[8.5]]} />
{/each}
</div>
{:else if scripts.length == 0 && sectionTab == 'personal'}
<p class="text-xs text-gray-600 italic mt-2">No scripts yet</p>
{:else}
<div class="grid md:grid-cols-2 gap-4 sm:grid-cols-1 xl:grid-cols-3 mt-2">
{#each scripts as { summary, path, hash, language, extra_perms, canWrite, lock_error_logs, kind }}
<a
class="border border-gray-400 p-4 rounded-sm shadow-sm space-y-2 hover:border-blue-600 text-gray-800 flex flex-col justify-between"
href="/scripts/get/{hash}"
>
<div class="flex flex-col gap-1">
<a href="/scripts/get/{hash}" class="px-6">
<div class="font-semibold text-gray-700">
{!summary || summary.length == 0 ? path : summary}
</div>
<p class="text-gray-700 text-xs">
{path}
<Badge color="gray" baseClass="text-xs">{truncateHash(hash)}</Badge>
</p>
</a>
<div class="flex flex-wrap items-center gap-2 mt-1 px-6">
<SharedBadge {canWrite} extraPerms={extra_perms} />
<Badge color="blue" capitalize>{language}</Badge>
{#if kind != 'script'}
<Badge color="blue" capitalize>{kind}</Badge>
{/if}
{#if lock_error_logs}
<Badge color="red">Deployment error</Badge>
{/if}
</div>
</div>
<div class="flex flex-row-reverse items-end w-full gap-2 pr-2 mt-2">
<div class="gap-2 w-full flex flex-wrap pb-1 pt-2">
{#each owners as owner}
<Badge
class="cursor-pointer hover:bg-gray-200"
on:click={() => {
ownerFilter = ownerFilter == owner ? undefined : owner
}}
color={owner === ownerFilter ? 'blue' : 'gray'}
>
{owner}
{#if owner === ownerFilter}&cross;{/if}
</Badge>
{/each}
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 mt-2 w-full">
{#if !loading}
{#each filteredScripts as { summary, path, hash, language, extra_perms, canWrite, lock_error_logs, kind, marked } (path)}
<a
class="border border-gray-400 p-2 rounded-sm shadow-sm hover:border-blue-600 text-gray-800"
href="/scripts/get/{hash}"
>
<div class="flex flex-col gap-1 w-full h-full">
<div class="font-semibold text-gray-700 truncate">
{#if marked}
{@html marked}
{:else}
{!summary || summary.length == 0 ? path : summary}
{/if}
</div>
<div class="flex flex-row justify-between w-full grow gap-2 items-start">
<div class="text-gray-700 text-xs flex flex-row flex-wrap gap-x-1 items-center">
{path}
<SharedBadge {canWrite} extraPerms={extra_perms} />
<div><LanguageIcon height={16} lang={language} /></div>
{#if kind != 'script'}
<Badge color="blue" capitalize>{kind}</Badge>
{/if}
{#if lock_error_logs}
<Badge color="red">Deployment error</Badge>
{/if}
</div>
<div class="flex flex-col items-end grow pt-4">
<div class="flex flex-row-reverse place gap-x-2 items-end">
<div>
<Dropdown
dropdownItems={[
@@ -350,10 +243,21 @@
]}
/>
</div>
<div>
<Button
color="dark"
size="xs"
startIcon={{ icon: faPlay }}
href="/scripts/run/{hash}"
>
Run
</Button>
</div>
{#if canWrite}
<div>
<Button
variant="border"
color="dark"
size="xs"
startIcon={{ icon: faEdit }}
href="/scripts/edit/{hash}?step=2"
@@ -364,6 +268,7 @@
{:else}
<div>
<Button
color="dark"
variant="border"
size="xs"
startIcon={{ icon: faCodeFork }}
@@ -373,25 +278,21 @@
</Button>
</div>
{/if}
<div>
<Button
variant="border"
size="xs"
startIcon={{ icon: faPlay }}
href="/scripts/run/{hash}"
>
Run
</Button>
</div>
</div>
</a>
{/each}
</div>
{/if}
</div></div
>
</div></a
>
{/each}
</div>
{/each}
</div>
{:else}
{#each Array(10).fill(0) as sk}
<Skeleton layout={[[4]]} />
{/each}
{/if}
</div>
{:else}
<PickHubScript on:pick={(e) => viewCode(e.detail)} />
{/if}
</CenteredPage>
<ShareModal
+5
View File
@@ -176,6 +176,11 @@ const config = {
fontSize: '25px'
}
},
mark: {
backgroundColor: theme('colors.yellow.200'),
border: `1px solid ${theme('colors.gray.600')}`,
borderRadius: theme('borderRadius.sm'),
},
h3: {
fontSize: '18px',
fontWeight: theme('fontWeight.bold'),