Compare commits

...

10 Commits

Author SHA1 Message Date
Diego Imbert
7581a95f94 fix: use backend embeddings for all hub script text filters instead of client-side filtering
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 14:47:54 +01:00
Diego Imbert
d259a54e78 Revert "fix: route hub _default_ free text to summary filter instead of queryHubScripts"
This reverts commit 9ccb38eecd.
2026-03-17 14:25:00 +01:00
Diego Imbert
9ccb38eecd fix: route hub _default_ free text to summary filter instead of queryHubScripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 14:01:28 +01:00
Diego Imbert
9fcbe0df01 Revert "fix: fallback to client-side filtering when queryHubScripts is unavailable"
This reverts commit 4f6da9a6a5.
2026-03-17 14:00:03 +01:00
Diego Imbert
4f6da9a6a5 fix: fallback to client-side filtering when queryHubScripts is unavailable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 12:26:16 +01:00
Diego Imbert
182ee485d4 feat: add integration presets to hub FilterSearchbar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 12:20:47 +01:00
Diego Imbert
07770a6219 feat: add tag suggestions in hub FilterSearchbar and remove old ListFilters
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 12:20:22 +01:00
Diego Imbert
93ec1ada9c fix: add null guards in hub displayItems filters to prevent TypeError
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 12:17:39 +01:00
Diego Imbert
5abcc09617 Merge remote-tracking branch 'origin/main' into home-filter-searchbar 2026-03-17 11:57:12 +01:00
Diego Imbert
f33e796025 feat: replace home page search bars with FilterSearchbar component
Replace both Workspace and Hub tab search bars with the new
FilterSearchbar component for structured filtering.

Workspace tab filters: summary, path, description, kind, user, group, folder
Hub tab filters: tag, summary, path, kind

Existing filter shortcuts (ToggleButtonGroup, ListFilters badges,
owner filter, archived/tree toggles) are preserved and bidirectionally
synced with the new FilterSearchbar state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 11:53:41 +01:00
5 changed files with 542 additions and 284 deletions

View File

@@ -15,14 +15,26 @@
syncQuery?: boolean
children?: import('svelte').Snippet
size?: ButtonType.UnifiedSize
hideSearchbar?: boolean
appFilter?: string | undefined
summaryFilter?: string
pathFilter?: string
}
let { filter = $bindable(''), syncQuery = false, children, size = 'md' }: Props = $props()
let {
filter = $bindable(''),
syncQuery = false,
children,
size = 'md',
hideSearchbar = false,
appFilter = $bindable(undefined),
summaryFilter,
pathFilter
}: Props = $props()
type Item = { apps: string[]; summary: string; path: string }
let hubApps: any[] | undefined = $state(undefined)
let filteredItems: (Item & { marked?: string })[] = $state([])
let appFilter: string | undefined = $state(undefined)
const prefilteredItems = $derived(
appFilter ? (hubApps ?? []).filter((i: Item) => i.apps.includes(appFilter!)) : (hubApps ?? [])
@@ -30,6 +42,20 @@
const apps = $derived(Array.from(new Set(filteredItems?.flatMap((x) => x.apps) ?? [])).sort())
// Apply summary/path post-filters
let displayItems = $derived.by(() => {
let result = filteredItems
if (summaryFilter) {
const s = summaryFilter.toLowerCase()
result = result.filter((x) => (x.summary ?? '').toLowerCase().includes(s))
}
if (pathFilter) {
const p = pathFilter.toLowerCase()
result = result.filter((x) => (x.path ?? '').toLowerCase().includes(p))
}
return result
})
const dispatch = createEventDispatcher()
let hubNotAvailable = $state(false)
@@ -48,66 +74,69 @@
{#if $disableHubStore}
<!-- Hub disabled, show nothing -->
{:else}
<SearchItems
{filter}
items={prefilteredItems}
bind:filteredItems
f={(x) => x.summary + ' (' + x.apps.join(', ') + ')'}
/>
<div class="w-full flex items-center gap-2">
{@render children?.()}
<TextInput
inputProps={{
placeholder: 'Search Hub Apps'
}}
bind:value={filter}
class="grow !pr-9"
{size}
<SearchItems
{filter}
items={prefilteredItems}
bind:filteredItems
f={(x) => x.summary + ' (' + x.apps.join(', ') + ')'}
/>
</div>
<ListFilters {syncQuery} filters={apps} bind:selectedFilter={appFilter} resourceType />
{#if !hideSearchbar}
<div class="w-full flex items-center gap-2">
{@render children?.()}
<TextInput
inputProps={{
placeholder: 'Search Hub Apps'
}}
bind:value={filter}
class="grow !pr-9"
{size}
/>
</div>
<ListFilters {syncQuery} filters={apps} bind:selectedFilter={appFilter} resourceType />
{/if}
{#if hubNotAvailable}
<Alert type="warning" title="Hub not available">
Could not connect to the Windmill Hub. If you are in a closed environment, you can disable the Hub in the <a href="/#superadmin-settings?tab=private_hub">instance settings</a>.
</Alert>
{:else if hubApps}
{#if filteredItems.length == 0}
<NoItemFound />
{:else}
<ul class="divide-y border rounded-md bg-surface-tertiary">
{#each filteredItems as item (item)}
<li class="flex flex-row w-full">
<button
class="p-4 gap-4 flex flex-row grow justify-between hover:bg-surface-hover transition-all items-center"
onclick={() => dispatch('pick', item)}
>
<div class="flex items-center gap-4">
<RowIcon kind="app" />
{#if hubNotAvailable}
<Alert type="warning" title="Hub not available">
Could not connect to the Windmill Hub. If you are in a closed environment, you can disable the
Hub in the <a href="/#superadmin-settings?tab=private_hub">instance settings</a>.
</Alert>
{:else if hubApps}
{#if displayItems.length == 0}
<NoItemFound />
{:else}
<ul class="divide-y border rounded-md bg-surface-tertiary">
{#each displayItems as item (item)}
<li class="flex flex-row w-full">
<button
class="p-4 gap-4 flex flex-row grow justify-between hover:bg-surface-hover transition-all items-center"
onclick={() => dispatch('pick', item)}
>
<div class="flex items-center gap-4">
<RowIcon kind="app" />
<div class="w-full text-left">
<div class="text-emphasis flex-wrap text-xs font-semibold">
{#if item.marked}
{@html item.marked ?? ''}
{:else}
{item.summary ?? ''}
{/if}
<div class="w-full text-left">
<div class="text-emphasis flex-wrap text-xs font-semibold">
{#if item.marked}
{@html item.marked ?? ''}
{:else}
{item.summary ?? ''}
{/if}
</div>
</div>
</div>
</div>
<div class="min-w-1/3 gap-2 flex flex-wrap justify-end">
{#each item.apps as app}
<Badge color="gray" baseClass="border">{app}</Badge>
{/each}
</div>
</button>
</li>
{/each}
</ul>
<div class="min-w-1/3 gap-2 flex flex-wrap justify-end">
{#each item.apps as app}
<Badge color="gray" baseClass="border">{app}</Badge>
{/each}
</div>
</button>
</li>
{/each}
</ul>
{/if}
{:else}
{#each Array(10).fill(0) as _}
<Skeleton layout={[[4], 0.5]} />
{/each}
{/if}
{:else}
{#each Array(10).fill(0) as _}
<Skeleton layout={[[4], 0.5]} />
{/each}
{/if}
{/if}

View File

@@ -15,14 +15,26 @@
syncQuery?: boolean
children?: import('svelte').Snippet
size?: ButtonType.UnifiedSize
hideSearchbar?: boolean
appFilter?: string | undefined
summaryFilter?: string
pathFilter?: string
}
let { filter = $bindable(''), syncQuery = false, children, size = 'md' }: Props = $props()
let {
filter = $bindable(''),
syncQuery = false,
children,
size = 'md',
hideSearchbar = false,
appFilter = $bindable(undefined),
summaryFilter,
pathFilter
}: Props = $props()
type Item = { apps: string[]; summary: string; path: string }
let hubFlows: any[] | undefined = $state(undefined)
let filteredItems: (Item & { marked?: string })[] = $state([])
let appFilter: string | undefined = $state(undefined)
const prefilteredItems = $derived(
appFilter ? (hubFlows ?? []).filter((i: Item) => i.apps.includes(appFilter!)) : (hubFlows ?? [])
@@ -30,6 +42,20 @@
const apps = $derived(Array.from(new Set(filteredItems?.flatMap((x) => x.apps) ?? [])).sort())
// Apply summary/path post-filters
let displayItems = $derived.by(() => {
let result = filteredItems
if (summaryFilter) {
const s = summaryFilter.toLowerCase()
result = result.filter((x) => (x.summary ?? '').toLowerCase().includes(s))
}
if (pathFilter) {
const p = pathFilter.toLowerCase()
result = result.filter((x) => (x.path ?? '').toLowerCase().includes(p))
}
return result
})
const dispatch = createEventDispatcher()
let hubNotAvailable = $state(false)
@@ -48,68 +74,71 @@
{#if $disableHubStore}
<!-- Hub disabled, show nothing -->
{:else}
<SearchItems
{filter}
items={prefilteredItems}
bind:filteredItems
f={(x) => x.summary + ' (' + x.apps.join(', ') + ')'}
/>
<div class="w-full flex items-center gap-2">
{@render children?.()}
<TextInput
inputProps={{
placeholder: 'Search Hub Flows'
}}
bind:value={filter}
{size}
class="grow !pr-9"
<SearchItems
{filter}
items={prefilteredItems}
bind:filteredItems
f={(x) => x.summary + ' (' + x.apps.join(', ') + ')'}
/>
</div>
<ListFilters {syncQuery} filters={apps} bind:selectedFilter={appFilter} resourceType />
{#if !hideSearchbar}
<div class="w-full flex items-center gap-2">
{@render children?.()}
<TextInput
inputProps={{
placeholder: 'Search Hub Flows'
}}
bind:value={filter}
{size}
class="grow !pr-9"
/>
</div>
<ListFilters {syncQuery} filters={apps} bind:selectedFilter={appFilter} resourceType />
{/if}
{#if hubNotAvailable}
<Alert type="warning" title="Hub not available">
Could not connect to the Windmill Hub. If you are in a closed environment, you can disable the Hub in the <a href="/#superadmin-settings?tab=private_hub">instance settings</a>.
</Alert>
{:else if hubFlows}
{#if filteredItems.length == 0}
<NoItemFound />
{:else}
<ul class="divide-y border rounded-md bg-surface-tertiary overflow-hidden">
{#each filteredItems as item (item)}
<li class="flex flex-row w-full">
<button
class="p-4 gap-4 flex flex-row grow justify-between hover:bg-surface-hover transition-all items-center"
onclick={() => dispatch('pick', item)}
>
<div class="flex items-center gap-4">
<RowIcon kind="flow" />
{#if hubNotAvailable}
<Alert type="warning" title="Hub not available">
Could not connect to the Windmill Hub. If you are in a closed environment, you can disable the
Hub in the <a href="/#superadmin-settings?tab=private_hub">instance settings</a>.
</Alert>
{:else if hubFlows}
{#if displayItems.length == 0}
<NoItemFound />
{:else}
<ul class="divide-y border rounded-md bg-surface-tertiary overflow-hidden">
{#each displayItems as item (item)}
<li class="flex flex-row w-full">
<button
class="p-4 gap-4 flex flex-row grow justify-between hover:bg-surface-hover transition-all items-center"
onclick={() => dispatch('pick', item)}
>
<div class="flex items-center gap-4">
<RowIcon kind="flow" />
<div class="w-full text-left">
<div class="text-emphasis flex-wrap text-xs font-semibold">
{#if item.marked}
{@html item.marked ?? ''}
{:else}
{item.summary ?? ''}
{/if}
<div class="w-full text-left">
<div class="text-emphasis flex-wrap text-xs font-semibold">
{#if item.marked}
{@html item.marked ?? ''}
{:else}
{item.summary ?? ''}
{/if}
</div>
</div>
</div>
</div>
<div class="min-w-1/3 gap-2 flex flex-wrap justify-end">
{#each item.apps as app}
<Badge color="gray" baseClass="border">{app}</Badge>
{/each}
</div>
</button>
</li>
{/each}
</ul>
{/if}
{:else}
<div class="my-2"></div>
<div class="min-w-1/3 gap-2 flex flex-wrap justify-end">
{#each item.apps as app}
<Badge color="gray" baseClass="border">{app}</Badge>
{/each}
</div>
</button>
</li>
{/each}
</ul>
{/if}
{:else}
<div class="my-2"></div>
{#each Array(10).fill(0) as _}
<Skeleton layout={[[4], 0.5]} />
{/each}
{/if}
{#each Array(10).fill(0) as _}
<Skeleton layout={[[4], 0.5]} />
{/each}
{/if}
{/if}

View File

@@ -16,6 +16,10 @@
syncQuery?: boolean
children?: import('svelte').Snippet
size?: ButtonType.UnifiedSize
hideSearchbar?: boolean
appFilter?: string | undefined
summaryFilter?: string
pathFilter?: string
}
let {
@@ -23,7 +27,11 @@
filter = $bindable(''),
syncQuery = false,
children,
size = 'md'
size = 'md',
hideSearchbar = false,
appFilter = $bindable(undefined),
summaryFilter,
pathFilter
}: Props = $props()
let loading = $state(false)
@@ -31,7 +39,6 @@
const dispatch = createEventDispatcher()
let appFilter: string | undefined = $state(undefined)
let items: {
path: string
summary: string
@@ -67,7 +74,9 @@
async function applyFilter(
filter: string,
filterKind: typeof kind,
appFilter: string | undefined
appFilter: string | undefined,
summaryFilter: string | undefined,
pathFilter: string | undefined
) {
if ($disableHubStore) return
try {
@@ -77,10 +86,11 @@
startTs = ts
await new Promise((r) => setTimeout(r, 100))
if (ts < startTs) return
const queryText = [filter, summaryFilter, pathFilter].filter(Boolean).join(' ')
const scripts =
filter.length > 0
queryText.length > 0
? await ScriptService.queryHubScripts({
text: `${filter}`,
text: queryText,
limit: 20,
kind: filterKind,
app: appFilter
@@ -132,8 +142,8 @@
}
$effect(() => {
;[filter, kind, appFilter]
untrack(() => applyFilter(filter, kind, appFilter))
;[filter, kind, appFilter, summaryFilter, pathFilter]
untrack(() => applyFilter(filter, kind, appFilter, summaryFilter, pathFilter))
})
$effect(() => {
kind
@@ -144,72 +154,77 @@
{#if $disableHubStore}
<!-- Hub disabled, show nothing -->
{:else}
<div class="w-full flex items-center gap-2">
{@render children?.()}
<div class="relative w-full">
<TextInput
inputProps={{
placeholder: 'Search Hub Scripts'
}}
bind:value={filter}
class="grow !pr-9"
{size}
/>
{#if loading}
<Loader2 class="animate-spin text-gray-400 absolute right-2 top-1" />
{/if}
</div>
</div>
{#if hubNotAvailable}
<Alert type="warning" title="Hub not available">
Could not connect to the Windmill Hub. If you are in a closed environment, you can disable the Hub in the <a href="/#superadmin-settings?tab=private_hub">instance settings</a>.
</Alert>
{:else if (items.length > 0 && apps.length > 0) || !loading}
<ListFilters {syncQuery} filters={apps} bind:selectedFilter={appFilter} resourceType />
{#if items.length == 0}
<NoItemFound />
{:else}
<ul class="divide-y border rounded-md bg-surface-tertiary">
{#each items as item (item.path)}
<li class="flex flex-row w-full">
<button
class="p-4 gap-4 flex flex-row grow hover:bg-surface-hover transition-all items-center"
onclick={() => handlePick(item)}
>
<div class="flex items-center gap-4">
<div class="flex justify-center items-center">
{#if item['app'] in APP_TO_ICON_COMPONENT}
{@const SvelteComponent = APP_TO_ICON_COMPONENT[item['app']]}
<SvelteComponent height={18} width={18} />
{/if}
</div>
<div class="w-full text-left">
<div class="text-emphasis flex-wrap text-xs font-semibold mb-1">
{item.summary ?? ''}
</div>
<div class="text-secondary text-2xs font-normal">
{item.path}
</div>
</div>
</div>
{#if kind !== 'script'}
<Badge color="gray" baseClass="border">{capitalize(kind)}</Badge>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
{#if items.length == 20}
<div class="text-primary text-xs font-normal py-4">
There are more items than being displayed. Refine your search.
{#if !hideSearchbar}
<div class="w-full flex items-center gap-2">
{@render children?.()}
<div class="relative w-full">
<TextInput
inputProps={{
placeholder: 'Search Hub Scripts'
}}
bind:value={filter}
class="grow !pr-9"
{size}
/>
{#if loading}
<Loader2 class="animate-spin text-gray-400 absolute right-2 top-1" />
{/if}
</div>
</div>
{/if}
{:else}
{#each Array(10).fill(0) as _}
<Skeleton layout={[0.5, [4]]} />
{/each}
{/if}
{#if hubNotAvailable}
<Alert type="warning" title="Hub not available">
Could not connect to the Windmill Hub. If you are in a closed environment, you can disable the
Hub in the <a href="/#superadmin-settings?tab=private_hub">instance settings</a>.
</Alert>
{:else if (items.length > 0 && apps.length > 0) || !loading}
{#if !hideSearchbar}
<ListFilters {syncQuery} filters={apps} bind:selectedFilter={appFilter} resourceType />
{/if}
{#if items.length == 0}
<NoItemFound />
{:else}
<ul class="divide-y border rounded-md bg-surface-tertiary">
{#each items as item (item.path)}
<li class="flex flex-row w-full">
<button
class="p-4 gap-4 flex flex-row grow hover:bg-surface-hover transition-all items-center"
onclick={() => handlePick(item)}
>
<div class="flex items-center gap-4">
<div class="flex justify-center items-center">
{#if item['app'] in APP_TO_ICON_COMPONENT}
{@const SvelteComponent = APP_TO_ICON_COMPONENT[item['app']]}
<SvelteComponent height={18} width={18} />
{/if}
</div>
<div class="w-full text-left">
<div class="text-emphasis flex-wrap text-xs font-semibold mb-1">
{item.summary ?? ''}
</div>
<div class="text-secondary text-2xs font-normal">
{item.path}
</div>
</div>
</div>
{#if kind !== 'script'}
<Badge color="gray" baseClass="border">{capitalize(kind)}</Badge>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
{#if items.length == 20}
<div class="text-primary text-xs font-normal py-4">
There are more items than being displayed. Refine your search.
</div>
{/if}
{:else}
{#each Array(10).fill(0) as _}
<Skeleton layout={[0.5, [4]]} />
{/each}
{/if}
{/if}

View File

@@ -17,22 +17,30 @@
ChevronsDownUp,
ChevronsUpDown,
Code2,
FileText,
FolderOpen,
LayoutDashboard,
ListFilterPlus,
SearchCode
Route,
SearchCode,
Type,
User,
Users
} from 'lucide-svelte'
import { HOME_SEARCH_SHOW_FLOW, HOME_SEARCH_PLACEHOLDER } from '$lib/consts'
import { HOME_SEARCH_SHOW_FLOW } from '$lib/consts'
import SearchItems from '../SearchItems.svelte'
import FilterSearchbar, {
type FilterSchemaRec,
useUrlSyncedFilterInstance
} from '../FilterSearchbar.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 { canWrite, getLocalSetting, storeLocalSetting } from '$lib/utils'
import { page } from '$app/state'
import { setQuery } from '$lib/navigation'
import Drawer from '../common/drawer/Drawer.svelte'
import HighlightCode from '../HighlightCode.svelte'
import DrawerContent from '../common/drawer/DrawerContent.svelte'
@@ -41,19 +49,65 @@
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { getContext, untrack } from 'svelte'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import TextInput from '../text_input/TextInput.svelte'
interface Props {
filter?: string
subtab?: 'flow' | 'script' | 'app'
showEditButtons?: boolean
}
let {
filter = $bindable(''),
subtab = $bindable('script'),
showEditButtons = true
}: Props = $props()
const filterSchema: FilterSchemaRec = {
_default_: { type: 'string', hidden: true },
summary: {
type: 'string',
label: 'Summary',
description: 'Filter by summary text',
icon: Type
},
path: { type: 'string', label: 'Path', description: 'Filter by path', icon: Route },
description: {
type: 'string',
label: 'Description',
description: 'Filter by description',
icon: FileText
},
kind: {
type: 'oneof',
label: 'Kind',
description: 'Filter by runnable type',
options: [
{ value: 'script', label: 'Script' },
{ value: 'flow', label: 'Flow' },
{ value: 'app', label: 'App' }
]
},
user: {
type: 'string',
label: 'User',
description: 'Filter by owner user (u/...)',
icon: User
},
group: {
type: 'string',
label: 'Group',
description: 'Filter by group access',
icon: Users
},
folder: {
type: 'string',
label: 'Folder',
description: 'Filter by folder (f/...)',
icon: FolderOpen
}
}
let filterValue = useUrlSyncedFilterInstance(filterSchema)
let freeTextFilter = $derived((filterValue.val._default_ as string) ?? '')
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
canWrite: boolean
marked?: string
@@ -76,9 +130,21 @@
let filteredItems: (TableScript | TableFlow | TableApp | TableRawApp)[] = $state([])
let itemKind = $state(
(page.url.searchParams.get('kind') as 'script' | 'flow' | 'app' | 'all') ?? 'all'
)
let itemKind = $state('all' as 'script' | 'flow' | 'app' | 'all')
// Sync FilterSearchbar kind → itemKind
$effect(() => {
const k = filterValue.val.kind
itemKind = k ? (k as 'script' | 'flow' | 'app') : 'all'
})
// Sync kind → subtab
$effect(() => {
const k = filterValue.val.kind as string | undefined
if (k === 'script' || k === 'flow' || k === 'app') {
subtab = k
}
})
let loading = $state(true)
@@ -89,8 +155,7 @@
workspace: $workspaceStore!,
showArchived: archived ? true : undefined,
includeWithoutMain: includeWithoutMain ? true : undefined,
includeDraftOnly: true,
withoutDescription: true
includeDraftOnly: true
})
scripts = loadedScripts.map((script: Script) => {
@@ -107,8 +172,7 @@
await FlowService.listFlows({
workspace: $workspaceStore!,
showArchived: archived ? true : undefined,
includeDraftOnly: true,
withoutDescription: true
includeDraftOnly: true
})
).map((x: Flow) => {
return {
@@ -301,21 +365,76 @@
ownerFilter = undefined
}
})
let preFilteredItems = $derived(
ownerFilter != undefined
? combinedItems?.filter(
(x) =>
x.path.startsWith(ownerFilter + '/') &&
(x.type == itemKind || itemKind == 'all') &&
filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType)
)
: combinedItems?.filter(
(x) =>
(x.type == itemKind || itemKind == 'all') &&
filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType)
)
let preFilteredItems = $derived.by(() => {
let result = combinedItems
if (!result) return undefined
const fv = filterValue.val
// Kind filter (from searchbar or ToggleButtonGroup)
if (fv.kind) {
const k = fv.kind as string
result = result.filter((x) =>
k === 'app' ? x.type === 'app' || x.type === 'raw_app' : x.type === k
)
}
// Owner filter from ListFilters badges
if (ownerFilter != undefined) {
result = result.filter((x) => x.path.startsWith(ownerFilter + '/'))
}
// User filter
if (fv.user) {
const u = (fv.user as string).toLowerCase()
result = result.filter((x) => x.path.toLowerCase().startsWith(`u/${u}/`))
}
// Folder filter
if (fv.folder) {
const f = (fv.folder as string).toLowerCase()
result = result.filter((x) => x.path.toLowerCase().startsWith(`f/${f}/`))
}
// Group filter (check extra_perms for g/<group>)
if (fv.group) {
const g = `g/${fv.group as string}`
result = result.filter((x) => {
const perms = (x as any).extra_perms as Record<string, boolean> | undefined
return perms && g in perms
})
}
// Summary filter
if (fv.summary) {
const s = (fv.summary as string).toLowerCase()
result = result.filter((x) => x.summary?.toLowerCase().includes(s))
}
// Path filter
if (fv.path) {
const p = (fv.path as string).toLowerCase()
result = result.filter((x) => x.path.toLowerCase().includes(p))
}
// Description filter
if (fv.description) {
const d = (fv.description as string).toLowerCase()
result = result.filter((x) => (x as any).description?.toLowerCase().includes(d))
}
// User folders filter
result = result.filter((x) =>
filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType)
)
return result
})
let hasActiveFilters = $derived(
Object.keys(filterValue.val).some((k) => k !== '_default_' && filterValue.val[k] != null) ||
ownerFilter != undefined
)
let items = $derived(filter !== '' ? filteredItems : preFilteredItems)
let items = $derived(freeTextFilter !== '' ? filteredItems : preFilteredItems)
$effect(() => {
items && resetScroll()
})
@@ -331,7 +450,7 @@
</script>
<SearchItems
{filter}
filter={freeTextFilter}
items={preFilteredItems}
bind:filteredItems
f={(x) => (x.summary ? x.summary + ' (' + x.path + ')' : x.path)}
@@ -368,10 +487,11 @@
<ToggleButtonGroup
bind:selected={itemKind}
onSelected={(v) => {
if (itemKind != 'all') {
subtab = v
if (v === 'all') {
delete filterValue.val.kind
} else {
filterValue.val.kind = v
}
setQuery(page.url, 'kind', v)
}}
>
{#snippet children({ item })}
@@ -399,39 +519,12 @@
</ToggleButtonGroup>
</div>
<div class="relative text-primary grow min-w-[100px]">
<!-- svelte-ignore a11y_autofocus -->
<TextInput
inputProps={{
autofocus: true,
placeholder: HOME_SEARCH_PLACEHOLDER,
id: 'home-search-input'
}}
size="md"
bind:value={filter}
class="!pr-10"
/>
<button aria-label="Search" type="submit" class="absolute right-0 top-0 mt-2 mr-4">
<svg
class="h-4 w-4 fill-current"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
id="Capa_1"
x="0px"
y="0px"
viewBox="0 0 56.966 56.966"
style="enable-background:new 0 0 56.966 56.966;"
xml:space="preserve"
width="512px"
height="512px"
>
<path
d="M55.146,51.887L41.588,37.786c3.486-4.144,5.396-9.358,5.396-14.786c0-12.682-10.318-23-23-23s-23,10.318-23,23 s10.318,23,23,23c4.761,0,9.298-1.436,13.177-4.162l13.661,14.208c0.571,0.593,1.339,0.92,2.162,0.92 c0.779,0,1.518-0.297,2.079-0.837C56.255,54.982,56.293,53.08,55.146,51.887z M23.984,6c9.374,0,17,7.626,17,17s-7.626,17-17,17 s-17-7.626-17-17S14.61,6,23.984,6z"
/>
</svg>
</button>
</div>
<FilterSearchbar
schema={filterSchema}
bind:value={filterValue.val}
placeholder="Filter scripts, flows, and apps..."
class="grow min-w-[100px]"
/>
<Button
on:click={() => openSearchWithPrefilledText('#')}
variant="default"
@@ -522,13 +615,15 @@
<Skeleton layout={[[4], 0.5]} />
{/each}
{:else if filteredItems.length === 0}
<NoItemFound hasFilters={filter !== '' || archived || filterUserFolders} />
<NoItemFound
hasFilters={freeTextFilter !== '' || hasActiveFilters || archived || filterUserFolders}
/>
{:else if treeView}
<TreeViewRoot
{items}
{nbDisplayed}
{collapseAll}
isSearching={filter !== ''}
isSearching={freeTextFilter !== '' || hasActiveFilters}
on:scriptChanged={() => loadScripts(includeWithoutMain)}
on:flowChanged={loadFlows}
on:appChanged={loadApps}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { AppService, FlowService, type OpenFlow, type Script } from '$lib/gen'
import { AppService, FlowService, IntegrationService, type OpenFlow, type Script } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { Alert, Button, Drawer, DrawerContent, Tab, Tabs } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
@@ -20,18 +20,21 @@
Globe2,
Loader2,
Code,
LayoutDashboard
LayoutDashboard,
Route,
Tag,
Type
} from 'lucide-svelte'
import { hubBaseUrlStore } from '$lib/stores'
import { base } from '$lib/base'
import ItemsList from '$lib/components/home/ItemsList.svelte'
import FilterSearchbar, { type FilterSchemaRec } from '$lib/components/FilterSearchbar.svelte'
import CreateActionsApp from '$lib/components/flows/CreateActionsApp.svelte'
import PickHubApp from '$lib/components/flows/pickers/PickHubApp.svelte'
import { writable } from 'svelte/store'
import type { EditorBreakpoint } from '$lib/components/apps/types'
import { HOME_SHOW_HUB, HOME_SHOW_CREATE_FLOW, HOME_SHOW_CREATE_APP } from '$lib/consts'
import { setQuery } from '$lib/navigation'
import { page } from '$app/state'
import { goto, replaceState } from '$app/navigation'
import ForkWorkspaceBanner from '$lib/components/ForkWorkspaceBanner.svelte'
@@ -52,7 +55,56 @@
let subtab: 'flow' | 'script' | 'app' = $state('script')
let filter: string = $state('')
// Hub filter: load integration names for tag suggestions
let hubIntegrations: string[] = $state([])
// Hub filter schema (tag options update when integrations load)
let hubFilterSchema: FilterSchemaRec = $derived({
_default_: { type: 'string', hidden: true },
tag: {
type: 'oneof',
label: 'Tag',
description: 'Filter by integration/app tag',
icon: Tag,
allowCustomValue: true,
options: hubIntegrations.map((name) => ({ value: name, label: name }))
},
summary: {
type: 'string',
label: 'Summary',
description: 'Filter by summary text',
icon: Type
},
path: { type: 'string', label: 'Path', description: 'Filter by path', icon: Route },
kind: {
type: 'oneof',
label: 'Kind',
description: 'Filter by runnable type',
options: [
{ value: 'script', label: 'Script' },
{ value: 'flow', label: 'Flow' },
{ value: 'app', label: 'App' }
]
}
})
let hubFilterValue: Record<string, any> = $state({})
let hubFreeText = $derived((hubFilterValue._default_ as string) ?? '')
let hubAppFilter: string | undefined = $state(undefined)
// Sync hub kind filter ↔ subtab
$effect(() => {
const k = hubFilterValue.kind as string | undefined
if (k === 'script' || k === 'flow' || k === 'app') {
subtab = k
}
})
// Sync hub tag filter → hubAppFilter (for PickHub* API calls)
$effect(() => {
const tag = hubFilterValue.tag as string | undefined
hubAppFilter = tag ?? undefined
})
let flowViewer: Drawer | undefined = $state(undefined)
let flowViewerFlow: { flow?: OpenFlow & { id?: number } } | undefined = $state(undefined)
@@ -109,7 +161,16 @@
let showCreateButtons = $state(false)
onMount(() => {
onMount(async () => {
// Load hub integrations for tag suggestions
try {
hubIntegrations = (await IntegrationService.listHubIntegrations({ kind: 'script' })).map(
(x) => x.name
)
} catch {
hubIntegrations = []
}
// Check if there's a tutorial parameter in the URL
const tutorialParam = page.url.searchParams.get('tutorial')
if (tutorialParam === 'workspace-onboarding') {
@@ -117,7 +178,8 @@
setTimeout(() => {
workspaceTutorials?.runTutorialById('workspace-onboarding')
}, 500)
} else if (tutorialParam === 'workspace-onboarding-operator') { // Small delay to ensure page is fully loaded
} else if (tutorialParam === 'workspace-onboarding-operator') {
// Small delay to ensure page is fully loaded
setTimeout(() => {
workspaceTutorials?.runTutorialById('workspace-onboarding-operator')
}, 500)
@@ -293,7 +355,7 @@
<TutorialBanner />
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => showCreateButtons = v}/>
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
{#if !$userStore?.operator}
<div class="w-full overflow-auto scrollbar-hidden pb-2">
@@ -308,11 +370,11 @@
{#if tab == 'hub'}
<div class="flex flex-col gap-y-16">
<div class="flex flex-col pb-8">
{#snippet toggleKinds()}
<div class="w-full flex items-center gap-2">
<ToggleButtonGroup
bind:selected={subtab}
onSelected={(v) => {
setQuery(page.url, 'kind', v, window.location.hash)
hubFilterValue.kind = v
}}
noWFull
>
@@ -334,6 +396,25 @@
/>
{/snippet}
</ToggleButtonGroup>
<FilterSearchbar
schema={hubFilterSchema}
bind:value={hubFilterValue}
placeholder="Search hub..."
class="grow"
presets={[
{ name: 'Slack', value: 'tag:slack' },
{ name: 'Gmail', value: 'tag:gmail' },
{ name: 'GitHub', value: 'tag:github' },
{ name: 'Notion', value: 'tag:notion' },
{ name: 'Stripe', value: 'tag:stripe' },
{ name: 'OpenAI', value: 'tag:openai' },
{ name: 'HubSpot', value: 'tag:hubspot' },
{ name: 'PostgreSQL', value: 'tag:postgresql' },
{ name: 'Google Sheets', value: 'tag:gsheets' },
{ name: 'Shopify', value: 'tag:shopify' },
{ name: 'Other...', value: 'tag:' }
]}
/>
<Button
startIcon={{ icon: ExternalLink }}
target="_blank"
@@ -342,26 +423,35 @@
>
Hub
</Button>
{/snippet}
</div>
{#if subtab == 'script'}
<PickHubScript syncQuery bind:filter on:pick={(e) => viewCode(e.detail)}>
{#snippet children()}
{@render toggleKinds?.()}
{/snippet}
</PickHubScript>
<PickHubScript
filter={hubFreeText}
appFilter={hubAppFilter}
summaryFilter={hubFilterValue.summary}
pathFilter={hubFilterValue.path}
hideSearchbar
on:pick={(e) => viewCode(e.detail)}
/>
{:else if subtab == 'flow'}
<PickHubFlow syncQuery bind:filter on:pick={(e) => viewFlow(e.detail)}>
{#snippet children()}
{@render toggleKinds?.()}
{/snippet}
</PickHubFlow>
<PickHubFlow
filter={hubFreeText}
appFilter={hubAppFilter}
summaryFilter={hubFilterValue.summary}
pathFilter={hubFilterValue.path}
hideSearchbar
on:pick={(e) => viewFlow(e.detail)}
/>
{:else if subtab == 'app'}
<PickHubApp syncQuery bind:filter on:pick={(e) => viewApp(e.detail)}>
{#snippet children()}
{@render toggleKinds?.()}
{/snippet}
</PickHubApp>
<PickHubApp
filter={hubFreeText}
appFilter={hubAppFilter}
summaryFilter={hubFilterValue.summary}
pathFilter={hubFilterValue.path}
hideSearchbar
on:pick={(e) => viewApp(e.detail)}
/>
{/if}
</div>
</div>
@@ -369,7 +459,7 @@
</div>
{#if tab == 'workspace'}
<ItemsList bind:filter bind:subtab showEditButtons={showCreateButtons} />
<ItemsList bind:subtab showEditButtons={showCreateButtons} />
{/if}
</div>