Olivier Massot 1 éve
szülő
commit
ba969bee68
36 módosított fájl, 1412 hozzáadás és 175 törlés
  1. 5 0
      .eslintrc.cjs
  2. 19 22
      components/Layout/Header/Notification.vue
  3. 2 2
      components/Layout/SubHeader/PersonnalizedList.vue
  4. 2 9
      components/Ui/Collection.vue
  5. 1 5
      components/Ui/Form.vue
  6. 8 4
      components/Ui/Form/Edition.vue
  7. 30 8
      composables/data/useEntityFetch.ts
  8. 179 0
      doc/entity_manager.md
  9. 7 1
      models/Organization/Subdomain.ts
  10. 1 0
      nuxt.config.ts
  11. 1 0
      package.json
  12. 115 0
      pages/dev/poc_fetch_collection.vue
  13. 3 0
      pages/dev/readme.md
  14. 9 20
      pages/parameters/attendances.vue
  15. 3 16
      pages/parameters/education_timings/index.vue
  16. 6 16
      pages/parameters/residence_areas/index.vue
  17. 7 3
      pages/parameters/website.vue
  18. 1 0
      regex_pattern.txt
  19. 14 0
      services/data/Filters/AbstractFilter.ts
  20. 54 0
      services/data/Filters/EqualFilter.ts
  21. 35 0
      services/data/Filters/OrderBy.ts
  22. 34 0
      services/data/Filters/PageFilter.ts
  23. 90 0
      services/data/Filters/SearchFilter.ts
  24. 79 0
      services/data/Query.ts
  25. 12 6
      services/data/apiRequestService.ts
  26. 42 16
      services/data/entityManager.ts
  27. 27 0
      services/utils/refUtils.ts
  28. 12 7
      services/utils/stringUtils.ts
  29. 78 39
      tests/units/services/data/entityManager.test.ts
  30. 73 0
      tests/units/services/data/filters/equalFilter.test.ts
  31. 56 0
      tests/units/services/data/filters/orderBy.test.ts
  32. 68 0
      tests/units/services/data/filters/pageFilter.test.ts
  33. 208 0
      tests/units/services/data/filters/searchFilter.test.ts
  34. 104 0
      tests/units/services/data/query.test.ts
  35. 12 1
      types/data.d.ts
  36. 15 0
      types/enum/data.ts

+ 5 - 0
.eslintrc.cjs

@@ -31,9 +31,11 @@ module.exports = {
       },
     ],
     'vue/multi-word-component-names': 0,
+    '@typescript-eslint/no-inferrable-types': 0,
   },
   globals: {
     useRuntimeConfig: 'readonly',
+    useAsyncData: 'readonly',
     navigateTo: 'readonly',
     computed: 'readonly',
     ref: 'readonly',
@@ -42,5 +44,8 @@ module.exports = {
     useRoute: 'readonly',
     useI18n: 'readonly',
     onMounted: 'readonly',
+    onUnmounted: 'readonly',
+    watch: 'readonly',
+    useRepo: 'readonly',
   },
 }

+ 19 - 22
components/Layout/Header/Notification.vue

@@ -17,8 +17,8 @@
 
   <v-menu
     v-if="btn !== null"
-    :activator="btn"
     v-model="isOpen"
+    :activator="btn"
     location="bottom left"
   >
     <v-card max-width="400">
@@ -42,8 +42,8 @@
               <v-icon
                 v-if="notification.link"
                 icon="mdi:mdi-download"
-                @click="download(notification.link)"
                 class="pt-4"
+                @click="download(notification.link)"
               />
             </template>
           </v-list-item>
@@ -72,10 +72,9 @@
           class="theme-primary"
           style="width: 100%; height: 52px"
         >
-          <v-list-item-title
-            class="text-body-2"
-            v-text="$t('all_notification')"
-          />
+          <v-list-item-title class="text-body-2">
+            <span v-text="$t('all_notification')" />
+          </v-list-item-title>
         </v-list-item>
       </v-card-actions>
     </v-card>
@@ -83,24 +82,26 @@
 </template>
 
 <script setup lang="ts">
+import { computed, ref } from 'vue'
+import type { ComputedRef, Ref } from 'vue'
+import { useRepo } from 'pinia-orm'
 import { NOTIFICATION_TYPE } from '~/types/enum/enums'
 import Notification from '~/models/Core/Notification'
 import NotificationUsers from '~/models/Core/NotificationUsers'
 import { useAccessProfileStore } from '~/stores/accessProfile'
-import { computed, ref } from '@vue/reactivity'
-import type { ComputedRef, Ref } from '@vue/reactivity'
 import { useEntityFetch } from '~/composables/data/useEntityFetch'
 import type { AnyJson, Pagination } from '~/types/data'
 import { useEntityManager } from '~/composables/data/useEntityManager'
 import UrlUtils from '~/services/utils/urlUtils'
-import { useRepo } from 'pinia-orm'
 import NotificationRepository from '~/stores/repositories/NotificationRepository'
+import Query from '~/services/data/Query'
+import PageFilter from '~/services/data/Filters/PageFilter'
 
 const accessProfileStore = useAccessProfileStore()
 
-const loading: Ref<Boolean> = ref(true)
-const isOpen: Ref<Boolean> = ref(false)
+const isOpen: Ref<boolean> = ref(false)
 const page: Ref<number> = ref(1)
+const itemsPerPage: Ref<number> = ref(5)
 
 const i18n = useI18n()
 const runtimeConfig = useRuntimeConfig()
@@ -111,18 +112,16 @@ const { em } = useEntityManager()
 const { fetchCollection } = useEntityFetch()
 const notificationRepo = useRepo(NotificationRepository)
 
-const query: ComputedRef<AnyJson> = computed(() => {
-  return { page: page.value }
-})
+const query = new Query(new PageFilter(page, itemsPerPage))
 
-let {
+const {
   data: collection,
   pending,
   refresh,
-} = await fetchCollection(Notification, null, query)
+} = fetchCollection(Notification, null, query)
 
 /**
- * On récupère les Notifications via le store (sans ça, les mises à jour SSE ne seront pas prises en compte)
+ * On récupère les Notifications via le store
  */
 const notifications: ComputedRef<Array<Notification>> = computed(() => {
   return notificationRepo.getNotifications()
@@ -139,9 +138,7 @@ const unreadNotification: ComputedRef<Array<Notification>> = computed(() => {
  * Les metadata dépendront de la dernière valeur du GET lancé
  */
 const pagination: ComputedRef<Pagination> = computed(() => {
-  return !pending.value && collection.value !== null
-    ? collection.value.pagination
-    : {}
+  return collection.value !== null ? collection.value.pagination : {}
 })
 
 const notificationUrl = UrlUtils.join(
@@ -214,7 +211,7 @@ const getMessage = (notification: Notification) => {
 /**
  * Dès la fermeture du menu, on indique que les notifications non lues, le sont.
  */
-const unwatch = watch(isOpen, (newValue, oldValue) => {
+const unwatch = watch(isOpen, (newValue, _) => {
   if (!newValue) {
     markNotificationsAsRead()
   }
@@ -246,7 +243,7 @@ const markNotificationAsRead = (notification: Notification) => {
  */
 const markNotificationsAsRead = () => {
   unreadNotification.value.map((notification: Notification) => {
-    markNotificationAsRead(notification)
+    return markNotificationAsRead(notification)
   })
 }
 

+ 2 - 2
components/Layout/SubHeader/PersonnalizedList.vue

@@ -61,9 +61,9 @@ import UrlUtils from '~/services/utils/urlUtils'
 
 const btn: Ref = ref(null)
 
-const { fetch, fetchCollection } = useEntityFetch()
+const { fetchCollection } = useEntityFetch()
 
-const { data: collection, pending } = await fetchCollection(PersonalizedList)
+const { data: collection, pending } = fetchCollection(PersonalizedList)
 
 const i18n = useI18n()
 

+ 2 - 9
components/Ui/Collection.vue

@@ -5,7 +5,7 @@
     <v-skeleton-loader v-if="pending" :type="loaderType" />
     <div v-else>
       <!-- Content -->
-      <slot name="list.item" v-bind="{ items }" />
+      <slot name="list.item" v-bind="{ collection.items }" />
 
       <!-- New button -->
       <v-btn v-if="newLink" class="theme-primary float-right">
@@ -49,12 +49,5 @@ const { model, parent }: ToRefs = toRefs(props)
 
 const { fetchCollection } = useEntityFetch()
 
-const { data: collection, pending } = await fetchCollection(
-  model.value,
-  parent.value,
-)
-
-const items: ComputedRef<Collection> = computed(
-  () => collection.value ?? { items: [], pagination: {}, totalItems: 0 },
-)
+const { data: collection, pending } = fetchCollection(model.value, parent.value)
 </script>

+ 1 - 5
components/Ui/Form.vue

@@ -92,11 +92,7 @@ de quitter si des données ont été modifiées.
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue'
 import type { ComputedRef, Ref, PropType } from 'vue'
-import type {
-  Route,
-  RouteLocationNormalized,
-  RouteLocationRaw,
-} from '@intlify/vue-router-bridge'
+import type { RouteLocationNormalized, RouteLocationRaw } from 'vue-router'
 import * as _ from 'lodash-es'
 import { FORM_FUNCTION, SUBMIT_TYPE, TYPE_ALERT } from '~/types/enum/enums'
 import { useFormStore } from '~/stores/form'

+ 8 - 4
components/Ui/Form/Edition.vue

@@ -20,12 +20,13 @@
 
 <script setup lang="ts">
 import type { PropType } from '@vue/runtime-core'
-import type { RouteLocationRaw } from '@intlify/vue-router-bridge'
 import ApiModel from '~/models/ApiModel'
 import type { AnyJson } from '~/types/data'
 import { SUBMIT_TYPE } from '~/types/enum/enums'
 import { useRoute } from 'vue-router'
+import type { RouteLocationRaw } from 'vue-router'
 import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import type { AsyncData } from '#app'
 
 const props = defineProps({
   /**
@@ -40,7 +41,7 @@ const props = defineProps({
    * Si non renseigné, le component essaiera de l'extraire de la route actuelle
    */
   id: {
-    type: Number,
+    type: Number as PropType<number | null>,
     required: false,
     default: null,
   },
@@ -48,7 +49,7 @@ const props = defineProps({
    * Route de retour
    */
   goBackRoute: {
-    type: Object as PropType<RouteLocationRaw>,
+    type: Object as PropType<RouteLocationRaw | null>,
     required: false,
     default: null,
   },
@@ -77,7 +78,10 @@ const router = useRouter()
 const entityId =
   props.id !== null ? props.id : parseInt(route.params.id as string)
 
-const { data: entity, pending } = fetch(props.model, entityId)
+const { data: entity, pending } = fetch(props.model, entityId) as AsyncData<
+  ApiModel,
+  Error | null
+>
 
 const submitActions = computed(() => {
   let actions: AnyJson = {}

+ 30 - 8
composables/data/useEntityFetch.ts

@@ -1,9 +1,14 @@
 import type { AsyncData } from '#app'
 import type { ComputedRef, Ref } from 'vue'
 import { v4 as uuid4 } from 'uuid'
+import type {
+  AsyncDataExecuteOptions,
+  AsyncDataRequestStatus,
+} from '#app/composables/asyncData'
 import { useEntityManager } from '~/composables/data/useEntityManager'
 import ApiResource from '~/models/ApiResource'
-import type { AssociativeArray, Collection } from '~/types/data'
+import type { Collection } from '~/types/data'
+import Query from '~/services/data/Query'
 
 interface useEntityFetchReturnType {
   fetch: (
@@ -14,8 +19,16 @@ interface useEntityFetchReturnType {
   fetchCollection: (
     model: typeof ApiResource,
     parent?: ApiResource | null,
-    query?: Ref<AssociativeArray>,
-  ) => AsyncData<Collection | null, Error | null>
+    query?: Query | null,
+  ) => {
+    data: ComputedRef<Collection | null>
+    pending: Ref<boolean>
+    refresh: (
+      opts?: AsyncDataExecuteOptions,
+    ) => Promise<ComputedRef<Collection> | null>
+    error: Ref<Error | null>
+    status: Ref<AsyncDataRequestStatus>
+  }
 
   getRef: <T extends ApiResource>(
     model: new () => T,
@@ -39,14 +52,23 @@ export const useEntityFetch = (
   const fetchCollection = (
     model: typeof ApiResource,
     parent: ApiResource | null = null,
-    query: Ref<AssociativeArray | null> = ref(null),
-  ) =>
-    useAsyncData(
+    query: Query | null = null,
+  ) => {
+    const { data, pending, refresh, error, status } = useAsyncData(
       model.entity + '_many_' + uuid4(),
-      () => em.fetchCollection(model, parent, query.value ?? undefined),
-      { lazy },
+      () => em.fetchCollection(model, parent, query),
+      { lazy, deep: true },
     )
 
+    return {
+      data: computed(() => (data.value !== null ? data.value.value : null)),
+      pending,
+      refresh,
+      error,
+      status,
+    }
+  }
+
   const getRef = <T extends ApiResource>(
     model: new () => T,
     id: Ref<number | null>,

+ 179 - 0
doc/entity_manager.md

@@ -0,0 +1,179 @@
+# Entity Manager
+
+L'entity manager est la classe au coeur du requêtage des données. Il assure la liaison entre l'api et
+le store pinia-orm.
+
+## Modèles et entités
+
+Les modèles d'entités sont définis dans le répertoire `~/models`, sous forme de modèles
+[Pinia-ORM](https://pinia-orm.codedredd.de/guide/model/getting-started) et en correspondance
+avec leur définition côté API.
+
+### ApiResource et ApiModel
+
+Les ApiResources représentent toute entité pouvant être obtenue via une requête à l'API. C'est la classe de base
+gérée par l'entity manager.
+
+Les ApiModel sont des ApiResources qui supportent toutes les opérations de type CRUD.
+
+Ces deux classes correspondent aux ApiResources et aux Entities de Api-Platform.
+
+## Format Json-Ld (Hydra) et normalizer
+
+L'API renvoie ses données au format Json-Ld. La classe `hydraNormalizer` s'occupe ensuite de transformer ces données
+dans un format compréhensible par l'entity manager.
+
+Les entités simples sont castées en une instance de leur modèle.
+Les collections sont parsées en une liste d'instances de leur modèle.
+
+Les métadonnées, en particulier en ce qui concerne la pagination, sont aussi extraites.
+
+Le résultat est retourné sous la forme d'un objet de la forme :
+
+    {
+      data: [...],
+      metadata: {...},
+    }
+
+### Les champs IriEncoded
+
+Les relations entre les entités sont fournies par l'API sous forme d'IRI. De même, lors des opérations PUT/POST/PATCH,
+celle-ci attend aussi des IRI pour représenter ces relations.
+
+Hors le choix a été fait de stocker les ids des entités sous leurs formes numériques dans les stores Pinia-ORM.
+
+Pour permettre cette inter-opérabilité dans les deux sens, on utilise le décorateur `IriEncoded`, qui va permettre
+de signaler au normalizer qu'une propriété doit être décodée en entrée (son id sera extrait au format numérique), et
+qu'elle doit être retransformée en IRI dans l'autre sens.
+
+Exemple d'utilisation (classe `Access.ts`):
+
+    @IriEncoded(Organization)
+    declare organization: number | null
+
+## Entity Manager
+
+### Créer une nouvelle entité (non persistée)
+
+On créé une nouvelle instance d'entité en se servant de la méthode `newInstance`, à laquelle on peut passer les
+propriétés du nouvel objet sous forme d'objet JS.
+
+    const newOrganizationInstance = em.newInstance(Organization)
+
+    const newAccessInstance = em.newInstance(Access, { id: 1 })
+
+La nouvelle instance sera crée et enregistrée dans le store Pinia-ORM (mais pas persistée côté API)
+
+Les nouvelles entités (non persistée) se voient attribuer un id temporaire sous forme d'uuid.
+
+### Persister une entité
+
+Une entité existante ou nouvellement crée peut-être persistée côté API. Pour ce faire, on utilise la méthode `persist`
+à laquelle on passe le modèle et l'instance de l'entité à persister.
+
+    em.persist(Organization, myOrganization)
+
+C'est entre autres la méthode utilisée par les formulaires lors de l'action "enregistrer".
+
+**Attention** : Lorsqu'on persiste une entité, l'entity manager va utiliser les données de retour de la requête envoyée
+à l'API, et mettre à jour le store en fonction (et ce afin d'éviter des écarts involontaires entre les données front
+et back). Ce qui veut dire que selon le traitement effectué par l'API, l'entité envoyée et celle qui résulte
+de l'opération peuvent différer.
+
+### Fetch une entité simple
+
+Si l'entité a déjà été fetchée, et que l'argument `forceRefresh` est faux, l'entité est simplement retournée depuis le
+store.
+
+Sinon, l'opération fetch se déroule en deux temps.
+
+D'abord, une requête GET est envoyée à l'API au moyen de la classe ApiRequestService (surcouche de la librairie
+ohfetch). Le résultat de cette requête est converti en ApiResource par le normalizer, puis enregistrée dans le store.
+
+On la récupère ensuite dans le store sous forme de référence avant de la retourner. En effet, si on retournait
+directement le résultat de l'appel à l'API plutôt qu'une référence au store Pinia-ORM, on perdrait la réactivité.
+
+### Fetch une Collection
+
+Fetcher une collection est plus compliqué. La requête implique des conditions de filtre, de tri, de pagination.
+Afin de pouvoir garder un lien entre les résultats retournés par l'API et les résultats à aller
+ensuite chercher dans le store, il faut un système permettant de passer les mêmes conditions à ces deux
+interfaces (API / PiniaOrm). C'est le rôle de la classe Query et des Filters (voir plus bas).
+
+Une fois définies les conditions au moyen de l'objet Query, l'appel à fetchCollection se passe de la même manière
+que lors de l'appel à la méthode `fetch` (appel, puis récupération des résultats dans le store). La seule nuance
+est que le résultat est un objet Collection, incluant les résultats sous la forme d'une référence
+calculée (`ComputedRef`), et les données de pagination :
+
+    {
+      items,
+      totalItems: 100,
+      pagination: {
+        first: 1,
+        last: 10,
+        next: 5,
+        previous: 3,
+      },
+    }
+
+#### L'objet Query et les Filters
+
+Les filtres, les tris et la pagination sont donc empaquetés dans un objet Query :
+
+    const query = new Query()
+
+On pourra ensuite ajouter des filtres à cette query :
+
+    query.add(new SearchFilter('name', searchFilter, SEARCH_STRATEGY.IPARTIAL))
+    query.add(new OrderBy('name', ORDER_BY_DIRECTION.ASC))
+    query.add(new PageFilter(page, itemsPerPage))
+
+Cette query traduira ensuite ces conditions en URL query pour l'appel à l'API d'une part,
+et en query PiniaORM d'autre part.
+
+> NB : les filtres de type 'where' seront toujours appliqués en premier, puis les tris, et enfin la pagination.
+
+### Reset une entité
+
+L'entity manager stocke dans le store un clone non modifié de chaque entité fetchée depuis l'API. Ces clones sont
+enregistrés avec des ids préfixés par `__clone__`, et ils sont automatiquement exclus des requêtes Pinia-ORM
+lorsque celles-ci sont construites au moyen de la méthode `getQuery` de l'entity manager.
+
+La méthode `reset` permettra de réinitialiser une entité modifiée dans le store pour la ramener à l'état qu'elle
+avait la dernière fois que l'API l'a renvoyée.
+
+### Supprimer une entité
+
+On peut supprimer une entité au moyen de la méthode `delete` de l'entity manager.
+Si l'entité existe dans l'API, une requête de suppression sera envoyée à celle ci.
+
+## EnumManager et ImageManager
+
+### EnumManager
+
+La classe EnumManager permettra de fetcher auprès de l'API des Enum, et de les retourner sous forme de listes JS.
+
+### ImageManager
+
+La classe ImageManger donnera accès à des méthodes permettant de télécharger une image depuis l'API et de la retourner
+sous forme de Base64, ou d'uploader une image.
+
+## Composables et useAsyncData
+
+### Utiliser les services dans Vue
+
+Les différentes classes de manager, ainsi que le service de requête à l'API, sont disponibles sous forme de
+composables : `useEntityManger`, `useImageManager`, ...etc.
+
+Exemple :
+
+    const ap2iRequestService = useAp2iRequestService()
+
+### Fetch avec useAsyncData
+
+Les composables `useEntityFetch`, `useEnumFetch` et `useImageFetch` permettent d'accéder aux différentes
+méthodes `fetch` des managers, sous la forme d'un appel à [useAsyncData](https://nuxt.com/docs/api/composables/use-async-data).
+
+Exemple d'utilisation :
+
+    const { data: parameters, pending, refresh } = fetch(Parameters, 123)

+ 7 - 1
models/Organization/Subdomain.ts

@@ -1,5 +1,7 @@
-import { Bool, Str, Uid } from 'pinia-orm/dist/decorators'
+import { Attr, Bool, Str, Uid } from 'pinia-orm/dist/decorators'
 import ApiModel from '~/models/ApiModel'
+import { IriEncoded } from '~/models/decorators'
+import Organization from '~/models/Organization/Organization'
 
 /**
  * AP2i Model : Subdomain
@@ -17,4 +19,8 @@ export default class Subdomain extends ApiModel {
 
   @Bool(false, { notNullable: true })
   declare active: boolean
+
+  @Attr(null)
+  @IriEncoded(Organization)
+  declare organization: number | null
 }

+ 1 - 0
nuxt.config.ts

@@ -215,4 +215,5 @@ export default defineNuxtConfig({
   build: {
     transpile,
   },
+  ignore: [process.env.NUXT_ENV === 'prod' ? 'pages/dev/*' : ''],
 })

+ 1 - 0
package.json

@@ -34,6 +34,7 @@
     "@vuepic/vue-datepicker": "^7.4.0",
     "cleave.js": "^1.6.0",
     "date-fns": "^2.30.0",
+    "eslint-import-resolver-typescript": "^3.6.1",
     "event-source-polyfill": "^1.0.31",
     "file-saver": "^2.0.5",
     "js-yaml": "^4.1.0",

+ 115 - 0
pages/dev/poc_fetch_collection.vue

@@ -0,0 +1,115 @@
+<!--
+Permet de tester la méthode fetchCollection de nuxt
+et la réactivité de la collection retournée.
+
+Le filtre textuel doit s'appliquer à la liste de résultats quand on clique sur refresh.
+Si on ajoute un nouveau pays, il doit apparaitre dans la liste seulement s'il valide le filtre actif.
+
+Exemple :
+
+1. A l'affichage, je dois avoir la liste des pays
+2. Si je tape "fra" dans le filtre et que je valide, il ne doit rester que la France
+3. Si je créé le pays "francie", il doit s'ajouter à la liste, trié si un OrderBy est actif
+4. Si je créé le pays "mon super pays", il ne doit pas apparaitre.
+-->
+
+<template>
+  <div class="pa-8">
+    <h1>POC</h1>
+
+    <div class="d-flex flex-row my-8">
+      <v-text-field v-model="searchFilter" style="max-width: 200px" />
+      <v-btn class="ma-4" @click="onRefreshClick">Refresh</v-btn>
+    </div>
+
+    <v-row>
+      <v-col cols="3">
+        <h3>From Entity Manager</h3>
+
+        <div v-if="!pending && data !== null">
+          <div>{{ data.totalItems || 0 }} results</div>
+
+          <ul>
+            <li v-for="country in data.items" :key="country.id">
+              {{ country.name }}
+            </li>
+          </ul>
+        </div>
+        <span v-else>Loading...</span>
+      </v-col>
+    </v-row>
+
+    <v-pagination
+      v-model="page"
+      :length="totalPages"
+      :total-visible="7"
+      @update:model-value="refresh"
+    />
+
+    <div class="d-flex flex-row">
+      <v-text-field v-model="newVal" class="my-4" style="max-width: 200px" />
+      <v-btn class="ma-4" @click="onAddClick">Add organization</v-btn>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import Query from '~/services/data/Query'
+import SearchFilter from '~/services/data/Filters/SearchFilter'
+import { ORDER_BY_DIRECTION, SEARCH_STRATEGY } from '~/types/enum/data'
+import Country from '~/models/Core/Country'
+import OrderBy from '~/services/data/Filters/OrderBy'
+import PageFilter from '~/services/data/Filters/PageFilter'
+
+const { fetchCollection } = useEntityFetch()
+
+const query = new Query()
+
+const searchFilter = ref('')
+
+const newVal = ref('')
+
+const page = ref(1)
+const itemsPerPage = ref(10)
+
+query.add(new SearchFilter('name', searchFilter, SEARCH_STRATEGY.IPARTIAL))
+query.add(new OrderBy('name', ORDER_BY_DIRECTION.ASC))
+query.add(new PageFilter(page, itemsPerPage))
+
+const { data, pending, refresh } = fetchCollection(Country, null, query)
+
+const totalPages = computed(() =>
+  data.value ? data.value?.pagination.last : 1,
+)
+
+console.log(data)
+// console.log(data.value!.items)
+
+let id = 100000000
+const onAddClick = () => {
+  if (!newVal.value) {
+    throw new Error('no val')
+  }
+
+  const country = new Country()
+  country.id = id
+  country.name = newVal.value
+
+  const repo = useRepo(Country)
+  repo.save(country)
+
+  id += 1
+}
+
+const onRefreshClick = () => {
+  page.value = 1
+  refresh()
+}
+</script>
+
+<style scoped lang="scss">
+h1 {
+  color: rgb(var(--v-theme-primary));
+}
+</style>

+ 3 - 0
pages/dev/readme.md

@@ -0,0 +1,3 @@
+# Pages de test
+
+Ces pages sont exclues de la build de l'application en production (cf. nuxt.config.ts => ignore section).

+ 9 - 20
pages/parameters/attendances.vue

@@ -47,8 +47,11 @@
             <td></td>
           </tr>
         </thead>
-        <tbody v-if="attendanceBookingReasons.length > 0">
-          <tr v-for="reason in attendanceBookingReasons" :key="reason.id">
+        <tbody v-if="attendanceBookingReasons!.items.length > 0">
+          <tr
+            v-for="reason in attendanceBookingReasons!.items"
+            :key="reason.id"
+          >
             <td class="cycle-editable-cell">
               {{ reason.reason }}
             </td>
@@ -90,14 +93,11 @@
 </template>
 <script setup lang="ts">
 import type { AsyncData } from '#app'
-import { type Collection, useRepo } from 'pinia-orm'
-import type { ComputedRef } from 'vue'
 import Parameters from '~/models/Organization/Parameters'
 import { useEntityFetch } from '~/composables/data/useEntityFetch'
 import { useOrganizationProfileStore } from '~/stores/organizationProfile'
 import UrlUtils from '~/services/utils/urlUtils'
 import AttendanceBookingReason from '~/models/Booking/AttendanceBookingReason'
-import AttendanceBookingReasonRepository from '~/stores/repositories/AttendanceBookingReasonRepository'
 
 definePageMeta({
   name: 'parameters_attendances_page',
@@ -120,11 +120,10 @@ const { data: parameters, pending } = fetch(
 
 const { fetchCollection } = useEntityFetch()
 
-const { pending: attendanceBookingReasonsPending } = fetchCollection(
-  AttendanceBookingReason,
-)
-
-const attendanceBookingReasonsRepo = useRepo(AttendanceBookingReasonRepository)
+const {
+  data: attendanceBookingReasons,
+  pending: attendanceBookingReasonsPending,
+} = fetchCollection(AttendanceBookingReason)
 
 const rules = () => [
   (numberConsecutiveAbsences: string | null) =>
@@ -133,16 +132,6 @@ const rules = () => [
     i18n.t('please_enter_a_value'),
 ]
 
-/**
- * On récupère les timings via le store
- * (sans ça, les mises à jour SSE ne seront pas prises en compte)
- */
-const attendanceBookingReasons: ComputedRef<
-  Collection<AttendanceBookingReason>
-> = computed(() => {
-  return attendanceBookingReasonsRepo.getReasons()
-})
-
 const goToEditPage = (id: number) => {
   navigateTo(UrlUtils.join('/parameters/attendance_booking_reasons', id))
 }

+ 3 - 16
pages/parameters/education_timings/index.vue

@@ -9,8 +9,8 @@
             <td></td>
           </tr>
         </thead>
-        <tbody v-if="educationTimings.length > 0">
-          <tr v-for="timing in educationTimings" :key="timing.id">
+        <tbody v-if="educationTimings!.items.length > 0">
+          <tr v-for="timing in educationTimings!.items" :key="timing.id">
             <td class="cycle-editable-cell">
               {{ timing.timing }}
             </td>
@@ -52,11 +52,8 @@
 </template>
 
 <script setup lang="ts">
-import { useRepo } from 'pinia-orm'
-import type { ComputedRef } from 'vue'
 import { useEntityFetch } from '~/composables/data/useEntityFetch'
 import EducationTiming from '~/models/Education/EducationTiming'
-import EducationTimingsRepository from '~/stores/repositories/EducationTimingsRepository'
 import { useOrganizationProfileStore } from '~/stores/organizationProfile'
 import UrlUtils from '~/services/utils/urlUtils'
 
@@ -72,17 +69,7 @@ if (organizationProfile.parametersId === null) {
 
 const { fetchCollection } = useEntityFetch()
 
-const { pending } = fetchCollection(EducationTiming)
-
-const educationTimingRepo = useRepo(EducationTimingsRepository)
-
-/**
- * On récupère les timings via le store
- * (sans ça, les mises à jour SSE ne seront pas prises en compte)
- */
-const educationTimings: ComputedRef<Array<EducationTiming>> = computed(() => {
-  return educationTimingRepo.getEducationTimings()
-})
+const { data: educationTimings, pending } = fetchCollection(EducationTiming)
 
 const goToEditPage = (id: number) => {
   navigateTo(UrlUtils.join('/parameters/education_timings', id))

+ 6 - 16
pages/parameters/residence_areas/index.vue

@@ -9,8 +9,11 @@
             <td></td>
           </tr>
         </thead>
-        <tbody v-if="residenceAreas.length > 0">
-          <tr v-for="residenceArea in residenceAreas" :key="residenceArea.id">
+        <tbody v-if="residenceAreas!.items.length > 0">
+          <tr
+            v-for="residenceArea in residenceAreas!.items"
+            :key="residenceArea.id"
+          >
             <td class="cycle-editable-cell">
               {{ residenceArea.label }}
             </td>
@@ -53,30 +56,17 @@
 </template>
 
 <script setup lang="ts">
-import { useRepo } from 'pinia-orm'
-import type { ComputedRef } from 'vue'
 import { useEntityFetch } from '~/composables/data/useEntityFetch'
 import ResidenceArea from '~/models/Billing/ResidenceArea'
-import ResidenceAreasRepository from '~/stores/repositories/ResidenceAreasRepository'
 import UrlUtils from '~/services/utils/urlUtils'
 
 definePageMeta({
   name: 'parameters_residence_areas_page',
 })
 
-const residenceAreasRepo = useRepo(ResidenceAreasRepository)
-
 const { fetchCollection } = useEntityFetch()
 
-const { pending } = fetchCollection(ResidenceArea)
-
-/**
- * On récupère les Residence Area via le store
- * (sans ça, les mises à jour SSE ne seront pas prises en compte)
- */
-const residenceAreas: ComputedRef<Array<ResidenceArea>> = computed(() => {
-  return residenceAreasRepo.getResidenceAreas()
-})
+const { data: residenceAreas, pending } = fetchCollection(ResidenceArea)
 
 const goToEditPage = (id: number) => {
   navigateTo(UrlUtils.join('/parameters/residence_areas', id))

+ 7 - 3
pages/parameters/website.vue

@@ -45,10 +45,10 @@
             </div>
             <UiLoadingPanel v-if="subdomainsPending" />
             <div v-else>
-              <v-table v-if="subdomains" class="subdomains-table my-2">
+              <v-table v-if="subdomains!.items" class="subdomains-table my-2">
                 <tbody>
                   <tr
-                    v-for="subdomain in subdomains.items"
+                    v-for="subdomain in subdomains!.items"
                     :key="subdomain.id"
                     :title="subdomain.subdomain"
                     :class="
@@ -145,6 +145,8 @@ import Parameters from '~/models/Organization/Parameters'
 import { useEntityFetch } from '~/composables/data/useEntityFetch'
 import Subdomain from '~/models/Organization/Subdomain'
 import ApiResource from '~/models/ApiResource'
+import EqualFilter from '~/services/data/Filters/EqualFilter'
+import Query from '~/services/data/Query'
 
 definePageMeta({
   name: 'parameters_website_page',
@@ -163,10 +165,12 @@ const { data: parameters, pending } = fetch(
   organizationProfile.parametersId,
 ) as AsyncData<ApiResource | null, Error | null>
 
+const query = new Query(new EqualFilter('organization', organizationProfile.id))
+
 const { data: subdomains, pending: subdomainsPending } = fetchCollection(
   Subdomain,
   null,
-  ref({ organization: organizationProfile.id }),
+  query,
 )
 
 const canAddNewSubdomain: ComputedRef<boolean> = computed(

+ 1 - 0
regex_pattern.txt

@@ -0,0 +1 @@
+(?!typo3/).*

+ 14 - 0
services/data/Filters/AbstractFilter.ts

@@ -0,0 +1,14 @@
+export default abstract class AbstractFilter {
+  reactiveFilter: boolean
+
+  /**
+   * @param reactiveFilter Est-ce qu'on doit conserver la réactivité du filtre ? Concrètement, dans le cas d'une
+   *                       recherche textuelle, si le filtre est réactif, le résultat de la query Pinia-ORM sera
+   *                       filtré à chaque fois que le filtre est modifié (même sans refresh ou nouvel appel à
+   *                       fetchCollection). Si reactiveFilter est false (comportement par défaut), le résultat
+   *                       de la query ne sera mis à jour qu'en cas de nouvel appel à fetchCollection (ou à refresh()).
+   */
+  protected constructor(reactiveFilter: boolean = false) {
+    this.reactiveFilter = reactiveFilter
+  }
+}

+ 54 - 0
services/data/Filters/EqualFilter.ts

@@ -0,0 +1,54 @@
+import type { Query as PiniaOrmQuery } from 'pinia-orm'
+import type { Ref } from 'vue'
+import type { ApiFilter } from '~/types/data'
+import ApiResource from '~/models/ApiResource'
+import AbstractFilter from '~/services/data/Filters/AbstractFilter'
+import RefUtils from '~/services/utils/refUtils'
+
+export default class EqualFilter extends AbstractFilter implements ApiFilter {
+  field: string
+  filterValue: string | number | null | Ref<string | number | null>
+
+  /**
+   * @param field
+   * @param value
+   * @param reactiveFilter
+   */
+  constructor(
+    field: string,
+    value: string | number | null | Ref<string | number | null>,
+    reactiveFilter: boolean = false,
+  ) {
+    super(reactiveFilter)
+    this.field = field
+    this.filterValue = value
+  }
+
+  public applyToPiniaOrmQuery(
+    query: PiniaOrmQuery<ApiResource>,
+  ): PiniaOrmQuery<ApiResource> {
+    const filterValue = RefUtils.castToRef(
+      this.filterValue,
+      this.reactiveFilter,
+    )
+
+    if (filterValue.value === null) {
+      return query
+    }
+
+    return query.where(this.field, filterValue.value)
+  }
+
+  public getApiQueryPart(): string {
+    const filterValue = RefUtils.castToRef(
+      this.filterValue,
+      this.reactiveFilter,
+    )
+
+    if (filterValue.value === null) {
+      return ''
+    }
+
+    return `${this.field}=${filterValue.value}`
+  }
+}

+ 35 - 0
services/data/Filters/OrderBy.ts

@@ -0,0 +1,35 @@
+import type { Query as PiniaOrmQuery } from 'pinia-orm'
+import type { ApiFilter } from '~/types/data'
+import ApiResource from '~/models/ApiResource'
+import { ORDER_BY_DIRECTION } from '~/types/enum/data'
+import StringUtils from '~/services/utils/stringUtils'
+
+export default class OrderBy implements ApiFilter {
+  field: string
+  mode: ORDER_BY_DIRECTION
+
+  /**
+   * @param field
+   * @param mode
+   */
+  constructor(
+    field: string,
+    mode: ORDER_BY_DIRECTION = ORDER_BY_DIRECTION.ASC,
+  ) {
+    this.field = field
+    this.mode = mode
+  }
+
+  public applyToPiniaOrmQuery(
+    query: PiniaOrmQuery<ApiResource>,
+  ): PiniaOrmQuery<ApiResource> {
+    return query.orderBy(
+      (instance) => StringUtils.normalize(instance[this.field]),
+      this.mode,
+    )
+  }
+
+  public getApiQueryPart(): string {
+    return `order[${this.field}]=${this.mode}`
+  }
+}

+ 34 - 0
services/data/Filters/PageFilter.ts

@@ -0,0 +1,34 @@
+import type { Query as PiniaOrmQuery } from 'pinia-orm'
+import type { ApiFilter } from '~/types/data'
+import ApiResource from '~/models/ApiResource'
+import RefUtils from '~/services/utils/refUtils'
+
+export default class PageFilter implements ApiFilter {
+  page: Ref<number>
+  itemsPerPage: Ref<number>
+
+  /**
+   * @param page
+   * @param itemsPerPage
+   */
+  constructor(page: Ref<number>, itemsPerPage: Ref<number>) {
+    this.page = page
+    this.itemsPerPage = itemsPerPage
+  }
+
+  public applyToPiniaOrmQuery(
+    query: PiniaOrmQuery<ApiResource>,
+  ): PiniaOrmQuery<ApiResource> {
+    const page = RefUtils.castToRef(this.page, false)
+
+    const itemsPerPage = RefUtils.castToRef(this.itemsPerPage, false)
+
+    return query
+      .offset(itemsPerPage.value * (page.value - 1))
+      .limit(itemsPerPage.value)
+  }
+
+  public getApiQueryPart(): string {
+    return `page=${this.page.value}&itemsPerPage=${this.itemsPerPage.value}`
+  }
+}

+ 90 - 0
services/data/Filters/SearchFilter.ts

@@ -0,0 +1,90 @@
+import type { Query as PiniaOrmQuery } from 'pinia-orm'
+import type { Ref } from 'vue'
+import type { ApiFilter } from '~/types/data'
+import ApiResource from '~/models/ApiResource'
+import { SEARCH_STRATEGY } from '~/types/enum/data'
+import AbstractFilter from '~/services/data/Filters/AbstractFilter'
+import RefUtils from '~/services/utils/refUtils'
+
+export default class SearchFilter extends AbstractFilter implements ApiFilter {
+  field: string
+  filterValue: Ref<string | null>
+  mode: SEARCH_STRATEGY
+
+  /**
+   * @param field
+   * @param value
+   * @param mode The 'search' strategy (exact [default], partial, start, end, word_start).
+   *             This strategy is defined API-side, but PiniaOrm needs to know how to handle this.
+   *             @see https://api-platform.com/docs/core/filters/
+   * @param reactiveFilter
+   */
+  constructor(
+    field: string,
+    value: Ref<string | null>,
+    mode: SEARCH_STRATEGY = SEARCH_STRATEGY.EXACT,
+    reactiveFilter: boolean = false,
+  ) {
+    super(reactiveFilter)
+    this.field = field
+    this.filterValue = value
+    this.mode = mode
+  }
+
+  protected search(value: string, filterValue: Ref<string | null>): boolean {
+    if (filterValue.value === null) {
+      return false
+    }
+
+    let wordStartRx: RegExp | null = null
+    if (this.mode === SEARCH_STRATEGY.WORD_START) {
+      wordStartRx = new RegExp(
+        `^${filterValue.value}|\\s${filterValue.value}`,
+        'i',
+      )
+    }
+
+    if (this.mode === SEARCH_STRATEGY.EXACT) {
+      return value === filterValue.value
+    } else if (this.mode === SEARCH_STRATEGY.IEXACT) {
+      return value.toLowerCase() === filterValue.value.toLowerCase()
+    } else if (this.mode === SEARCH_STRATEGY.PARTIAL) {
+      return value.includes(filterValue.value)
+    } else if (this.mode === SEARCH_STRATEGY.IPARTIAL) {
+      return value.toLowerCase().includes(filterValue.value.toLowerCase())
+    } else if (this.mode === SEARCH_STRATEGY.START) {
+      return value.startsWith(filterValue.value)
+    } else if (this.mode === SEARCH_STRATEGY.END) {
+      return value.endsWith(filterValue.value)
+    } else if (this.mode === SEARCH_STRATEGY.WORD_START) {
+      return wordStartRx!.test(value)
+    } else {
+      throw new Error('Unrecognized mode')
+    }
+  }
+
+  public applyToPiniaOrmQuery(
+    query: PiniaOrmQuery<ApiResource>,
+  ): PiniaOrmQuery<ApiResource> {
+    const filterValue = RefUtils.castToRef(
+      this.filterValue,
+      this.reactiveFilter,
+    )
+
+    if (!filterValue.value) {
+      return query
+    }
+
+    return query.where(this.field, (value: string) =>
+      this.search(value, filterValue),
+    )
+  }
+
+  public getApiQueryPart(): string {
+    if (!this.filterValue.value) {
+      return ''
+    }
+
+    return `${this.field}[]=${this.filterValue.value}`
+  }
+}

+ 79 - 0
services/data/Query.ts

@@ -0,0 +1,79 @@
+import type { Query as PiniaOrmQuery } from 'pinia-orm'
+import type { ApiFilter } from '~/types/data'
+import type ApiResource from '~/models/ApiResource'
+import PageFilter from '~/services/data/Filters/PageFilter'
+import OrderBy from '~/services/data/Filters/OrderBy'
+
+/**
+ * A Query to filter and sort ApiResources.
+ * Pass it to the `fetchCollection` method of the EntityManager to apply these filters to both
+ * API fetch and PiniaOrm query, which allow to maintain this collection reactivity.
+ */
+export default class Query {
+  protected filters: Array<ApiFilter> = []
+
+  constructor(...filters: Array<ApiFilter>) {
+    this.filters = filters
+  }
+
+  /**
+   * Add an ApiFilter to the query
+   *
+   * @param filter
+   */
+  public add(filter: ApiFilter): this {
+    this.filters.push(filter)
+    return this
+  }
+
+  /**
+   * Returns the URL's query in the Api Platform format.
+   *
+   * @see https://api-platform.com/docs/core/filters/
+   */
+  public getUrlQuery(): string {
+    const queryParts: string[] = []
+
+    this.filters.forEach((filter) => {
+      const queryPart = filter.getApiQueryPart()
+      if (queryPart) {
+        queryParts.push(queryPart)
+      }
+    })
+
+    return queryParts.filter((p) => !!p).join('&')
+  }
+
+  /**
+   * Apply this query to the pinia orm query and return it.
+   *
+   * @see https://pinia-orm.codedredd.de/guide/repository/retrieving-data
+   * @param query
+   */
+  public applyToPiniaOrmQuery(
+    query: PiniaOrmQuery<ApiResource>,
+  ): PiniaOrmQuery<ApiResource> {
+    // 'Where' filters shall be applied first, then orderBy filters, and finally pagination
+    const where: ApiFilter[] = []
+    const orderBy: ApiFilter[] = []
+    const pagination: ApiFilter[] = []
+
+    this.filters.forEach((filter) => {
+      if (filter instanceof PageFilter) {
+        pagination.push(filter)
+      } else if (filter instanceof OrderBy) {
+        orderBy.push(filter)
+      } else {
+        where.push(filter)
+      }
+    })
+
+    const filters = [...where, ...orderBy, ...pagination]
+
+    filters.forEach((filter) => {
+      query = filter.applyToPiniaOrmQuery(query)
+    })
+
+    return query
+  }
+}

+ 12 - 6
services/data/apiRequestService.ts

@@ -21,7 +21,10 @@ class ApiRequestService {
    * @param url
    * @param query
    */
-  public async get(url: string, query: AssociativeArray | null = null) {
+  public async get(
+    url: string,
+    query: AssociativeArray | string | null = null,
+  ) {
     return await this.request(HTTP_METHOD.GET, url, null, query)
   }
 
@@ -35,7 +38,7 @@ class ApiRequestService {
   public async post(
     url: string,
     body: string | AnyJson | null = null,
-    query: AssociativeArray | null = null,
+    query: AssociativeArray | string | null = null,
   ) {
     return await this.request(HTTP_METHOD.POST, url, body, query)
   }
@@ -50,7 +53,7 @@ class ApiRequestService {
   public async put(
     url: string,
     body: string | AnyJson | null = null,
-    query: AssociativeArray | null = null,
+    query: AssociativeArray | string | null = null,
   ) {
     return await this.request(HTTP_METHOD.PUT, url, body, query)
   }
@@ -61,7 +64,10 @@ class ApiRequestService {
    * @param url
    * @param query
    */
-  public async delete(url: string, query: AssociativeArray | null = null) {
+  public async delete(
+    url: string,
+    query: AssociativeArray | string | null = null,
+  ) {
     return await this.request(HTTP_METHOD.DELETE, url, null, query)
   }
 
@@ -78,10 +84,10 @@ class ApiRequestService {
     method: HTTP_METHOD,
     url: string,
     body: string | AnyJson | null = null,
-    query: AssociativeArray | null = null,
+    query: AssociativeArray | string | null = null,
   ): Promise<Response> {
     const config: FetchOptions = { method }
-    if (query) {
+    if (query && typeof query !== 'string') {
       config.query = query
     }
     if (method === HTTP_METHOD.POST || method === HTTP_METHOD.PUT) {

+ 42 - 16
services/data/entityManager.ts

@@ -1,6 +1,8 @@
-import { Repository } from 'pinia-orm'
+import { Query as PiniaOrmQuery, Repository } from 'pinia-orm'
+import type { Collection as PiniaOrmCollection } from 'pinia-orm'
 import { v4 as uuid4 } from 'uuid'
 import * as _ from 'lodash-es'
+import { computed } from 'vue'
 import ApiRequestService from './apiRequestService'
 import UrlUtils from '~/services/utils/urlUtils'
 import ApiModel from '~/models/ApiModel'
@@ -8,6 +10,7 @@ import ApiResource from '~/models/ApiResource'
 import type { AnyJson, AssociativeArray, Collection } from '~/types/data.d'
 import models from '~/models/models'
 import HydraNormalizer from '~/services/data/normalizer/hydraNormalizer'
+import Query from '~/services/data/Query'
 
 /**
  * Entity manager: make operations on the models defined with the Pinia-Orm library
@@ -47,6 +50,16 @@ class EntityManager {
     return this._getRepo(model)
   }
 
+  /**
+   * Return a pinia-orm query for the model
+   *
+   * @param model
+   */
+  public getQuery(model: typeof ApiResource): PiniaOrmQuery<ApiResource> {
+    // TODO: quid des uuid?
+    return this.getRepository(model).where((val) => Number.isInteger(val.id))
+  }
+
   /**
    * Cast an object as an ApiResource
    * This in used internally to ensure the object is recognized as an ApiResource
@@ -166,6 +179,7 @@ class EntityManager {
   ): Promise<ApiResource> {
     // If the model instance is already in the store and forceRefresh is false, return the object in store
     if (!forceRefresh) {
+      // TODO: est-ce qu'il y a vraiment des situations où on appellera cette méthode sans le forceRefresh?
       const item = this.find(model, id)
       if (item && typeof item !== 'undefined') {
         return item
@@ -185,17 +199,16 @@ class EntityManager {
 
   /**
    * Fetch a collection of model instances
-   * The content of `query` is converted into a query-string in the request URL
    *
    * @param model
-   * @param query
    * @param parent
+   * @param query
    */
   public async fetchCollection(
     model: typeof ApiResource,
     parent: ApiResource | null,
-    query: AssociativeArray | null = null,
-  ): Promise<Collection> {
+    query: Query | null = null,
+  ): Promise<ComputedRef<Collection>> {
     let url
 
     if (parent !== null) {
@@ -204,25 +217,38 @@ class EntityManager {
       url = UrlUtils.join('api', model.entity)
     }
 
-    const response = await this.apiRequestService.get(url, query)
+    if (query) {
+      url += '?' + query.getUrlQuery()
+    }
+
+    const response = await this.apiRequestService.get(url)
 
     // deserialize the response
     const apiCollection = HydraNormalizer.denormalize(response, model)
 
-    const items = apiCollection.data.map((attributes: object) => {
+    apiCollection.data.map((attributes: object) => {
       return this.newInstance(model, attributes)
     })
 
-    return {
-      items,
-      totalItems: apiCollection.metadata.totalItems,
-      pagination: {
-        first: apiCollection.metadata.firstPage || 1,
-        last: apiCollection.metadata.lastPage || 1,
-        next: apiCollection.metadata.nextPage || undefined,
-        previous: apiCollection.metadata.previousPage || undefined,
-      },
+    let piniaOrmQuery = this.getQuery(model)
+    if (query) {
+      piniaOrmQuery = query.applyToPiniaOrmQuery(piniaOrmQuery)
     }
+
+    return computed(() => {
+      const items: PiniaOrmCollection<ApiResource> = piniaOrmQuery.get()
+
+      return {
+        items,
+        totalItems: apiCollection.metadata.totalItems,
+        pagination: {
+          first: apiCollection.metadata.firstPage || 1,
+          last: apiCollection.metadata.lastPage || 1,
+          next: apiCollection.metadata.nextPage || undefined,
+          previous: apiCollection.metadata.previousPage || undefined,
+        },
+      }
+    })
   }
 
   /**

+ 27 - 0
services/utils/refUtils.ts

@@ -0,0 +1,27 @@
+import type { UnwrapRef } from 'vue'
+import { ref, isRef } from 'vue'
+
+export default class RefUtils {
+  /**
+   * Convertit la valeur du filtre en référence. S'il s'agit déjà d'une ref,
+   * selon que `maintainReactivity` est vrai ou faux, on conserve la référence existante
+   * ou bien on la recréé pour briser la réactivité.
+   *
+   * @param value
+   * @param maintainReactivity
+   */
+  static castToRef<T>(
+    value: T | Ref<T>,
+    maintainReactivity: boolean = true,
+  ): Ref<T> | Ref<UnwrapRef<T>> {
+    if (isRef(value)) {
+      if (maintainReactivity) {
+        return value
+      } else {
+        return ref(value.value as T)
+      }
+    } else {
+      return ref(value as T)
+    }
+  }
+}

+ 12 - 7
services/utils/stringUtils.ts

@@ -7,13 +7,18 @@ export default class StringUtils {
   public static normalize(s: string): string {
     return s
       .toLowerCase()
-      .replace(/[éèẽëê]/g, 'e')
-      .replace(/[ç]/g, 'c')
-      .replace(/[îïĩ]/g, 'i')
-      .replace(/[àã]/g, 'a')
-      .replace(/[öôõ]/g, 'o')
-      .replace(/[ûüũ]/g, 'u')
-      .replace(/[-]/g, ' ')
+      .replace(/[éèẽëêēĕėęě]/g, 'e')
+      .replace(/[çćĉċč]/g, 'c')
+      .replace(/[îïĩìíīĭ]/g, 'i')
+      .replace(/[àãâåáäāăą]/g, 'a')
+      .replace(/[ĝğġģ]/g, 'g')
+      .replace(/[ħĥ]/g, 'h')
+      .replace(/[öôõó]/g, 'o')
+      .replace(/[ûüũùú]/g, 'u')
+      .replace(/[š]/g, 's')
+      .replace(/[ÿý]/g, 'y')
+      .replace(/[ž]/g, 'z')
+      .replace(/-/g, ' ')
       .trim()
   }
 

+ 78 - 39
tests/units/services/data/entityManager.test.ts

@@ -2,10 +2,19 @@ import { describe, test, vi, expect, beforeEach, afterEach } from 'vitest'
 import { Repository } from 'pinia-orm'
 import type { Element } from 'pinia-orm'
 import { Str, Uid } from 'pinia-orm/dist/decorators'
-import EntityManager from '~/services/data/entityManager'
 import ApiResource from '~/models/ApiResource'
 import ApiModel from '~/models/ApiModel'
 import ApiRequestService from '~/services/data/apiRequestService'
+import EntityManager from '~/services/data/entityManager'
+
+class TestableEntityManager extends EntityManager {
+  public removeTempAfterPersist(
+    model: typeof ApiResource,
+    tempInstanceId: number | string,
+  ): void {
+    return super.removeTempAfterPersist(model, tempInstanceId)
+  }
+}
 
 class DummyApiResource extends ApiResource {
   static entity = 'dummyResource'
@@ -46,7 +55,7 @@ vi.mock('~/models/models', async () => {
 })
 
 let apiRequestService: ApiRequestService
-let entityManager: EntityManager
+let entityManager: TestableEntityManager
 let repo: Repository<ApiResource>
 let _getRepo: (model: typeof ApiResource) => Repository<ApiResource>
 
@@ -58,7 +67,7 @@ beforeEach(() => {
   apiRequestService = vi.fn() as ApiRequestService
   _getRepo = vi.fn((model: typeof ApiResource) => repo)
 
-  entityManager = new EntityManager(apiRequestService, _getRepo)
+  entityManager = new TestableEntityManager(apiRequestService, _getRepo)
 })
 
 afterEach(() => {
@@ -76,6 +85,31 @@ describe('getRepository', () => {
   })
 })
 
+describe('getQuery', () => {
+  test('simple call', () => {
+    // @ts-ignore
+    const repo = vi.fn()
+
+    // @ts-ignore
+    entityManager.getRepository = vi.fn(() => repo)
+
+    const query = vi.fn()
+
+    // @ts-ignore
+    repo.where = vi.fn((method) => {
+      // Query method is supposed to exclude NaN ids values
+      expect(method({ id: 123 })).toBeTruthy()
+      expect(method({ id: 'abc' })).toBeFalsy()
+
+      return query
+    })
+
+    const result = entityManager.getQuery(DummyApiResource)
+
+    expect(result).toEqual(query)
+  })
+})
+
 describe('cast', () => {
   test('simple cast', () => {
     // @ts-ignore
@@ -324,12 +358,14 @@ describe('fetchCollection', () => {
       },
     )
 
+    const piniaOrmQuery = vi.fn()
+
+    // @ts-ignore
+    entityManager.getQuery = vi.fn((model: typeof ApiResource) => piniaOrmQuery)
+
     const result = await entityManager.fetchCollection(DummyApiResource, null)
 
-    expect(apiRequestService.get).toHaveBeenCalledWith(
-      'api/dummyResource',
-      null,
-    )
+    expect(apiRequestService.get).toHaveBeenCalledWith('api/dummyResource')
     expect(entityManager.newInstance).toHaveBeenCalledTimes(3)
     expect(entityManager.newInstance).toHaveBeenCalledWith(DummyApiResource, {
       id: 1,
@@ -347,13 +383,20 @@ describe('fetchCollection', () => {
       _model: undefined,
     })
 
-    expect(result.items).toEqual([
+    // @ts-ignore
+    piniaOrmQuery.get = vi.fn(() => [
       new DummyApiResource({ id: 1 }),
       new DummyApiResource({ id: 2 }),
       new DummyApiResource({ id: 3 }),
     ])
 
-    expect(result.pagination, 'default pagination').toEqual({
+    expect(result.value.items).toEqual([
+      new DummyApiResource({ id: 1 }),
+      new DummyApiResource({ id: 2 }),
+      new DummyApiResource({ id: 3 }),
+    ])
+
+    expect(result.value.pagination, 'default pagination').toEqual({
       first: 1,
       last: 1,
       next: undefined,
@@ -371,6 +414,11 @@ describe('fetchCollection', () => {
     // @ts-ignore
     apiRequestService.get = vi.fn(async (url: string) => collection)
 
+    const piniaOrmQuery = vi.fn()
+
+    // @ts-ignore
+    entityManager.getQuery = vi.fn((model: typeof ApiResource) => piniaOrmQuery)
+
     // @ts-ignore
     entityManager.newInstance = vi.fn(
       (model: typeof ApiResource, props: object) => {
@@ -386,7 +434,6 @@ describe('fetchCollection', () => {
 
     expect(apiRequestService.get).toHaveBeenCalledWith(
       'api/dummyModel/100/dummyResource',
-      null,
     )
   })
 
@@ -397,6 +444,19 @@ describe('fetchCollection', () => {
       'hydra:member': [{ id: 1 }, { id: 2 }, { id: 3 }],
     }
 
+    const query = vi.fn()
+
+    // @ts-ignore
+    query.getUrlQuery = vi.fn(() => 'foo=bar')
+
+    // @ts-ignore
+    query.applyToPiniaOrmQuery = vi.fn((q) => q)
+
+    const piniaOrmQuery = vi.fn()
+
+    // @ts-ignore
+    entityManager.getQuery = vi.fn((model: typeof ApiResource) => piniaOrmQuery)
+
     // @ts-ignore
     apiRequestService.get = vi.fn(async (url: string) => collection)
 
@@ -407,37 +467,16 @@ describe('fetchCollection', () => {
       },
     )
 
-    await entityManager.fetchCollection(DummyApiResource, null, { page: 10 })
-
-    expect(apiRequestService.get).toHaveBeenCalledWith('api/dummyResource', {
-      page: 10,
-    })
-  })
-
-  test('with pagination', async () => {
-    const collection = {
-      '@type': 'hydra:Collection',
-      'hydra:totalItems': 1000,
-      'hydra:member': [],
-      'hydra:view': {
-        '@id': '/api/subdomains?organization=498&page=50',
-        'hydra:first': '/api/subdomains?organization=498&page=1',
-        'hydra:last': '/api/subdomains?organization=498&page=100',
-        'hydra:next': '/api/subdomains?organization=498&page=51',
-        'hydra:previous': '/api/subdomains?organization=498&page=49',
-      },
-    }
-
     // @ts-ignore
-    apiRequestService.get = vi.fn(async (url: string) => collection)
-
-    const result = await entityManager.fetchCollection(DummyApiResource, null)
+    await entityManager.fetchCollection(DummyApiResource, null, query)
 
-    expect(result.totalItems).toEqual(1000)
-    expect(result.pagination.first).toEqual(1)
-    expect(result.pagination.last).toEqual(100)
-    expect(result.pagination.previous).toEqual(49)
-    expect(result.pagination.next).toEqual(51)
+    expect(apiRequestService.get).toHaveBeenCalledWith(
+      'api/dummyResource?foo=bar',
+    )
+    // @ts-ignore
+    expect(query.getUrlQuery).toHaveBeenCalledWith()
+    // @ts-ignore
+    expect(query.applyToPiniaOrmQuery).toHaveBeenCalledWith(piniaOrmQuery)
   })
 })
 

+ 73 - 0
tests/units/services/data/filters/equalFilter.test.ts

@@ -0,0 +1,73 @@
+import { describe, expect, test, vi } from 'vitest'
+import type { Query as PiniaOrmQuery } from 'pinia-orm'
+import EqualFilter from '~/services/data/Filters/EqualFilter'
+import type ApiResource from '~/models/ApiResource'
+
+describe('constructor', () => {
+  test('simple call', () => {
+    const filter = new EqualFilter('foo', 123)
+
+    expect(filter.field).toEqual('foo')
+    expect(filter.filterValue).toEqual(123)
+    expect(filter.reactiveFilter).toEqual(false)
+  })
+
+  test('simple call with reactive filters', () => {
+    const filter = new EqualFilter('foo', 123, true)
+
+    expect(filter.field).toEqual('foo')
+    expect(filter.filterValue).toEqual(123)
+    expect(filter.reactiveFilter).toEqual(true)
+  })
+})
+
+describe('applyToPiniaOrmQuery', () => {
+  test('simple call', () => {
+    const filter = new EqualFilter('foo', 123)
+
+    // @ts-ignore
+    const piniaOrmQuery1 = vi.fn() as PiniaOrmQuery<ApiResource>
+
+    // @ts-ignore
+    const piniaOrmQuery2 = vi.fn() as PiniaOrmQuery<ApiResource>
+
+    piniaOrmQuery1.where = vi.fn(() => piniaOrmQuery2)
+
+    const result = filter.applyToPiniaOrmQuery(piniaOrmQuery1)
+
+    expect(result).toEqual(piniaOrmQuery2)
+    expect(piniaOrmQuery1.where).toHaveBeenCalledWith('foo', 123)
+  })
+
+  test('empty filter value', () => {
+    const filter = new EqualFilter('foo', null)
+
+    // @ts-ignore
+    const piniaOrmQuery1 = vi.fn() as PiniaOrmQuery<ApiResource>
+
+    piniaOrmQuery1.where = vi.fn()
+
+    const result = filter.applyToPiniaOrmQuery(piniaOrmQuery1)
+
+    expect(result).toEqual(piniaOrmQuery1)
+    expect(piniaOrmQuery1.where).toHaveBeenCalledTimes(0)
+  })
+})
+
+describe('getApiQueryPart', () => {
+  test('simple call', () => {
+    const filter = new EqualFilter('foo', 123)
+
+    const result = filter.getApiQueryPart()
+
+    expect(result).toEqual('foo=123')
+  })
+
+  test('empty filter value', () => {
+    const filter = new EqualFilter('foo', null)
+
+    const result = filter.getApiQueryPart()
+
+    expect(result).toEqual('')
+  })
+})

+ 56 - 0
tests/units/services/data/filters/orderBy.test.ts

@@ -0,0 +1,56 @@
+import { describe, expect, test, vi } from 'vitest'
+import type { Query as PiniaOrmQuery } from 'pinia-orm'
+import type ApiResource from '~/models/ApiResource'
+import OrderBy from '~/services/data/Filters/OrderBy'
+import { ORDER_BY_DIRECTION } from '~/types/enum/data'
+
+describe('constructor', () => {
+  test('simple call', () => {
+    const filter = new OrderBy('foo')
+
+    expect(filter.field).toEqual('foo')
+  })
+
+  test('simple call with desc direction', () => {
+    const filter = new OrderBy('foo', ORDER_BY_DIRECTION.DESC)
+
+    expect(filter.field).toEqual('foo')
+    expect(filter.mode).toEqual('desc')
+  })
+})
+
+describe('applyToPiniaOrmQuery', () => {
+  test('simple call', () => {
+    const filter = new OrderBy('foo')
+
+    // @ts-ignore
+    const piniaOrmQuery1 = vi.fn() as PiniaOrmQuery<ApiResource>
+
+    // @ts-ignore
+    const piniaOrmQuery2 = vi.fn() as PiniaOrmQuery<ApiResource>
+
+    piniaOrmQuery1.orderBy = vi.fn(() => piniaOrmQuery2)
+
+    const result = filter.applyToPiniaOrmQuery(piniaOrmQuery1)
+
+    expect(result).toEqual(piniaOrmQuery2)
+  })
+})
+
+describe('getApiQueryPart', () => {
+  test('simple call', () => {
+    const filter = new OrderBy('foo', ORDER_BY_DIRECTION.ASC)
+
+    const result = filter.getApiQueryPart()
+
+    expect(result).toEqual('order[foo]=asc')
+  })
+
+  test('with descendent direction', () => {
+    const filter = new OrderBy('foo', ORDER_BY_DIRECTION.DESC)
+
+    const result = filter.getApiQueryPart()
+
+    expect(result).toEqual('order[foo]=desc')
+  })
+})

+ 68 - 0
tests/units/services/data/filters/pageFilter.test.ts

@@ -0,0 +1,68 @@
+import { describe, expect, test, vi } from 'vitest'
+import type { Query as PiniaOrmQuery } from 'pinia-orm'
+import { ref } from 'vue'
+import type ApiResource from '~/models/ApiResource'
+import PageFilter from '~/services/data/Filters/PageFilter'
+
+describe('constructor', () => {
+  test('simple call', () => {
+    const filter = new PageFilter(ref(1), ref(10))
+
+    expect(filter.page.value).toEqual(1)
+    expect(filter.itemsPerPage.value).toEqual(10)
+  })
+})
+
+describe('applyToPiniaOrmQuery', () => {
+  test('simple call', () => {
+    const filter = new PageFilter(ref(1), ref(10))
+
+    // @ts-ignore
+    const piniaOrmQuery1 = vi.fn() as PiniaOrmQuery<ApiResource>
+    // @ts-ignore
+    const piniaOrmQuery2 = vi.fn() as PiniaOrmQuery<ApiResource>
+    // @ts-ignore
+    const piniaOrmQuery3 = vi.fn() as PiniaOrmQuery<ApiResource>
+
+    piniaOrmQuery1.offset = vi.fn(() => piniaOrmQuery2)
+    piniaOrmQuery2.limit = vi.fn(() => piniaOrmQuery3)
+
+    const result = filter.applyToPiniaOrmQuery(piniaOrmQuery1)
+
+    expect(result).toEqual(piniaOrmQuery3)
+
+    expect(piniaOrmQuery1.offset).toHaveBeenCalledWith(0)
+    expect(piniaOrmQuery2.limit).toHaveBeenCalledWith(10)
+  })
+
+  test('with other page number', () => {
+    const filter = new PageFilter(ref(2), ref(20))
+
+    // @ts-ignore
+    const piniaOrmQuery1 = vi.fn() as PiniaOrmQuery<ApiResource>
+    // @ts-ignore
+    const piniaOrmQuery2 = vi.fn() as PiniaOrmQuery<ApiResource>
+    // @ts-ignore
+    const piniaOrmQuery3 = vi.fn() as PiniaOrmQuery<ApiResource>
+
+    piniaOrmQuery1.offset = vi.fn(() => piniaOrmQuery2)
+    piniaOrmQuery2.limit = vi.fn(() => piniaOrmQuery3)
+
+    const result = filter.applyToPiniaOrmQuery(piniaOrmQuery1)
+
+    expect(result).toEqual(piniaOrmQuery3)
+
+    expect(piniaOrmQuery1.offset).toHaveBeenCalledWith(20)
+    expect(piniaOrmQuery2.limit).toHaveBeenCalledWith(20)
+  })
+})
+
+describe('getApiQueryPart', () => {
+  test('simple call', () => {
+    const filter = new PageFilter(ref(1), ref(10))
+
+    const result = filter.getApiQueryPart()
+
+    expect(result).toEqual('page=1&itemsPerPage=10')
+  })
+})

+ 208 - 0
tests/units/services/data/filters/searchFilter.test.ts

@@ -0,0 +1,208 @@
+import { describe, expect, test, vi } from 'vitest'
+import type { Query as PiniaOrmQuery } from 'pinia-orm'
+import type { Ref } from 'vue'
+import { ref } from 'vue'
+import EqualFilter from '~/services/data/Filters/EqualFilter'
+import type ApiResource from '~/models/ApiResource'
+import SearchFilter from '~/services/data/Filters/SearchFilter'
+import { SEARCH_STRATEGY } from '~/types/enum/data'
+
+class TestableSearchFilter extends SearchFilter {
+  public search(value: string, filterValue: Ref<string | null>): boolean {
+    return super.search(value, filterValue)
+  }
+}
+
+describe('constructor', () => {
+  test('simple call', () => {
+    const filter = new SearchFilter('foo', ref('abc'))
+
+    expect(filter.field).toEqual('foo')
+    expect(filter.filterValue.value).toEqual('abc')
+    expect(filter.reactiveFilter).toEqual(false)
+  })
+
+  test('simple call with reactive filters and mode', () => {
+    const filter = new SearchFilter(
+      'foo',
+      ref('abc'),
+      SEARCH_STRATEGY.PARTIAL,
+      true,
+    )
+
+    expect(filter.field).toEqual('foo')
+    expect(filter.filterValue.value).toEqual('abc')
+    expect(filter.mode).toEqual(SEARCH_STRATEGY.PARTIAL)
+    expect(filter.reactiveFilter).toEqual(true)
+  })
+})
+
+describe('search', () => {
+  test('exact mode (default)', () => {
+    const filter = new TestableSearchFilter('foo', ref('abc'))
+
+    expect(filter.search('azerty', ref('azerty'))).toBeTruthy()
+    expect(filter.search('azerty', ref('AZERTY'))).toBeFalsy()
+    expect(filter.search('azerty', ref('foo'))).toBeFalsy()
+  })
+
+  test('iexact mode', () => {
+    const filter = new TestableSearchFilter(
+      'foo',
+      ref('abc'),
+      SEARCH_STRATEGY.IEXACT,
+    )
+
+    expect(filter.search('azerty', ref('azerty'))).toBeTruthy()
+    expect(filter.search('azerty', ref('AZERTY'))).toBeTruthy()
+    expect(filter.search('azerty', ref('foo'))).toBeFalsy()
+  })
+
+  test('partial mode', () => {
+    const filter = new TestableSearchFilter(
+      'foo',
+      ref('abc'),
+      SEARCH_STRATEGY.PARTIAL,
+    )
+
+    expect(filter.search('azerty', ref('azerty'))).toBeTruthy()
+    expect(filter.search('azerty', ref('azer'))).toBeTruthy()
+    expect(filter.search('azerty', ref('zer'))).toBeTruthy()
+    expect(filter.search('azerty', ref('AZER'))).toBeFalsy()
+    expect(filter.search('azerty', ref('foo'))).toBeFalsy()
+  })
+
+  test('ipartial mode', () => {
+    const filter = new TestableSearchFilter(
+      'foo',
+      ref('abc'),
+      SEARCH_STRATEGY.IPARTIAL,
+    )
+
+    expect(filter.search('azerty', ref('azerty'))).toBeTruthy()
+    expect(filter.search('azerty', ref('azer'))).toBeTruthy()
+    expect(filter.search('azerty', ref('zer'))).toBeTruthy()
+    expect(filter.search('azerty', ref('AZER'))).toBeTruthy()
+    expect(filter.search('azerty', ref('foo'))).toBeFalsy()
+  })
+
+  test('start mode', () => {
+    const filter = new TestableSearchFilter(
+      'foo',
+      ref('abc'),
+      SEARCH_STRATEGY.START,
+    )
+
+    expect(filter.search('azerty', ref('azerty'))).toBeTruthy()
+    expect(filter.search('azerty', ref('azer'))).toBeTruthy()
+    expect(filter.search('azerty', ref('zer'))).toBeFalsy()
+    expect(filter.search('azerty', ref('AZER'))).toBeFalsy()
+    expect(filter.search('azerty', ref('foo'))).toBeFalsy()
+  })
+
+  test('end mode', () => {
+    const filter = new TestableSearchFilter(
+      'foo',
+      ref('abc'),
+      SEARCH_STRATEGY.END,
+    )
+
+    expect(filter.search('azerty', ref('azerty'))).toBeTruthy()
+    expect(filter.search('azerty', ref('rty'))).toBeTruthy()
+    expect(filter.search('azerty', ref('zer'))).toBeFalsy()
+    expect(filter.search('azerty', ref('AZER'))).toBeFalsy()
+    expect(filter.search('azerty', ref('foo'))).toBeFalsy()
+  })
+
+  test('word-start mode', () => {
+    const filter = new TestableSearchFilter(
+      'foo',
+      ref('abc'),
+      SEARCH_STRATEGY.WORD_START,
+    )
+
+    expect(filter.search('Once upon a time', ref('tim'))).toBeTruthy()
+    expect(filter.search('Once upon a time', ref('onc'))).toBeTruthy()
+    expect(filter.search('Once upon a time', ref('TIM'))).toBeTruthy()
+    expect(filter.search('Once upon a time', ref('ime'))).toBeFalsy()
+    expect(filter.search('Once upon a time', ref('foo'))).toBeFalsy()
+  })
+
+  test('unknown mode', () => {
+    // @ts-ignore
+    const filter = new TestableSearchFilter('foo', ref('abc'), 'other')
+
+    expect(() => filter.search('azerty', ref('azerty'))).toThrowError()
+  })
+
+  test('null filter', () => {
+    // @ts-ignore
+    const filter = new TestableSearchFilter('foo', ref(null))
+
+    expect(filter.search('azerty', ref(null))).toBeFalsy()
+  })
+})
+
+describe('applyToPiniaOrmQuery', () => {
+  test('simple call', () => {
+    const filterValue = ref('abc')
+    const filter = new TestableSearchFilter('foo', filterValue)
+
+    // @ts-ignore
+    const piniaOrmQuery1 = vi.fn() as PiniaOrmQuery<ApiResource>
+
+    // @ts-ignore
+    const piniaOrmQuery2 = vi.fn() as PiniaOrmQuery<ApiResource>
+
+    piniaOrmQuery1.where = vi.fn((field, callback) => {
+      // eslint-disable-next-line n/no-callback-literal
+      callback('test')
+      return piniaOrmQuery2
+    })
+
+    filter.search = vi.fn((value, filterValue) => true)
+
+    const result = filter.applyToPiniaOrmQuery(piniaOrmQuery1)
+
+    expect(result).toEqual(piniaOrmQuery2)
+    expect(filter.search).toHaveBeenCalledWith('test', filterValue)
+  })
+
+  test('empty filter value', () => {
+    const filterValue = ref(null)
+    const filter = new TestableSearchFilter('foo', filterValue)
+
+    // @ts-ignore
+    const piniaOrmQuery1 = vi.fn() as PiniaOrmQuery<ApiResource>
+
+    const result = filter.applyToPiniaOrmQuery(piniaOrmQuery1)
+
+    expect(result).toEqual(piniaOrmQuery1)
+  })
+})
+
+describe('getApiQueryPart', () => {
+  test('simple call', () => {
+    const filter = new TestableSearchFilter('foo', ref('abc'))
+
+    const result = filter.getApiQueryPart()
+
+    expect(result).toEqual('foo[]=abc')
+  })
+
+  test('empty filter value', () => {
+    const filter = new TestableSearchFilter('foo', ref(''))
+
+    const result = filter.getApiQueryPart()
+
+    expect(result).toEqual('')
+  })
+
+  test('null filter value', () => {
+    const filter = new TestableSearchFilter('foo', ref(null))
+
+    const result = filter.getApiQueryPart()
+
+    expect(result).toEqual('')
+  })
+})

+ 104 - 0
tests/units/services/data/query.test.ts

@@ -0,0 +1,104 @@
+import { describe, test, vi, expect, beforeEach, afterEach } from 'vitest'
+import type { Query as PiniaOrmQuery } from 'pinia-orm'
+import { ref } from 'vue'
+import Query from '~/services/data/Query'
+import type { ApiFilter } from '~/types/data'
+import type ApiResource from '~/models/ApiResource'
+import PageFilter from '~/services/data/Filters/PageFilter'
+import EqualFilter from '~/services/data/Filters/EqualFilter'
+import OrderBy from '~/services/data/Filters/OrderBy'
+
+class TestableQuery extends Query {
+  public getFilters() {
+    return this.filters
+  }
+}
+
+describe('constructor', () => {
+  test('simple call', () => {
+    // @ts-ignore
+    const filter1 = vi.fn() as ApiFilter
+    // @ts-ignore
+    const filter2 = vi.fn() as ApiFilter
+
+    const query = new TestableQuery(filter1, filter2)
+
+    expect(query.getFilters()).toEqual([filter1, filter2])
+  })
+})
+
+describe('add', () => {
+  test('simple call', () => {
+    // @ts-ignore
+    const filter1 = vi.fn() as ApiFilter
+    // @ts-ignore
+    const filter2 = vi.fn() as ApiFilter
+
+    const query = new TestableQuery(filter1)
+
+    expect(query.getFilters()).toEqual([filter1])
+
+    const result = query.add(filter2)
+
+    expect(query.getFilters()).toEqual([filter1, filter2])
+
+    expect(result).toEqual(query)
+  })
+})
+
+describe('getUrlQuery', () => {
+  test('simple call', () => {
+    // @ts-ignore
+    const filter1 = vi.fn() as ApiFilter
+    // @ts-ignore
+    const filter2 = vi.fn() as ApiFilter
+    // @ts-ignore
+    const filter3 = vi.fn() as ApiFilter
+
+    const query = new TestableQuery(filter1, filter2, filter3)
+
+    filter1.getApiQueryPart = vi.fn(() => 'foo=1')
+    filter2.getApiQueryPart = vi.fn(() => '')
+    filter3.getApiQueryPart = vi.fn(() => 'bar=2')
+
+    const result = query.getUrlQuery()
+
+    expect(result).toEqual('foo=1&bar=2')
+  })
+})
+
+describe('applyToPiniaOrmQuery', () => {
+  test('simple call', () => {
+    const filter1 = new EqualFilter('a', 1)
+    const filter2 = new EqualFilter('a', 1)
+    const orderBy = new OrderBy('a')
+    const pageFilter = new PageFilter(ref(1), ref(10))
+
+    // @ts-ignore
+    const piniaOrmQuery1 = vi.fn() as PiniaOrmQuery<ApiResource>
+    // @ts-ignore
+    const piniaOrmQuery2 = vi.fn() as PiniaOrmQuery<ApiResource>
+    // @ts-ignore
+    const piniaOrmQuery3 = vi.fn() as PiniaOrmQuery<ApiResource>
+    // @ts-ignore
+    const piniaOrmQuery4 = vi.fn() as PiniaOrmQuery<ApiResource>
+    // @ts-ignore
+    const piniaOrmQuery5 = vi.fn() as PiniaOrmQuery<ApiResource>
+
+    const query = new TestableQuery(orderBy, filter1, pageFilter, filter2)
+
+    filter1.applyToPiniaOrmQuery = vi.fn((query) => piniaOrmQuery2)
+    filter2.applyToPiniaOrmQuery = vi.fn((query) => piniaOrmQuery3)
+    orderBy.applyToPiniaOrmQuery = vi.fn((query) => piniaOrmQuery4)
+    pageFilter.applyToPiniaOrmQuery = vi.fn((query) => piniaOrmQuery5)
+
+    const result = query.applyToPiniaOrmQuery(piniaOrmQuery1)
+
+    expect(filter1.applyToPiniaOrmQuery).toHaveBeenCalledWith(piniaOrmQuery1)
+    expect(filter2.applyToPiniaOrmQuery).toHaveBeenCalledWith(piniaOrmQuery2)
+    expect(orderBy.applyToPiniaOrmQuery).toHaveBeenCalledWith(piniaOrmQuery3)
+    expect(pageFilter.applyToPiniaOrmQuery).toHaveBeenCalledWith(piniaOrmQuery4)
+
+    expect(result).toEqual(piniaOrmQuery5)
+  })
+})

+ 12 - 1
types/data.d.ts

@@ -1,5 +1,9 @@
 import ApiResource from '~/models/ApiResource'
 import type { EnumChoice } from '~/types/interfaces'
+import type {
+  Query as PiniaOrmQuery,
+  Collection as PiniaOrmCollection,
+} from 'pinia-orm'
 
 type AnyJson = Record<string, any>
 
@@ -44,11 +48,18 @@ interface Pagination {
 }
 
 interface Collection {
-  items: Array<ApiResource>
+  items: PiniaOrmCollection<ApiResource>
   pagination: Pagination
   totalItems: number | undefined
 }
 
+interface ApiFilter {
+  applyToPiniaOrmQuery: (
+    query: PiniaOrmQuery<ApiResource>,
+  ) => PiniaOrmQuery<ApiResource>
+  getApiQueryPart: () => string
+}
+
 interface EnumItem {
   value: string
   label: string

+ 15 - 0
types/enum/data.ts

@@ -9,3 +9,18 @@ export const enum METADATA_TYPE {
   ITEM,
   COLLECTION,
 }
+
+export const enum SEARCH_STRATEGY {
+  EXACT = 'exact',
+  PARTIAL = 'partial',
+  START = 'start',
+  END = 'end',
+  WORD_START = 'word-start',
+  IEXACT = 'iexact',
+  IPARTIAL = 'ipartial',
+}
+
+export const enum ORDER_BY_DIRECTION {
+  ASC = 'asc',
+  DESC = 'desc',
+}