Browse Source

documents entity manger

Olivier Massot 1 year ago
parent
commit
238580cd24

+ 2 - 1
.eslintrc.cjs

@@ -30,7 +30,8 @@ module.exports = {
         allowModifiers: true,
       },
     ],
-    'vue/multi-word-component-names': 0
+    'vue/multi-word-component-names': 0,
+    '@typescript-eslint/no-inferrable-types': 0
   },
   globals: {
     useRuntimeConfig: 'readonly',

+ 5 - 4
composables/data/useEntityFetch.ts

@@ -3,7 +3,8 @@ import type { ComputedRef, Ref } from 'vue'
 import { v4 as uuid4 } from 'uuid'
 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,7 +15,7 @@ interface useEntityFetchReturnType {
   fetchCollection: (
     model: typeof ApiResource,
     parent?: ApiResource | null,
-    query?: Ref<AssociativeArray>,
+    query?: Query | null,
   ) => AsyncData<Collection | null, Error | null>
 
   getRef: <T extends ApiResource>(
@@ -39,11 +40,11 @@ export const useEntityFetch = (
   const fetchCollection = (
     model: typeof ApiResource,
     parent: ApiResource | null = null,
-    query: Ref<AssociativeArray | null> = ref(null),
+    query: Query | null = null,
   ) =>
     useAsyncData(
       model.entity + '_many_' + uuid4(),
-      () => em.fetchCollection(model, parent, query.value ?? undefined),
+      () => em.fetchCollection(model, parent, query),
       { lazy },
     )
 

+ 182 - 0
doc/entity_manager.md

@@ -0,0 +1,182 @@
+# 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 Models 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
+
+...
+
+#### Réactivité
+
+
+
+### 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)
+
+
+    
+
+
+
+
+
+
+

+ 3 - 2
package.json

@@ -32,6 +32,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",
@@ -59,12 +60,12 @@
     "@nuxtjs/eslint-module": "^4.1.0",
     "@types/cleave.js": "^1.4.12",
     "@types/event-source-polyfill": "^1.0.5",
+    "@types/file-saver": "^2.0.7",
     "@types/jest": "^29.5.11",
+    "@types/js-yaml": "^4.0.9",
     "@types/lodash": "^4.14.202",
     "@types/lodash-es": "^4.17.12",
     "@types/uuid": "^9.0.7",
-    "@types/file-saver": "^2.0.7",
-    "@types/js-yaml": "^4.0.9",
     "@types/vue-the-mask": "^0.11.1",
     "@typescript-eslint/eslint-plugin": "^6.13.2",
     "@typescript-eslint/parser": "^6.13.2",

+ 28 - 7
pages/poc.vue

@@ -1,13 +1,34 @@
 <template>
-  <main>
-    <div class="pa-8">
-      <h1>POC</h1>
-      <NuxtPage />
-    </div>
-  </main>
+  <div class="pa-8">
+    <h1>POC</h1>
+
+    <v-text-field v-model="searchFilter" />
+
+    <ul v-if="!pending && data !== null">
+      <li v-for="organization in data.items" :key="organization.id">
+        {{ organization.name }}
+      </li>
+    </ul>
+    <span v-else>Loading...</span>
+  </div>
 </template>
 
-<script setup lang="ts"></script>
+<script setup lang="ts">
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import Query from '~/services/data/Query'
+import Search from '~/services/data/Filters/Search'
+import Subdomain from '~/models/Organization/Subdomain'
+
+const { fetchCollection } = useEntityFetch()
+
+const query = new Query()
+
+const searchFilter = ref('2io')
+
+query.addWhere(new Search('subdomain', searchFilter.value))
+
+const { data, pending } = fetchCollection(Subdomain, null, query)
+</script>
 
 <style scoped lang="scss">
 h1 {

+ 40 - 0
services/data/Filters/Search.ts

@@ -0,0 +1,40 @@
+import type { Query as PiniaOrmQuery } from 'pinia-orm'
+import type { ApiFilter } from '~/types/data'
+import ApiResource from '~/models/ApiResource'
+import { SEARCH_STRATEGY } from '~/types/enum/data'
+
+/**
+ *
+ */
+export default class Search implements ApiFilter {
+  field: string
+  value: string | number
+  private mode: string
+
+  /**
+   * @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/
+   */
+  constructor(
+    field: string,
+    value: string | number,
+    mode: SEARCH_STRATEGY = SEARCH_STRATEGY.EXACT,
+  ) {
+    this.field = field
+    this.value = value
+    this.mode = mode
+  }
+
+  public applyToPiniaOrmQuery(
+    query: PiniaOrmQuery<ApiResource>,
+  ): PiniaOrmQuery<ApiResource> {
+    return query.where(this.field, this.value)
+  }
+
+  public getApiQueryPart(): string {
+    return `${this.field}[]=${this.value}`
+  }
+}

+ 66 - 0
services/data/Query.ts

@@ -0,0 +1,66 @@
+import type { Query as PiniaOrmQuery } from 'pinia-orm'
+import type { ApiFilter, OrderBy } from '~/types/data'
+import type ApiResource from '~/models/ApiResource'
+
+/**
+ * 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> = []
+  protected orderBy: Array<OrderBy> = []
+
+  /**
+   * Add an ApiFilter to the query
+   *
+   * @param filter
+   */
+  public addWhere(filter: ApiFilter) {
+    this.filters.push(filter)
+  }
+
+  /**
+   * Add an OrderBy directive to the query
+   *
+   * @param field
+   * @param direction
+   */
+  public addOrderBy(field: string, direction: 'asc' | 'desc' = 'asc') {
+    const orderBy: OrderBy = { field, direction }
+
+    this.orderBy.push(orderBy)
+  }
+
+  /**
+   * 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()
+      queryParts.push(queryPart)
+    })
+    console.log(queryParts)
+    return queryParts.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> {
+    this.filters.forEach((filter) => {
+      query = filter.applyToPiniaOrmQuery(query)
+    })
+    console.log(query)
+    return query
+  }
+}

+ 6 - 6
services/data/apiRequestService.ts

@@ -21,7 +21,7 @@ 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 +35,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 +50,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 +61,7 @@ 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 +78,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) {

+ 29 - 7
services/data/entityManager.ts

@@ -1,13 +1,19 @@
-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 ApiRequestService from './apiRequestService'
 import UrlUtils from '~/services/utils/urlUtils'
 import ApiModel from '~/models/ApiModel'
 import ApiResource from '~/models/ApiResource'
-import type { AnyJson, AssociativeArray, Collection } from '~/types/data.d'
+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 +53,15 @@ class EntityManager {
     return this._getRepo(model)
   }
 
+  /**
+   * Return a pinia-orm query for the model
+   *
+   * @param model
+   */
+  public getQuery(model: typeof ApiResource): PiniaOrmQuery<ApiResource> {
+    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
@@ -185,16 +200,15 @@ 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,
+    query: Query | null = null,
   ): Promise<Collection> {
     let url
 
@@ -204,15 +218,23 @@ class EntityManager {
       url = UrlUtils.join('api', model.entity)
     }
 
-    const response = await this.apiRequestService.get(url, query)
+    query = query ?? new Query()
+    const urlQuery = query.getUrlQuery()
+
+    const response = await this.apiRequestService.get(url, urlQuery)
 
     // 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)
     })
 
+    let piniaOrmQuery = this.getQuery(model)
+    piniaOrmQuery = query.applyToPiniaOrmQuery(piniaOrmQuery)
+
+    const items = piniaOrmQuery.get()
+
     return {
       items,
       totalItems: apiCollection.metadata.totalItems,

+ 14 - 1
types/data.d.ts

@@ -1,5 +1,6 @@
 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 +45,23 @@ interface Pagination {
 }
 
 interface Collection {
-  items: Array<ApiResource>
+  items: PiniaOrmCollection<ApiResource>
   pagination: Pagination
   totalItems: number | undefined
 }
 
+interface ApiFilter {
+  field: string
+  value: string | number
+  applyToPiniaOrmQuery: (query: PiniaOrmQuery<ApiResource>) => PiniaOrmQuery<ApiResource>
+  getApiQueryPart: () => string
+}
+
+interface OrderBy {
+  field: string
+  direction : 'asc' | 'desc'
+}
+
 interface EnumItem {
   value: string
   label: string

+ 8 - 0
types/enum/data.ts

@@ -9,3 +9,11 @@ export const enum METADATA_TYPE {
   ITEM,
   COLLECTION,
 }
+
+export const enum SEARCH_STRATEGY {
+  EXACT = 'exact',
+  PARTIAL = 'partial',
+  START = 'start',
+  END = 'end',
+  WORD_START = 'word-start',
+}

+ 29 - 4
yarn.lock

@@ -4226,7 +4226,7 @@ eslint-import-resolver-node@^0.3.6, eslint-import-resolver-node@^0.3.9:
     is-core-module "^2.13.0"
     resolve "^1.22.4"
 
-eslint-import-resolver-typescript@^3.6.0:
+eslint-import-resolver-typescript@^3.6.0, eslint-import-resolver-typescript@^3.6.1:
   version "3.6.1"
   resolved "https://registry.yarnpkg.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.1.tgz#7b983680edd3f1c5bce1a5829ae0bc2d57fe9efa"
   integrity sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg==
@@ -8355,7 +8355,16 @@ streamx@^2.15.0:
     fast-fifo "^1.1.0"
     queue-tick "^1.0.1"
 
-"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+"string-width-cjs@npm:string-width@^4.2.0":
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+  dependencies:
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.1"
+
+"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -8414,7 +8423,7 @@ string_decoder@~1.1.1:
   dependencies:
     safe-buffer "~5.1.0"
 
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -8428,6 +8437,13 @@ strip-ansi@^3.0.0:
   dependencies:
     ansi-regex "^2.0.0"
 
+strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+  dependencies:
+    ansi-regex "^5.0.1"
+
 strip-ansi@^7.0.1:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -9621,7 +9637,16 @@ wide-align@^1.1.2:
   dependencies:
     string-width "^1.0.2 || 2 || 3 || 4"
 
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
+  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+  dependencies:
+    ansi-styles "^4.0.0"
+    string-width "^4.1.0"
+    strip-ansi "^6.0.0"
+
+wrap-ansi@^7.0.0:
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==