feat(admin) : filtres + pagination serveur sur /admin/users/sites/roles
Ajoute le filtrage par colonne et la pagination negociee via query params
sur les 3 DataTables admin existantes. Tout est cote serveur (API Platform
SearchFilter + BooleanFilter) pour scaler naturellement.
Backend :
- api_platform.yaml : scan du mapping Sites + pagination_client_items_per_page
(avec borne max 100 pour proteger contre les payloads exagerement grands).
- User : SearchFilter username (partial), rbacRoles.code (exact),
sites.name (exact) + BooleanFilter isAdmin.
- Site : SearchFilter name/city/postalCode (partial).
- Role : SearchFilter label/code (partial), permissions.code (exact).
(BooleanFilter isSystem deja present.)
Frontend :
- Composable useDataTableServerState (shared) : singleton de page/perPage/
filters avec debounce 300ms sur les filters, fetch immediat sur page/
perPage, reset page=1 au changement filter, token anti-race-condition.
- Pages admin : chaque filtre dans un slot #header-{key} (input text avec
debounce, select mono-selection pour les relations). Font-size 20px sur
les inputs de filtre.
- /admin/users : colonne Sites + filtre Sites conditionnes par
useModules().isModuleActive('sites') — preserve l'invariant "module
desactivable sans casse".
Tests : 215/215 PHPUnit (14 nouveaux filtres/pagination) + 48/48 Vitest
(8 nouveaux useDataTableServerState).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,16 +14,68 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Table des roles -->
|
||||
<!-- Table des roles avec filtres + pagination -->
|
||||
<MalioDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="roleItems"
|
||||
:total-items="roles.length"
|
||||
:total-items="totalItems"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.roles.noRoles')"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<template #header-label>
|
||||
<input
|
||||
v-model="filters.label"
|
||||
type="text"
|
||||
:placeholder="t('admin.roles.table.label')"
|
||||
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
</template>
|
||||
<template #header-code>
|
||||
<input
|
||||
v-model="filters.code"
|
||||
type="text"
|
||||
:placeholder="t('admin.roles.table.code')"
|
||||
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
</template>
|
||||
<template #header-permissions>
|
||||
<select
|
||||
v-model="filters['permissions.code']"
|
||||
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
<option value="">
|
||||
{{ t('admin.roles.table.permissions') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="perm in allPermissions"
|
||||
:key="perm.id"
|
||||
:value="perm.code"
|
||||
>
|
||||
{{ perm.code }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
<template #header-system>
|
||||
<select
|
||||
v-model="filters.isSystem"
|
||||
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
<option value="">
|
||||
{{ t('admin.roles.table.system') }}
|
||||
</option>
|
||||
<option value="true">
|
||||
{{ t('common.yes') }}
|
||||
</option>
|
||||
<option value="false">
|
||||
{{ t('common.no') }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<template #cell-code="{ item }">
|
||||
<span class="font-mono text-xs">{{ item.code }}</span>
|
||||
</template>
|
||||
@@ -59,7 +111,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Role } from '~/shared/types/rbac'
|
||||
import type { Permission, Role } from '~/shared/types/rbac'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
@@ -68,8 +120,39 @@ const canManage = computed(() => can('core.roles.manage'))
|
||||
|
||||
useHead({ title: t('admin.roles.title') })
|
||||
|
||||
const roles = ref<Role[]>([])
|
||||
const loading = ref(false)
|
||||
// Etat DataTable centralise : pagination serveur + filtres debounces.
|
||||
// `isSystem` est une string ('true'/'false'/'') plutot qu'un bool : les
|
||||
// <select> HTML travaillent en string et API Platform BooleanFilter
|
||||
// accepte les strings 'true'/'false' telles quelles.
|
||||
const {
|
||||
items,
|
||||
totalItems,
|
||||
page,
|
||||
perPage,
|
||||
filters,
|
||||
reload,
|
||||
} = useDataTableServerState<Role>('/roles', {
|
||||
label: '',
|
||||
code: '',
|
||||
isSystem: '',
|
||||
'permissions.code': '',
|
||||
})
|
||||
|
||||
// Chargement one-shot des permissions pour alimenter le select filter.
|
||||
// Independant du composable de table : cette liste ne bouge pas pendant
|
||||
// la session admin.
|
||||
const allPermissions = ref<Permission[]>([])
|
||||
|
||||
async function loadPermissions(): Promise<void> {
|
||||
const data = await api.get<{ member: Permission[] }>(
|
||||
'/permissions',
|
||||
{ itemsPerPage: 999, orphan: false },
|
||||
{ toast: false },
|
||||
)
|
||||
allPermissions.value = (data.member ?? []).sort(
|
||||
(a, b) => a.code.localeCompare(b.code),
|
||||
)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'label', label: t('admin.roles.table.label') },
|
||||
@@ -80,45 +163,31 @@ const columns = [
|
||||
|
||||
// Transformer les roles en items compatibles MalioDataTable
|
||||
const roleItems = computed(() =>
|
||||
roles.value.map(role => ({
|
||||
items.value.map(role => ({
|
||||
id: role.id,
|
||||
label: role.label,
|
||||
code: role.code,
|
||||
permissions: role.permissions.length,
|
||||
isSystem: role.isSystem,
|
||||
system: '', // colonne geree par le slot
|
||||
}))
|
||||
})),
|
||||
)
|
||||
|
||||
function getRoleById(id: number): Role | undefined {
|
||||
return roles.value.find(r => r.id === id)
|
||||
return items.value.find(r => r.id === id)
|
||||
}
|
||||
|
||||
function onRowClick(item: Record<string, unknown>) {
|
||||
const role = getRoleById(item.id as number)
|
||||
if (role) openEditDrawer(role)
|
||||
}
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const selectedRole = ref<Role | null>(null)
|
||||
const deleteModalOpen = ref(false)
|
||||
const roleToDelete = ref<Role | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
// Charger la liste des roles
|
||||
async function loadRoles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ member: Role[] }>(
|
||||
'/roles',
|
||||
{},
|
||||
{ toast: false },
|
||||
)
|
||||
roles.value = data.member
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
selectedRole.value = null
|
||||
drawerOpen.value = true
|
||||
@@ -145,17 +214,18 @@ async function handleDelete() {
|
||||
deleteModalOpen.value = false
|
||||
roleToDelete.value = null
|
||||
drawerOpen.value = false
|
||||
await loadRoles()
|
||||
reload()
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onRoleSaved() {
|
||||
loadRoles()
|
||||
reload()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRoles()
|
||||
loadPermissions()
|
||||
reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -7,16 +7,77 @@
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Table des utilisateurs -->
|
||||
<!-- Table des utilisateurs avec filtres + pagination -->
|
||||
<MalioDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="userItems"
|
||||
:total-items="users.length"
|
||||
:total-items="totalItems"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.users.noUsers')"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<template #header-username>
|
||||
<input
|
||||
v-model="filters.username"
|
||||
type="text"
|
||||
:placeholder="t('admin.users.table.username')"
|
||||
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
</template>
|
||||
<template #header-admin>
|
||||
<select
|
||||
v-model="filters.isAdmin"
|
||||
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
<option value="">
|
||||
{{ t('admin.users.table.admin') }}
|
||||
</option>
|
||||
<option value="true">
|
||||
{{ t('common.yes') }}
|
||||
</option>
|
||||
<option value="false">
|
||||
{{ t('common.no') }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
<template #header-roles>
|
||||
<select
|
||||
v-model="filters['rbacRoles.code']"
|
||||
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
<option value="">
|
||||
{{ t('admin.users.table.roles') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="role in allRoles"
|
||||
:key="role.id"
|
||||
:value="role.code"
|
||||
>
|
||||
{{ role.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
<template v-if="sitesModuleActive" #header-sites>
|
||||
<select
|
||||
v-model="filters['sites.name']"
|
||||
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
<option value="">
|
||||
{{ t('admin.users.table.sites') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="site in allSites"
|
||||
:key="site.id"
|
||||
:value="site.name"
|
||||
>
|
||||
{{ site.name }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<template #cell-admin="{ item }">
|
||||
<span
|
||||
v-if="item.admin"
|
||||
@@ -37,30 +98,77 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserListItem } from '~/shared/types/rbac'
|
||||
import type { Role, UserListItem } from '~/shared/types/rbac'
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const { can } = usePermissions()
|
||||
const { isModuleActive } = useModules()
|
||||
|
||||
useHead({ title: t('admin.users.title') })
|
||||
|
||||
const canManage = computed(() => can('core.users.manage'))
|
||||
// Conditionne la colonne Sites + le filtre Sites : si le module Sites
|
||||
// est desactive, inutile de charger /api/sites ni d'afficher ces elements.
|
||||
// L'invariant "module inactif = app fonctionnelle" est preserve.
|
||||
const sitesModuleActive = computed(() => isModuleActive('sites'))
|
||||
|
||||
const users = ref<UserListItem[]>([])
|
||||
// Etat DataTable centralise. On declare le filtre sites.name meme si le
|
||||
// module Sites est inactif : le composable omet les filtres a valeur
|
||||
// vide donc ca ne produit aucun impact cote API, et ca evite de casser
|
||||
// la forme du state si le module est reactive sans reloader la page.
|
||||
const {
|
||||
items,
|
||||
totalItems,
|
||||
page,
|
||||
perPage,
|
||||
filters,
|
||||
reload,
|
||||
} = useDataTableServerState<UserListItem>('/users', {
|
||||
username: '',
|
||||
isAdmin: '',
|
||||
'rbacRoles.code': '',
|
||||
'sites.name': '',
|
||||
})
|
||||
|
||||
const allRoles = ref<Role[]>([])
|
||||
const allSites = ref<Site[]>([])
|
||||
const sitesById = ref(new Map<number, Site>())
|
||||
const loading = ref(false)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedUser = ref<UserListItem | null>(null)
|
||||
|
||||
const columns = [
|
||||
{ key: 'username', label: t('admin.users.table.username') },
|
||||
{ key: 'admin', label: t('admin.users.table.admin') },
|
||||
{ key: 'roles', label: t('admin.users.table.roles') },
|
||||
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
|
||||
{ key: 'sites', label: t('admin.users.table.sites') },
|
||||
]
|
||||
async function loadFilterOptions(): Promise<void> {
|
||||
const rolesPromise = api.get<{ member: Role[] }>(
|
||||
'/roles',
|
||||
{ itemsPerPage: 999 },
|
||||
{ toast: false },
|
||||
)
|
||||
|
||||
// /api/sites est protege par `sites.view`. On skip si module off pour
|
||||
// eviter un 403 inutile dans la console devtools — la UI ne consomme
|
||||
// pas le resultat dans ce cas.
|
||||
const sitesPromise = sitesModuleActive.value
|
||||
? api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false })
|
||||
: Promise.resolve({ member: [] as Site[] })
|
||||
|
||||
const [rolesData, sitesData] = await Promise.all([rolesPromise, sitesPromise])
|
||||
allRoles.value = rolesData.member ?? []
|
||||
allSites.value = sitesData.member ?? []
|
||||
sitesById.value = new Map(allSites.value.map(s => [s.id, s]))
|
||||
}
|
||||
|
||||
// Colonnes dynamiques : on omet la colonne Sites si le module est off.
|
||||
const columns = computed(() => {
|
||||
const base = [
|
||||
{ key: 'username', label: t('admin.users.table.username') },
|
||||
{ key: 'admin', label: t('admin.users.table.admin') },
|
||||
{ key: 'roles', label: t('admin.users.table.roles') },
|
||||
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
|
||||
]
|
||||
if (sitesModuleActive.value) {
|
||||
base.push({ key: 'sites', label: t('admin.users.table.sites') })
|
||||
}
|
||||
return base
|
||||
})
|
||||
|
||||
// Extraire l'id numerique depuis une IRI API Platform type `/api/sites/3`.
|
||||
function iriToId(iri: string): number {
|
||||
@@ -68,7 +176,7 @@ function iriToId(iri: string): number {
|
||||
}
|
||||
|
||||
const userItems = computed(() =>
|
||||
users.value.map(user => ({
|
||||
items.value.map(user => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
admin: user.isAdmin,
|
||||
@@ -76,7 +184,7 @@ const userItems = computed(() =>
|
||||
directPermissions: user.directPermissions.length,
|
||||
// Affichage : liste des noms de sites separes par virgule. Les IRIs
|
||||
// du payload /api/users (groupe user:list) sont resolues via la Map
|
||||
// construite en parallele depuis /api/sites.
|
||||
// construite par loadFilterOptions. Vide si module Sites off.
|
||||
sites: (user.sites ?? [])
|
||||
.map(iri => sitesById.value.get(iriToId(iri))?.name)
|
||||
.filter((name): name is string => Boolean(name))
|
||||
@@ -84,24 +192,11 @@ const userItems = computed(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
// Chargement parallele : les sites alimentent la Map de resolution
|
||||
// IRI→name pour la colonne "Sites" de la table.
|
||||
const [usersData, sitesData] = await Promise.all([
|
||||
api.get<{ member: UserListItem[] }>('/users', {}, { toast: false }),
|
||||
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
|
||||
])
|
||||
users.value = usersData.member
|
||||
sitesById.value = new Map(sitesData.member.map(s => [s.id, s]))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const drawerOpen = ref(false)
|
||||
const selectedUser = ref<UserListItem | null>(null)
|
||||
|
||||
function getUserById(id: number): UserListItem | undefined {
|
||||
return users.value.find(u => u.id === id)
|
||||
return items.value.find(u => u.id === id)
|
||||
}
|
||||
|
||||
function openDrawer(user: UserListItem) {
|
||||
@@ -115,10 +210,11 @@ function onRowClick(item: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
function onUserSaved() {
|
||||
loadUsers()
|
||||
reload()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
loadFilterOptions()
|
||||
reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user