Compare commits

..

8 Commits

Author SHA1 Message Date
9fe3ce9f96 fix : .gitignore 2026-05-13 10:32:27 +02:00
c2996b21ae fix : icon des boutons d'ajout 2026-05-13 09:55:53 +02:00
cbcde54187 fix : validation du formulaire login avec la touche entrée 2026-05-13 09:29:22 +02:00
3123c5ff4d feat : mise à jour de la lib Malio UI 2026-05-13 09:28:41 +02:00
gitea-actions
dce189d982 chore: bump version to v0.1.33
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 9s
2026-04-20 17:56:54 +00:00
140dca9061 fix : resolve var/cache permission issue in Docker
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Create var/cache and var/log directories in Dockerfile and ensure
correct ownership in Makefile before running composer install.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 19:56:30 +02:00
gitea-actions
93f47e9111 chore: bump version to v0.1.32
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 8s
2026-04-20 15:32:05 +00:00
6cf5ef4cfc Module sites (#8)
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: MALIO-DEV/Coltura#8
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-20 15:31:58 +00:00
18 changed files with 906 additions and 909 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@
/.env.local.php
/.env.*.local
/config/secrets/dev/dev.decrypt.private.php
/config/reference.php
/public/bundles/
/var/
/vendor/

View File

@@ -1,15 +1,14 @@
api_platform:
title: Coltura API
version: 1.0.0
# Scan des modules pour decouvrir les classes ApiResource et ApiFilter.
# Ajouter un chemin par module lors de l'ajout d'entites ApiResource
# dans d'autres modules. Sans ces paths, le compile pass d'API Platform
# ne declare pas les services de filtres annotes (les filtres etaient
# silencieusement ignores sur Permission — cf. ticket #344).
# Scan du module Core pour decouvrir les classes ApiResource et ApiFilter.
# Ajouter un chemin par module lors de l'ajout d'entites ApiResource dans d'autres modules.
# Sans ces paths, le compile pass d'API Platform ne declare pas les
# services de filtres annotes (les filtres etaient silencieusement
# ignores sur Permission — cf. ticket #344).
mapping:
paths:
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
- '%kernel.project_dir%/src/Module/Sites/Domain/Entity'
formats:
jsonld: ['application/ld+json']
json: ['application/json']
@@ -19,10 +18,3 @@ api_platform:
stateless: true
cache_headers:
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

View File

@@ -467,7 +467,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
* http_client?: bool|array{ // HTTP Client configuration
* enabled?: bool|Param, // Default: true
* enabled?: bool|Param, // Default: false
* max_host_connections?: int|Param, // The maximum number of connections to a single host.
* default_options?: array{
* headers?: array<string, mixed>,

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.31'
app.version: '0.1.33'

View File

@@ -8,74 +8,22 @@
<MalioButton
v-if="can('core.roles.manage')"
:label="t('admin.roles.newRole')"
icon-name="mdi:plus"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
</div>
<!-- Table des roles avec filtres + pagination -->
<!-- Table des roles -->
<MalioDataTable
v-model:page="page"
v-model:per-page="perPage"
class="mt-6"
:columns="columns"
:items="roleItems"
:total-items="totalItems"
:total-items="roles.length"
: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.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 }">
<span class="font-mono text-xs">{{ item.code }}</span>
</template>
@@ -111,7 +59,7 @@
</template>
<script setup lang="ts">
import type { Permission, Role } from '~/shared/types/rbac'
import type { Role } from '~/shared/types/rbac'
const { t } = useI18n()
const api = useApi()
@@ -120,42 +68,8 @@ const canManage = computed(() => can('core.roles.manage'))
useHead({ title: t('admin.roles.title') })
// 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 },
)
// 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 roles = ref<Role[]>([])
const loading = ref(false)
const columns = [
{ key: 'label', label: t('admin.roles.table.label') },
@@ -166,31 +80,45 @@ const columns = [
// Transformer les roles en items compatibles MalioDataTable
const roleItems = computed(() =>
items.value.map(role => ({
roles.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 items.value.find(r => r.id === id)
return roles.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
@@ -217,18 +145,17 @@ async function handleDelete() {
deleteModalOpen.value = false
roleToDelete.value = null
drawerOpen.value = false
reload()
await loadRoles()
} finally {
deleting.value = false
}
}
function onRoleSaved() {
reload()
loadRoles()
}
onMounted(() => {
loadPermissions()
reload()
loadRoles()
})
</script>

View File

@@ -7,77 +7,16 @@
</h1>
</div>
<!-- Table des utilisateurs avec filtres + pagination -->
<!-- Table des utilisateurs -->
<MalioDataTable
v-model:page="page"
v-model:per-page="perPage"
class="mt-6"
:columns="columns"
:items="userItems"
:total-items="totalItems"
:total-items="users.length"
: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"
@@ -98,77 +37,30 @@
</template>
<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'
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'))
// 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 users = ref<UserListItem[]>([])
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 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
})
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') },
]
// Extraire l'id numerique depuis une IRI API Platform type `/api/sites/3`.
function iriToId(iri: string): number {
@@ -176,7 +68,7 @@ function iriToId(iri: string): number {
}
const userItems = computed(() =>
items.value.map(user => ({
users.value.map(user => ({
id: user.id,
username: user.username,
admin: user.isAdmin,
@@ -184,7 +76,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 par loadFilterOptions. Vide si module Sites off.
// construite en parallele depuis /api/sites.
sites: (user.sites ?? [])
.map(iri => sitesById.value.get(iriToId(iri))?.name)
.filter((name): name is string => Boolean(name))
@@ -192,11 +84,24 @@ const userItems = computed(() =>
})),
)
const drawerOpen = ref(false)
const selectedUser = ref<UserListItem | null>(null)
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
}
}
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) {
@@ -210,11 +115,10 @@ function onRowClick(item: Record<string, unknown>) {
}
function onUserSaved() {
reload()
loadUsers()
}
onMounted(() => {
loadFilterOptions()
reload()
loadUsers()
})
</script>

View File

@@ -27,8 +27,8 @@
<MalioButton
label="Se connecter"
button-class="w-full"
type="submit"
:disabled="isSubmitting"
@click="handleSubmit"
/>
<p class="font-bold">v{{ version }}</p>
</form>

View File

@@ -8,49 +8,22 @@
<MalioButton
v-if="can('sites.manage')"
:label="t('admin.sites.newSite')"
icon-name="mdi:plus"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
</div>
<!-- Table des sites avec filtres + pagination -->
<!-- Table des sites -->
<MalioDataTable
v-model:page="page"
v-model:per-page="perPage"
class="mt-6"
:columns="columns"
:items="siteItems"
:total-items="totalItems"
:total-items="sites.length"
:row-clickable="canManage"
:empty-message="t('admin.sites.noSites')"
@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 }">
<span class="inline-flex items-center gap-2">
<span
@@ -96,20 +69,8 @@ const canManage = computed(() => can('sites.manage'))
useHead({ title: t('admin.sites.title') })
// Etat DataTable centralise : pagination serveur + filtres debounces.
// Les filtres name/city/postalCode sont des partiels SearchFilter cote API.
const {
items,
totalItems,
page,
perPage,
filters,
reload,
} = useDataTableServerState<Site>('/sites', {
name: '',
city: '',
postalCode: '',
})
const sites = ref<Site[]>([])
const loading = ref(false)
const columns = [
{ key: 'name', label: t('admin.sites.table.name') },
@@ -123,7 +84,7 @@ const columns = [
// `fullAddress` provient du getter computed cote backend (Site::getFullAddress)
// au format multi-lignes — on l'aplatit en virgules pour l'affichage table.
const siteItems = computed(() =>
items.value.map(site => ({
sites.value.map(site => ({
id: site.id,
name: site.name,
city: site.city,
@@ -134,7 +95,7 @@ const siteItems = computed(() =>
)
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>) {
@@ -148,6 +109,20 @@ const deleteModalOpen = ref(false)
const siteToDelete = ref<Site | null>(null)
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() {
selectedSite.value = null
drawerOpen.value = true
@@ -174,7 +149,7 @@ async function handleDelete() {
deleteModalOpen.value = false
siteToDelete.value = null
drawerOpen.value = false
reload()
await loadSites()
// Rafraichit auth.user apres suppression d'un site : le backend
// applique ON DELETE SET NULL sur user.current_site_id, donc
// auth.user.currentSite peut etre devenu null sans que le front
@@ -186,10 +161,10 @@ async function handleDelete() {
}
function onSiteSaved() {
reload()
loadSites()
}
onMounted(() => {
reload()
loadSites()
})
</script>

View File

@@ -7,7 +7,7 @@
"name": "coltura-frontend",
"hasInstallScript": true,
"dependencies": {
"@malio/layer-ui": "^1.4.2",
"@malio/layer-ui": "^1.5.0",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -1268,6 +1268,32 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1820,14 +1846,22 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
"version": "1.4.2",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.4.2/layer-ui-1.4.2.tgz",
"integrity": "sha512-H8f5FJXHFH9ZI1Jx4u9XE7w6VlR/d9Zr2encfQyMax1I0UZ3SiGBUjictcL33r0OhgsrgSmPq0J9aF6aab85Nw==",
"version": "1.5.0",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.5.0/layer-ui-1.5.0.tgz",
"integrity": "sha512-uVuG8kRakWgpWYQCMUf1LFD+gjx0iRFfNJn/jlqjxiZmZyGZMckcMW2qA9hGZBiheBsTJWw1pRR4ufuyAYPY0A==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
"@tiptap/extension-color": "^3.22.5",
"@tiptap/extension-highlight": "^3.22.5",
"@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/extension-text-style": "^3.22.5",
"@tiptap/pm": "^3.22.5",
"@tiptap/starter-kit": "^3.22.5",
"@tiptap/vue-3": "^3.22.5",
"maska": "^3.2.0",
"tailwind-merge": "^3.3.1"
"tailwind-merge": "^3.3.1",
"tiptap-markdown": "^0.9.0"
},
"peerDependencies": {
"nuxt": "^4.0.0"
@@ -4565,6 +4599,484 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@tiptap/core": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.2.tgz",
"integrity": "sha512-yjv2N7gaQMbIVfsSZHBMscLoybgetcTraXsSMrELAerl/jfRipg5S1dBXMFvgRy8Kh48+TGoH+5nqshxdOEGoQ==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.23.2"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.23.2.tgz",
"integrity": "sha512-BBN22/nUErRzUr1HI/Q4iPFBNavWoNC5Fx0KXp9oDOCCrCvj55rtGwLn2dB3r4vLfhjYvhEZ38wN+r7Y9RhORA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.23.2.tgz",
"integrity": "sha512-onPFP260enNkjMGhR7sDSqaF80A4sgvMZ/VZLxp7zGa6DAfcuN4PyUQhZN61KmYgbUdr+qrAAMx+tPuid22F4g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.23.2.tgz",
"integrity": "sha512-S0LZg6EMdkr5CiYAGmOwzuNPd2T18ZR5pWrgHHHZjt0IFqU01tBJa3zLzC1Vd7noPCec64ygW9S8+p8CHBgOIA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2",
"@tiptap/pm": "3.23.2"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.23.2.tgz",
"integrity": "sha512-jhvu1qa8F53r//6DrLdA2OkTbRgY73NkQL51Ruozzi+JrfVIo3IfaTNRq01BXO5qcvNH7P/aDBF5cByvuiUMzQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.2"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.23.2.tgz",
"integrity": "sha512-5yHYavvBEkxgWL6DCPYNHujxBXF7c812MFNoU62ljNJjIYDc2Mu8ftOie/ajNJbMLqwSVzCuGfG9swEuEKxY5g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.23.2.tgz",
"integrity": "sha512-oWwjsLi+m7zHTqM0kYnnogjS3vFENCaLk3ue/1XE9ccpZlmmPzmK70hodB/UPylFJmgr3g726rhxViYv9LRS7A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2",
"@tiptap/pm": "3.23.2"
}
},
"node_modules/@tiptap/extension-color": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.23.2.tgz",
"integrity": "sha512-4G5ikeZUB27YyI3F1MpopD+liTJYKeIQMRDbHdSIxce6TbTqsOXY5tMzKIEkRk85dF/edFcen8QE/AKr+/zXhg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-text-style": "3.23.2"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.23.2.tgz",
"integrity": "sha512-Ff/MAaWPzFtb7nhPG6ETdu7/OqkD6cAmJfViBAiRqh9PPULH98fGDaMBm3TGa1s3XB7hGO7c/xVKxEdjYij5rg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.23.2.tgz",
"integrity": "sha512-T7pVfIF5uDj043CDJlurNoMAmXPy36EjBz/YQVYrVMSSUbvUlOaL3xxoDODRmir29jVrmCROjaKwVExqooQTqQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.23.2"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.23.2.tgz",
"integrity": "sha512-1rfxx5X52h44jSS9R1fUx8sOaRpNq8CM0IR3EdjpthVvaRZ9XD1tKX4h5EcwGFqNiwtWXxNne2mV7ZJuH2UYsQ==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.23.2",
"@tiptap/pm": "3.23.2"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.23.2.tgz",
"integrity": "sha512-I3LKYVwyxRaiVaJxss98QWOXzZZrHmsJD3RgAZ0Uoguu3pL29Krn2tXfwp4FAd9NG79wdQHj20c+XuqJLNkAbw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.23.2"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.23.2.tgz",
"integrity": "sha512-3p00oC2caRAVtkOweJ9IjTgjhzm34R9tNyMCykzMRygC8CdO08Jv2JKvUaOeMVVmWZI+O9wkGUpaA2Drg6pY4g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.23.2.tgz",
"integrity": "sha512-ldQh38ZWTIedSY6XsOkn1ULtk+mD/mezLogrvcVv8neez0qM/S25vyMZKqP7i2/x4i4ds1JYIJLw9y3HX/B69Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2"
}
},
"node_modules/@tiptap/extension-highlight": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.23.2.tgz",
"integrity": "sha512-5DkjvdptftHFb0jBG/3ca5ZpwN+IPv4W37TXwBPcIsKZf4jPuvAYqaxMVQFyQRkxuHsxEEiXMrAsiXbRVKnMPQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.23.2.tgz",
"integrity": "sha512-LbLACqe/OiTdYIq3zbsY7Ue7WOP+mYTGufNDfs8rSGcUbWI2GwUwoGRrTPBbLIesnuCdH6FyMB60Tg/HzjKaYw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2",
"@tiptap/pm": "3.23.2"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.23.2.tgz",
"integrity": "sha512-pauWOkom8riyZoIGn8I8uFE//uRgE6kL46Pd6PdSd5Wt6w/ao/le9Ju2Ln947NGjz/6whaDiFnBrkSbZEAhpmw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.23.2.tgz",
"integrity": "sha512-h0D3Lwom0hGDsbpZ6tW0+DRKgBP0qSl4rO/iBfGk3Krb2CoyEn1yII3pSTd1PwAld7bHt/buURi6DWudQsIhgg==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2",
"@tiptap/pm": "3.23.2"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.23.2.tgz",
"integrity": "sha512-tRbbjpOPrY4ApIHtn3ctnKIhkkioewMsZa5gJzqVB47LJFNyzLXLo/aID4sJRKTIMi1wd1fA9TiBKPe6KqczPA==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2",
"@tiptap/pm": "3.23.2"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.23.2.tgz",
"integrity": "sha512-I9Yed0mmBE6AAhc4Cc5UhZLZUMCft9XdPkdBM/wjcjpLwdMhdzelVQXEhNDyPfRkA1TTGs5VHlc6twjCPCTgLg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.2"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.23.2.tgz",
"integrity": "sha512-aE4PzCy+OWXXFc8UsrrYjcfEbyXCh8f7LuGgFPl8jf0JjgIK0FHmzWDg3912zWb5Ww66AF2j0KJzTB9NFOMWlw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.2"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.23.2.tgz",
"integrity": "sha512-ZhMejT4jXfOEECqI60AVBzULl8AVN8BXk/9vjVnJHxBtcQxW+tIu5GHVZ4DPaCfTX7mH+hsceFMhQ2BvV7qvbg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.2"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.23.2.tgz",
"integrity": "sha512-UzHx5zOi9fjhC9TAqHeD7BV5eJIA3t65pa2Mq8b54V9nrIg1yAlgiafU0WDWoom11TF+IWxz5JBsjlW0/P/VeQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.23.2.tgz",
"integrity": "sha512-HTLOE+xrj9B4wTx1ssMfLiTC8sxy1GdzCK1hqm7EACmnFbY9yH27QzXDfOKbo/J4+Ffg64eSqjs/NRnQwMWAww==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.23.2"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.23.2.tgz",
"integrity": "sha512-m1BN3EXwYagzVdReGoI7McvuZFyolFwuXLgzU6c+RN7E+u+K68EY+2U3pJI5ee8UgPYGW4eBs/3BT/5k+0so3Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.23.2.tgz",
"integrity": "sha512-Ny4FC+BivfwACTuJO3QPe+BXtVFfzWQVBcfhlFlA7xTaeBktFeBavH2cEyXR8Qbez3Wio/Hn+bibT4d44EBLyg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.23.2.tgz",
"integrity": "sha512-K2o1gMwn09nrd5ewftSy08U6LMC1cW3Cmml5+vHT9P/VeMtYwkbNg+9Mt1uFh7VfAZmlkj8d3u7RYqfl8xMVJA==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.23.2.tgz",
"integrity": "sha512-HdPX1nZarOF8UkDccu6gBy6brZd4vDFCa5Mz1liw3B3D6UBiC+8vzE1YGHtggO+KEBBQYf4GgjT3qqIgOcsTEQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.23.2.tgz",
"integrity": "sha512-kRHQ3nSbAfkFdxj9FtDdr4hpREndGgWFA6ZEAwlLeGUxf8QYTpuF9zb2yxdBPBlTc5+JsbPcskNt+u1PazGKYw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.2",
"@tiptap/pm": "3.23.2"
}
},
"node_modules/@tiptap/pm": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.23.2.tgz",
"integrity": "sha512-1kvsBqGNu2ZJ0P/lkxN0pAMqSyUcpkMIzE4xwGUIyAiD0pZV6dr+OCMwGWOTLllSyrn91xI5K7OLk3pYeCPKqA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-keymap": "^1.2.2",
"prosemirror-model": "^1.24.1",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.38.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.23.2.tgz",
"integrity": "sha512-t0o//bLmlUW/3j9ke1wIrrKkvfVYcBiC/cowCUNwN/YQx9Bc3S++yInkR6hat4qI4+O49+NRknVeaF/Sp247QA==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^3.23.2",
"@tiptap/extension-blockquote": "^3.23.2",
"@tiptap/extension-bold": "^3.23.2",
"@tiptap/extension-bullet-list": "^3.23.2",
"@tiptap/extension-code": "^3.23.2",
"@tiptap/extension-code-block": "^3.23.2",
"@tiptap/extension-document": "^3.23.2",
"@tiptap/extension-dropcursor": "^3.23.2",
"@tiptap/extension-gapcursor": "^3.23.2",
"@tiptap/extension-hard-break": "^3.23.2",
"@tiptap/extension-heading": "^3.23.2",
"@tiptap/extension-horizontal-rule": "^3.23.2",
"@tiptap/extension-italic": "^3.23.2",
"@tiptap/extension-link": "^3.23.2",
"@tiptap/extension-list": "^3.23.2",
"@tiptap/extension-list-item": "^3.23.2",
"@tiptap/extension-list-keymap": "^3.23.2",
"@tiptap/extension-ordered-list": "^3.23.2",
"@tiptap/extension-paragraph": "^3.23.2",
"@tiptap/extension-strike": "^3.23.2",
"@tiptap/extension-text": "^3.23.2",
"@tiptap/extension-underline": "^3.23.2",
"@tiptap/extensions": "^3.23.2",
"@tiptap/pm": "^3.23.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/vue-3": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-3.23.2.tgz",
"integrity": "sha512-/m9SjhKczAPoOE4MXzwTlibhJMmQsWcLHtYtamYoJcKoa84oJrkuyF4SGdmDeMGJTc07h07QSur+hcVbLQRiHg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.23.2",
"@tiptap/extension-floating-menu": "^3.23.2"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.23.2",
"@tiptap/pm": "3.23.2",
"vue": "^3.0.0"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -4605,6 +5117,28 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
"node_modules/@types/linkify-it": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz",
"integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==",
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "13.0.9",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz",
"integrity": "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^3",
"@types/mdurl": "^1"
}
},
"node_modules/@types/mdurl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz",
"integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
@@ -10010,6 +10544,21 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/listhen": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/listhen/-/listhen-1.9.1.tgz",
@@ -10182,6 +10731,41 @@
"source-map-js": "^1.2.1"
}
},
"node_modules/markdown-it": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it-task-lists": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz",
"integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==",
"license": "ISC"
},
"node_modules/markdown-it/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/maska": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/maska/-/maska-3.2.0.tgz",
@@ -10203,6 +10787,12 @@
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"license": "CC0-1.0"
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -12270,6 +12860,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/oxc-minify": {
"version": "0.117.0",
"resolved": "https://registry.npmjs.org/oxc-minify/-/oxc-minify-0.117.0.tgz",
@@ -13293,6 +13889,168 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-markdown": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
"license": "MIT",
"dependencies": {
"@types/markdown-it": "^14.0.0",
"markdown-it": "^14.0.0",
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-markdown/node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/prosemirror-markdown/node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/prosemirror-markdown/node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT"
},
"node_modules/prosemirror-model": {
"version": "1.25.4",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
@@ -13309,6 +14067,15 @@
"node": ">=6"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@@ -13898,6 +14665,12 @@
"node": ">= 12"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/rou3": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/rou3/-/rou3-0.8.1.tgz",
@@ -14989,6 +15762,24 @@
"node": ">=14.0.0"
}
},
"node_modules/tiptap-markdown": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
"integrity": "sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==",
"license": "MIT",
"workspaces": [
"example"
],
"dependencies": {
"@types/markdown-it": "^13.0.7",
"markdown-it": "^14.1.0",
"markdown-it-task-lists": "^2.1.1",
"prosemirror-markdown": "^1.11.1"
},
"peerDependencies": {
"@tiptap/core": "^3.0.1"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -15165,6 +15956,12 @@
"node": ">=14.17"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/ufo": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
@@ -16685,6 +17482,12 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",

View File

@@ -15,7 +15,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@malio/layer-ui": "^1.4.2",
"@malio/layer-ui": "^1.5.0",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -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 }])
})
})

View File

@@ -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,
}
}

View File

@@ -81,7 +81,7 @@ RUN mkdir -p /var/www/.composer/cache/vcs \
ENV COMPOSER_HOME=/var/www/.composer
# Création de la structure du projet
RUN mkdir /var/www/html/LOG
RUN mkdir -p /var/www/html/LOG /var/www/html/var/cache /var/www/html/var/log
###> User ###
ARG CURRENT_UID

View File

@@ -44,6 +44,8 @@ install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migrat
reset: delete_built_dir remove_orphans build-without-cache start wait install
composer-install:
$(EXEC_PHP_ROOT) mkdir -p /var/www/html/var/cache /var/www/html/var/log
$(EXEC_PHP_ROOT) chown -R www-data:www-data /var/www/html/var
$(EXEC_PHP) composer install
$(SYMFONY_CONSOLE) lexik:jwt:generate-keypair --skip-if-exists

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
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\Delete;
@@ -63,15 +62,6 @@ use Symfony\Component\Validator\Constraints as Assert;
denormalizationContext: ['groups' => ['role:write']],
)]
#[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\Table(name: '`role`')]
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]

View File

@@ -4,9 +4,6 @@ declare(strict_types=1);
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\Delete;
use ApiPlatform\Metadata\Get;
@@ -64,18 +61,6 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
],
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\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface

View File

@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Module\Sites\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
@@ -64,15 +62,6 @@ use Symfony\Component\Validator\Constraints as Assert;
normalizationContext: ['groups' => ['site:read']],
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\Table(name: 'site')]
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]

View File

@@ -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.',
);
}
}