Skip to content

Commit 5769156

Browse files
committed
feat(frontend): add pagination and search to admin teams view
- Add pagination types to match backend response structure - Implement TeamService.getTeamsAdminPaginated() method - Implement TeamService.searchTeamsAdmin() method - Replace client-side filtering with backend search API - Add skeleton loading states for better UX - Add PaginationControls component below teams table - Support name-based search with Enter key trigger - Fix breadcrumb navigation to use RouterLink for client-side routing
1 parent 35c79a1 commit 5769156

File tree

5 files changed

+280
-56
lines changed

5 files changed

+280
-56
lines changed

services/frontend/src/i18n/locales/en/adminTeams.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export default {
2020
},
2121
search: {
2222
placeholder: 'Search teams by name, slug, or description...',
23+
button: 'Search',
2324
},
2425
actions: {
2526
view: 'View',

services/frontend/src/services/teamService.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { getEnv } from '@/utils/env'
22
import { z } from 'zod'
3-
import type { UpdateTeamAdminRequest } from '@/views/admin/teams/types'
3+
import type {
4+
UpdateTeamAdminRequest,
5+
PaginationParams,
6+
PaginatedTeamsResponse,
7+
TeamSearchParams
8+
} from '@/views/admin/teams/types'
49

510
// Zod schemas for validation
611
export const TeamSchema = z.object({
@@ -510,4 +515,101 @@ export class TeamService {
510515
throw error
511516
}
512517
}
518+
519+
/**
520+
* Get teams as global admin with pagination support
521+
*/
522+
static async getTeamsAdminPaginated(
523+
pagination?: PaginationParams
524+
): Promise<PaginatedTeamsResponse> {
525+
try {
526+
const apiUrl = this.getApiUrl()
527+
const url = new URL(`${apiUrl}/api/admin/teams`)
528+
529+
if (pagination) {
530+
if (pagination.limit !== undefined) {
531+
url.searchParams.append('limit', String(pagination.limit))
532+
}
533+
if (pagination.offset !== undefined) {
534+
url.searchParams.append('offset', String(pagination.offset))
535+
}
536+
}
537+
538+
const response = await fetch(url.toString(), {
539+
method: 'GET',
540+
headers: { 'Content-Type': 'application/json' },
541+
credentials: 'include',
542+
})
543+
544+
if (!response.ok) {
545+
if (response.status === 401) {
546+
throw new Error('Unauthorized - please log in')
547+
}
548+
if (response.status === 403) {
549+
throw new Error('Forbidden - Global admin access required')
550+
}
551+
throw new Error(`Failed to fetch teams: ${response.status}`)
552+
}
553+
554+
const data = await response.json()
555+
556+
if (data.success && data.data) {
557+
return {
558+
teams: data.data.teams,
559+
pagination: data.data.pagination,
560+
}
561+
} else {
562+
throw new Error('Invalid response format')
563+
}
564+
} catch (error) {
565+
console.error('Error fetching teams as admin (paginated):', error)
566+
throw error
567+
}
568+
}
569+
570+
/**
571+
* Search teams as global admin with pagination
572+
*/
573+
static async searchTeamsAdmin(
574+
params: TeamSearchParams
575+
): Promise<PaginatedTeamsResponse> {
576+
try {
577+
const apiUrl = this.getApiUrl()
578+
const url = new URL(`${apiUrl}/api/admin/teams/search`)
579+
580+
if (params.name) url.searchParams.append('name', params.name)
581+
if (params.limit !== undefined) url.searchParams.append('limit', String(params.limit))
582+
if (params.offset !== undefined) url.searchParams.append('offset', String(params.offset))
583+
584+
const response = await fetch(url.toString(), {
585+
method: 'GET',
586+
headers: { 'Content-Type': 'application/json' },
587+
credentials: 'include',
588+
})
589+
590+
if (!response.ok) {
591+
if (response.status === 401) {
592+
throw new Error('Unauthorized - please log in')
593+
}
594+
if (response.status === 403) {
595+
throw new Error('Forbidden - Global admin access required')
596+
}
597+
throw new Error(`Failed to search teams: ${response.status}`)
598+
}
599+
600+
const data = await response.json()
601+
602+
if (data.success && data.data) {
603+
return {
604+
teams: data.data.teams,
605+
pagination: data.data.pagination,
606+
}
607+
} else {
608+
throw new Error('Invalid response format')
609+
}
610+
} catch (error) {
611+
console.error('Error searching teams as admin:', error)
612+
throw error
613+
}
614+
}
513615
}

services/frontend/src/views/admin/teams/[id].vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,10 @@ const handleTeamUpdated = (updatedTeam: Team) => {
102102
<Breadcrumb>
103103
<BreadcrumbList>
104104
<BreadcrumbItem>
105-
<BreadcrumbLink href="/admin/teams">
106-
{{ t('adminTeams.title') }}
105+
<BreadcrumbLink as-child>
106+
<RouterLink to="/admin/teams">
107+
{{ t('adminTeams.title') }}
108+
</RouterLink>
107109
</BreadcrumbLink>
108110
</BreadcrumbItem>
109111
<BreadcrumbSeparator />

services/frontend/src/views/admin/teams/index.vue

Lines changed: 147 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
11
<script setup lang="ts">
2-
import { ref, onMounted, computed } from 'vue'
2+
import { ref, onMounted } from 'vue'
33
import { useI18n } from 'vue-i18n'
44
import { useRouter } from 'vue-router'
55
import { useBreadcrumbs } from '@/composables/useBreadcrumbs'
6-
import { Input } from '@/components/ui/input'
6+
import { Search } from 'lucide-vue-next'
77
import { DsPageHeading } from '@/components/ui/ds-page-heading'
88
import NavbarLayout from '@/components/NavbarLayout.vue'
9-
import { getEnv } from '@/utils/env'
9+
import { TeamService } from '@/services/teamService'
1010
import TeamTableColumns from './TeamTableColumns.vue'
11-
import type { Team, TeamsApiResponse } from './types'
11+
import PaginationControls from '@/components/ui/pagination/PaginationControls.vue'
12+
import { Skeleton } from '@/components/ui/skeleton'
13+
import {
14+
Table,
15+
TableBody,
16+
TableCell,
17+
TableHead,
18+
TableHeader,
19+
TableRow,
20+
} from '@/components/ui/table'
21+
import type { Team, PaginationMeta } from './types'
1222
1323
const { t } = useI18n()
1424
const router = useRouter()
@@ -19,63 +29,110 @@ const isLoading = ref(true)
1929
const error = ref<string | null>(null)
2030
const searchQuery = ref('')
2131
22-
const apiUrl = getEnv('VITE_DEPLOYSTACK_BACKEND_URL') || ''
23-
24-
// Filter teams based on search query
25-
const filteredTeams = computed(() => {
26-
if (!searchQuery.value) {
27-
return teams.value
28-
}
29-
const query = searchQuery.value.toLowerCase()
30-
return teams.value.filter(team => {
31-
return team.name.toLowerCase().includes(query) ||
32-
team.slug.toLowerCase().includes(query) ||
33-
(team.description && team.description.toLowerCase().includes(query))
34-
})
32+
// Pagination state
33+
const currentPage = ref(1)
34+
const pageSize = ref(20)
35+
const totalItems = ref(0)
36+
const pagination = ref<PaginationMeta>({
37+
total: 0,
38+
limit: 20,
39+
offset: 0,
40+
has_more: false
3541
})
3642
3743
// Navigation function for viewing team details
3844
const handleViewTeam = (teamId: string) => {
3945
router.push(`/admin/teams/${teamId}`)
4046
}
4147
42-
// Fetch teams from API
43-
async function fetchTeams(): Promise<Team[]> {
44-
if (!apiUrl) {
45-
throw new Error('VITE_DEPLOYSTACK_BACKEND_URL is not configured.')
46-
}
48+
// Check if text search is active
49+
const hasTextSearch = () => {
50+
return !!searchQuery.value && searchQuery.value.trim().length > 0
51+
}
4752
48-
const response = await fetch(`${apiUrl}/api/admin/teams`, {
49-
credentials: 'include'
50-
})
53+
// Search via backend API
54+
const searchTeams = async (): Promise<void> => {
55+
try {
56+
isLoading.value = true
57+
error.value = null
58+
const offset = (currentPage.value - 1) * pageSize.value
5159
52-
if (!response.ok) {
53-
const errorData = await response.json().catch(() => ({}))
54-
throw new Error(errorData.error || `Failed to fetch teams: ${response.statusText} (status: ${response.status})`)
55-
}
60+
const response = await TeamService.searchTeamsAdmin({
61+
name: searchQuery.value.trim(),
62+
limit: pageSize.value,
63+
offset
64+
})
5665
57-
const result: TeamsApiResponse = await response.json()
58-
if (!result.success || !Array.isArray(result.data)) {
59-
throw new Error('API response for teams was not successful or data format is incorrect.')
66+
teams.value = response.teams
67+
pagination.value = response.pagination
68+
totalItems.value = response.pagination.total
69+
} catch (err) {
70+
error.value = err instanceof Error ? err.message : 'An unknown error occurred'
71+
teams.value = []
72+
totalItems.value = 0
73+
} finally {
74+
isLoading.value = false
6075
}
61-
62-
return result.data
6376
}
6477
65-
// Load teams on component mount
66-
onMounted(async () => {
67-
setBreadcrumbs([{ label: t('adminTeams.title') }])
68-
78+
// Fetch all teams with pagination
79+
const fetchTeams = async (): Promise<void> => {
6980
try {
7081
isLoading.value = true
71-
teams.value = await fetchTeams()
7282
error.value = null
83+
const offset = (currentPage.value - 1) * pageSize.value
84+
85+
const response = await TeamService.getTeamsAdminPaginated({
86+
limit: pageSize.value,
87+
offset
88+
})
89+
90+
teams.value = response.teams
91+
pagination.value = response.pagination
92+
totalItems.value = response.pagination.total
7393
} catch (err) {
7494
error.value = err instanceof Error ? err.message : 'An unknown error occurred'
7595
teams.value = []
96+
totalItems.value = 0
7697
} finally {
7798
isLoading.value = false
7899
}
100+
}
101+
102+
// Execute search or fetch
103+
const executeSearch = async () => {
104+
currentPage.value = 1
105+
if (hasTextSearch()) {
106+
await searchTeams()
107+
} else {
108+
await fetchTeams()
109+
}
110+
}
111+
112+
// Pagination handlers
113+
const handlePageChange = async (page: number) => {
114+
currentPage.value = page
115+
if (hasTextSearch()) {
116+
await searchTeams()
117+
} else {
118+
await fetchTeams()
119+
}
120+
}
121+
122+
const handlePageSizeChange = async (newPageSize: number) => {
123+
pageSize.value = newPageSize
124+
currentPage.value = 1
125+
if (hasTextSearch()) {
126+
await searchTeams()
127+
} else {
128+
await fetchTeams()
129+
}
130+
}
131+
132+
// Load teams on component mount
133+
onMounted(async () => {
134+
setBreadcrumbs([{ label: t('adminTeams.title') }])
135+
await fetchTeams()
79136
})
80137
</script>
81138

@@ -84,32 +141,70 @@ onMounted(async () => {
84141
<DsPageHeading :title="t('adminTeams.title')" />
85142

86143
<div class="space-y-6">
87-
<!-- Loading State -->
88-
<div v-if="isLoading" class="text-muted-foreground">
89-
{{ t('adminTeams.table.loading') }}
90-
</div>
91-
92144
<!-- Error State -->
93-
<div v-else-if="error" class="text-red-500">
145+
<div v-if="error" class="text-red-500">
94146
{{ t('adminTeams.table.error', { error }) }}
95147
</div>
96148

97-
<!-- Data Table -->
149+
<!-- Data Table with Search -->
98150
<div v-else class="space-y-4">
99151
<!-- Search Input -->
100152
<div class="flex items-center py-4">
101-
<Input
102-
:placeholder="t('adminTeams.table.search.placeholder')"
103-
v-model="searchQuery"
104-
class="max-w-sm"
105-
/>
153+
<div class="flex items-center rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px] has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed max-w-sm">
154+
<input
155+
type="text"
156+
:placeholder="t('adminTeams.table.search.placeholder')"
157+
v-model="searchQuery"
158+
@keyup.enter="executeSearch"
159+
class="flex-1 h-9 min-w-0 bg-transparent px-3 py-1 text-base outline-none placeholder:text-muted-foreground md:text-sm disabled:pointer-events-none disabled:cursor-not-allowed"
160+
/>
161+
<div class="flex items-center justify-center text-muted-foreground order-last pr-3">
162+
<Search class="h-4 w-4" />
163+
</div>
164+
</div>
165+
</div>
166+
167+
<!-- Loading State with Skeleton -->
168+
<div v-if="isLoading" class="rounded-md border">
169+
<Table>
170+
<TableHeader>
171+
<TableRow>
172+
<TableHead>{{ t('adminTeams.table.columns.name') }}</TableHead>
173+
<TableHead>{{ t('adminTeams.table.columns.slug') }}</TableHead>
174+
<TableHead>{{ t('adminTeams.table.columns.type') }}</TableHead>
175+
<TableHead>{{ t('adminTeams.table.columns.createdAt') }}</TableHead>
176+
<TableHead class="w-[100px]">{{ t('adminTeams.table.columns.actions') }}</TableHead>
177+
</TableRow>
178+
</TableHeader>
179+
<TableBody>
180+
<TableRow v-for="i in 5" :key="i">
181+
<TableCell><Skeleton class="h-4 w-32" /></TableCell>
182+
<TableCell><Skeleton class="h-4 w-24" /></TableCell>
183+
<TableCell><Skeleton class="h-5 w-16" /></TableCell>
184+
<TableCell><Skeleton class="h-4 w-20" /></TableCell>
185+
<TableCell><Skeleton class="h-8 w-16" /></TableCell>
186+
</TableRow>
187+
</TableBody>
188+
</Table>
106189
</div>
107190

108191
<!-- Teams Table Component -->
109192
<TeamTableColumns
110-
:teams="filteredTeams"
193+
v-else
194+
:teams="teams"
111195
:on-view-team="handleViewTeam"
112196
/>
197+
198+
<!-- Pagination Controls -->
199+
<PaginationControls
200+
v-if="totalItems > 0"
201+
:current-page="currentPage"
202+
:page-size="pageSize"
203+
:total-items="totalItems"
204+
:is-loading="isLoading"
205+
@page-change="handlePageChange"
206+
@page-size-change="handlePageSizeChange"
207+
/>
113208
</div>
114209
</div>
115210
</NavbarLayout>

0 commit comments

Comments
 (0)