fix: improve teams search ux (#7407)

* feat: improve teams search ux

* ee ref

* claude review

* chore: update ee-repo-ref to e218dfce97dcea56c6ef6032592dab812a3f5047

This commit updates the EE repository reference after PR #363 was merged in windmill-ee-private.

Previous ee-repo-ref: 1b95a24ab25d96e59d2f22588901e9d3ce6c72b3

New ee-repo-ref: e218dfce97dcea56c6ef6032592dab812a3f5047

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Alexander Petric
2025-12-19 08:03:15 -05:00
committed by GitHub
parent ace111b862
commit 2a59ca2819
5 changed files with 334 additions and 151 deletions
+1 -1
View File
@@ -1 +1 @@
505eadbff32d102ea5245a2bef88ce6f1bb95395
e218dfce97dcea56c6ef6032592dab812a3f5047
+41 -23
View File
@@ -2558,7 +2558,13 @@ paths:
- $ref: "#/components/parameters/WorkspaceId"
- name: search
in: query
description: Search teams by name
description: Search teams by name. If omitted, returns first page of all teams.
required: false
schema:
type: string
- name: next_link
in: query
description: Pagination cursor URL from previous response. Pass this to fetch the next page of results.
required: false
schema:
type: string
@@ -2568,14 +2574,27 @@ paths:
content:
application/json:
schema:
type: array
items:
type: object
properties:
team_name:
type: string
team_id:
type: string
type: object
properties:
teams:
type: array
items:
type: object
properties:
team_name:
type: string
team_id:
type: string
total_count:
type: integer
description: Total number of teams across all pages
per_page:
type: integer
description: Number of teams per page (configurable via TEAMS_PER_PAGE env var)
next_link:
type: string
nullable: true
description: URL to fetch next page of results. Null if no more pages.
/w/{workspace}/workspaces/available_teams_channels:
get:
@@ -2591,26 +2610,25 @@ paths:
required: true
schema:
type: string
- name: search
in: query
description: Search channels by name
required: false
schema:
type: string
responses:
"200":
description: List of channels for the specified team
content:
application/json:
schema:
type: array
items:
type: object
properties:
channel_name:
type: string
channel_id:
type: string
type: object
properties:
channels:
type: array
items:
type: object
properties:
channel_name:
type: string
channel_id:
type: string
total_count:
type: integer
/w/{workspace}/workspaces/connect_teams:
post:
@@ -2,7 +2,7 @@
import Select from './select/Select.svelte'
import { WorkspaceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { debounce } from '$lib/utils'
import { RefreshCcw } from 'lucide-svelte'
interface ChannelItem {
channel_id?: string
@@ -17,6 +17,7 @@
minWidth?: string
channels?: ChannelItem[]
teamId?: string
showRefreshButton?: boolean
onError?: (error: Error) => void
onSelectedChannelChange?: (channel: ChannelItem | undefined) => void
}
@@ -29,20 +30,22 @@
minWidth = '160px',
channels = undefined,
teamId,
showRefreshButton = true,
onError,
onSelectedChannelChange
}: Props = $props()
let isFetching = $state(false)
let searchResults = $state<ChannelItem[]>([])
let loadedChannels = $state<ChannelItem[]>([])
let loadedForTeamId = $state<string | undefined>(undefined)
let selectedChannelId = $state<string | undefined>(selectedChannel?.channel_id)
const searchMode = !channels && !!teamId
const searchMode = $derived(!channels && !!teamId)
let displayChannels = $derived.by(() => {
const baseChannels = channels || searchResults
if (selectedChannel && !baseChannels.find(c => c.channel_id === selectedChannel?.channel_id)) {
const baseChannels = channels || loadedChannels
if (selectedChannel && !baseChannels.find((c) => c.channel_id === selectedChannel?.channel_id)) {
return [selectedChannel, ...baseChannels]
}
return baseChannels
@@ -50,7 +53,7 @@
$effect(() => {
const newChannel = selectedChannelId
? displayChannels.find(c => c.channel_id === selectedChannelId)
? displayChannels.find((c) => c.channel_id === selectedChannelId)
: undefined
if (newChannel?.channel_id !== selectedChannel?.channel_id) {
@@ -73,77 +76,96 @@
}
})
let searchFilterText = $state('')
const debouncedSearch = debounce(async (query: string) => {
await searchChannels(query)
}, 500)
// Fetch channels when teamId is set or changes
$effect(() => {
if (searchMode) {
if (searchFilterText.length >= 1) {
debouncedSearch.debounced(searchFilterText)
} else if (searchFilterText.length === 0) {
searchResults = []
}
if (searchMode && teamId && teamId !== loadedForTeamId) {
loadedForTeamId = teamId
fetchChannels()
}
})
async function searchChannels(query: string) {
if (!query || !teamId) return
async function fetchChannels() {
if (!teamId) return
isFetching = true
try {
const response = await WorkspaceService.listAvailableTeamsChannels({
workspace: $workspaceStore!,
teamId: teamId,
search: query
teamId: teamId
})
searchResults = response || []
isFetching = false
return searchResults
loadedChannels =
response.channels?.map((c) => ({
channel_id: c.channel_id || '',
channel_name: c.channel_name || ''
})) || []
} catch (error) {
onError?.(error as Error)
console.error('Error fetching channels:', error)
loadedChannels = []
} finally {
isFetching = false
onError?.(error)
console.error('Error searching channels:', error)
searchResults = []
return []
}
}
async function refreshChannels() {
if (searchMode) {
await fetchChannels()
}
}
</script>
<div class={containerClass}>
<div class="flex items-center gap-2">
<div class="flex-grow" style="min-width: {minWidth};">
{#if searchMode}
<Select
containerStyle={'min-width: ' + minWidth}
items={displayChannels.filter(channel => channel.channel_id && channel.channel_name).map((channel) => ({
label: channel.channel_name ?? 'Unknown Channel',
value: channel.channel_id ?? ''
}))}
placeholder={isFetching ? "Searching..." : (teamId ? "Search channels..." : "Select a team first")}
clearable
disabled={disabled || isFetching || !teamId}
bind:filterText={searchFilterText}
bind:value={selectedChannelId}
/>
{:else}
<Select
containerStyle={'min-width: ' + minWidth}
items={displayChannels.filter(channel => channel.channel_id && channel.channel_name).map((channel) => ({
label: channel.channel_name ?? 'Unknown Channel',
value: channel.channel_id ?? ''
}))}
{placeholder}
clearable
disabled={disabled || displayChannels.length === 0}
bind:value={selectedChannelId}
/>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<div class="flex-grow" style="min-width: {minWidth};">
{#if searchMode}
<Select
containerStyle={'min-width: ' + minWidth}
items={displayChannels
.filter((channel) => channel.channel_id && channel.channel_name)
.map((channel) => ({
label: channel.channel_name ?? 'Unknown Channel',
value: channel.channel_id ?? ''
}))}
placeholder={isFetching ? 'Loading...' : teamId ? placeholder : 'Select a team first'}
clearable
disabled={disabled || isFetching || !teamId}
bind:value={selectedChannelId}
/>
{:else}
<Select
containerStyle={'min-width: ' + minWidth}
items={displayChannels
.filter((channel) => channel.channel_id && channel.channel_name)
.map((channel) => ({
label: channel.channel_name ?? 'Unknown Channel',
value: channel.channel_id ?? ''
}))}
{placeholder}
clearable
disabled={disabled || displayChannels.length === 0}
bind:value={selectedChannelId}
/>
{/if}
</div>
{#if showRefreshButton && searchMode}
<button
onclick={refreshChannels}
disabled={isFetching || disabled || !teamId}
class="flex items-center justify-center p-1.5 rounded hover:bg-surface-hover focus:bg-surface-hover disabled:opacity-50"
title="Refresh channels"
>
<RefreshCcw size={16} class={isFetching ? 'animate-spin' : ''} />
</button>
{/if}
</div>
</div>
{#if searchMode && loadedChannels.length > 0 && !isFetching}
<span class="text-2xs text-tertiary pl-1">
{loadedChannels.length} channel{loadedChannels.length === 1 ? '' : 's'}
</span>
{/if}
</div>
</div>
@@ -523,7 +523,7 @@
<div class="w-full max-w-lg">
{#if $enterpriseLicense && Array.isArray($values[setting.key])}
{#each $values[setting.key] ?? [] as v, i}
<div class="flex w-full max-w-lg mt-1 gap-2 items-center">
<div class="flex w-full max-w-lg mt-1 gap-2 items-start">
<select
class="max-w-24"
onchange={(e) => {
@@ -574,7 +574,7 @@
$values['critical_error_channels'][i]?.teams_channel?.channel_name
}
: undefined}
<div class="flex flex-row gap-2 w-full">
<div class="flex flex-row gap-2 w-full items-start">
<TeamSelector
containerClass="w-44"
minWidth="140px"
+211 -68
View File
@@ -33,24 +33,36 @@
}: Props = $props()
let isFetching = $state(false)
let searchResults = $state<TeamItem[]>([])
let loadedTeams = $state<TeamItem[]>([])
let hasLoadedInitial = $state(false)
let isLoadingMore = $state(false)
let nextLink = $state<string | null>(null)
let totalCount = $state(0)
// Store pre-search state to restore when search is cleared
let preSearchTeams = $state<TeamItem[] | null>(null)
let preSearchNextLink = $state<string | null>(null)
let preSearchTotalCount = $state(0)
let selectedTeamId = $state<string | undefined>(selectedTeam?.team_id)
const searchMode = !teams
const searchMode = $derived(!teams)
// Check if there are more teams to load (based on next_link presence)
const hasMoreTeams = $derived(!!nextLink)
// Show indicator when we have more teams to load
const showLoadMoreIndicator = $derived(searchMode && hasMoreTeams)
let displayTeams = $derived.by(() => {
const baseTeams = teams || searchResults
if (selectedTeam && !baseTeams.find(t => t.team_id === selectedTeam?.team_id)) {
const baseTeams = teams || loadedTeams
if (selectedTeam && !baseTeams.find((t) => t.team_id === selectedTeam?.team_id)) {
return [selectedTeam, ...baseTeams]
}
return baseTeams
})
$effect(() => {
const newTeam = selectedTeamId
? displayTeams.find(t => t.team_id === selectedTeamId)
: undefined
const newTeam = selectedTeamId ? displayTeams.find((t) => t.team_id === selectedTeamId) : undefined
if (newTeam?.team_id !== selectedTeam?.team_id) {
selectedTeam = newTeam
@@ -73,91 +85,222 @@
})
let searchFilterText = $state('')
let searchRequestId = $state(0)
const debouncedSearch = debounce(async (query: string) => {
await searchTeams(query)
}, 500)
// Preload initial teams on mount when in search mode
$effect(() => {
if (searchMode && !hasLoadedInitial) {
hasLoadedInitial = true
fetchInitialTeams()
}
})
// Track previous search text to detect when cleared
let previousSearchText = $state('')
// Handle search input
$effect(() => {
if (searchMode) {
if (searchFilterText.length >= 1) {
debouncedSearch.debounced(searchFilterText)
} else if (searchFilterText.length === 0) {
searchResults = []
previousSearchText = searchFilterText
} else if (previousSearchText.length > 0) {
// Search was cleared - restore pre-search state
previousSearchText = ''
restorePreSearchState()
}
}
})
async function searchTeams(query: string) {
if (!query) return
isFetching = true
try {
const response = (await WorkspaceService.listAvailableTeamsIds({
workspace: $workspaceStore!,
search: query
})) as unknown as TeamItem[]
searchResults = response || []
isFetching = false
return searchResults
} catch (error) {
isFetching = false
onError?.(error)
console.error('Error searching teams:', error)
searchResults = []
return []
function restorePreSearchState() {
searchRequestId++ // Invalidate any in-flight search
if (preSearchTeams !== null) {
// Restore the accumulated teams from before the search
loadedTeams = preSearchTeams
nextLink = preSearchNextLink
totalCount = preSearchTotalCount
// Clear the saved state
preSearchTeams = null
preSearchNextLink = null
preSearchTotalCount = 0
} else {
// No saved state, fetch fresh
fetchInitialTeams()
}
}
async function refreshSearch() {
if (searchMode && searchFilterText.length >= 2) {
await searchTeams(searchFilterText)
async function fetchInitialTeams() {
isFetching = true
nextLink = null
totalCount = 0
try {
const response = await WorkspaceService.listAvailableTeamsIds({
workspace: $workspaceStore!
})
loadedTeams =
response.teams?.map((t) => ({
team_id: t.team_id || '',
team_name: t.team_name || ''
})) || []
nextLink = response.next_link ?? null
totalCount = response.total_count ?? loadedTeams.length
} catch (error) {
onError?.(error as Error)
console.error('Error fetching initial teams:', error)
loadedTeams = []
} finally {
isFetching = false
}
}
async function searchTeams(query: string) {
if (!query) return
// Save current state before searching (only if not already in search mode)
if (preSearchTeams === null) {
preSearchTeams = loadedTeams
preSearchNextLink = nextLink
preSearchTotalCount = totalCount
}
const thisRequestId = ++searchRequestId
isFetching = true
nextLink = null
try {
const response = await WorkspaceService.listAvailableTeamsIds({
workspace: $workspaceStore!,
search: query
})
// Ignore stale results if a newer search was initiated
if (thisRequestId !== searchRequestId) {
return
}
loadedTeams =
response.teams?.map((t) => ({
team_id: t.team_id || '',
team_name: t.team_name || ''
})) || []
// Search results don't have pagination
nextLink = null
} catch (error) {
// Only handle error if this is still the current request
if (thisRequestId === searchRequestId) {
onError?.(error as Error)
console.error('Error searching teams:', error)
loadedTeams = []
}
} finally {
// Only clear loading state if this is the current request
if (thisRequestId === searchRequestId) {
isFetching = false
}
}
}
async function loadMoreTeams() {
// Don't load more if: already loading, no next page, or user started searching
if (isLoadingMore || !nextLink || preSearchTeams !== null) return
isLoadingMore = true
try {
const response = await WorkspaceService.listAvailableTeamsIds({
workspace: $workspaceStore!,
nextLink: nextLink
})
const newTeams =
response.teams?.map((t) => ({
team_id: t.team_id || '',
team_name: t.team_name || ''
})) || []
// Append new teams to existing list
loadedTeams = [...loadedTeams, ...newTeams]
nextLink = response.next_link ?? null
} catch (error) {
onError?.(error as Error)
console.error('Error loading more teams:', error)
} finally {
isLoadingMore = false
}
}
async function refreshTeams() {
if (searchMode) {
if (searchFilterText.length >= 1) {
await searchTeams(searchFilterText)
} else {
await fetchInitialTeams()
}
}
}
</script>
<div class={containerClass}>
<div class="flex items-center gap-2">
<div class="flex-grow" style="min-width: {minWidth};">
{#if searchMode}
<Select
containerStyle={'min-width: ' + minWidth}
items={displayTeams.map((team) => ({
label: team.team_name,
value: team.team_id
}))}
placeholder={isFetching ? "Searching..." : "Search teams..."}
clearable
disabled={disabled || isFetching}
bind:filterText={searchFilterText}
bind:value={selectedTeamId}
/>
{:else}
<Select
containerStyle={'min-width: ' + minWidth}
items={displayTeams.map((team) => ({
label: team.team_name,
value: team.team_id
}))}
placeholder="Select a team"
clearable
disabled={disabled || isFetching}
bind:value={selectedTeamId}
/>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<div class="flex-grow" style="min-width: {minWidth};">
{#if searchMode}
<Select
containerStyle={'min-width: ' + minWidth}
items={displayTeams.map((team) => ({
label: team.team_name,
value: team.team_id
}))}
placeholder={isFetching ? 'Loading...' : 'Search teams...'}
clearable
disabled={disabled || isFetching}
bind:filterText={searchFilterText}
bind:value={selectedTeamId}
/>
{:else}
<Select
containerStyle={'min-width: ' + minWidth}
items={displayTeams.map((team) => ({
label: team.team_name,
value: team.team_id
}))}
placeholder="Select a team"
clearable
disabled={disabled || isFetching}
bind:value={selectedTeamId}
/>
{/if}
</div>
{#if showRefreshButton}
<button
onclick={refreshTeams}
disabled={isFetching || disabled}
class="flex items-center justify-center p-1.5 rounded hover:bg-surface-hover focus:bg-surface-hover disabled:opacity-50"
title={searchMode ? 'Refresh teams' : 'Refresh teams from Microsoft'}
>
<RefreshCcw size={16} class={isFetching ? 'animate-spin' : ''} />
</button>
{/if}
</div>
{#if showRefreshButton}
<button
onclick={refreshSearch}
disabled={isFetching || disabled || (searchMode && searchFilterText.length < 2)}
class="flex items-center justify-center p-1.5 rounded hover:bg-surface-hover focus:bg-surface-hover disabled:opacity-50"
title={searchMode ? "Refresh search results" : "Refresh teams from Microsoft"}
>
<RefreshCcw size={16} class={isFetching ? 'animate-spin' : ''} />
</button>
{#if showLoadMoreIndicator}
<div class="flex items-center gap-2 pl-1">
<span class="text-2xs text-tertiary">
Loaded {loadedTeams.length} of {totalCount}
</span>
<button
type="button"
class="text-2xs text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
onclick={loadMoreTeams}
disabled={isLoadingMore}
>
{isLoadingMore ? 'loading...' : 'load more...'}
</button>
</div>
{/if}
</div>
</div>