mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 16:01:42 +00:00
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:
@@ -1 +1 @@
|
||||
505eadbff32d102ea5245a2bef88ce6f1bb95395
|
||||
e218dfce97dcea56c6ef6032592dab812a3f5047
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user