Files
windmill/frontend/src/lib/components/TeamSelector.svelte
Guilhem 210b8285d4 fix(frontend): settings redesign (#7406)
* improve collapsible link

* do not show superadmin ws link when already in it

* improve OAuth UI

* sso/oauth instance settings ui

* refactor instance settings alerts WIP

* Indexer and Oauth to brand guidelines

* refactor ws error handler page

* Create a tab SMTP in the Instance Settings

* Ractivity isssue fix for tabs

* nit

* Add smtp settings status in Error handler

* Add smtp configuration status

* Display teams connection status for instance alerts

* nit

* Add critical alerts description

* nit

* nit

* improve ee display

* nit

* nit

* fix typo

* nit

* restore vit config

---------

Co-authored-by: Alexander Petric <alex@windmill.dev>
2025-12-19 20:33:03 +00:00

310 lines
8.0 KiB
Svelte

<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { RefreshCcw } from 'lucide-svelte'
import { WorkspaceService } from '$lib/gen'
import Select from './select/Select.svelte'
import { debounce } from '$lib/utils'
import { Button } from './common'
interface TeamItem {
team_id: string
team_name: string
}
interface Props {
disabled?: boolean
selectedTeam?: TeamItem | undefined
containerClass?: string
showRefreshButton?: boolean
teams?: TeamItem[] | undefined
minWidth?: string
onError?: (error: Error) => void
onSelectedTeamChange?: (team: TeamItem | undefined) => void
}
let {
disabled = false,
selectedTeam = $bindable(),
containerClass = 'w-64',
showRefreshButton = true,
teams = undefined,
minWidth = '160px',
onError,
onSelectedTeamChange
}: Props = $props()
let isFetching = $state(false)
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 = $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 || 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
if (newTeam?.team_id !== selectedTeam?.team_id) {
selectedTeam = newTeam
}
})
$effect(() => {
if (selectedTeam?.team_id !== selectedTeamId) {
selectedTeamId = selectedTeam?.team_id
}
})
let previousTeamId = $state<string | undefined>(undefined)
$effect(() => {
if (selectedTeam?.team_id !== previousTeamId) {
previousTeamId = selectedTeam?.team_id
onSelectedTeamChange?.(selectedTeam)
}
})
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)
previousSearchText = searchFilterText
} else if (previousSearchText.length > 0) {
// Search was cleared - restore pre-search state
previousSearchText = ''
restorePreSearchState()
}
}
})
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 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 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}
loading={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'}
startIcon={{ icon: RefreshCcw, props: { class: isFetching ? 'animate-spin' : '' } }}
/>
{/if}
</div>
{#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-xs text-accent cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
onclick={loadMoreTeams}
disabled={isLoadingMore}
>
{isLoadingMore ? 'loading...' : 'load more...'}
</button>
</div>
{/if}
</div>
</div>