Selaa lähdekoodia

add new subdomain page

Olivier Massot 2 vuotta sitten
vanhempi
commit
6960e1e4a3

+ 72 - 33
components/Layout/Parameters/Website.vue

@@ -6,38 +6,58 @@
         :model="Parameters"
         :entity="parameters"
     >
-      <v-col cols="6" class="d-flex flex-column">
-        <div>{{ $t('your_opentalent_website_is')}} : </div>
-        <div class="mb-1">{{ organizationProfile.website }}</div>
-
-        <v-btn :disabled="true" class="my-2">
-          {{ $t('record_a_new_subdomain')}}
-        </v-btn>
-
-        <div class="flex-extend" />
-
-        <UiInputText
-          v-model="parameters.otherWebsite"
-          field="otherWebsite"
-        />
-      </v-col>
-
-
-      <v-col cols="6">
-        <div>{{ $t('subdomains_history') }}</div>
-        <ul>
-          <li>foo</li>
-          <li>bar</li>
-          <li>boo</li>
-        </ul>
-
-        <UiInputCheckbox
-          v-model="parameters.desactivateOpentalentSiteWeb"
-          field="desactivateOpentalentSiteWeb"
-        />
-
-        <UiInputAutocomplete field="publicationDirectors"/>
-      </v-col>
+      <v-row>
+        <v-col cols="6">
+          <div class="mb-6">
+            <div>{{ $t('your_opentalent_website_address_is')}} : </div>
+            <div class="ma-2 text-primary"><strong>{{ organizationProfile.website }}</strong></div>
+          </div>
+
+          <div class="mb-6">
+            <div>{{ $t('subdomains_history') }} : </div>
+            <UiLoadingPanel v-if="subdomainsPending" />
+            <v-list
+                v-else
+                :items="subdomains.items"
+                item-value="id"
+                item-title="subdomain"
+                density="compact"
+                bg-color="transparent"
+            >
+              <template #prepend>
+                <v-icon icon="fas fa-circle" style="font-size: 5px;"/>
+              </template>
+            </v-list>
+          </div>
+
+          <v-btn
+              :disabled="!canAddNewSubdomain"
+              class="my-2"
+              @click="onAddSubdomainClick"
+          >
+            {{ $t('record_a_new_subdomain')}}
+          </v-btn>
+        </v-col>
+
+
+        <v-col cols="6">
+          <!-- les publicationDirectors sont des entités Access -->
+          <UiInputAutocomplete
+              field="publicationDirectors"
+              itemTitle="person.name"
+          />
+
+          <UiInputCheckbox
+            v-model="parameters.desactivateOpentalentSiteWeb"
+            field="desactivateOpentalentSiteWeb"
+          />
+
+          <UiInputText
+              v-model="parameters.otherWebsite"
+              field="otherWebsite"
+          />
+        </v-col>
+      </v-row>
     </UiForm>
   </LayoutContainer>
 </template>
@@ -47,8 +67,10 @@ import {useOrganizationProfileStore} from "~/stores/organizationProfile";
 import Parameters from "~/models/Organization/Parameters";
 import {useEntityFetch} from "~/composables/data/useEntityFetch";
 import {AsyncData} from "#app";
+import Subdomain from "~/models/Organization/Subdomain";
+import ApiResource from "~/models/ApiResource";
 
-const { fetch } = useEntityFetch()
+const { fetch, fetchCollection } = useEntityFetch()
 
 const organizationProfile = useOrganizationProfileStore()
 
@@ -58,6 +80,23 @@ if (organizationProfile.parametersId === null) {
 
 const { data: parameters, pending } = fetch(Parameters, organizationProfile.parametersId) as AsyncData<Parameters, Parameters | true>
 
+
+const { data: subdomains, pending: subdomainsPending } = fetchCollection(Subdomain, null, ref({ 'organization' : organizationProfile.id }) )
+
+const canAddNewSubdomain: ComputedRef<boolean> = computed(() => subdomains.value.items.length < 3)
+
+
+
+
+
+const onAddSubdomainClick = () => {
+  if (!canAddNewSubdomain) {
+    throw new Error('Max number of subdomains reached')
+  }
+
+
+}
+
 </script>
 
 <style scoped lang="scss">

+ 21 - 10
components/Ui/Button/Submit.vue

@@ -1,5 +1,11 @@
 <template>
-  <v-btn class="mr-4 theme-primary" :class="hasOtherActions ? 'pr-0' : ''" @click="submitAction(mainAction)" ref="mainBtn">
+  <v-btn
+      class="mr-4 theme-primary"
+      :class="hasOtherActions ? 'pr-0' : ''"
+      @click="submitAction(mainAction)"
+      ref="mainBtn"
+      :disabled="validationPending"
+  >
 
     {{ $t(mainAction) }}
 
@@ -41,15 +47,20 @@
 import {computed, ComputedRef, ref, Ref} from "@vue/reactivity";
 
 const props = defineProps({
-    actions: {
-      type: Array,
-      required: true
-    },
-    dropDirection: {
-      type: String,
-      required: false,
-      default:'bottom'
-    }
+  actions: {
+    type: Array,
+    required: true
+  },
+  dropDirection: {
+    type: String,
+    required: false,
+    default:'bottom'
+  },
+  validationPending: {
+    type: Boolean,
+    required: false,
+    default: false
+  }
 })
 
 const emit = defineEmits(['submit'])

+ 21 - 2
components/Ui/Form.vue

@@ -17,7 +17,7 @@ de quitter si des données ont été modifiées.
       @update:entity="onFormChange"
     >
       <!-- Top action bar -->
-      <v-container fluid class="container btnActions">
+      <v-container :fluid="true" class="container btnActions">
         <v-row>
           <v-col cols="12" sm="12">
             <slot name="form.button"/>
@@ -26,6 +26,7 @@ de quitter si des données ont été modifiées.
               v-if="!readonly"
               @submit="submit"
               :actions="actions"
+              :validation-pending="validationPending || !isValid"
             ></UiButtonSubmit>
           </v-col>
         </v-row>
@@ -35,7 +36,7 @@ de quitter si des données ont été modifiées.
       <slot v-bind="{model, entity}"/>
 
       <!-- Bottom action bar -->
-      <v-container fluid class="container btnActions">
+      <v-container :fluid="true" class="container btnActions">
         <v-row>
           <v-col cols="12" sm="12">
             <slot name="form.button"/>
@@ -43,6 +44,7 @@ de quitter si des données ont été modifiées.
             <UiButtonSubmit
               @submit="submit"
               :actions="actions"
+              :validation-pending="validationPending || !isValid"
             ></UiButtonSubmit>
           </v-col>
         </v-row>
@@ -89,6 +91,7 @@ import {usePageStore} from "~/stores/page";
 import {watch} from "@vue/runtime-core";
 import {AnyJson} from "~/types/data";
 import * as _ from 'lodash-es'
+import {Bool} from "pinia-orm/dist/decorators";
 
 const props = defineProps({
   /**
@@ -123,6 +126,11 @@ const props = defineProps({
       actions[SUBMIT_TYPE.SAVE] = {}
       return actions
     }
+  },
+  validationPending: {
+    type: Boolean,
+    required: false,
+    default: false
   }
 })
 
@@ -151,6 +159,8 @@ const isConfirmationDialogShowing: ComputedRef<boolean> = computed(() => {
   return useFormStore().showConfirmToLeave
 })
 
+
+
 /**
  * Ferme la fenêtre de confirmation
  */
@@ -165,6 +175,10 @@ const closeConfirmationDialog = () => {
  * @param next
  */
 const submit = async (next: string|null = null) => {
+  if (props.validationPending) {
+    return
+  }
+
   // Valide les données
   await validate()
 
@@ -345,6 +359,11 @@ const setIsDirty = (dirty: boolean) => {
     }
   }
 }
+
+
+
+defineExpose({ validate })
+
 </script>
 
 <style scoped>

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

@@ -29,7 +29,7 @@ Liste déroulante avec autocompletion, à placer dans un composant `UiForm`
         @update:model-value="onUpdate"
     >
       <template v-if="slotText" #item="data">
-        <v-list-item-content v-text="data.item.slotTextDisplay"></v-list-item-content>
+<!--        <v-list-item-content v-text="data.item.slotTextDisplay"></v-list-item-content>-->
       </template>
     </v-autocomplete>
   </main>

+ 1 - 18
composables/form/useValidation.ts

@@ -38,24 +38,7 @@ export function useValidation() {
     }
   }
 
-  function useValidateSubdomain() {
-    const isSubdomainAvailable = async (subdomain: string | null): Promise<boolean> => {
-      if (subdomain === null) {
-        return true
-      }
-
-      const { apiRequestService } = useAp2iRequestService()
-      const response: any = await apiRequestService.get('/api/subdomains', {'subdomain': subdomain})
-
-      return typeof response !== 'undefined' && response.metadata.totalItems === 0
-    }
-    return {
-      isSubdomainAvailable
-    }
-  }
-
   return {
-    useValidateSiret,
-    useValidateSubdomain
+    useValidateSiret
   }
 }

+ 14 - 0
composables/form/validation/useSubdomainValidation.ts

@@ -0,0 +1,14 @@
+import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
+import SubdomainValidation from "~/services/validation/subdomainValidation";
+
+let subdomainValidation: SubdomainValidation | null = null
+
+
+export function useSubdomainValidation() {
+  if (subdomainValidation === null) {
+    const { apiRequestService } = useAp2iRequestService()
+
+    subdomainValidation = new SubdomainValidation(apiRequestService)
+  }
+  return { subdomainValidation }
+}

+ 7 - 3
lang/fr.json

@@ -604,8 +604,12 @@
   "sms_option": "Option SMS",
   "super_admin": "Compte super-admin",
   "an_error_happened": "Une erreur s'est produite",
-  "your_opentalent_website_is": "Votre site Opentalent est",
+  "your_opentalent_website_address_is": "L'adresse de votre site Opentalent est",
   "record_a_new_subdomain": "Enregistrer un nouveau sous-domaine",
-  "subdomains_history": "Historique de vos sous domaine(s)",
-  "desactivateOpentalentSiteWeb": "Désactiver le site opentalent"
+  "subdomains_history": "Historique de vos sous-domaine(s)",
+  "desactivateOpentalentSiteWeb": "Désactiver le site Opentalent",
+  "Not Found": "Données non trouvée",
+  "subdomains_breadcrumbs": "Sous-domaines",
+  "new_breadcrumbs": "Nouveau",
+  "validation_pending": "Validation en cours"
 }

+ 15 - 0
models/Organization/SubdomainAvailability.ts

@@ -0,0 +1,15 @@
+import ApiResource from "~/models/ApiResource";
+import {Str, Uid, Attr, Bool, Num} from "pinia-orm/dist/decorators";
+
+export default class SubdomainAvailability extends ApiResource {
+    static entity = 'subdomains/is_available'
+
+    @Uid()
+    declare id: number | string | null
+
+    @Str(null)
+    declare subdomain: string
+
+    @Bool(false)
+    declare available: boolean
+}

+ 4 - 77
pages/parameters.vue

@@ -1,87 +1,14 @@
-<!--
-Page Paramètres
--->
+<!-- Page de détails des paramètres -->
+
 <template>
   <LayoutContainer>
-    <v-col cols="12" sm="12" md="12">
-      <v-tabs
-          v-model="currentTab"
-          bg-color="primary"
-          color="on-primary"
-          :grow="true"
-          density="default"
-      >
-        <v-tab v-for="tab in tabs" :value="tab">
-          {{ $t(tab) }}
-        </v-tab>
-      </v-tabs>
-
-      <v-card-text>
-        <v-window v-model="currentTab">
-          <v-window-item value="general_parameters">
-            <LayoutParametersGeneral />
-          </v-window-item>
-
-          <v-window-item value="website">
-            <LayoutParametersWebsite />
-          </v-window-item>
-
-          <v-window-item value="teaching">
-          </v-window-item>
-
-          <v-window-item value="intranet_access">
-          </v-window-item>
-
-          <v-window-item value="educationNotations">
-          </v-window-item>
-
-          <v-window-item value="bulletin">
-          </v-window-item>
-
-          <v-window-item value="educationTimings">
-          </v-window-item>
-
-          <v-window-item value="attendances">
-          </v-window-item>
-
-          <v-window-item value="residenceAreas">
-          </v-window-item>
-
-          <v-window-item value="sms_option">
-          </v-window-item>
-
-          <v-window-item value="super_admin">
-          </v-window-item>
-        </v-window>
-      </v-card-text>
-
-    </v-col>
+    <!-- Rend le contenu de la page -->
+    <NuxtPage />
   </LayoutContainer>
 </template>
 
 <script setup lang="ts">
-  const currentTab: Ref<string | null> = ref(null)
-
-  const tabs = [
-    'general_parameters',
-    'website',
-    'teaching',
-    'intranet_access',
-    'educationNotations',
-    'bulletin',
-    'educationTimings',
-    'attendances',
-    'residenceAreas',
-    'sms_option',
-    'super_admin',
-  ]
-
 </script>
 
 <style scoped lang="scss">
-
-:deep(.v-tabs .v-btn__content) {
-  text-transform: capitalize;
-  letter-spacing: 0.04em;
-}
 </style>

+ 87 - 0
pages/parameters/index.vue

@@ -0,0 +1,87 @@
+<!--
+Page Paramètres
+-->
+<template>
+  <LayoutContainer>
+    <v-col cols="12" sm="12" md="12">
+      <v-tabs
+          v-model="currentTab"
+          bg-color="primary"
+          color="on-primary"
+          :grow="true"
+          density="default"
+      >
+        <v-tab v-for="tab in tabs" :value="tab">
+          {{ $t(tab) }}
+        </v-tab>
+      </v-tabs>
+
+      <v-card-text>
+        <v-window v-model="currentTab">
+          <v-window-item value="general_parameters">
+            <LayoutParametersGeneral />
+          </v-window-item>
+
+          <v-window-item value="website">
+            <LayoutParametersWebsite />
+          </v-window-item>
+
+          <v-window-item value="teaching">
+          </v-window-item>
+
+          <v-window-item value="intranet_access">
+          </v-window-item>
+
+          <v-window-item value="educationNotations">
+          </v-window-item>
+
+          <v-window-item value="bulletin">
+          </v-window-item>
+
+          <v-window-item value="educationTimings">
+          </v-window-item>
+
+          <v-window-item value="attendances">
+          </v-window-item>
+
+          <v-window-item value="residenceAreas">
+          </v-window-item>
+
+          <v-window-item value="sms_option">
+          </v-window-item>
+
+          <v-window-item value="super_admin">
+          </v-window-item>
+        </v-window>
+      </v-card-text>
+
+    </v-col>
+  </LayoutContainer>
+</template>
+
+<script setup lang="ts">
+  const currentTab: Ref<string | null> = ref(null)
+
+  const tabs = [
+    'general_parameters',
+    'website',
+    'teaching',
+    'intranet_access',
+    'educationNotations',
+    'bulletin',
+    'educationTimings',
+    'attendances',
+    'residenceAreas',
+    'sms_option',
+    'super_admin',
+  ]
+
+</script>
+
+<style scoped lang="scss">
+
+:deep(.v-tabs .v-btn__content) {
+  text-transform: capitalize;
+  letter-spacing: 0.04em;
+}
+</style>

+ 88 - 0
pages/parameters/subdomains/_id.vue

@@ -0,0 +1,88 @@
+<!-- Page de détails d'un sous-domaine -->
+<template>
+  <main>
+    <LayoutContainer>
+      <UiLoadingPanel v-if="pending" />
+      <UiForm
+          v-else
+          :model="Subdomain"
+          :entity="subdomain"
+      >
+
+
+
+
+      </UiForm>
+    </LayoutContainer>
+
+
+
+
+
+
+<!--      <v-card class="mb-5 mt-4">-->
+<!--        <FormToolbar title="subdomain" icon="fa-at"/>-->
+
+<!--        <v-container fluid class="container pa-6">-->
+<!--          <v-row>-->
+<!--            <v-col cols="12" sm="12">-->
+<!--              <v-skeleton-loader-->
+<!--                  v-if="fetchState.pending"-->
+<!--                  type="text"-->
+<!--              />-->
+<!--              <div v-else>-->
+<!--                <div>-->
+<!--                  {{ $t('youRegisteredTheFollowingSubdomain')}} :-->
+<!--                </div>-->
+
+<!--                <div class="pa-8">-->
+<!--                  <b>{{ entry.subdomain }}</b> <span class="grey&#45;&#45;text">.opentalent.fr</span>-->
+<!--                </div>-->
+
+<!--                <div>-->
+<!--                  <div v-if="entry.active">-->
+<!--                    <v-icon class="ot_green&#45;&#45;text icon small mr-2">-->
+<!--                      fa-solid fa-check-->
+<!--                    </v-icon>-->
+<!--                    {{ $t('subdomainIsCurrentlyActive') }}-->
+<!--                  </div>-->
+<!--                  <div v-else>-->
+<!--                    {{ $t('doYouWantToActivateThisSubdomain') }} ?-->
+<!--                  </div>-->
+<!--                </div>-->
+
+<!--                <div class="mt-6 d-flex flex-row">-->
+<!--                  <v-btn to="/parameters/communication" class="mr-12">{{ $t('back') }}</v-btn>-->
+<!--                  <div v-if="!entry.active">-->
+<!--                    <v-btn color="primary" @click="activateAndQuit(entry)">{{ $t('activate') }}</v-btn>-->
+<!--                  </div>-->
+<!--                </div>-->
+
+<!--              </div>-->
+<!--            </v-col>-->
+<!--          </v-row>-->
+<!--        </v-container>-->
+<!--      </v-card>-->
+<!--    </LayoutContainer>-->
+  </main>
+</template>
+
+<script setup lang="ts">
+
+
+import Parameters from "~/models/Organization/Parameters";
+import {AsyncData} from "#app";
+import Subdomain from "~/models/Organization/Subdomain";
+import {useEntityFetch} from "~/composables/data/useEntityFetch";
+import {useOrganizationProfileStore} from "~/stores/organizationProfile";
+
+const { fetch } = useEntityFetch()
+const organizationProfile = useOrganizationProfileStore()
+
+const id = parseInt(route.value.params.id)
+
+
+const { data: subdomain, pending } = fetch(Subdomain) as AsyncData<Parameters, Parameters | true>
+
+
+</script>

+ 151 - 0
pages/parameters/subdomains/new.vue

@@ -0,0 +1,151 @@
+<template>
+  <main>
+    <LayoutContainer>
+      <UiForm
+          ref="form"
+          :model="Subdomain"
+          :entity="subdomain"
+          :submitActions="submitActions"
+          :validation-pending="validationPending"
+      >
+        <v-container :fluid="true" class="container">
+          <v-row>
+            <v-col cols="12" sm="6">
+              <div>{{ $t('pleaseEnterYourNewSubdomain')}} :</div>
+            </v-col>
+          </v-row>
+          <v-row>
+            <v-col cols="12" sm="6">
+              <UiInputText
+                  v-model="subdomain.subdomain"
+                  field="subdomain"
+                  type="string"
+                  :rules="rules()"
+                  @update:modelValue="checkSubdomainHook($event)"
+              />
+            </v-col>
+          </v-row>
+          <div class="validationMessage">
+            <i v-if="validationPending" class="validation_status">{{ $t('validation_ongoing') }}</i>
+            <i v-else-if="subdomainAvailable === true" class="validation_status text-success">{{ $t('this_subdomain_is_available') }}</i>
+          </div>
+        </v-container>
+
+        <template #form.button>
+          <NuxtLink :to="{ path: '/parameters#website'}" class="no-decoration">
+            <v-btn class="mr-4 theme-neutral">
+              {{ $t('back') }}
+            </v-btn>
+          </NuxtLink>
+        </template>
+      </UiForm>
+    </LayoutContainer>
+  </main>
+
+</template>
+
+<script setup lang="ts">
+import {useEntityManager} from "~/composables/data/useEntityManager";
+import Subdomain from "~/models/Organization/Subdomain";
+import {SUBMIT_TYPE} from "~/types/enum/enums";
+import {AnyJson} from "~/types/data";
+import SubdomainValidation from "~/services/validation/subdomainValidation";
+import {Ref} from "@vue/reactivity";
+import {useValidation} from "~/composables/form/useValidation";
+import {useSubdomainValidation} from "~/composables/form/validation/useSubdomainValidation";
+
+const i18n = useI18n()
+
+const { em } = useEntityManager()
+const { subdomainValidation } = useSubdomainValidation()
+
+//@ts-ignore
+const subdomain: Ref<Subdomain> = ref(em.newInstance(Subdomain) as Subdomain)
+
+const submitActions = computed(() => {
+  let actions: AnyJson = {}
+  actions[SUBMIT_TYPE.SAVE_AND_BACK] = { path: `/parameters/communication` }
+  return actions
+})
+
+const form: Ref<HTMLCanvasElement | null> = ref(null);
+const subdomainAvailable: Ref<boolean | null> = ref(null)
+const validationPending: Ref<boolean> = ref(false)
+
+
+/**
+ * Délai entre le dernier caractère saisi et la requête de vérification de la disponibilité du sous-domaine (en ms)
+ */
+const inputDelay = 600
+
+/**
+ * Nombre de requêtes en attentes. On n'effectuera la vérification de disponibilité qu'à la dernière d'entre elles.
+ */
+let requestPile = 0
+
+/**
+ * La valeur du sous-domaine a été modifiée, on ajoute une demande de vérification à la pile.
+ * @param subdomain
+ */
+const requestAvailabilityCheck = (subdomain: string) => {
+  requestPile += 1
+  validationPending.value = true
+  setTimeout(() => popLastRequest(subdomain), inputDelay)
+}
+
+/**
+ * Le délai passé, on retire une requête de la pile. Si c'est la dernière de la pile, c'est que la saisie est terminée
+ * depuis le délai attendu, on effectue la vérification de disponibilité.
+ * @param subdomain
+ */
+const popLastRequest = async (subdomain: string) => {
+  requestPile -= 1
+  if (requestPile === 0) {
+    await performAvailabilityCheck(subdomain)
+  }
+}
+
+/**
+ * Procède à la vérification de disponibilité.
+ * @param subdomain
+ */
+const performAvailabilityCheck = async (subdomain: string) => {
+  subdomainAvailable.value = await subdomainValidation.isAvailable(subdomain);
+  validationPending.value = false
+
+  //@ts-ignore
+  form.value.validate()
+}
+
+const checkSubdomainHook = async (subdomain: string | null) => {
+  subdomainAvailable.value = null
+  if (subdomain !== null && SubdomainValidation.isValid(subdomain)) {
+    requestAvailabilityCheck(subdomain)
+  }
+}
+
+/**
+ * Règles de validation
+ */
+const rules = () => [
+  (subdomain: string | null) => (subdomain !== null && subdomain.length > 0) || i18n.t('please_enter_a_value_for_the_subdomain'),
+  (subdomain: string | null) => (subdomain !== null && subdomain.length >= 2 && subdomain.length <=60) || i18n.t('subdomain_need_to_have_0_to_60_cars'),
+  (subdomain: string | null) => SubdomainValidation.isValid(subdomain) || i18n.t('subdomain_can_not_contain_spaces_or_special_cars'),
+  () => (subdomainAvailable.value !== false) || i18n.t('this_subdomain_is_already_in_use')
+]
+
+
+</script>
+
+<style scoped lang="scss">
+.validation_status {
+  font-size: 13px;
+  font-weight: 600;
+}
+
+.validationMessage {
+  height: 20px;
+  min-height: 20px;
+}
+
+</style>

+ 32 - 0
services/validation/subdomainValidation.ts

@@ -0,0 +1,32 @@
+import ApiRequestService from "~/services/data/apiRequestService";
+import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
+
+
+export default class SubdomainValidation {
+    private apiRequestService: ApiRequestService
+
+    public constructor(apiRequestService: ApiRequestService) {
+        this.apiRequestService = apiRequestService
+    }
+
+    /**
+     * Le sous-domaine est valide s'il contient entre 2 et 60 caractères, et pas de caractères spéciaux
+     * @param subdomain
+     */
+    public static isValid(subdomain: string | null): boolean {
+        return subdomain !== null && subdomain.match(/^[\w\-]{2,60}$/) !== null
+    }
+
+    /**
+     * Returns true if the given subdomain has not been registered yet or is not reserved
+     * @param subdomain
+     */
+    public async isAvailable(subdomain: string): Promise<boolean> {
+        if (subdomain === null) {
+            return true
+        }
+        const { apiRequestService } = useAp2iRequestService()
+        const subdomainAvailability: any = await apiRequestService.get('/api/subdomains/is_available', {'subdomain': subdomain})
+        return subdomainAvailability && subdomainAvailability['available'] === true
+    }
+}