Vincent GUFFON 4 anos atrás
pai
commit
9e5cbe9b7c
54 arquivos alterados com 567 adições e 154 exclusões
  1. 6 0
      assets/css/global.scss
  2. 3 0
      assets/css/variables.scss
  3. 59 0
      components/Layout/ActivityYear.vue
  4. 1 0
      components/Layout/BannerTop.vue
  5. 17 54
      components/Layout/Breadcrumbs.vue
  6. 14 1
      components/Layout/Subheader.vue
  7. 8 41
      components/Ui/Form.vue
  8. 1 1
      components/Ui/Input/Text.vue
  9. 68 0
      components/Ui/Xeditable/Text.vue
  10. 4 1
      config/nuxtConfig/vuetify.js
  11. 2 1
      jest.config.js
  12. 3 0
      lang/layout/fr-FR.js
  13. 11 0
      models/Access/Access.ts
  14. 1 1
      pages/organization/address/_id.vue
  15. 0 4
      plugins/route.ts
  16. 1 2
      services/dataProvider/provider/modelProvider.ts
  17. 10 6
      services/store/repository.ts
  18. 5 0
      store/profile/access.ts
  19. 0 13
      test/Helpers.ts
  20. 0 0
      tests/README
  21. 33 0
      tests/unit/Helpers.ts
  22. 0 1
      tests/unit/component/Layout/Breadcrumbs.spec.js
  23. 65 0
      tests/unit/component/Ui/Xeditable/Text.spec.js
  24. 0 0
      tests/unit/fixture/files/test.yaml
  25. 0 0
      tests/unit/fixture/models/Organization.ts
  26. 0 0
      tests/unit/fixture/models/User.ts
  27. 9 0
      tests/unit/fixture/state/profile.ts
  28. 5 0
      tests/unit/index.ts
  29. 1 1
      tests/unit/services/connection/connection.spec.ts
  30. 5 5
      tests/unit/services/connection/constructUrl.spec.ts
  31. 0 0
      tests/unit/services/datadeleter/hook/baseHook.spec.ts
  32. 0 0
      tests/unit/services/datapersister/hook/baseHook.spec.ts
  33. 0 0
      tests/unit/services/dataprovider/hook/baseHook.spec.ts
  34. 2 2
      tests/unit/services/profile/accessProfile.spec.ts
  35. 2 2
      tests/unit/services/profile/organizationProfile.spec.ts
  36. 2 2
      tests/unit/services/rights/abilitiesUtils.spec.ts
  37. 0 0
      tests/unit/services/rights/roleUtils.spec.ts
  38. 0 0
      tests/unit/services/serializer/denormalizer/baseDenormalizer.spec.ts
  39. 0 0
      tests/unit/services/serializer/denormalizer/hydra.spec.ts
  40. 2 2
      tests/unit/services/serializer/denormalizer/yaml.spec.ts
  41. 0 0
      tests/unit/services/serializer/normalizer/baseNormalizer.spec.ts
  42. 2 2
      tests/unit/services/serializer/normalizer/model.spec.ts
  43. 2 2
      tests/unit/services/store/query.spec.ts
  44. 7 7
      tests/unit/services/store/repository.spec.ts
  45. 0 0
      tests/unit/services/utils/apiError.spec.ts
  46. 0 0
      tests/unit/services/utils/objectProperties.spec.ts
  47. 0 0
      tests/unit/use/form/useChecker.spec.ts
  48. 33 0
      tests/unit/use/form/useDirtyForm.spec.ts
  49. 0 0
      tests/unit/use/layout/menu.spec.ts
  50. 39 0
      tests/unit/use/updater/useActivityYearUpdater.spec.ts
  51. 1 0
      types/interfaces.d.ts
  52. 3 3
      use/form/useChecker.ts
  53. 71 0
      use/form/useDirtyForm.ts
  54. 69 0
      use/updater/useActivityYearUpdater.ts

+ 6 - 0
assets/css/global.scss

@@ -17,3 +17,9 @@ header .v-toolbar__content{
 .margin-bottom-20{
   margin-bottom: 20px;
 }
+
+.v-application a{
+  color: var(--v-ot_green-base, white)
+}
+
+

+ 3 - 0
assets/css/variables.scss

@@ -3,3 +3,6 @@
 // The variables you want to modify
 // $font-size-root: 20px;
 $btn-text-transform: none;
+
+$breadcrumbs-padding: 5px 5px;
+$breadcrumbs-even-child-padding: 0 10px;

+ 59 - 0
components/Layout/ActivityYear.vue

@@ -0,0 +1,59 @@
+<template>
+  <main class="d-flex">
+    <span class="mr-2 ot_dark_grey--text font-weight-bold">{{$t(label)}}</span>
+    <UiXeditableText
+      class="activity-year-input"
+      type="number"
+      :data="activityYear"
+      v-on:update="updateActivityYear">
+      <template v-slot:xeditable.read="{inputValue}">
+        <v-icon aria-hidden="false" class="ot_green--text" x-small>fas fa-edit</v-icon>
+        <strong class="ot_green--text"> {{inputValue}} <span v-if="yearPlusOne">/ {{ parseInt(inputValue) + 1}}</span></strong>
+      </template>
+    </UiXeditableText>
+  </main>
+</template>
+
+<script lang="ts">
+  import {defineComponent, useContext} from '@nuxtjs/composition-api'
+  import {$useActivityYearUpdater} from "~/use/updater/useActivityYearUpdater";
+  import {$organizationProfile} from "~/services/profile/organizationProfile";
+  import {$useDirtyForm} from "~/use/form/useDirtyForm";
+
+  export default defineComponent({
+    setup() {
+      const {store, $dataPersister} = useContext()
+      const {updater, activityYear} = $useActivityYearUpdater(store, $dataPersister)
+      const {markFormAsNotDirty} = $useDirtyForm(store)
+
+      const organizationProfile = $organizationProfile(store);
+
+      let yearPlusOne = !organizationProfile.isManagerProduct()
+      const label = organizationProfile.isSchool() ? 'schooling_year' : organizationProfile.isArtist() ? 'season_year' : 'cotisation_year'
+
+      const updateActivityYear = async (newDate:number) =>{
+        markFormAsNotDirty()
+        await updater(newDate)
+        console.log('1')
+        setTimeout(()=>window.location.reload(), 0)
+      }
+
+      return {
+        activityYear,
+        label,
+        yearPlusOne,
+        updateActivityYear
+      }
+    }
+  })
+</script>
+
+<style lang="scss">
+  .activity-year-input{
+    max-height: 20px;
+    input{
+      font-size: 14px;
+      width: 55px !important;
+    }
+  }
+</style>

+ 1 - 0
components/Layout/BannerTop.vue

@@ -21,5 +21,6 @@
   .bannerTopForm > .col{
     min-height: 100px;
     padding: 10px;
+    padding-left: 24px;
   }
 </style>

+ 17 - 54
components/Layout/Breadcrumbs.vue

@@ -1,21 +1,7 @@
 <template>
-  <ol id="breadcrumbs">
-    <li>
-      <a class="no-decoration" :href="homeUrl + '/'">
-        <span class="ot_green--text">{{$t('welcome')}}</span>
-      </a>
-    </li>
-    <li
-      v-for="(crumb, index) in crumbs"
-      :key="index"
-    >
-      <NuxtLink class="no-decoration" :to="crumb.path">
-        <span class="ot_green--text">{{
-          crumb.title
-        }}</span>
-      </NuxtLink>
-    </li>
-  </ol>
+  <v-breadcrumbs
+    :items="items">
+  </v-breadcrumbs>
 </template>
 
 <script lang="ts">
@@ -28,58 +14,35 @@
       const $router = useRouter()
       const homeUrl = $config.baseURL_adminLegacy
 
-      const crumbs = computed(()=>{
+      const items = computed(()=>{
+        const crumbs:Array<AnyJson> = []
+        crumbs.push({
+          text: i18n.t('welcome'),
+          href: homeUrl
+        })
+
         const fullPath = route.value.path
         const params = fullPath.startsWith('/') ? fullPath.substring(1).split('/') : fullPath.split('/')
-        const crumbs:Array<AnyJson> = []
         let path = ''
-        params.forEach((param, index) => {
+        params.forEach((param) => {
           path = `${path}/${param}`
           const match = $router.match(path)
           if (match.name !== null) {
-            const title = !parseInt(param, 10) ? i18n.t(param + '_breadcrumbs') : i18n.t('item')
             crumbs.push({
-              title: title,
-              ...match,
+              text: !parseInt(param, 10) ? i18n.t(param + '_breadcrumbs') : i18n.t('item'),
+              nuxt: true,
+              'exact-path':true,
+              to: path
             })
           }
         })
+
         return crumbs
       })
 
       return {
-        crumbs,
-        homeUrl
+        items
       }
     }
   })
 </script>
-
-<style scoped>
-  ol {
-    list-style: none;
-    padding-left: 12px;
-    padding-top: 10px;
-  }
-  li {
-    display: inline;
-  }
-  li:after {
-    content: ' / ';
-    display: inline;
-    font-size: 12px;
-    color: grey ;
-    padding: 0 0.0725em 0 0.15em;
-  }
-  li:last-child:after {
-    content: '';
-  }
-
-  li a.nuxt-link-exact-active.nuxt-link-active > span {
-    color: grey !important;
-  }
-
-  li a > span{
-    font-size: 12px;
-  }
-</style>

+ 14 - 1
components/Layout/Subheader.vue

@@ -1,6 +1,19 @@
 <template>
   <main>
-    <LayoutBreadcrumbs class="d-none sd-sm-none d-md-flex"></LayoutBreadcrumbs>
+    <v-card
+      class="d-flex ot_super_light_grey text-body-2"
+      flat
+      tile
+    >
+      <LayoutBreadcrumbs class="d-none sd-sm-none d-md-flex mr-auto"></LayoutBreadcrumbs>
+      <v-card
+        class="d-md-flex ot_super_light_grey pt-2 mr-6"
+        flat
+        tile
+      >
+        <LayoutActivityYear></LayoutActivityYear>
+      </v-card>
+    </v-card>
   </main>
 </template>
 

+ 8 - 41
components/Ui/Form.vue

@@ -59,12 +59,13 @@
 </template>
 
 <script lang="ts">
-  import {computed, defineComponent, onBeforeMount, onBeforeUnmount, reactive, toRefs, useContext} from '@nuxtjs/composition-api'
+  import {computed, defineComponent, onBeforeMount, onBeforeUnmount, onUnmounted, reactive, toRefs, useContext, watch, ref} from '@nuxtjs/composition-api'
   import {repositoryHelper} from "~/services/store/repository";
   import {queryHelper} from "~/services/store/query";
   import {Query} from "@vuex-orm/core";
   import {QUERY_TYPE, TYPE_ALERT} from "~/types/enums";
   import {alert} from "~/types/interfaces";
+  import {$useDirtyForm} from "~/use/form/useDirtyForm";
 
   export default defineComponent({
     props: {
@@ -83,9 +84,9 @@
     },
     setup: function (props) {
       const {$dataPersister, store, app: {router, i18n}} = useContext()
+      const {markFormAsDirty, markFormAsNotDirty} = $useDirtyForm(store)
 
       const {id, query} = toRefs(props)
-      const repository = repositoryHelper.getRepository(props.model)
       const properties = reactive({
         valid: false,
         saveOk: false,
@@ -100,18 +101,14 @@
         return queryHelper.getFlattenEntry(query.value, id.value)
       })
 
-      const handler = getEventHandler()
-
       const updateRepository = (newValue: string, field: string) => {
-        addEventListener(handler)
-        store.commit('form/setDirty', true)
-        repositoryHelper.updateStoreFromField(repository, entry.value, newValue, field)
+        markFormAsDirty()
+        repositoryHelper.updateStoreFromField(props.model, entry.value, newValue, field)
       }
 
       const submit = async () => {
         try {
-          store.commit('form/setDirty', false)
-          clearEventListener(handler)
+          markFormAsNotDirty()
           await $dataPersister.invoke({
             type: QUERY_TYPE.MODEL,
             model: props.model,
@@ -133,14 +130,6 @@
         }
       }
 
-      onBeforeMount(() => {
-        clearEventListener(handler)
-      })
-
-      onBeforeUnmount(() => {
-        clearEventListener(handler)
-      })
-
       const showDialog = computed(() => {
         return store.state.form.showConfirmToLeave
       })
@@ -155,12 +144,12 @@
       }
 
       const goEvenUnsavedData = () => {
-        store.commit('form/setDirty', false)
+        markFormAsNotDirty()
         store.commit('form/setShowConfirmToLeave', false)
 
         const entryCopy = query.value.first()
         if (entryCopy && entryCopy.$getAttributes()['originalState']) {
-          repositoryHelper.persist(repository, entryCopy.$getAttributes()['originalState'])
+          repositoryHelper.persist(props.model, entryCopy.$getAttributes()['originalState'])
         }
 
         if (router) {
@@ -181,28 +170,6 @@
       }
     }
   })
-
-  function getEventHandler() {
-    return function (e: any) {
-      // Cancel the event
-      e.preventDefault();
-      // Chrome requires returnValue to be set
-      e.returnValue = '';
-    };
-  }
-
-  function addEventListener(handler: any) {
-    if (process.browser) {
-      window.addEventListener('beforeunload', handler);
-    }
-  }
-
-  function clearEventListener(handler: any) {
-    if (process.browser) {
-      window.removeEventListener('beforeunload', handler);
-    }
-  }
-
 </script>
 
 <style scoped>

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

@@ -30,7 +30,7 @@
         required: false
       },
       data: {
-        type: String,
+        type: [String, Number],
         required: false
       },
       readOnly: {

+ 68 - 0
components/Ui/Xeditable/Text.vue

@@ -0,0 +1,68 @@
+<template>
+    <main>
+      <div v-if="edit" class="d-flex align-baseline x-editable-input mt-n1">
+        <UiInputText
+          class="mt-0 pt-0 mt-n1"
+            :type="type"
+            :data="inputValue"
+            v-on:update="inputValue=$event"
+        ></UiInputText>
+        <v-icon aria-hidden="false" class="valid icons ot_green--text" small @click="update">fas fa-check</v-icon>
+        <v-icon aria-hidden="false" class="cancel icons ot_grey--text" small @click="close">fas fa-times</v-icon>
+      </div>
+      <div v-else @click="edit=true" class="edit-link">
+        <slot name="xeditable.read" v-bind="{inputValue}"></slot>
+      </div>
+    </main>
+</template>
+
+<script lang="ts">
+  import {defineComponent, ref} from '@nuxtjs/composition-api'
+
+  export default defineComponent({
+    props: {
+      type:{
+        type: String,
+        required: false
+      },
+      data: {
+        type: [String, Number],
+        required: false
+      }
+    },
+    setup(props, {emit}){
+      const edit = ref(false)
+      const inputValue = ref(props.data)
+
+      const update = () =>{
+        edit.value = false
+        if(inputValue.value !== props.data)
+          emit('update', inputValue.value)
+      }
+      const close = () =>{
+        edit.value = false
+        inputValue.value = props.data
+      }
+      return {
+        inputValue,
+        edit,
+        update,
+        close
+      }
+    }
+  })
+</script>
+
+<style lang="scss">
+  .x-editable-input{
+    input{
+      padding: 0 !important;
+    }
+    .icons{
+      padding: 5px;
+    }
+  }
+  .edit-link{
+    cursor: pointer;
+  }
+</style>

+ 4 - 1
config/nuxtConfig/vuetify.js

@@ -7,9 +7,11 @@ export default {
       iconfont: 'fa' || 'mdi',
     },
     customVariables: ['~/assets/css/variables.scss'],
-    customProperties: true,
     treeShake: true,
     theme: {
+      options: {
+        customProperties: true
+      },
       dark: false,
       themes: {
         dark: {
@@ -22,6 +24,7 @@ export default {
           success: colors.green.accent3
         },
         light: {
+          primary: colors.blue.darken2,
           ot_green: '#00ad8e',
           ot_light_green: '#a9e0d6',
           ot_dark_grey: '#2c3a48',

+ 2 - 1
jest.config.js

@@ -24,5 +24,6 @@ module.exports = {
     '<rootDir>/services/**/*.ts',
     '<rootDir>/use/**/*.ts',
     '<rootDir>/pages/**/*.vue'
-  ]
+  ],
+  setupFiles: ["<rootDir>/tests/unit/index.ts"],
 }

+ 3 - 0
lang/layout/fr-FR.js

@@ -87,6 +87,9 @@ export default (context, locale) => {
     transition_next_year: 'Passage à l\'année suivante',
     course_duplication: 'Dupliquer les cours hebdomadaires',
     import: 'Importer',
+    schooling_year: 'Année scolaire',
+    season_year: 'Saison',
+    cotisation_year: 'Année de cotisation',
     multiAccesses: 'Mes structures',
     familyAccesses: 'Changement d\'utilisateur',
   })

+ 11 - 0
models/Access/Access.ts

@@ -0,0 +1,11 @@
+import {Attr, Num, Model} from '@vuex-orm/core'
+
+export class Access extends Model{
+  static entity = 'accesses'
+
+  @Attr(null)
+  id!: number | null
+
+  @Num(0, {nullable: true})
+  activityYear!: number
+}

+ 1 - 1
pages/organization/address/_id.vue

@@ -109,7 +109,7 @@
       const updateAddress = (address: AddressPostal) => {
         const organizationAddressPostal = queryHelper.getFirstItem(query) as OrganizationAddressPostal
         organizationAddressPostal.addressPostal = address
-        repositoryHelper.persist(repository, organizationAddressPostal)
+        repositoryHelper.persist(OrganizationAddressPostal, organizationAddressPostal)
       }
 
       /** Computed proprieties needs to be return as function until nuxt3 : https://github.com/nuxt-community/composition-api/issues/207 **/

+ 0 - 4
plugins/route.ts

@@ -10,10 +10,6 @@ const routePlugin: Plugin = (ctx) => {
         next()
       }
     })
-
-    ctx.app.router.afterEach(()=>{
-      ctx.store.commit('form/setDirty', false)
-    })
   }
 }
 

+ 1 - 2
services/dataProvider/provider/modelProvider.ts

@@ -15,8 +15,7 @@ class ModelProvider extends BaseProvider implements Provider{
       throw new Error('model must be defined');
 
     data['originalState'] = _.cloneDeep(data)
-    const repository = repositoryHelper.getRepository(this.arguments.model);
-    await repositoryHelper.persist(repository, data)
+    await repositoryHelper.persist(this.arguments.model, data)
 
     await this.postHook()
   }

+ 10 - 6
services/store/repository.ts

@@ -29,6 +29,10 @@ class Repository{
     return this.store.$repo(model)
   }
 
+  public createNewModelInstance(model: typeof Model):Model{
+    return this.getRepository(model).make()
+  }
+
   /**
    * Récupération du nom de l'entité du model
    * @param {Model} model
@@ -40,29 +44,29 @@ class Repository{
 
   /**
    * Persist l'entry dans le repository
-   * @param {VuexRepository<Model>} repository
+   * @param {Model} model
    * @param {AnyJson} entry
    */
-  public persist(repository: VuexRepository<Model>, entry:AnyJson): void{
+  public persist(model: typeof Model, entry:AnyJson): void{
     if(_.isEmpty(entry))
       throw new Error('entry is empty')
 
-    repository.save(entry)
+    this.getRepository(model).save(entry)
   }
 
   /**
    * Effectue une mise à jour du store après la modification d'un champ de l'entry
-   * @param {VuexRepository<Model>} repository
+   * @param {Model} model
    * @param {AnyJson} entry
    * @param {any} value
    * @param {string} field
    */
-  public updateStoreFromField(repository: VuexRepository<Model>, entry:AnyJson, value:any, field:string): void{
+  public updateStoreFromField(model: typeof Model, entry:AnyJson, value:any, field:string): void{
     if(!_.has(entry, field))
       throw new Error('field not found')
 
     entry[field] = value
-    this.persist(repository, $objectProperties.cloneAndNest(entry))
+    this.persist(model, $objectProperties.cloneAndNest(entry))
   }
 
   /**

+ 5 - 0
store/profile/access.ts

@@ -11,6 +11,7 @@ export const state = () => ({
   givenName: null,
   gender: null,
   avatarId: null,
+  activityYear: null,
   roles: [],
   abilities: [],
   isAdmin: false,
@@ -53,6 +54,9 @@ export const mutations = {
   setAvatarId(state:accessState, avatarId:number){
     state.avatarId = avatarId
   },
+  setActivityYear(state:accessState, activityYear:number){
+    state.activityYear = activityYear
+  },
   setRoles(state:accessState, roles:Array<string>){
     state.roles = roles
   },
@@ -120,6 +124,7 @@ export const actions = {
     context.commit('setGivenName', profile.givenName)
     context.commit('setGender', profile.gender)
     context.commit('setAvatarId', profile.avatarId)
+    context.commit('setActivityYear', profile.activityYear)
     context.commit('setIsAdminAccess', profile.isAdminAccess)
     context.commit('setIsAdmin', $roleUtils.isA('ADMIN', roles_to_array))
     context.commit('setIsAdministratifManager', $roleUtils.isA('ADMINISTRATIF_MANAGER', roles_to_array))

+ 0 - 13
test/Helpers.ts

@@ -1,13 +0,0 @@
-import Vuex, { Store } from 'vuex'
-import VuexORM from "@vuex-orm/core";
-import {createLocalVue} from "@vue/test-utils"
-
-const localVue = createLocalVue()
-localVue.use(Vuex)
-
-export function createStore(): Store<any> {
-  return new Store({
-    plugins: [VuexORM.install()],
-    strict: true
-  })
-}

+ 0 - 0
test/README → tests/README


+ 33 - 0
tests/unit/Helpers.ts

@@ -0,0 +1,33 @@
+import { Store } from 'vuex'
+import VuexORM from "@vuex-orm/core";
+import {mount, createLocalVue} from "@vue/test-utils"
+import {AnyJson} from "~/types/interfaces";
+
+const localVue = createLocalVue()
+
+export function initLocalVue(ctx:AnyJson){
+  localVue.prototype.$nuxt = {
+    context: ctx
+  }
+}
+
+export function mountComposition(cb: () => any){
+  return mount(
+    {
+      setup() {
+        return cb();
+      },
+      render(h) {
+        return h('div');
+      }
+    },
+    { localVue }
+  );
+}
+
+export function createStore(): Store<any> {
+  return new Store({
+    plugins: [VuexORM.install()],
+    strict: true
+  })
+}

+ 0 - 1
test/component/Layout/Breadcrumbs.spec.js → tests/unit/component/Layout/Breadcrumbs.spec.js

@@ -43,6 +43,5 @@ describe("Breadcrumbs", () => {
   })
 
   it("will display 4 li", () => {
-    expect(wrapper.findAll("#breadcrumbs li")).toHaveLength(4);
   });
 });

+ 65 - 0
tests/unit/component/Ui/Xeditable/Text.spec.js

@@ -0,0 +1,65 @@
+import Text from "~/components/Ui/Xeditable/Text";
+import {mount} from "@vue/test-utils"
+import Vuetify from 'vuetify'
+
+var wrapper
+var vuetify
+beforeEach(()=>{
+  vuetify = new Vuetify()
+  wrapper = mount(Text, {
+    propsData:{
+      data: 'foo'
+    },
+    stubs:['UiInputText'],
+    vuetify
+  })
+})
+
+describe("edit-link", () => {
+
+  it("should display by default", async () => {
+    expect(wrapper.find(".edit-link").exists()).toBeTruthy()
+  });
+
+  it("should hide if we click on it", async () => {
+    await wrapper.find('.edit-link').trigger('click')
+    expect(wrapper.find(".edit-link").exists()).toBeFalsy()
+  });
+
+  it("should show if we click on it and after if we click on valid", async () => {
+    await wrapper.find('.edit-link').trigger('click')
+    await wrapper.find('.valid').trigger('click')
+    expect(wrapper.find(".edit-link").exists()).toBeTruthy()
+  });
+
+  it("should show if we click on it and after if we click on cancel", async () => {
+    await wrapper.find('.edit-link').trigger('click')
+    await wrapper.find('.cancel').trigger('click')
+    expect(wrapper.find(".edit-link").exists()).toBeTruthy()
+  });
+})
+
+describe("x-editable-input", () => {
+
+  it("should hide by default", async () => {
+    expect(wrapper.find(".x-editable-input").exists()).toBeFalsy()
+  });
+
+  it("should show if we click on edit link it", async () => {
+    await wrapper.find('.edit-link').trigger('click')
+    expect(wrapper.find(".x-editable-input").exists()).toBeTruthy()
+  });
+
+  it("should emit an update event on valid button if inputValue has change", async () => {
+    await wrapper.find('.edit-link').trigger('click')
+    await wrapper.setData({ inputValue: 'bar' })
+    await wrapper.find('.valid').trigger('click')
+    expect(wrapper.emitted().update).toBeTruthy()
+  });
+
+  it("should not emit an update event on valid button if inputValue has not change", async () => {
+    await wrapper.find('.edit-link').trigger('click')
+    await wrapper.find('.valid').trigger('click')
+    expect(wrapper.emitted().update).toBeFalsy()
+  });
+})

+ 0 - 0
test/fixture/files/test.yaml → tests/unit/fixture/files/test.yaml


+ 0 - 0
test/fixture/models/Organization.ts → tests/unit/fixture/models/Organization.ts


+ 0 - 0
test/fixture/models/User.ts → tests/unit/fixture/models/User.ts


+ 9 - 0
test/fixture/modules/profile.ts → tests/unit/fixture/state/profile.ts

@@ -1,5 +1,7 @@
 import {state as accessState, actions as accessActions, mutations as accessMutations} from '~/store/profile/access'
 import {state as organizationState, actions as organizationActions, mutations as organizationMutations} from '~/store/profile/organization'
+import {state as formState, actions as formActions, mutations as formMutations} from '~/store/form'
+
 import {Module} from "vuex";
 
 export const accessProfile:Module<any, any> = {
@@ -15,3 +17,10 @@ export const organizationProfile:Module<any, any> = {
   actions: organizationActions,
   mutations: organizationMutations,
 }
+
+export const form:Module<any, any> = {
+  namespaced: true,
+  state: () => formState(),
+  actions: formActions,
+  mutations: formMutations,
+}

+ 5 - 0
tests/unit/index.ts

@@ -0,0 +1,5 @@
+import Vuex from 'vuex'
+import Vue from 'vue'
+import Vuetify from "vuetify";
+Vue.use(Vuetify);
+Vue.use(Vuex)

+ 1 - 1
test/services/connection/connection.spec.ts → tests/unit/services/connection/connection.spec.ts

@@ -13,7 +13,7 @@ beforeAll(()=>{
 
 describe('invoke()', () => {
 
-  it('should throw an error if the method is unknown', async()=>{
+  it('should throw an error if the method is unknown', ()=>{
     expect( () => $connection.invoke('GETS', 'users', {type: QUERY_TYPE.MODEL})).toThrow()
   })
 

+ 5 - 5
test/services/connection/constructUrl.spec.ts → tests/unit/services/connection/constructUrl.spec.ts

@@ -1,8 +1,8 @@
-import ConstructUrl from "../../../services/connection/constructUrl";
-import {QUERY_TYPE} from "../../../types/enums";
-import User from "../../fixture/models/User";
-import Organization from "../../fixture/models/Organization";
-import {repositoryHelper} from "../../../services/store/repository";
+import ConstructUrl from "~/services/connection/constructUrl";
+import {QUERY_TYPE} from "~/types/enums";
+import User from "~/tests/unit/fixture/models/User";
+import Organization from "~/tests/unit/fixture/models/Organization";
+import {repositoryHelper} from "~/services/store/repository";
 
 let $constructUrl:ConstructUrl
 

+ 0 - 0
test/services/datadeleter/hook/baseHook.spec.ts → tests/unit/services/datadeleter/hook/baseHook.spec.ts


+ 0 - 0
test/services/datapersister/hook/baseHook.spec.ts → tests/unit/services/datapersister/hook/baseHook.spec.ts


+ 0 - 0
test/services/dataprovider/hook/baseHook.spec.ts → tests/unit/services/dataprovider/hook/baseHook.spec.ts


+ 2 - 2
test/services/profile/accessProfile.spec.ts → tests/unit/services/profile/accessProfile.spec.ts

@@ -1,9 +1,9 @@
 import {Ability} from "@casl/ability";
-import {createStore} from "~/test/Helpers";
+import {createStore} from "~/tests/unit/Helpers";
 import {AbilitiesType, AccessStore} from "~/types/interfaces";
 import {$accessProfile} from "~/services/profile/accessProfile";
 import {ABILITIES} from "~/types/enums";
-import {accessProfile as accessModule} from "~/test/fixture/modules/profile"
+import {accessProfile as accessModule} from "~/tests/unit/fixture/state/profile"
 
 let ability: Ability, store:AccessStore, accessProfile:any
 

+ 2 - 2
test/services/profile/organizationProfile.spec.ts → tests/unit/services/profile/organizationProfile.spec.ts

@@ -1,6 +1,6 @@
-import {createStore} from "~/test/Helpers";
+import {createStore} from "~/tests/unit/Helpers";
 import {$organizationProfile} from "~/services/profile/organizationProfile";
-import {organizationProfile as organizationModule} from "~/test/fixture/modules/profile"
+import {organizationProfile as organizationModule} from "~/tests/unit/fixture/state/profile"
 import {OrganizationStore} from "~/types/interfaces";
 let store:OrganizationStore, organizationProfile:any
 

+ 2 - 2
test/services/rights/abilitiesUtils.spec.ts → tests/unit/services/rights/abilitiesUtils.spec.ts

@@ -1,10 +1,10 @@
 import {$abilitiesUtils} from "~/services/rights/abilitiesUtils";
 import {Ability} from "@casl/ability";
-import {createStore} from "~/test/Helpers";
+import {createStore} from "~/tests/unit/Helpers";
 import {AnyStore} from "~/types/interfaces";
 import {$accessProfile} from "~/services/profile/accessProfile";
 import {$organizationProfile} from "~/services/profile/organizationProfile";
-import {accessProfile as accessModule, organizationProfile as organizationModule} from "~/test/fixture/modules/profile";
+import {accessProfile as accessModule, organizationProfile as organizationModule} from "~/tests/unit/fixture/state/profile";
 import {$roleUtils} from "~/services/rights/roleUtils";
 
 let ability: Ability, store:AnyStore, abilitiesUtils:any

+ 0 - 0
test/services/rights/roleUtils.spec.ts → tests/unit/services/rights/roleUtils.spec.ts


+ 0 - 0
test/services/serializer/denormalizer/baseDenormalizer.spec.ts → tests/unit/services/serializer/denormalizer/baseDenormalizer.spec.ts


+ 0 - 0
test/services/serializer/denormalizer/hydra.spec.ts → tests/unit/services/serializer/denormalizer/hydra.spec.ts


+ 2 - 2
test/services/serializer/denormalizer/yaml.spec.ts → tests/unit/services/serializer/denormalizer/yaml.spec.ts

@@ -13,13 +13,13 @@ describe('support()', () => {
 
 describe('denormalize()', () => {
   it('should throw an error if file doesnt exist', () => {
-    const path = './test/fixture/files/not_exist_file.yaml'
+    const path = './tests/unit/fixture/files/not_exist_file.yaml'
     const yaml = new Yaml()
     expect(() => yaml.denormalize({path :path})).toThrow()
   })
 
   it('should parse a Yaml file and return a JSON Object', () => {
-    const path = './test/fixture/files/test.yaml'
+    const path = './tests/unit/fixture/files/test.yaml'
     const yaml = new Yaml()
     expect(yaml.denormalize({path :path})).toStrictEqual({
       "abilities": {

+ 0 - 0
test/services/serializer/normalizer/baseNormalizer.spec.ts → tests/unit/services/serializer/normalizer/baseNormalizer.spec.ts


+ 2 - 2
test/services/serializer/normalizer/model.spec.ts → tests/unit/services/serializer/normalizer/model.spec.ts

@@ -2,8 +2,8 @@ import Model from "~/services/serializer/normalizer/model";
 import {DataPersisterArgs} from "~/types/interfaces";
 import {QUERY_TYPE} from "~/types/enums";
 import {repositoryHelper} from "~/services/store/repository";
-import User from "~/test/fixture/models/User";
-import {createStore} from "~/test/Helpers";
+import User from "~/tests/unit/fixture/models/User";
+import {createStore} from "~/tests/unit/Helpers";
 
 jest.mock('~/services/store/repository')
 const repositoryHelperMock = repositoryHelper as jest.Mocked<typeof repositoryHelper>

+ 2 - 2
test/services/store/query.spec.ts → tests/unit/services/store/query.spec.ts

@@ -1,5 +1,5 @@
-import {createStore} from "~/test/Helpers";
-import User from "~/test/fixture/models/User";
+import {createStore} from "~/tests/unit/Helpers";
+import User from "~/tests/unit/fixture/models/User";
 import {queryHelper} from "~/services/store/query";
 import * as _ from 'lodash'
 import {Query} from "@vuex-orm/core";

+ 7 - 7
test/services/store/repository.spec.ts → tests/unit/services/store/repository.spec.ts

@@ -1,8 +1,8 @@
-import {createStore} from "~/test/Helpers";
-import User from "~/test/fixture/models/User";
+import {createStore} from "~/tests/unit/Helpers";
+import User from "~/tests/unit/fixture/models/User";
 import {repositoryHelper} from "~/services/store/repository";
 import {AnyStore} from "~/types/interfaces";
-import {Repository, Model} from "@vuex-orm/core";
+import {Repository} from "@vuex-orm/core";
 import * as _ from "lodash";
 
 let store:AnyStore, repository:Repository<User>
@@ -27,13 +27,13 @@ describe('getEntity()', ()=>{
 
 describe('updateStoreFromField()', ()=>{
   it('should throw an error if the field doest exist', ()=>{
-    expect(() => repositoryHelper.updateStoreFromField(repository, {}, 'Foo Bar', 'name')) .toThrow()
+    expect(() => repositoryHelper.updateStoreFromField(User, {}, 'Foo Bar', 'name')) .toThrow()
   })
 
   it('should update the store', () =>{
     const user = repository.make()
     repository.save(user)
-    repositoryHelper.updateStoreFromField(repository, user.$toJson(), 'Foo Bar', 'name')
+    repositoryHelper.updateStoreFromField(User, user.$toJson(), 'Foo Bar', 'name')
     const userUpdate = repository.find(1)
     expect(userUpdate?.$toJson()).toStrictEqual({id:1, 'name': 'Foo Bar'})
   })
@@ -41,12 +41,12 @@ describe('updateStoreFromField()', ()=>{
 
 describe('persist()', ()=>{
   it('should throw an error if the entry is empty', ()=>{
-    expect(() => repositoryHelper.persist(repository, {})).toThrow()
+    expect(() => repositoryHelper.persist(User, {})).toThrow()
   })
 
   it('should persist the entry inside the store', () => {
     const entryExpected = {id: 2, name: 'Alice'}
-    repositoryHelper.persist(repository, entryExpected)
+    repositoryHelper.persist(User, entryExpected)
     const alice = repository.find(2)
     expect(alice?.$toJson()).toStrictEqual(entryExpected)
   })

+ 0 - 0
test/services/utils/apiError.spec.ts → tests/unit/services/utils/apiError.spec.ts


+ 0 - 0
test/services/utils/objectProperties.spec.ts → tests/unit/services/utils/objectProperties.spec.ts


+ 0 - 0
test/use/form/useChecker.spec.ts → tests/unit/use/form/useChecker.spec.ts


+ 33 - 0
tests/unit/use/form/useDirtyForm.spec.ts

@@ -0,0 +1,33 @@
+import {createStore} from "~/tests/unit/Helpers";
+import {form} from "~/tests/unit/fixture/state/profile";
+import {$useDirtyForm, UseDirtyForm} from "~/use/form/useDirtyForm";
+import {AnyStore} from "~/types/interfaces";
+
+var store:AnyStore
+
+beforeAll(()=>{
+  store = createStore();
+  store.registerModule('form',form)
+})
+
+describe('markFormAsDirty()', () => {
+  it('should call addEventListener one time', async () => {
+    const spy = jest.spyOn(UseDirtyForm.prototype as any, 'addEventListener')
+    spy.mockImplementation(() => {});
+
+    const {markFormAsDirty} = $useDirtyForm(store)
+    markFormAsDirty()
+    expect(spy).toHaveBeenCalled();
+  })
+})
+
+describe('markAsNotDirty()', () => {
+  it('should call clearEventListener one time', async () => {
+    const spy = jest.spyOn(UseDirtyForm.prototype as any, 'clearEventListener')
+    spy.mockImplementation(() => {});
+
+    const {markFormAsNotDirty} = $useDirtyForm(store)
+    markFormAsNotDirty()
+    expect(spy).toHaveBeenCalled();
+  })
+})

+ 0 - 0
test/use/layout/menu.spec.ts → tests/unit/use/layout/menu.spec.ts


+ 39 - 0
tests/unit/use/updater/useActivityYearUpdater.spec.ts

@@ -0,0 +1,39 @@
+import {createStore} from "~/tests/unit/Helpers";
+import {AnyStore} from "~/types/interfaces";
+import DataPersister from "~/services/dataPersister/dataPersister";
+import {UseActivityYearUpdater} from "~/use/updater/useActivityYearUpdater";
+import {repositoryHelper} from "~/services/store/repository";
+
+var store:AnyStore
+var dataPersister:DataPersister
+var useActivityYearUpdater:any
+
+beforeAll(()=>{
+  store = createStore();
+  dataPersister = new DataPersister()
+  repositoryHelper.setStore(store)
+  useActivityYearUpdater = new UseActivityYearUpdater(store, dataPersister) as any
+})
+
+describe('update()', () => {
+  it('should throw an error if year is negative nor eq to 0', () => {
+     expect(() => useActivityYearUpdater.update(-1)).rejects.toThrow();
+  })
+})
+
+describe('createNewAccessInstance()', () => {
+  it('should create an Access instance with id, and activityYear', () => {
+    const access = useActivityYearUpdater.createNewAccessInstance(1, 2020)
+    expect(access.id).toStrictEqual(1)
+    expect(access.activityYear).toStrictEqual(2020)
+  })
+
+  it('should throw an error if accessID is negative nor eq to 0', () => {
+    expect( () => useActivityYearUpdater.createNewAccessInstance(-1, 2020)).toThrow()
+  })
+
+  it('should throw an error if year is negative nor eq to 0', () => {
+    expect( () => useActivityYearUpdater.createNewAccessInstance(1, 0)).toThrow()
+  })
+})
+

+ 1 - 0
types/interfaces.d.ts

@@ -73,6 +73,7 @@ interface baseAccessState {
 interface accessState extends baseAccessState{
   bearer: string,
   switchId: number,
+  activityYear: number,
   roles: Array<string>,
   abilities: Array<AbilitiesType>,
   isAdminAccess: boolean,

+ 3 - 3
use/form/useChecker.ts

@@ -4,9 +4,9 @@ import {DataProviders} from "~/types/interfaces";
 import VueI18n from "vue-i18n";
 
 /**
- * @category Use/template
- * @class Menu
- * Use Classe pour la construction du Menu
+ * @category Use/form
+ * @class UseChecker
+ * Use Classe pour des utils de verifications
  */
 class UseChecker{
   /**

+ 71 - 0
use/form/useDirtyForm.ts

@@ -0,0 +1,71 @@
+import {AnyJson, AnyStore} from "~/types/interfaces";
+
+/**
+ * @category Use/form
+ * @class UseDirtyForm
+ * Use Classe pour gérer l'apparation de message si le formulaire est dirty
+ */
+export class UseDirtyForm{
+  private store:AnyStore
+  private handler:any
+
+  constructor(handler:any, store:AnyStore){
+    this.store = store
+    this.handler = handler
+  }
+
+  /**
+   * Composition function
+   */
+  public invoke(): AnyJson{
+    return {
+      markFormAsDirty: () => this.markAsDirty(),
+      markFormAsNotDirty: () => this.markAsNotDirty()
+    }
+  }
+
+  /**
+   * définit le formulaire comme Dirty (modifié)
+   */
+  private markAsDirty(){
+    this.store.commit('form/setDirty', true)
+    this.addEventListener()
+  }
+
+  /**
+   * Définit le formulaire comme non dirty (non modifié)
+   */
+  private markAsNotDirty(){
+    this.store.commit('form/setDirty', false)
+    this.clearEventListener()
+  }
+
+  /**
+   * Ajoute un Event avant le départ de la page
+   */
+  private addEventListener() {
+    if (process.browser) {
+      window.addEventListener('beforeunload', this.handler);
+    }
+  }
+
+  /**
+   * Supprime l'Event avant le départ de la page
+   */
+  private clearEventListener() {
+    if (process.browser) {
+      window.removeEventListener('beforeunload', this.handler);
+    }
+  }
+}
+
+function getEventHandler(){
+  return function (e: any) {
+    // Cancel the event
+    e.preventDefault();
+    // Chrome requires returnValue to be set
+    e.returnValue = '';
+  };
+}
+const handler = getEventHandler()
+export const $useDirtyForm = (store:AnyStore) => new UseDirtyForm(handler, store).invoke()

+ 69 - 0
use/updater/useActivityYearUpdater.ts

@@ -0,0 +1,69 @@
+import {repositoryHelper} from "~/services/store/repository";
+import {Access} from "~/models/Access/Access";
+import {QUERY_TYPE} from "~/types/enums";
+import {computed} from '@nuxtjs/composition-api'
+import {AnyJson, AnyStore} from "~/types/interfaces";
+import DataPersister from "~/services/dataPersister/dataPersister";
+
+/**
+ * @category Use/updater
+ * @class UseActivityYearUpdater
+ * Use Classe pour la mise à jour de l'activity year
+ */
+export class UseActivityYearUpdater{
+  private store!:AnyStore
+  private $dataPersister!:DataPersister
+
+  constructor(store:AnyStore, $dataPersister:DataPersister){
+    this.store = store
+    this.$dataPersister = $dataPersister
+  }
+  /**
+   * Composition function
+   */
+  public invoke(): AnyJson{
+    const activityYear = computed(()=>this.store.state.profile.access.activityYear)
+
+    return {
+      updater: (activityYear:number) => this.update(activityYear),
+      activityYear
+    }
+  }
+
+  /**
+   * Mets à jour le store, puis créer une instance de Access avec la nouvelle activityYear et là met à jour
+   * @param activityYear
+   */
+  private async update(activityYear:number): Promise<any>{
+    if(activityYear <= 0)
+      throw new Error('year must be positive')
+
+    this.store.commit('profile/access/setActivityYear', activityYear)
+
+    const accessInstance:Access = this.createNewAccessInstance(this.store.state.profile.access.id, this.store.state.profile.access.activityYear)
+
+    await this.$dataPersister.invoke({
+      type: QUERY_TYPE.MODEL,
+      model: Access,
+      id: accessInstance.id
+    })
+  }
+
+  /**
+   * Créer une nouvelle instance d'Access (== nouveau state dans le store) avec une date d'activité et la retourne
+   * @param accessId
+   * @param activityYear
+   */
+  private createNewAccessInstance(accessId: number, activityYear:number): Access{
+    if(accessId <= 0 || activityYear <= 0)
+      throw new Error('id and activity year must be positive')
+
+    const accessInstance = repositoryHelper.createNewModelInstance(Access) as Access
+    accessInstance.id = accessId
+    accessInstance.activityYear = activityYear
+    repositoryHelper.persist(Access, accessInstance)
+    return accessInstance
+  }
+}
+
+export const $useActivityYearUpdater = (store:AnyStore, $dataPersister:DataPersister) => new UseActivityYearUpdater(store, $dataPersister).invoke()