diff --git a/frontend/src/lib/components/flows/pickers/PickHubApp.svelte b/frontend/src/lib/components/flows/pickers/PickHubApp.svelte
index 234f053f7b..6118c5dd9e 100644
--- a/frontend/src/lib/components/flows/pickers/PickHubApp.svelte
+++ b/frontend/src/lib/components/flows/pickers/PickHubApp.svelte
@@ -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)
@@ -54,6 +80,7 @@
bind:filteredItems
f={(x) => x.summary + ' (' + x.apps.join(', ') + ')'}
/>
+{#if !hideSearchbar}
{@render children?.()}
+{/if}
{#if hubNotAvailable}
@@ -72,11 +100,11 @@
Could not connect to the Windmill Hub. If you are in a closed environment, you can disable the Hub in the instance settings .
{:else if hubApps}
- {#if filteredItems.length == 0}
+ {#if displayItems.length == 0}
{:else}
- {#each filteredItems as item (item)}
+ {#each displayItems as item (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)
@@ -54,6 +80,7 @@
bind:filteredItems
f={(x) => x.summary + ' (' + x.apps.join(', ') + ')'}
/>
+{#if !hideSearchbar}
{@render children?.()}
+{/if}
{#if hubNotAvailable}
@@ -72,11 +100,11 @@
Could not connect to the Windmill Hub. If you are in a closed environment, you can disable the Hub in the instance settings .
{:else if hubFlows}
- {#if filteredItems.length == 0}
+ {#if displayItems.length == 0}
{:else}
- {#each filteredItems as item (item)}
+ {#each displayItems as item (item)}
{
+ let result = items
+ 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
+ })
+
$effect(() => {
;[filter, kind, appFilter]
untrack(() => applyFilter(filter, kind, appFilter))
@@ -144,6 +165,7 @@
{#if $disableHubStore}
{:else}
+{#if !hideSearchbar}
{@render children?.()}
@@ -160,6 +182,7 @@
{/if}
+{/if}
{#if hubNotAvailable}
@@ -167,11 +190,11 @@
{:else if (items.length > 0 && apps.length > 0) || !loading}
- {#if items.length == 0}
+ {#if displayItems.length == 0}
{:else}
- {#each items as item (item.path)}
+ {#each displayItems as item (item.path)}
{/if}
- {#if items.length == 20}
+ {#if displayItems.length == 20}
There are more items than being displayed. Refine your search.
diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte
index cae0144169..d08b8681c1 100644
--- a/frontend/src/lib/components/home/ItemsList.svelte
+++ b/frontend/src/lib/components/home/ItemsList.svelte
@@ -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 & {
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/)
+ if (fv.group) {
+ const g = `g/${fv.group as string}`
+ result = result.filter((x) => {
+ const perms = (x as any).extra_perms as Record | 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 @@
(x.summary ? x.summary + ' (' + x.path + ')' : x.path)}
@@ -368,10 +487,11 @@
{
- 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 @@
-
+
openSearchWithPrefilledText('#')}
variant="default"
@@ -522,13 +615,15 @@
{/each}
{:else if filteredItems.length === 0}
-
+
{:else if treeView}
loadScripts(includeWithoutMain)}
on:flowChanged={loadFlows}
on:appChanged={loadApps}
diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte
index 9f7c9b493c..2053c6785c 100644
--- a/frontend/src/routes/(root)/(logged)/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/+page.svelte
@@ -20,18 +20,23 @@
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 +57,65 @@
let subtab: 'flow' | 'script' | 'app' = $state('script')
- let filter: string = $state('')
+ // Hub filter schema and state
+ const hubFilterSchema: FilterSchemaRec = {
+ _default_: { type: 'string', hidden: true },
+ tag: {
+ type: 'string',
+ label: 'Tag',
+ description: 'Filter by integration/app tag',
+ icon: Tag
+ },
+ 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 = $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
+ $effect(() => {
+ const tag = hubFilterValue.tag as string | undefined
+ if (tag !== hubAppFilter) {
+ hubAppFilter = tag ?? undefined
+ }
+ })
+
+ // Sync hubAppFilter → hub tag filter (when ListFilters badge is clicked)
+ $effect(() => {
+ const currentTag = hubFilterValue.tag as string | undefined
+ if (hubAppFilter !== currentTag) {
+ if (hubAppFilter) {
+ hubFilterValue.tag = hubAppFilter
+ } else {
+ delete hubFilterValue.tag
+ }
+ }
+ })
let flowViewer: Drawer | undefined = $state(undefined)
let flowViewerFlow: { flow?: OpenFlow & { id?: number } } | undefined = $state(undefined)
@@ -308,11 +371,11 @@
{#if tab == 'hub'}
- {#snippet toggleKinds()}
+
{
- setQuery(page.url, 'kind', v, window.location.hash)
+ hubFilterValue.kind = v
}}
noWFull
>
@@ -334,6 +397,12 @@
/>
{/snippet}
+
Hub
- {/snippet}
+
{#if subtab == 'script'}
-
viewCode(e.detail)}>
- {#snippet children()}
- {@render toggleKinds?.()}
- {/snippet}
-
+
viewCode(e.detail)}
+ />
{:else if subtab == 'flow'}
- viewFlow(e.detail)}>
- {#snippet children()}
- {@render toggleKinds?.()}
- {/snippet}
-
+ viewFlow(e.detail)}
+ />
{:else if subtab == 'app'}
- viewApp(e.detail)}>
- {#snippet children()}
- {@render toggleKinds?.()}
- {/snippet}
-
+ viewApp(e.detail)}
+ />
{/if}
@@ -369,7 +450,7 @@
{#if tab == 'workspace'}
-
+
{/if}