瀏覽代碼

post MR fixes

Olivier Massot 2 年之前
父節點
當前提交
ab994e15a5

+ 4 - 0
components/Layout/Dialog.vue

@@ -83,6 +83,10 @@ const _show = computed(() => props.show) as boolean
     }
   }
 
+  .dialog-container {
+    overflow-x: scroll;
+  }
+
   .dialog-text-container {
     max-height: 70vh;
     overflow: auto;

+ 2 - 2
components/Layout/Header/Menu.vue

@@ -17,7 +17,7 @@ header principal (configuration, paramètres du compte...)
           size="30"
       >
         <UiImage
-            :id="menu.icon.avatarId"
+            :imageId="menu.icon.avatarId"
             :defaultImage="menu.icon.avatarByDefault"
             :width="30"
         />
@@ -56,7 +56,7 @@ header principal (configuration, paramètres du compte...)
               >
                 <span v-if="child.icon" class="pr-2 d-flex align-center">
                   <v-avatar v-if="menu.icon.avatarId || child.icon.avatarByDefault" size="30" >
-                    <UiImage :id="child.icon.avatarId" :defaultImage="child.icon.avatarByDefault" :width="30" />
+                    <UiImage :imageId="child.icon.avatarId" :defaultImage="child.icon.avatarByDefault" :width="30" />
                   </v-avatar>
                   <v-icon v-else class="on-primary" size="small">
                     {{ child.icon.name }}

+ 0 - 0
components/Layout/Parameters/ResidenceAreas.vue


+ 56 - 74
components/Ui/Image.vue

@@ -1,8 +1,6 @@
 <!--
-Composant Image permettant de charger et d'afficher une image stockée sur les serveurs Opentalent à partir de son id.
+Composant Image permettant d'afficher une image stockée sur les serveurs Opentalent à partir de son id.
 Permet d'afficher une image par défaut si l'image demandée n'est pas disponible ou invalide.
-
-Si la propriété 'upload' est à 'true', propose aussi un input pour uploader une nouvelle image.
 -->
 <template>
   <main>
@@ -27,94 +25,81 @@ Si la propriété 'upload' est à 'true', propose aussi un input pour uploader u
             />
           </v-row>
         </template>
-      </v-img>
 
-      <div>
-        <div v-if="upload" class="click-action hover" @click="openUpload=true"><v-icon>mdi-upload</v-icon></div>
-        <UiInputImage
-          v-if="openUpload"
-          @close="openUpload=false"
-          :existingImageId="id"
-          :field="field"
-          :ownerId="ownerId"
-          @update="$emit('update', $event, field); openUpload=false"
-          @reload="onReload"
-          @reset="reset"
-        ></UiInputImage>
-      </div>
+        <div v-if="!pending && overlayIcon" class="overlay" @click="emit('overlay-clicked')">
+          <v-icon>{{ overlayIcon }}</v-icon>
+        </div>
+      </v-img>
     </div>
   </main>
 </template>
 
-
 <script setup lang="ts">
-import {ref} from "@vue/reactivity";
-import type {Ref} from "@vue/reactivity";
 import {useImageFetch} from "~/composables/data/useImageFetch";
-import {onUnmounted, watch} from "@vue/runtime-core";
-import type {WatchStopHandle} from "@vue/runtime-core";
-import {useImageManager} from "~/composables/data/useImageManager";
 import ImageManager from "~/services/data/imageManager";
 
 const props = defineProps({
-  id: {
-    type: Number,
+  /**
+   * Id de l'image (null si aucune)
+   */
+  imageId: {
+    type: Number as PropType<number | null>,
     required: false,
     default: null
   },
+  /**
+   * Image par défaut
+   */
   defaultImage: {
     type: String,
     required: false
   },
+  /**
+   * Hauteur de l'image à l'écran (en px)
+   */
   height: {
     type: Number,
     required: false
   },
+  /**
+   * Largeur de l'image à l'écran (en px)
+   */
   width: {
     type: Number,
     required: false
   },
-  field: {
+  /**
+   * Icône à afficher en overlay au survol de la souris
+   */
+  overlayIcon: {
     type: String,
-    required: false
-  },
-  upload: {
-    type: Boolean,
     required: false,
-    default: false
-  },
-  ownerId:{
-    type: Number,
-    required: false
+    default: null
   }
 })
 
-const { imageManager } = useImageManager()
 const { fetch } = useImageFetch()
 
 const defaultImagePath = props.defaultImage ?? ImageManager.defaultImage
 
-const openUpload: Ref<Boolean> = ref(false)
+const { data: imageSrc, pending, refresh: refreshImage } = fetch(props.imageId ?? null, defaultImagePath, props.height, props.width) as any
 
-const { data: imageSrc, pending, refresh } = fetch(props.id ?? null, defaultImagePath, props.height, props.width)
 
-const unwatch: WatchStopHandle = watch(() => props.id, async (newValue, oldValue) => {
-  await refresh()
-})
-
-const onReload = async () => {
-  await refresh()
-  openUpload.value = false
-}
+const emit = defineEmits(['overlay-clicked'])
 
 /**
- * Quand on souhaite faire un reset de l'image
+ * Si l'id change, on recharge l'image
  */
-const reset = () => {
-  imageSrc.value = defaultImagePath
-  openUpload.value = false
+const unwatch: WatchStopHandle = watch(() => props.imageId, async () => {
+  await refreshImage()
+})
+
+const refresh = () => {
+  refreshImage()
 }
 
+defineExpose({ refresh })
+
 /**
  * Lorsqu'on démonte le component, on supprime le watcher
  */
@@ -129,36 +114,33 @@ onUnmounted(() => {
     position: relative;
 
     img {
+      display: block;
       max-width: 100%;
     }
 
-    .click-action {
+    .overlay {
       position: absolute;
-      top:0;
-      left:0;
-      width: 100%;
+      top: 0;
+      bottom: 0;
+      left: 0;
+      right: 0;
       height: 100%;
-      background: transparent;
+      width: 100%;
       opacity: 0;
-      transition: all .2s;
-      &:hover {
-        opacity: 1;
-        background: rgb(var(--v-theme-neutral-strong));
-        cursor: pointer;
-      }
-      i {
-        color: rgb(var(--v-theme-on-neutral-strong));
-        position: absolute;
-        top: 50%;
-        left: 50%;
-        transform: translate(-50% , -50%);
-        font-size: 50px;
-        z-index: 1;
-        opacity: 1;
-        &:hover{
-          color: rgb(var(--v-theme-primary-alt));
-        }
-      }
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      transition: .3s ease;
+    }
+    .overlay:hover {
+      opacity: 0.8;
+      background-color: rgb(var(--v-theme-neutral-strong));
+      cursor: pointer;
+    }
+
+    .overlay .v-icon {
+      color: rgb(var(--v-theme-on-neutral-strong));
+      font-size: 36px;
     }
   }
 </style>

+ 1 - 1
components/Ui/Input/Autocomplete.vue

@@ -53,7 +53,7 @@ const props = defineProps({
    * v-model
    */
   modelValue: {
-    type: [String, Number, Object, Array],
+    type: [String, Number, Object, Array] as PropType<any>,
     required: false,
     default: null
   },

+ 292 - 119
components/Ui/Input/Image.vue

@@ -1,15 +1,28 @@
 <!--
 Assistant de création d'image
-https://norserium.github.io/vue-advanced-cropper/
+
+@see https://norserium.github.io/vue-advanced-cropper/
 -->
 <template>
-    <LazyLayoutDialog :show="true">
+  <div class="input-image" >
+    <UiImage
+        ref="imageElement"
+        :image-id="modelValue"
+        :default-image="defaultImage"
+        :width="width"
+        :height="height"
+        class="image"
+        overlay-icon="fas fa-upload"
+        @overlay-clicked="openModal()"
+    />
+
+    <LazyLayoutDialog :show="showModal">
       <template #dialogType>{{ $t('image_assistant') }}</template>
       <template #dialogTitle>{{ $t('modif_picture') }}</template>
       <template #dialogText>
         <div class="upload">
           <v-row
-            v-if="fetchState.pending"
+            v-if="pending"
             class="fill-height ma-0 loading"
             align="center"
             justify="center"
@@ -22,231 +35,382 @@ https://norserium.github.io/vue-advanced-cropper/
 
           <div v-else >
             <div class="upload__cropper-wrapper">
-              <cropper
+              <Cropper
                 ref="cropper"
                 class="upload__cropper"
                 check-orientation
-                :src="image.src"
-                :default-position="{left : coordinates.left, top : coordinates.top}"
-                :default-size="coordinates.width ? {width : coordinates.width, height : coordinates.height}: defaultSize"
-                @change="onChange"
+                :src="uploadedImage.src"
+                :default-position="defaultPosition"
+                :default-size="defaultSize"
+                @change="onCropperChange"
               />
-              <div v-if="image.src" class="upload__reset-button" title="Reset Image" @click="reset()">
-                <v-icon>mdi-delete</v-icon>
+
+              <div
+                  v-if="uploadedImage.src"
+                  class="upload__reset-button"
+                  title="Reset Image"
+                  @click="reset()"
+              >
+                <v-icon>fas fa-trash</v-icon>
               </div>
             </div>
             <div class="upload__buttons-wrapper">
-              <button class="upload__button" @click="$refs.file.click()">
-                <input ref="file" type="file" accept="image/*" @change="uploadImage($event)" />
+              <button class="upload__button" @click="fileInput?.click()">
+                <input ref="fileInput" type="file" accept="image/*" @change="uploadImage($event)" />
                 {{$t('upload_image')}}
               </button>
             </div>
+            <span class="max-size-label">{{ $t('max_size_4_mb') }}</span>
           </div>
 
         </div>
       </template>
       <template #dialogBtn>
-        <v-btn class="mr-4 submitBtn theme-neutral-strong" @click="$emit('close')">
+        <v-btn class="mr-4 submitBtn theme-neutral-strong" @click="cancel">
           {{ $t('cancel') }}
         </v-btn>
-        <v-btn class="mr-4 submitBtn theme-danger" @click="save">
+        <v-btn class="submitBtn theme-primary" @click="save" :disabled="pending">
           {{ $t('save') }}
         </v-btn>
       </template>
     </LazyLayoutDialog>
-
+  </div>
 </template>
 
 <script setup lang="ts">
 
-import {useNuxtApp} from "#app";
-import {ref} from "@vue/reactivity";
-import type {Ref} from "@vue/reactivity";
+import {ref, Ref} from "@vue/reactivity";
 import File from '~/models/Core/File'
-import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
-import UrlUtils from "~/services/utils/urlUtils";
-import {useImageFetch} from "~/composables/data/useImageFetch";
-import {onUnmounted, watch} from "@vue/runtime-core";
-import type {WatchStopHandle} from "@vue/runtime-core";
+import {PropType, watch} from "@vue/runtime-core";
 import {useEntityManager} from "~/composables/data/useEntityManager";
-import type {AnyJson} from "~/types/data";
+import {AnyJson} from "~/types/data";
+import ApiResource from "~/models/ApiResource";
+import {useImageManager} from "~/composables/data/useImageManager";
+import { Cropper } from 'vue-advanced-cropper'
+import 'vue-advanced-cropper/dist/style.css';
+import {FILE_FOLDER, FILE_STATUS, FILE_TYPE, FILE_VISIBILITY, TYPE_ALERT} from "~/types/enum/enums";
+import {usePageStore} from "~/stores/page";
 
 const props = defineProps({
-  imageId: {
+  /**
+   * Id de l'objet File, ou null
+   */
+  modelValue: {
+    type: Number as PropType<number | null>,
+    required: false,
+    default: null
+  },
+  /**
+   * Label du champ
+   * Si non défini, c'est le nom de propriété qui est utilisé
+   */
+  field: {
+    type: String,
+    required: false,
+    default: null
+  },
+  /**
+   * Image par défaut en cas d'absence d'une image uploadée
+   */
+  defaultImage: {
+    type: String,
+    required: false
+  },
+  /**
+   * Hauteur de l'image à l'écran (en px)
+   */
+  height: {
     type: Number,
     required: false
   },
-  ownerId: {
+  /**
+   * Largeur de l'image à l'écran (en px)
+   */
+  width: {
     type: Number,
     required: false
   },
-  field: {
-    type: String,
-    required: true
+  /**
+   * TODO: completer
+   */
+  ownerId: {
+    type: Number,
+    required: false
   }
 })
 
-const { emit } = useNuxtApp()
-
 const { em } = useEntityManager()
+const { imageManager } = useImageManager()
+const pageStore = usePageStore()
 
-const file = new File()
+const emit = defineEmits(['update:modelValue', 'update:image', 'reset'])
 
+/**
+ * Références à des composants
+ */
+const fileInput: Ref<null | any> = ref(null)
 const cropper: Ref<any> = ref(null)
+const imageElement: Ref<any> = ref(null)
 
-const image: Ref<AnyJson> = ref({
+/**
+ * L'objet File contenant les informations de l'image
+ */
+const file: Ref<ApiResource | null> = ref(null)
+
+/**
+ * L'image elle-même
+ */
+const image: Ref<ArrayBuffer | string | null> = ref(null)
+
+/**
+ * L'objet File ou l'image sont en cours de chargement
+ */
+const pending: Ref<boolean> = ref(false)
+
+/**
+ * Affiche la modale d'upload / modification de l'image
+ */
+const showModal = ref(false)
+
+/**
+ * Données d'une nouvelle image uploadée par l'utilisateur
+ */
+const uploadedImage: Ref<AnyJson> = ref({
   id: null,
   src: null,
   file: null,
   name: null
 })
 
-const coordinates: Ref<AnyJson> = ref({})
+const MAX_FILE_SIZE = 4 * 1024 * 1024
 
-const defaultSize = ({ imageSize, visibleArea }: any) => {
-  return {
-    width: (visibleArea || imageSize).width,
-    height: (visibleArea || imageSize).height,
-  };
-}
+/**
+ * Coordonnées du cropper
+ */
+const coordinates: Ref<{ left?: number, top?: number, height?: number, width?: number }> = ref({})
 
-// Si l'id est renseigné, on récupère l'Item File afin d'avoir les informations de config, le nom, etc.
-if (props.imageId && props.imageId > 0) {
-  const { apiRequestService } = useAp2iRequestService()
-  const result: any = await apiRequestService.get(UrlUtils.join('api/files', '' + props.imageId))
-
-  const config = JSON.parse(result.data.config)
-  coordinates.value.left = config.x
-  coordinates.value.top = config.y
-  coordinates.value.height = config.height
-  coordinates.value.width = config.width
-  image.value.name = result.data.name
-  image.value.id = result.data.id
+/**
+ * @see https://advanced-cropper.github.io/vue-advanced-cropper/components/cropper.html#defaultposition
+ */
+const defaultPosition = () => {
+  return { left : coordinates.value.left, top : coordinates.value.top }
 }
 
-//On récupère l'image...
-const { fetch } = useImageFetch()
-const { data: imageLoaded, pending } = fetch(props.imageId ?? null)
+/**
+ * @see https://advanced-cropper.github.io/vue-advanced-cropper/components/cropper.html#defaultsize
+ */
+const defaultSize = (params: any): { width: number, height: number } | null => {
+  if (!params) {
+    return null
+  }
+  const { imageSize, visibleArea } = params
+
+  return {
+    width: coordinates.value.width ?? (visibleArea || imageSize).width,
+    height: coordinates.value.height ?? (visibleArea || imageSize).height
+  }
+}
 
-const unwatch: WatchStopHandle = watch(
-  imageLoaded,
-(newValue, oldValue) => {
-    if (newValue === oldValue || typeof newValue === 'undefined') {
-      return
+/**
+ * Affiche la modale d'upload / modification de l'image
+ */
+const openModal = async () => {
+  showModal.value = true
+  pending.value = true
+
+  if (props.modelValue !== null) {
+    // Un objet File existe déjà: on le récupère
+    file.value = await em.fetch(File, props.modelValue)
+    image.value = await imageManager.get(props.modelValue)
+
+    if (file.value.config) {
+      const config = JSON.parse(file.value.config)
+      coordinates.value.left = config.x
+      coordinates.value.top = config.y
+      coordinates.value.height = config.height
+      coordinates.value.width = config.width
     }
-    image.value.src = newValue
+
+    uploadedImage.value.name = file.value.name
+    uploadedImage.value.id = file.value.id
+    uploadedImage.value.src = image.value
+
+  } else {
+    // Nouveau File
+    file.value = em.newInstance(File)
   }
-)
+
+  pending.value = false
+}
+
+/**
+ * Réinitialise l'image sélectionnée
+ */
+const reset = () => {
+  uploadedImage.value.src = null
+  uploadedImage.value.file = null
+  uploadedImage.value.name = null
+  uploadedImage.value.id = null
+  URL.revokeObjectURL(uploadedImage.value.src)
+}
 
 /**
- * Quand l'utilisateur choisit une image sur sa machine
+ * Upload une image depuis le poste client
  * @param event
  */
-const uploadImage = (event:any) => {
+const uploadImage = (event: any) => {
   const { files } = event.target
-  if (files && files[0]) {
-    reset()
-    image.value.name = files[0].name
-    image.value.src = URL.createObjectURL(files[0])
-    image.value.file = files[0]
+
+  if (!files || !files[0]) {
+    return
+  }
+
+  const uploadedFile = files[0]
+
+  if (uploadedFile.size > MAX_FILE_SIZE) {
+    pageStore.alerts.push({type: TYPE_ALERT.ALERT, messages: ['file_too_large'] })
+    return
   }
+
+  reset()
+
+  uploadedImage.value.name = files[0].name
+  uploadedImage.value.src = URL.createObjectURL(files[0])
+  uploadedImage.value.file = files[0]
+
+  coordinates.value.top = 0
+  coordinates.value.left = 0
+  coordinates.value.height = uploadedImage.value.height
+  coordinates.value.width = uploadedImage.value.width
 }
 
 /**
  * Lorsque le cropper change de position / taille, on met à jour les coordonnées
- * @param config
+ * @param newCoordinates
  */
-const onChange = ({ coordinates: config } : any) => {
-  coordinates.value = config;
+const onCropperChange = ({ coordinates: newCoordinates } : any) => {
+  coordinates.value = newCoordinates;
 }
 
 /**
- * Lorsque l'on sauvegarde l'image
+ * Annule l'upload et les modifications, et ferme la modale
  */
-// TODO: Voir si tout ou partie de cette fonction peut passer dans le useImageFetch, imageManager ou imageUtils
-const save = async () => {
-  file.config = JSON.stringify({
+const cancel = () => {
+  reset()
+  showModal.value = false
+}
+
+/**
+ * Construit la nouvelle config du fichier à partir des réglages actuels
+ */
+const updateFileConfig = () => {
+  file.value!!.config = JSON.stringify({
     x: coordinates.value.left,
     y: coordinates.value.top,
     height: coordinates.value.height,
     width: coordinates.value.width
   })
+}
 
-  if (image.value.id > 0) {
-    // Mise à jour d'une image existante : on bouge simplement le cropper
-
-    file.id = image.value.id as number
+/**
+ * Enregistre une nouvelle image
+ */
+const saveNewImage = async () => {
+  if (!file.value) {
+    throw new Error('No File object defined')
+  }
 
-    await em.persist(File, file) // TODO: à revoir
+  // On créé l'objet File à sauvegarder
+  file.value.name = uploadedImage.value.name
+  file.value.imgFieldName = props.field
+  file.value.visibility = FILE_VISIBILITY.EVERYBODY
+  file.value.folder = FILE_FOLDER.IMAGES
+  file.value.status = FILE_STATUS.READY
+  file.value.type = FILE_TYPE.UPLOADED
 
-    // On émet un évent afin de mettre à jour le formulaire de départ
-    emit('reload')
+  updateFileConfig()
 
-  } else {
-    // Création d'une nouvelle image
-    if (image.value.file) {
-
-      // On créé l'objet File à sauvegarder
-      file.name = image.value.name
-      file.imgFieldName = props.field
-      file.visibility = 'EVERYBODY'
-      file.folder = 'IMAGES'
-      file.status = 'READY'
-
-      if (props.ownerId) {
-        file.ownerId = props.ownerId
-      }
+  if (props.ownerId) {
+    // TODO: revoir
+    file.value.ownerId = props.ownerId
+  }
 
-      const returnedFile = await em.persist(File, file) // TODO: à revoir, il faudra pouvoir passer `image.value.file` avec la requête
+  // TODO: A revoir, on doit pouvoir persister l'image aussi
+  const returnedFile = await em.persist(File, file.value)
+  // await imageManager.persist(file.value, uploadedImage.src)
 
-      //On émet un évent afin de mettre à jour le formulaire de départ
-      emit('update', returnedFile.data['@id'])
+  //On émet un évent afin de mettre à jour le formulaire de départ
+  emit('update:modelValue', returnedFile.id)
+}
 
-    } else {
-      // On reset l'image : on a appuyé sur "poubelle" puis on enregistre
-      emit('reset')
-    }
+/**
+ * Met à jour l'image existante
+ */
+const saveExistingImage = async () => {
+  if (!file.value) {
+    throw new Error('No File object defined')
   }
+
+  updateFileConfig()
+
+  await em.persist(File, file.value) // TODO: à revoir
 }
 
 /**
- * On choisit de supprimer l'image présente
+ * Lorsque l'on sauvegarde l'image
  */
-const reset = () => {
-  image.value.src = null
-  image.value.file = null
-  image.value.name = null
-  image.value.id = null
-  URL.revokeObjectURL(image.value.src)
+// TODO: Voir si tout ou partie de cette fonction peut passer dans le useImageFetch, imageManager ou imageUtils
+const save = async () => {
+  if (!file.value) {
+    throw new Error('No File object defined')
+  }
+  pageStore.loading = true
+
+  if (uploadedImage.value.src && uploadedImage.value.src !== image.value) {
+    // Une nouvelle image a été uploadée
+    await saveNewImage()
+  } else if (uploadedImage.value.id) {
+    // L'image existante a été modifiée
+    await saveExistingImage()
+  } else {
+    // On a reset l'image
+    emit('update:modelValue', null)
+  }
+
+  imageElement.value.refresh()
+
+  showModal.value = false
+  pageStore.loading = false
 }
 
 /**
  * Lorsqu'on démonte le component on supprime le watcher et on revoke l'objet URL
  */
 onUnmounted(() => {
-  unwatch()
-    if (image.value.src) {
-      URL.revokeObjectURL(image.value.src)
-    }
+  if (uploadedImage.value && uploadedImage.value.src) {
+    URL.revokeObjectURL(uploadedImage.value.src)
+  }
 })
 </script>
 
-<style lang="scss">
-  .vue-advanced-cropper__stretcher{
+<style scoped lang="scss">
+  :deep(.vue-advanced-cropper__stretcher) {
     height: auto !important;
     width: auto !important;
   }
+
   .loading{
     height: 300px;
   }
+
   .upload {
     user-select: none;
     padding: 20px;
     display: block;
     &__cropper {
        border: solid 1px rgb(var(--v-theme-on-neutral-strong));;
-       min-height: 500px;
-       max-height: 500px;
+       min-height: 300px;
+       max-height: 300px;
      }
     &__cropper-wrapper {
        position: relative;
@@ -291,4 +455,13 @@ onUnmounted(() => {
       }
     }
   }
+
+  .max-size-label {
+    display: block;
+    width: 100%;
+    text-align: center;
+    font-size: 13px;
+    color: rgb(var(--v-theme-on-neutral-soft));
+    margin-top: 6px;
+  }
 </style>

+ 2 - 2
composables/data/useImageFetch.ts

@@ -2,7 +2,7 @@ import {useImageManager} from "~/composables/data/useImageManager";
 import type {FetchResult} from "#app";
 
 interface useImageFetchReturnType {
-    fetch: (id: number | null, defaultImage?: string | null, height?: number, width?: number) => FetchResult<any, any>
+    fetch: (id: number | null, defaultImage?: string | null, height?: number, width?: number) => AsyncData<string | ArrayBuffer | null, Error | null>
 }
 
 /**
@@ -16,7 +16,7 @@ export const useImageFetch = (): useImageFetchReturnType => {
         defaultImage: string | null = null,
         height: number = 0,
         width: number = 0
-    ): FetchResult<string, any> => useAsyncData(
+    ) => useAsyncData(
         'img' + (id ?? defaultImage ?? 0),
         () => imageManager.get(id, defaultImage, height, width),
         { lazy: true, server: false }  // Always fetch images client-side

+ 6 - 3
lang/fr.json

@@ -210,7 +210,8 @@
   "newSubDomain": "Nouveau sous domaine",
   "yourSubdomains": "Vos sous-domaines",
   "timezone": "Fuseau horaire",
-  "qrCode": "QrCode pour la licence",
+  "qrCode": "QrCode",
+  "qrCodeForLicence": "QrCode pour la licence",
   "studentsAreAdherents": "Les élèves sont également adhérents de l'association",
   "showAdherentList": "Afficher la liste des adhérents et leurs coordonnées",
   "endCourseDate": "Date de fin des cours ",
@@ -300,7 +301,7 @@
   "phoneNumberInvalid": "Numéro de téléphone invalide",
   "logo": "Logo",
   "subdomain": "Sous-domaine",
-  "upload_image": "Sélectionner une image",
+  "upload_image": "Charger une image",
   "of": "de",
   "allResult": "Tous",
   "itemsPerPage": "Nombre de résultats par page",
@@ -667,5 +668,7 @@
   "residence_areas_breadcrumbs": "Zones de résidence",
   "super_admin_explanation_text": "Le compte super-admin possède tous les droits de gestion sur votre logiciel. On l’utilise entre autre pour la gestion de votre site internet, pour créer les comptes des membres de votre structure à la première connexion au logiciel, ou dans des situations de dépannage.",
   "cycles_breadcrumbs": "Enseignements",
-  "exit": "Quitter"
+  "exit": "Quitter",
+  "max_size_4_mb": "Taille maximum: 4 MO",
+  "file_too_large": "Le fichier est trop volumineux"
 }

+ 3 - 1
models/Organization/Parameters.ts

@@ -3,6 +3,7 @@ import { Bool, Num, Str, Uid, Attr } from 'pinia-orm/dist/decorators'
 import Access from "~/models/Access/Access";
 import ApiResource from "~/models/ApiResource";
 import {IriEncoded} from "~/models/decorators";
+import File from "~/models/Core/File";
 
 /**
  * AP2i Model : Parameters
@@ -104,7 +105,8 @@ export default class Parameters extends ApiModel {
   declare studentsAreAdherents: boolean
 
   @Str(null)
-  declare qrCode: string | null
+  @IriEncoded(File)
+  declare qrCode: number | null
 
   @Str('Europe/Paris')
   declare timezone: string | null

+ 1 - 0
package.json

@@ -39,6 +39,7 @@
     "sass": "^1.69.5",
     "uuid": "^9.0.1",
     "vite-plugin-vuetify": "^1.0.2",
+    "vue-advanced-cropper": "^2.8.8",
     "vue-tel-input-vuetify": "^1.5.3",
     "vue-the-mask": "^0.11.1",
     "vuetify": "3.4.6",

+ 1 - 2
pages/organization/index.vue

@@ -43,8 +43,7 @@ Contient toutes les informations sur l'organization courante
                     </UiHelp>
                   </div>
                   <UiImage
-                    :id="getIdFromUri(organization.logo)"
-                    :upload="true"
+                    :imageId="getIdFromUri(organization.logo)"
                     :width="200"
                     field="logo"
                     :ownerId="id"

+ 5 - 0
plugins/vue-advanced-cropper.ts

@@ -0,0 +1,5 @@
+import { Cropper } from 'vue-advanced-cropper'
+import 'vue-advanced-cropper/dist/style.css';
+
+export default defineNuxtPlugin((nuxtApp) => {
+})

+ 3 - 2
services/data/normalizer/hydraNormalizer.ts

@@ -150,11 +150,12 @@ class HydraNormalizer {
 
     for (const field in iriEncodedFields) {
       const value = instance[field]
-      if (value === null) {
-        continue
+      if (_.isEmpty(value)) {
+          continue
       }
 
       const targetEntity = iriEncodedFields[field].entity
+
       if (_.isArray(value)) {
         instance[field] = value.map((iri: string) => {
           return HydraNormalizer.getIdFromEntityIri(iri, targetEntity)

+ 29 - 0
tests/units/services/data/normalizer/hydraNormalizer.test.ts

@@ -439,6 +439,35 @@ describe('denormalizeEntity', () => {
             oneToManyRelation: [123, 124, 125]
         }))
     })
+
+    test('should handle relations with empty values', () => {
+        const data = {
+            id: 7351,
+            name: null,
+            oneToOneRelation: null,
+            oneToManyRelation: []
+        }
+
+        //@ts-ignore
+        HydraNormalizer.getIriEncodedFields = vi.fn(
+            (entity) => {
+                return {
+                    oneToOneRelation: DummyApiChild,
+                    oneToManyRelation: DummyApiChild
+                }
+            }
+        )
+
+        //@ts-ignore
+        const result = HydraNormalizer.denormalizeEntity(DummyApiModel, data)
+
+        expect(result).toStrictEqual(new DummyApiModel({
+            id: 7351,
+            name: null,
+            oneToOneRelation: null,
+            oneToManyRelation: []
+        }))
+    })
 })
 
 describe('denormalizeEnum', () => {

+ 24 - 0
types/enum/enums.ts

@@ -75,3 +75,27 @@ export const enum SUBMIT_TYPE {
   SAVE_AND_BACK = 'save_and_back'
 }
 
+export const enum FILE_VISIBILITY {
+  EVERYBODY = 'EVERYBODY',
+  NOBODY = 'NOBODY',
+  ONLY_ORGANIZATION = 'ONLY_ORGANIZATION'
+}
+
+export const enum FILE_FOLDER {
+  IMAGES = 'IMAGES'
+}
+
+export const enum FILE_STATUS {
+  PENDING = 'PENDING',
+  READY = 'READY',
+  DELETED = 'DELETED',
+  ERROR = 'ERROR',
+}
+
+export const enum FILE_TYPE {
+  UNKNOWN = 'UNKNOWN',
+  NONE = 'NONE',
+  LICENCE_CMF ='LICENCE_CMF',
+  BILL ='BILL',
+  UPLOADED = 'UPLOADED'
+}

+ 8 - 8
yarn.lock

@@ -3670,10 +3670,10 @@ eastasianwidth@^0.2.0:
   resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
   integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
 
-editorconfig@^1.0.3:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-1.0.4.tgz#040c9a8e9a6c5288388b87c2db07028aa89f53a3"
-  integrity sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==
+editorconfig@^0.15.3:
+  version "0.15.3"
+  resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.3.tgz#bef84c4e75fb8dcb0ce5cee8efd51c15999befc5"
+  integrity sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==
   dependencies:
     "@one-ini/wasm" "0.1.1"
     commander "^10.0.0"
@@ -9007,10 +9007,10 @@ vscode-uri@^3.0.2:
   resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f"
   integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==
 
-vue-bundle-renderer@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/vue-bundle-renderer/-/vue-bundle-renderer-2.0.0.tgz#ecab5c9b2803ab2454ba212afef502e684ddbb8e"
-  integrity sha512-oYATTQyh8XVkUWe2kaKxhxKVuuzK2Qcehe+yr3bGiaQAhK3ry2kYE4FWOfL+KO3hVFwCdLmzDQTzYhTi9C+R2A==
+vue-bundle-renderer@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/vue-bundle-renderer/-/vue-bundle-renderer-1.0.3.tgz#422438aa4a024e2833e87a5a2d0b97c6c12fb2d8"
+  integrity sha512-EfjX+5TTUl70bki9hPuVp+54JiZOvFIfoWBcfXsSwLzKEiDYyHNi5iX8srnqLIv3YRnvxgbntdcG1WPq0MvffQ==
   dependencies:
     ufo "^1.2.0"