Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93f47e9111 | |||
| 6cf5ef4cfc |
@@ -1,15 +1,14 @@
|
|||||||
api_platform:
|
api_platform:
|
||||||
title: Coltura API
|
title: Coltura API
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
# Scan des modules pour decouvrir les classes ApiResource et ApiFilter.
|
# Scan du module Core pour decouvrir les classes ApiResource et ApiFilter.
|
||||||
# Ajouter un chemin par module lors de l'ajout d'entites ApiResource
|
# Ajouter un chemin par module lors de l'ajout d'entites ApiResource dans d'autres modules.
|
||||||
# dans d'autres modules. Sans ces paths, le compile pass d'API Platform
|
# Sans ces paths, le compile pass d'API Platform ne declare pas les
|
||||||
# ne declare pas les services de filtres annotes (les filtres etaient
|
# services de filtres annotes (les filtres etaient silencieusement
|
||||||
# silencieusement ignores sur Permission — cf. ticket #344).
|
# ignores sur Permission — cf. ticket #344).
|
||||||
mapping:
|
mapping:
|
||||||
paths:
|
paths:
|
||||||
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
||||||
- '%kernel.project_dir%/src/Module/Sites/Domain/Entity'
|
|
||||||
formats:
|
formats:
|
||||||
jsonld: ['application/ld+json']
|
jsonld: ['application/ld+json']
|
||||||
json: ['application/json']
|
json: ['application/json']
|
||||||
@@ -19,10 +18,3 @@ api_platform:
|
|||||||
stateless: true
|
stateless: true
|
||||||
cache_headers:
|
cache_headers:
|
||||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
vary: ['Content-Type', 'Authorization', 'Origin']
|
||||||
# Active la negociation client de la pagination via ?itemsPerPage=X
|
|
||||||
# (necessaire pour le dropdown perPage des DataTable admin). Borne
|
|
||||||
# haute a 100 pour eviter qu'un client abuse en demandant 10000
|
|
||||||
# items d'un coup — les UIs admin n'ont jamais besoin de plus de 50
|
|
||||||
# en pratique.
|
|
||||||
pagination_client_items_per_page: true
|
|
||||||
pagination_maximum_items_per_page: 100
|
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.31'
|
app.version: '0.1.32'
|
||||||
|
|||||||
@@ -14,68 +14,16 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table des roles avec filtres + pagination -->
|
<!-- Table des roles -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
v-model:page="page"
|
|
||||||
v-model:per-page="perPage"
|
|
||||||
class="mt-6"
|
class="mt-6"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="roleItems"
|
:items="roleItems"
|
||||||
:total-items="totalItems"
|
:total-items="roles.length"
|
||||||
:row-clickable="canManage"
|
:row-clickable="canManage"
|
||||||
:empty-message="t('admin.roles.noRoles')"
|
:empty-message="t('admin.roles.noRoles')"
|
||||||
@row-click="onRowClick"
|
@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.label }}
|
|
||||||
</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 }">
|
<template #cell-code="{ item }">
|
||||||
<span class="font-mono text-xs">{{ item.code }}</span>
|
<span class="font-mono text-xs">{{ item.code }}</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -111,7 +59,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Permission, Role } from '~/shared/types/rbac'
|
import type { Role } from '~/shared/types/rbac'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -120,42 +68,8 @@ const canManage = computed(() => can('core.roles.manage'))
|
|||||||
|
|
||||||
useHead({ title: t('admin.roles.title') })
|
useHead({ title: t('admin.roles.title') })
|
||||||
|
|
||||||
// Etat DataTable centralise : pagination serveur + filtres debounces.
|
const roles = ref<Role[]>([])
|
||||||
// `isSystem` est une string ('true'/'false'/'') plutot qu'un bool : les
|
const loading = ref(false)
|
||||||
// <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 },
|
|
||||||
)
|
|
||||||
// Tri par label pour coherence avec l'affichage du <option> : l'user
|
|
||||||
// lit le label (ex: "Gerer les roles et permissions"), donc l'ordre
|
|
||||||
// alphabetique doit etre base sur ce qu'il voit, pas sur le code.
|
|
||||||
allPermissions.value = (data.member ?? []).sort(
|
|
||||||
(a, b) => a.label.localeCompare(b.label),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'label', label: t('admin.roles.table.label') },
|
{ key: 'label', label: t('admin.roles.table.label') },
|
||||||
@@ -166,31 +80,45 @@ const columns = [
|
|||||||
|
|
||||||
// Transformer les roles en items compatibles MalioDataTable
|
// Transformer les roles en items compatibles MalioDataTable
|
||||||
const roleItems = computed(() =>
|
const roleItems = computed(() =>
|
||||||
items.value.map(role => ({
|
roles.value.map(role => ({
|
||||||
id: role.id,
|
id: role.id,
|
||||||
label: role.label,
|
label: role.label,
|
||||||
code: role.code,
|
code: role.code,
|
||||||
permissions: role.permissions.length,
|
permissions: role.permissions.length,
|
||||||
isSystem: role.isSystem,
|
isSystem: role.isSystem,
|
||||||
system: '', // colonne geree par le slot
|
system: '', // colonne geree par le slot
|
||||||
})),
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
function getRoleById(id: number): Role | undefined {
|
function getRoleById(id: number): Role | undefined {
|
||||||
return items.value.find(r => r.id === id)
|
return roles.value.find(r => r.id === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRowClick(item: Record<string, unknown>) {
|
function onRowClick(item: Record<string, unknown>) {
|
||||||
const role = getRoleById(item.id as number)
|
const role = getRoleById(item.id as number)
|
||||||
if (role) openEditDrawer(role)
|
if (role) openEditDrawer(role)
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
const selectedRole = ref<Role | null>(null)
|
const selectedRole = ref<Role | null>(null)
|
||||||
const deleteModalOpen = ref(false)
|
const deleteModalOpen = ref(false)
|
||||||
const roleToDelete = ref<Role | null>(null)
|
const roleToDelete = ref<Role | null>(null)
|
||||||
const deleting = ref(false)
|
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() {
|
function openCreateDrawer() {
|
||||||
selectedRole.value = null
|
selectedRole.value = null
|
||||||
drawerOpen.value = true
|
drawerOpen.value = true
|
||||||
@@ -217,18 +145,17 @@ async function handleDelete() {
|
|||||||
deleteModalOpen.value = false
|
deleteModalOpen.value = false
|
||||||
roleToDelete.value = null
|
roleToDelete.value = null
|
||||||
drawerOpen.value = false
|
drawerOpen.value = false
|
||||||
reload()
|
await loadRoles()
|
||||||
} finally {
|
} finally {
|
||||||
deleting.value = false
|
deleting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRoleSaved() {
|
function onRoleSaved() {
|
||||||
reload()
|
loadRoles()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPermissions()
|
loadRoles()
|
||||||
reload()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,77 +7,16 @@
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table des utilisateurs avec filtres + pagination -->
|
<!-- Table des utilisateurs -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
v-model:page="page"
|
|
||||||
v-model:per-page="perPage"
|
|
||||||
class="mt-6"
|
class="mt-6"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="userItems"
|
:items="userItems"
|
||||||
:total-items="totalItems"
|
:total-items="users.length"
|
||||||
:row-clickable="canManage"
|
:row-clickable="canManage"
|
||||||
:empty-message="t('admin.users.noUsers')"
|
:empty-message="t('admin.users.noUsers')"
|
||||||
@row-click="onRowClick"
|
@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 }">
|
<template #cell-admin="{ item }">
|
||||||
<span
|
<span
|
||||||
v-if="item.admin"
|
v-if="item.admin"
|
||||||
@@ -98,77 +37,30 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Role, UserListItem } from '~/shared/types/rbac'
|
import type { UserListItem } from '~/shared/types/rbac'
|
||||||
import type { Site } from '~/shared/types/sites'
|
import type { Site } from '~/shared/types/sites'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
const { isModuleActive } = useModules()
|
|
||||||
|
|
||||||
useHead({ title: t('admin.users.title') })
|
useHead({ title: t('admin.users.title') })
|
||||||
|
|
||||||
const canManage = computed(() => can('core.users.manage'))
|
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'))
|
|
||||||
|
|
||||||
// Etat DataTable centralise. On declare le filtre sites.name meme si le
|
const users = ref<UserListItem[]>([])
|
||||||
// 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 sitesById = ref(new Map<number, Site>())
|
||||||
|
const loading = ref(false)
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const selectedUser = ref<UserListItem | null>(null)
|
||||||
|
|
||||||
async function loadFilterOptions(): Promise<void> {
|
const columns = [
|
||||||
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: 'username', label: t('admin.users.table.username') },
|
||||||
{ key: 'admin', label: t('admin.users.table.admin') },
|
{ key: 'admin', label: t('admin.users.table.admin') },
|
||||||
{ key: 'roles', label: t('admin.users.table.roles') },
|
{ key: 'roles', label: t('admin.users.table.roles') },
|
||||||
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
|
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
|
||||||
|
{ key: 'sites', label: t('admin.users.table.sites') },
|
||||||
]
|
]
|
||||||
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`.
|
// Extraire l'id numerique depuis une IRI API Platform type `/api/sites/3`.
|
||||||
function iriToId(iri: string): number {
|
function iriToId(iri: string): number {
|
||||||
@@ -176,7 +68,7 @@ function iriToId(iri: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userItems = computed(() =>
|
const userItems = computed(() =>
|
||||||
items.value.map(user => ({
|
users.value.map(user => ({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
admin: user.isAdmin,
|
admin: user.isAdmin,
|
||||||
@@ -184,7 +76,7 @@ const userItems = computed(() =>
|
|||||||
directPermissions: user.directPermissions.length,
|
directPermissions: user.directPermissions.length,
|
||||||
// Affichage : liste des noms de sites separes par virgule. Les IRIs
|
// Affichage : liste des noms de sites separes par virgule. Les IRIs
|
||||||
// du payload /api/users (groupe user:list) sont resolues via la Map
|
// du payload /api/users (groupe user:list) sont resolues via la Map
|
||||||
// construite par loadFilterOptions. Vide si module Sites off.
|
// construite en parallele depuis /api/sites.
|
||||||
sites: (user.sites ?? [])
|
sites: (user.sites ?? [])
|
||||||
.map(iri => sitesById.value.get(iriToId(iri))?.name)
|
.map(iri => sitesById.value.get(iriToId(iri))?.name)
|
||||||
.filter((name): name is string => Boolean(name))
|
.filter((name): name is string => Boolean(name))
|
||||||
@@ -192,11 +84,24 @@ const userItems = computed(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
const drawerOpen = ref(false)
|
async function loadUsers() {
|
||||||
const selectedUser = ref<UserListItem | null>(null)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getUserById(id: number): UserListItem | undefined {
|
function getUserById(id: number): UserListItem | undefined {
|
||||||
return items.value.find(u => u.id === id)
|
return users.value.find(u => u.id === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDrawer(user: UserListItem) {
|
function openDrawer(user: UserListItem) {
|
||||||
@@ -210,11 +115,10 @@ function onRowClick(item: Record<string, unknown>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onUserSaved() {
|
function onUserSaved() {
|
||||||
reload()
|
loadUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadFilterOptions()
|
loadUsers()
|
||||||
reload()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -14,43 +14,16 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table des sites avec filtres + pagination -->
|
<!-- Table des sites -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
v-model:page="page"
|
|
||||||
v-model:per-page="perPage"
|
|
||||||
class="mt-6"
|
class="mt-6"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="siteItems"
|
:items="siteItems"
|
||||||
:total-items="totalItems"
|
:total-items="sites.length"
|
||||||
:row-clickable="canManage"
|
:row-clickable="canManage"
|
||||||
:empty-message="t('admin.sites.noSites')"
|
:empty-message="t('admin.sites.noSites')"
|
||||||
@row-click="onRowClick"
|
@row-click="onRowClick"
|
||||||
>
|
>
|
||||||
<template #header-name>
|
|
||||||
<input
|
|
||||||
v-model="filters.name"
|
|
||||||
type="text"
|
|
||||||
:placeholder="t('admin.sites.table.name')"
|
|
||||||
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
|
||||||
>
|
|
||||||
</template>
|
|
||||||
<template #header-city>
|
|
||||||
<input
|
|
||||||
v-model="filters.city"
|
|
||||||
type="text"
|
|
||||||
:placeholder="t('admin.sites.table.city')"
|
|
||||||
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
|
||||||
>
|
|
||||||
</template>
|
|
||||||
<template #header-postalCode>
|
|
||||||
<input
|
|
||||||
v-model="filters.postalCode"
|
|
||||||
type="text"
|
|
||||||
:placeholder="t('admin.sites.table.postalCode')"
|
|
||||||
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
|
||||||
>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-color="{ item }">
|
<template #cell-color="{ item }">
|
||||||
<span class="inline-flex items-center gap-2">
|
<span class="inline-flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
@@ -96,20 +69,8 @@ const canManage = computed(() => can('sites.manage'))
|
|||||||
|
|
||||||
useHead({ title: t('admin.sites.title') })
|
useHead({ title: t('admin.sites.title') })
|
||||||
|
|
||||||
// Etat DataTable centralise : pagination serveur + filtres debounces.
|
const sites = ref<Site[]>([])
|
||||||
// Les filtres name/city/postalCode sont des partiels SearchFilter cote API.
|
const loading = ref(false)
|
||||||
const {
|
|
||||||
items,
|
|
||||||
totalItems,
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
filters,
|
|
||||||
reload,
|
|
||||||
} = useDataTableServerState<Site>('/sites', {
|
|
||||||
name: '',
|
|
||||||
city: '',
|
|
||||||
postalCode: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'name', label: t('admin.sites.table.name') },
|
{ key: 'name', label: t('admin.sites.table.name') },
|
||||||
@@ -123,7 +84,7 @@ const columns = [
|
|||||||
// `fullAddress` provient du getter computed cote backend (Site::getFullAddress)
|
// `fullAddress` provient du getter computed cote backend (Site::getFullAddress)
|
||||||
// au format multi-lignes — on l'aplatit en virgules pour l'affichage table.
|
// au format multi-lignes — on l'aplatit en virgules pour l'affichage table.
|
||||||
const siteItems = computed(() =>
|
const siteItems = computed(() =>
|
||||||
items.value.map(site => ({
|
sites.value.map(site => ({
|
||||||
id: site.id,
|
id: site.id,
|
||||||
name: site.name,
|
name: site.name,
|
||||||
city: site.city,
|
city: site.city,
|
||||||
@@ -134,7 +95,7 @@ const siteItems = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
function getSiteById(id: number): Site | undefined {
|
function getSiteById(id: number): Site | undefined {
|
||||||
return items.value.find(s => s.id === id)
|
return sites.value.find(s => s.id === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRowClick(item: Record<string, unknown>) {
|
function onRowClick(item: Record<string, unknown>) {
|
||||||
@@ -148,6 +109,20 @@ const deleteModalOpen = ref(false)
|
|||||||
const siteToDelete = ref<Site | null>(null)
|
const siteToDelete = ref<Site | null>(null)
|
||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
async function loadSites() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ member: Site[] }>(
|
||||||
|
'/sites',
|
||||||
|
{ itemsPerPage: 999 },
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
sites.value = data.member
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openCreateDrawer() {
|
function openCreateDrawer() {
|
||||||
selectedSite.value = null
|
selectedSite.value = null
|
||||||
drawerOpen.value = true
|
drawerOpen.value = true
|
||||||
@@ -174,7 +149,7 @@ async function handleDelete() {
|
|||||||
deleteModalOpen.value = false
|
deleteModalOpen.value = false
|
||||||
siteToDelete.value = null
|
siteToDelete.value = null
|
||||||
drawerOpen.value = false
|
drawerOpen.value = false
|
||||||
reload()
|
await loadSites()
|
||||||
// Rafraichit auth.user apres suppression d'un site : le backend
|
// Rafraichit auth.user apres suppression d'un site : le backend
|
||||||
// applique ON DELETE SET NULL sur user.current_site_id, donc
|
// applique ON DELETE SET NULL sur user.current_site_id, donc
|
||||||
// auth.user.currentSite peut etre devenu null sans que le front
|
// auth.user.currentSite peut etre devenu null sans que le front
|
||||||
@@ -186,10 +161,10 @@ async function handleDelete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onSiteSaved() {
|
function onSiteSaved() {
|
||||||
reload()
|
loadSites()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
reload()
|
loadSites()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
||||||
import { nextTick } from 'vue'
|
|
||||||
import { useDataTableServerState } from '../useDataTableServerState'
|
|
||||||
|
|
||||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
|
||||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
|
||||||
|
|
||||||
function ldResponse<T>(member: T[], totalItems?: number): { member: T[], totalItems: number } {
|
|
||||||
return { member, totalItems: totalItems ?? member.length }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('useDataTableServerState', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockApiGet.mockReset()
|
|
||||||
vi.useFakeTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('fetch initial au premier reload() avec page=1 et perPage par defaut', async () => {
|
|
||||||
mockApiGet.mockResolvedValueOnce(ldResponse([{ id: 1 }, { id: 2 }], 42))
|
|
||||||
|
|
||||||
const { items, totalItems, reload } = useDataTableServerState('/sites', { name: '' })
|
|
||||||
reload()
|
|
||||||
await vi.runAllTimersAsync()
|
|
||||||
|
|
||||||
expect(mockApiGet).toHaveBeenCalledWith(
|
|
||||||
'/sites',
|
|
||||||
{ page: 1, itemsPerPage: 10 },
|
|
||||||
{ toast: false },
|
|
||||||
)
|
|
||||||
expect(items.value).toHaveLength(2)
|
|
||||||
expect(totalItems.value).toBe(42)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('omet les filtres a valeur vide dans les query params', async () => {
|
|
||||||
mockApiGet.mockResolvedValueOnce(ldResponse([]))
|
|
||||||
|
|
||||||
const { reload } = useDataTableServerState('/users', {
|
|
||||||
username: '',
|
|
||||||
isAdmin: null,
|
|
||||||
})
|
|
||||||
reload()
|
|
||||||
await vi.runAllTimersAsync()
|
|
||||||
|
|
||||||
expect(mockApiGet).toHaveBeenCalledWith(
|
|
||||||
'/users',
|
|
||||||
{ page: 1, itemsPerPage: 10 },
|
|
||||||
{ toast: false },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('inclut les filtres renseignes dans les query params', async () => {
|
|
||||||
// mockResolvedValue (sans Once) : chaque fetch retourne une
|
|
||||||
// reponse valide, y compris ceux declenches par le debounce des
|
|
||||||
// mutations de filters qui precedent reload().
|
|
||||||
mockApiGet.mockResolvedValue(ldResponse([]))
|
|
||||||
|
|
||||||
const { filters, reload } = useDataTableServerState('/users', {
|
|
||||||
username: '',
|
|
||||||
isAdmin: null,
|
|
||||||
})
|
|
||||||
filters.value.username = 'alice'
|
|
||||||
filters.value.isAdmin = true
|
|
||||||
reload()
|
|
||||||
await vi.runAllTimersAsync()
|
|
||||||
|
|
||||||
// Le reload() ecrase les scheduleReload en cours (clearTimeout),
|
|
||||||
// donc on verifie juste que la derniere requete emise porte bien
|
|
||||||
// les filtres + les parametres de pagination.
|
|
||||||
expect(mockApiGet).toHaveBeenLastCalledWith(
|
|
||||||
'/users',
|
|
||||||
{ page: 1, itemsPerPage: 10, username: 'alice', isAdmin: true },
|
|
||||||
{ toast: false },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('change page declenche un fetch immediat (pas de debounce)', async () => {
|
|
||||||
mockApiGet.mockResolvedValue(ldResponse([]))
|
|
||||||
|
|
||||||
const { page, reload } = useDataTableServerState('/sites', {})
|
|
||||||
reload()
|
|
||||||
await vi.runAllTimersAsync()
|
|
||||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
page.value = 3
|
|
||||||
await nextTick()
|
|
||||||
await vi.runAllTimersAsync()
|
|
||||||
|
|
||||||
expect(mockApiGet).toHaveBeenCalledTimes(2)
|
|
||||||
expect(mockApiGet).toHaveBeenLastCalledWith(
|
|
||||||
'/sites',
|
|
||||||
{ page: 3, itemsPerPage: 10 },
|
|
||||||
{ toast: false },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('change filter debounce 300ms avant fetch', async () => {
|
|
||||||
mockApiGet.mockResolvedValue(ldResponse([]))
|
|
||||||
|
|
||||||
const { filters, reload } = useDataTableServerState('/sites', { name: '' })
|
|
||||||
reload()
|
|
||||||
await vi.runAllTimersAsync()
|
|
||||||
mockApiGet.mockClear()
|
|
||||||
|
|
||||||
filters.value.name = 'a'
|
|
||||||
await nextTick()
|
|
||||||
// Pas encore de requete : debounce en cours.
|
|
||||||
expect(mockApiGet).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
filters.value.name = 'al'
|
|
||||||
await nextTick()
|
|
||||||
filters.value.name = 'ali'
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
// Avance le timer de 200ms : toujours pas fetch.
|
|
||||||
vi.advanceTimersByTime(200)
|
|
||||||
expect(mockApiGet).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
// Avance encore 100ms : debounce expire, fetch lance.
|
|
||||||
vi.advanceTimersByTime(100)
|
|
||||||
await vi.runAllTimersAsync()
|
|
||||||
|
|
||||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
|
||||||
expect(mockApiGet).toHaveBeenCalledWith(
|
|
||||||
'/sites',
|
|
||||||
{ page: 1, itemsPerPage: 10, name: 'ali' },
|
|
||||||
{ toast: false },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('changer un filtre reset page a 1', async () => {
|
|
||||||
mockApiGet.mockResolvedValue(ldResponse([]))
|
|
||||||
|
|
||||||
const { page, filters, reload } = useDataTableServerState('/sites', { name: '' })
|
|
||||||
reload()
|
|
||||||
await vi.runAllTimersAsync()
|
|
||||||
page.value = 5
|
|
||||||
await vi.runAllTimersAsync()
|
|
||||||
mockApiGet.mockClear()
|
|
||||||
|
|
||||||
filters.value.name = 'x'
|
|
||||||
await nextTick()
|
|
||||||
await vi.runAllTimersAsync()
|
|
||||||
|
|
||||||
// Page doit etre revenue a 1 avant le fetch.
|
|
||||||
expect(page.value).toBe(1)
|
|
||||||
expect(mockApiGet).toHaveBeenLastCalledWith(
|
|
||||||
'/sites',
|
|
||||||
expect.objectContaining({ page: 1, name: 'x' }),
|
|
||||||
{ toast: false },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('change perPage declenche un fetch immediat', async () => {
|
|
||||||
mockApiGet.mockResolvedValue(ldResponse([]))
|
|
||||||
|
|
||||||
const { perPage, reload } = useDataTableServerState('/sites', {})
|
|
||||||
reload()
|
|
||||||
await vi.runAllTimersAsync()
|
|
||||||
mockApiGet.mockClear()
|
|
||||||
|
|
||||||
perPage.value = 25
|
|
||||||
await nextTick()
|
|
||||||
await vi.runAllTimersAsync()
|
|
||||||
|
|
||||||
expect(mockApiGet).toHaveBeenLastCalledWith(
|
|
||||||
'/sites',
|
|
||||||
{ page: 1, itemsPerPage: 25 },
|
|
||||||
{ toast: false },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('race condition : seule la derniere reponse gagne', async () => {
|
|
||||||
// Scenario : user tape tres vite, 2 requetes partent, la premiere
|
|
||||||
// (plus ancienne) arrive apres la seconde. Le composable doit
|
|
||||||
// ignorer la premiere.
|
|
||||||
let resolveFirst!: (value: unknown) => void
|
|
||||||
let resolveSecond!: (value: unknown) => void
|
|
||||||
|
|
||||||
mockApiGet
|
|
||||||
.mockImplementationOnce(() => new Promise((r) => { resolveFirst = r }))
|
|
||||||
.mockImplementationOnce(() => new Promise((r) => { resolveSecond = r }))
|
|
||||||
|
|
||||||
const { items, reload } = useDataTableServerState<{ id: number }>('/sites', {})
|
|
||||||
|
|
||||||
reload() // requete #1
|
|
||||||
reload() // requete #2 (annule #1 du point de vue du token)
|
|
||||||
|
|
||||||
// Resout la seconde d'abord avec id=2
|
|
||||||
resolveSecond(ldResponse([{ id: 2 }]))
|
|
||||||
await vi.runAllTimersAsync()
|
|
||||||
expect(items.value).toEqual([{ id: 2 }])
|
|
||||||
|
|
||||||
// Resout la premiere apres avec id=1 : DOIT etre ignore.
|
|
||||||
resolveFirst(ldResponse([{ id: 1 }]))
|
|
||||||
await vi.runAllTimersAsync()
|
|
||||||
|
|
||||||
expect(items.value).toEqual([{ id: 2 }])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import { ref, watch } from 'vue'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Composable generique pour les DataTables admin avec pagination, perPage
|
|
||||||
* et filtres cote serveur (API Platform + Hydra).
|
|
||||||
*
|
|
||||||
* Usage type dans une page admin :
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* const { items, totalItems, page, perPage, filters, loading, reload } =
|
|
||||||
* useDataTableServerState<Site>('/sites', {
|
|
||||||
* name: '',
|
|
||||||
* city: '',
|
|
||||||
* postalCode: '',
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Le composable :
|
|
||||||
* - traque `page`, `perPage`, et un objet `filters` reactif.
|
|
||||||
* - re-fetch automatiquement a chaque changement (debounce 300ms sur
|
|
||||||
* `filters` pour eviter un spam lors de la frappe clavier).
|
|
||||||
* - re-fetch immediat (pas de debounce) quand `page` ou `perPage` change
|
|
||||||
* — ces changements sont deja des clics user discrets.
|
|
||||||
* - reinitialise `page` a 1 des qu'un filtre bouge (coherence UX : un
|
|
||||||
* filtre ajuste ne doit pas laisser l'user sur "page 5 de 2 pages").
|
|
||||||
* - expose `loading` pour afficher un feedback pendant la requete.
|
|
||||||
* - expose `reload()` pour forcer un fetch (ex: apres une mutation
|
|
||||||
* POST/PATCH/DELETE).
|
|
||||||
*
|
|
||||||
* Type parameter T = la forme d'un item renvoye par l'API (le member[]
|
|
||||||
* du payload Hydra est type T[]).
|
|
||||||
*/
|
|
||||||
export function useDataTableServerState<T = Record<string, unknown>>(
|
|
||||||
endpoint: string,
|
|
||||||
initialFilters: Record<string, string | boolean | null> = {},
|
|
||||||
options: { debounceMs?: number, initialPerPage?: number } = {},
|
|
||||||
) {
|
|
||||||
const api = useApi()
|
|
||||||
|
|
||||||
const debounceMs = options.debounceMs ?? 300
|
|
||||||
const initialPerPage = options.initialPerPage ?? 10
|
|
||||||
|
|
||||||
const items = ref<T[]>([]) as { value: T[] }
|
|
||||||
const totalItems = ref(0)
|
|
||||||
const page = ref(1)
|
|
||||||
const perPage = ref(initialPerPage)
|
|
||||||
const filters = ref<Record<string, string | boolean | null>>({ ...initialFilters })
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
// Token de generation : chaque reload incremente ce compteur. Quand
|
|
||||||
// une reponse arrive, on verifie que son token est toujours le plus
|
|
||||||
// recent — sinon on ignore (protection anti race condition si l'user
|
|
||||||
// tape vite plusieurs filtres).
|
|
||||||
let requestToken = 0
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Construit le payload query params pour useApi.get.
|
|
||||||
* Les filtres a valeur vide (chaine vide, null) sont omis pour eviter
|
|
||||||
* de filtrer sur "rien" (comportement API Platform : filtre present
|
|
||||||
* avec valeur vide = ne retourne aucun resultat).
|
|
||||||
*/
|
|
||||||
function buildQueryParams(): Record<string, string | number | boolean> {
|
|
||||||
const params: Record<string, string | number | boolean> = {
|
|
||||||
page: page.value,
|
|
||||||
itemsPerPage: perPage.value,
|
|
||||||
}
|
|
||||||
for (const [key, value] of Object.entries(filters.value)) {
|
|
||||||
if (value === '' || value === null) continue
|
|
||||||
params[key] = value as string | boolean
|
|
||||||
}
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchItems(): Promise<void> {
|
|
||||||
const currentToken = ++requestToken
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const data = await api.get<{ member: T[], totalItems: number }>(
|
|
||||||
endpoint,
|
|
||||||
buildQueryParams(),
|
|
||||||
{ toast: false },
|
|
||||||
)
|
|
||||||
// Ignore si une requete plus recente a ete lancee entre-temps.
|
|
||||||
if (currentToken !== requestToken) return
|
|
||||||
// Defensive : un mock/test ou une API mal configuree peut
|
|
||||||
// renvoyer undefined. On ne crash pas, on laisse les valeurs
|
|
||||||
// par defaut.
|
|
||||||
items.value = data?.member ?? []
|
|
||||||
totalItems.value = data?.totalItems ?? 0
|
|
||||||
} finally {
|
|
||||||
if (currentToken === requestToken) {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Force un refetch immediat, sans debounce. Utile apres une mutation
|
|
||||||
* (POST/PATCH/DELETE) ou au mount initial.
|
|
||||||
*/
|
|
||||||
function reload(): void {
|
|
||||||
if (debounceTimer) {
|
|
||||||
clearTimeout(debounceTimer)
|
|
||||||
debounceTimer = null
|
|
||||||
}
|
|
||||||
void fetchItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Programme un refetch debounced. Utilise par le watcher de `filters`.
|
|
||||||
*/
|
|
||||||
function scheduleReload(): void {
|
|
||||||
if (debounceTimer) clearTimeout(debounceTimer)
|
|
||||||
debounceTimer = setTimeout(() => {
|
|
||||||
debounceTimer = null
|
|
||||||
void fetchItems()
|
|
||||||
}, debounceMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watcher sur page/perPage : refetch immediat (pas de spam possible,
|
|
||||||
// l'user clique sur un bouton pagination).
|
|
||||||
watch([page, perPage], () => {
|
|
||||||
reload()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Watcher sur filters : refetch debounced + reset page a 1 pour
|
|
||||||
// eviter l'etat "filtre qui reduit le total mais user reste sur une
|
|
||||||
// page inexistante".
|
|
||||||
watch(filters, () => {
|
|
||||||
if (page.value !== 1) {
|
|
||||||
page.value = 1
|
|
||||||
// Le changement de page declenchera son propre watcher, qui
|
|
||||||
// appellera reload(). Pas besoin d'en programmer un.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scheduleReload()
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
totalItems,
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
filters,
|
|
||||||
loading,
|
|
||||||
reload,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Module\Core\Domain\Entity;
|
namespace App\Module\Core\Domain\Entity;
|
||||||
|
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
|
||||||
use ApiPlatform\Metadata\ApiFilter;
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use ApiPlatform\Metadata\Delete;
|
use ApiPlatform\Metadata\Delete;
|
||||||
@@ -63,15 +62,6 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
denormalizationContext: ['groups' => ['role:write']],
|
denormalizationContext: ['groups' => ['role:write']],
|
||||||
)]
|
)]
|
||||||
#[ApiFilter(BooleanFilter::class, properties: ['isSystem'])]
|
#[ApiFilter(BooleanFilter::class, properties: ['isSystem'])]
|
||||||
// Filtres /admin/roles : recherche partielle insensible a la casse
|
|
||||||
// (ILIKE) sur label/code — un admin qui tape "ad" doit trouver
|
|
||||||
// "Administrateur". Les relations restent en exact (alimentees par un
|
|
||||||
// <select> cote front, donc casse maitrisee).
|
|
||||||
#[ApiFilter(SearchFilter::class, properties: [
|
|
||||||
'label' => 'ipartial',
|
|
||||||
'code' => 'ipartial',
|
|
||||||
'permissions.code' => 'exact',
|
|
||||||
])]
|
|
||||||
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
|
||||||
#[ORM\Table(name: '`role`')]
|
#[ORM\Table(name: '`role`')]
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
|
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Module\Core\Domain\Entity;
|
namespace App\Module\Core\Domain\Entity;
|
||||||
|
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
|
||||||
use ApiPlatform\Metadata\ApiFilter;
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use ApiPlatform\Metadata\Delete;
|
use ApiPlatform\Metadata\Delete;
|
||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
@@ -64,18 +61,6 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
|||||||
],
|
],
|
||||||
denormalizationContext: ['groups' => ['user:write']],
|
denormalizationContext: ['groups' => ['user:write']],
|
||||||
)]
|
)]
|
||||||
// Filtres /admin/users : recherche partielle insensible a la casse
|
|
||||||
// (ILIKE) sur username + filtre bool isAdmin + filtres exacts sur les
|
|
||||||
// relations (code de role ou nom de site).
|
|
||||||
// Les relations sont filtrees par jointure : `rbacRoles.code=admin` declenche
|
|
||||||
// un INNER JOIN user_role → role. `sites.name=Chatellerault` declenche
|
|
||||||
// INNER JOIN user_site → site.
|
|
||||||
#[ApiFilter(SearchFilter::class, properties: [
|
|
||||||
'username' => 'ipartial',
|
|
||||||
'rbacRoles.code' => 'exact',
|
|
||||||
'sites.name' => 'exact',
|
|
||||||
])]
|
|
||||||
#[ApiFilter(BooleanFilter::class, properties: ['isAdmin'])]
|
|
||||||
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
||||||
#[ORM\Table(name: '`user`')]
|
#[ORM\Table(name: '`user`')]
|
||||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Module\Sites\Domain\Entity;
|
namespace App\Module\Sites\Domain\Entity;
|
||||||
|
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
|
||||||
use ApiPlatform\Metadata\ApiFilter;
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use ApiPlatform\Metadata\Delete;
|
use ApiPlatform\Metadata\Delete;
|
||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
@@ -64,15 +62,6 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
normalizationContext: ['groups' => ['site:read']],
|
normalizationContext: ['groups' => ['site:read']],
|
||||||
denormalizationContext: ['groups' => ['site:write']],
|
denormalizationContext: ['groups' => ['site:write']],
|
||||||
)]
|
)]
|
||||||
// Filtres cote API pour /admin/sites : recherche partielle insensible a
|
|
||||||
// la casse (SQL ILIKE %x%) sur les champs texte saisis dans les headers
|
|
||||||
// de la DataTable. postalCode est purement numerique donc le I/partial
|
|
||||||
// donne le meme resultat, mais on reste coherent avec name/city.
|
|
||||||
#[ApiFilter(SearchFilter::class, properties: [
|
|
||||||
'name' => 'ipartial',
|
|
||||||
'city' => 'ipartial',
|
|
||||||
'postalCode' => 'ipartial',
|
|
||||||
])]
|
|
||||||
#[ORM\Entity(repositoryClass: DoctrineSiteRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrineSiteRepository::class)]
|
||||||
#[ORM\Table(name: 'site')]
|
#[ORM\Table(name: 'site')]
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
|
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Core\Api;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests fonctionnels des ApiFilter ajoutes sur User, Role et Site pour
|
|
||||||
* les DataTables admin (filtrage serveur + pagination negociee).
|
|
||||||
*
|
|
||||||
* Ces tests s'appuient uniquement sur les fixtures (admin, alice, bob +
|
|
||||||
* 3 sites + 2 roles systeme + 6 permissions) — aucune mutation entre
|
|
||||||
* tests, pas de cleanup necessaire.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class AdminFiltersApiTest extends AbstractApiTestCase
|
|
||||||
{
|
|
||||||
// ========================================================================
|
|
||||||
// User filters
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
public function testUsersFilterByUsernamePartial(): void
|
|
||||||
{
|
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
|
||||||
$response = $client->request('GET', '/api/users?username=ali');
|
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
|
||||||
$data = $response->toArray();
|
|
||||||
self::assertSame(1, $data['totalItems']);
|
|
||||||
self::assertSame('alice', $data['member'][0]['username']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testUsersFilterByIsAdminTrueReturnsOnlyAdmins(): void
|
|
||||||
{
|
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
|
||||||
$response = $client->request('GET', '/api/users?isAdmin=true');
|
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
|
||||||
$data = $response->toArray();
|
|
||||||
self::assertGreaterThanOrEqual(1, $data['totalItems']);
|
|
||||||
foreach ($data['member'] as $user) {
|
|
||||||
self::assertTrue($user['isAdmin']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testUsersFilterByIsAdminFalseExcludesAdmins(): void
|
|
||||||
{
|
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
|
||||||
$response = $client->request('GET', '/api/users?isAdmin=false');
|
|
||||||
|
|
||||||
$data = $response->toArray();
|
|
||||||
foreach ($data['member'] as $user) {
|
|
||||||
self::assertFalse($user['isAdmin']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testUsersFilterBySiteNameReturnsUsersOfThatSite(): void
|
|
||||||
{
|
|
||||||
// alice est rattachee a Chatellerault uniquement, bob a Saint-Jean.
|
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
|
||||||
$response = $client->request('GET', '/api/users?sites.name=Saint-Jean');
|
|
||||||
|
|
||||||
$data = $response->toArray();
|
|
||||||
$usernames = array_column($data['member'], 'username');
|
|
||||||
self::assertContains('admin', $usernames);
|
|
||||||
self::assertContains('bob', $usernames);
|
|
||||||
self::assertNotContains('alice', $usernames);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testUsersFilterByRoleCodeReturnsUsersWithThatRole(): void
|
|
||||||
{
|
|
||||||
// admin porte le role systeme 'admin', alice/bob portent 'user'.
|
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
|
||||||
$response = $client->request('GET', '/api/users?rbacRoles.code=admin');
|
|
||||||
|
|
||||||
$data = $response->toArray();
|
|
||||||
$usernames = array_column($data['member'], 'username');
|
|
||||||
self::assertContains('admin', $usernames);
|
|
||||||
self::assertNotContains('alice', $usernames);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Site filters
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
public function testSitesFilterByNamePartial(): void
|
|
||||||
{
|
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
|
||||||
$response = $client->request('GET', '/api/sites?name=Chat');
|
|
||||||
|
|
||||||
$data = $response->toArray();
|
|
||||||
self::assertSame(1, $data['totalItems']);
|
|
||||||
self::assertSame('Chatellerault', $data['member'][0]['name']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSitesFilterByCityPartial(): void
|
|
||||||
{
|
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
|
||||||
// Fontenet est la ville du site Saint-Jean.
|
|
||||||
$response = $client->request('GET', '/api/sites?city=Fonten');
|
|
||||||
|
|
||||||
$data = $response->toArray();
|
|
||||||
self::assertSame(1, $data['totalItems']);
|
|
||||||
self::assertSame('Saint-Jean', $data['member'][0]['name']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSitesFilterByPostalCodePartial(): void
|
|
||||||
{
|
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
|
||||||
$response = $client->request('GET', '/api/sites?postalCode=82');
|
|
||||||
|
|
||||||
$data = $response->toArray();
|
|
||||||
self::assertSame(1, $data['totalItems']);
|
|
||||||
self::assertSame('Pommevic', $data['member'][0]['name']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Role filters
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
public function testRolesFilterByLabelPartial(): void
|
|
||||||
{
|
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
|
||||||
$response = $client->request('GET', '/api/roles?label=Admin');
|
|
||||||
|
|
||||||
$data = $response->toArray();
|
|
||||||
self::assertGreaterThanOrEqual(1, $data['totalItems']);
|
|
||||||
foreach ($data['member'] as $role) {
|
|
||||||
self::assertStringContainsStringIgnoringCase('admin', $role['label']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRolesFilterByLabelIsCaseInsensitive(): void
|
|
||||||
{
|
|
||||||
// Garde explicite : la strategy est `ipartial` (ILIKE) et pas
|
|
||||||
// `partial` (LIKE). Chercher "ad" en minuscules DOIT trouver
|
|
||||||
// "Administrateur" (A majuscule). Si un futur dev retombe en
|
|
||||||
// strategy `partial` par megarde, ce test cassera.
|
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
|
||||||
$response = $client->request('GET', '/api/roles?label=ad');
|
|
||||||
|
|
||||||
$data = $response->toArray();
|
|
||||||
$labels = array_column($data['member'], 'label');
|
|
||||||
self::assertContains('Administrateur', $labels);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRolesFilterByCodePartial(): void
|
|
||||||
{
|
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
|
||||||
$response = $client->request('GET', '/api/roles?code=user');
|
|
||||||
|
|
||||||
$data = $response->toArray();
|
|
||||||
self::assertGreaterThanOrEqual(1, $data['totalItems']);
|
|
||||||
foreach ($data['member'] as $role) {
|
|
||||||
self::assertStringContainsString('user', $role['code']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRolesFilterByIsSystemTrueReturnsOnlySystemRoles(): void
|
|
||||||
{
|
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
|
||||||
$response = $client->request('GET', '/api/roles?isSystem=true');
|
|
||||||
|
|
||||||
$data = $response->toArray();
|
|
||||||
self::assertGreaterThanOrEqual(2, $data['totalItems']);
|
|
||||||
foreach ($data['member'] as $role) {
|
|
||||||
self::assertTrue($role['isSystem']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRolesFilterByPermissionCodeReturnsRolesWithThatPermission(): void
|
|
||||||
{
|
|
||||||
// Le role systeme 'admin' a le flag isAdmin qui bypass toutes les
|
|
||||||
// permissions — il n'a pas necessairement des permissions explicites.
|
|
||||||
// On teste donc avec la permission sites.view qui devrait exister
|
|
||||||
// mais potentiellement n'etre sur aucun role custom. Le filtre
|
|
||||||
// fonctionne techniquement meme sur un resultat vide.
|
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
|
||||||
$response = $client->request('GET', '/api/roles?permissions.code=sites.view');
|
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
|
||||||
$data = $response->toArray();
|
|
||||||
// On valide juste que la requete est acceptee (200) et que le
|
|
||||||
// filtre transforme bien l'IRI en JOIN — nombre de resultats
|
|
||||||
// depend de l'etat des fixtures.
|
|
||||||
self::assertArrayHasKey('totalItems', $data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Pagination
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
public function testPaginationWithItemsPerPageReducesMember(): void
|
|
||||||
{
|
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
|
||||||
$response = $client->request('GET', '/api/sites?itemsPerPage=2');
|
|
||||||
|
|
||||||
$data = $response->toArray();
|
|
||||||
self::assertLessThanOrEqual(2, count($data['member']));
|
|
||||||
// totalItems reflete le TOTAL pas la page courante.
|
|
||||||
self::assertGreaterThanOrEqual(3, $data['totalItems']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPaginationPage2SkipsFirstItems(): void
|
|
||||||
{
|
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
|
||||||
$page1 = $client->request('GET', '/api/sites?itemsPerPage=1&page=1')->toArray();
|
|
||||||
$page2 = $client->request('GET', '/api/sites?itemsPerPage=1&page=2')->toArray();
|
|
||||||
|
|
||||||
self::assertCount(1, $page1['member']);
|
|
||||||
self::assertCount(1, $page2['member']);
|
|
||||||
self::assertNotSame(
|
|
||||||
$page1['member'][0]['id'],
|
|
||||||
$page2['member'][0]['id'],
|
|
||||||
'Les items de la page 2 doivent differer de ceux de la page 1.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user