* 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>
310 lines
8.0 KiB
Svelte
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>
|