瀏覽代碼

CRUD one to many

Vincent GUFFON 4 年之前
父節點
當前提交
8c389c118c

+ 98 - 0
components/Ui/Button/Submit.vue

@@ -0,0 +1,98 @@
+<template>
+  <v-btn v-if="!readonly" class="mr-4 ot_green ot_white--text" :class="otherActions ? 'pr-0' : ''" @click="onClick(mainAction)" ref="mainBtn">
+    {{ $t(mainAction) }}
+
+    <v-divider class="ml-3" vertical v-if="otherActions"></v-divider>
+
+    <v-menu
+      :top="dropDirection==='top'"
+      offset-y
+      left
+      v-if="otherActions"
+      :nudge-top="dropDirection==='top' ? 6 : 0"
+      :nudge-bottom="dropDirection==='bottom' ? 6 : 0"
+    >
+      <template v-slot:activator="{ on, attrs }">
+        <v-toolbar-title v-on="on">
+          <v-icon class="pl-3 pr-3">
+            {{ dropDirection==='top' ? 'fa-caret-up' : 'fa-caret-down'}}
+          </v-icon>
+        </v-toolbar-title>
+      </template>
+      <v-list
+        :min-width="menuSize"
+      >
+        <v-list-item
+          dense
+          v-for="(action, index) in actions"
+          :key="index"
+          class="subAction"
+          v-if="index > 0"
+        >
+          <v-list-item-title v-text="$t(action)" @click="onClick(action)" />
+        </v-list-item>
+      </v-list>
+    </v-menu>
+  </v-btn>
+</template>
+
+<script lang="ts">
+import {computed, ComputedRef, defineComponent, ref, Ref} from "@nuxtjs/composition-api";
+import {$useForm} from "~/use/form/useForm";
+
+export default defineComponent({
+  props: {
+    actions: {
+      type: Array,
+      required: true
+    },
+    dropDirection: {
+      type: String,
+      required: false,
+      default:'bottom'
+    }
+  },
+
+  setup(props, {emit}) {
+    const {readonly} = $useForm()
+
+    const mainBtn:Ref<any> = ref(null)
+    const menuSize = computed(()=>{
+      //Btn size + 40px de padding
+      return mainBtn.value?.$el.clientWidth + 40
+    })
+
+    const onClick = (action: string) => {
+      emit('submit', action)
+    }
+
+
+    const mainAction:ComputedRef<any> = computed(()=>{
+      return props.actions[0] as string
+    })
+
+    const otherActions:ComputedRef<boolean> = computed(()=>{
+      return props.actions.length > 1
+    })
+
+    return{
+      mainBtn,
+      menuSize,
+      readonly,
+      onClick,
+      mainAction,
+      otherActions
+    }
+
+  }
+})
+</script>
+
+<style scoped>
+.v-list-item--dense{
+  min-height: 25px;
+}
+.subAction{
+  cursor: pointer;
+}
+</style>

+ 78 - 55
components/Ui/Form.vue

@@ -8,30 +8,31 @@ Formulaire générique
   <main>
     <v-form
       ref="form"
-      v-model="properties.valid"
       lazy-validation
       :readonly="readonly"
     >
       <v-container fluid class="container btnActions">
         <v-row>
           <v-col cols="12" sm="12">
-            <slot name="form.button" />
-            <v-btn v-if="!readonly" class="mr-4 ot_green ot_white--text" @click="submit">
-              {{ $t('save') }}
-            </v-btn>
+            <slot name="form.button"/>
+            <UiButtonSubmit
+              @submit="submit"
+              :actions="actions"
+            ></UiButtonSubmit>
           </v-col>
         </v-row>
       </v-container>
 
-      <slot name="form.input" v-bind="{entry,updateRepository}" />
+      <slot name="form.input" v-bind="{entry,updateRepository}"/>
 
       <v-container fluid class="container btnActions">
         <v-row>
           <v-col cols="12" sm="12">
-            <slot name="form.button" />
-            <v-btn v-if="!readonly" class="mr-4 ot_green ot_white--text" @click="submit">
-              {{ $t('save') }}
-            </v-btn>
+            <slot name="form.button"/>
+            <UiButtonSubmit
+              @submit="submit"
+              :actions="actions"
+            ></UiButtonSubmit>
           </v-col>
         </v-row>
       </v-container>
@@ -56,24 +57,27 @@ Formulaire générique
         <v-btn class="mr-4 submitBtn ot_green ot_white--text" @click="saveAndQuit">
           {{ $t('save_and_quit') }}
         </v-btn>
-        <v-btn class="mr-4 submitBtn ot_danger ot_white--text" @click="goEvenUnsavedData">
+        <v-btn class="mr-4 submitBtn ot_danger ot_white--text" @click="quitForm">
           {{ $t('quit_form') }}
         </v-btn>
       </template>
     </lazy-LayoutDialog>
+
   </main>
 </template>
 
 <script lang="ts">
-import {
-  computed, defineComponent, reactive, toRefs, useContext, ref, Ref, ComputedRef, ToRefs, UnwrapRef
-} from '@nuxtjs/composition-api'
-import { Query } from '@vuex-orm/core'
-import { repositoryHelper } from '~/services/store/repository'
-import { queryHelper } from '~/services/store/query'
-import { QUERY_TYPE, TYPE_ALERT } from '~/types/enums'
-import { alert, AnyJson } from '~/types/interfaces'
-import { $useDirtyForm } from '~/use/form/useDirtyForm'
+import {computed, ComputedRef, defineComponent, toRefs, ToRefs, useContext} from '@nuxtjs/composition-api'
+import {Query} from '@vuex-orm/core'
+import {repositoryHelper} from '~/services/store/repository'
+import {queryHelper} from '~/services/store/query'
+import {FORM_STATUS, QUERY_TYPE, SUBMIT_TYPE, TYPE_ALERT} from '~/types/enums'
+import {AnyJson} from '~/types/interfaces'
+import {$useForm} from '~/use/form/useForm'
+import * as _ from 'lodash'
+import Form from "~/services/store/form";
+import Page from "~/services/store/page";
+import UseNextStepFactory from "~/use/form/useNextStepFactory";
 
 export default defineComponent({
   props: {
@@ -82,26 +86,29 @@ export default defineComponent({
       required: true
     },
     id: {
-      type: Number,
+      type: [Number, String],
       required: true
     },
     query: {
       type: Object as () => Query,
       required: true
+    },
+    submitActions: {
+      type: Object,
+      required: false,
+      default: () => {
+        let actions:AnyJson = {}
+        actions[SUBMIT_TYPE.SAVE] = {}
+        return actions
+      }
     }
   },
-  setup (props) {
-    const { $dataPersister, store, app: { router, i18n } } = useContext()
-    const { markFormAsDirty, markFormAsNotDirty } = $useDirtyForm(store)
-
-    const { id, query }: ToRefs = toRefs(props)
-    const properties: UnwrapRef<AnyJson> = reactive({
-      valid: false,
-      saveOk: false,
-      saveKo: false
-    })
-
-    const readonly: Ref<boolean> = ref(false)
+  setup(props) {
+    const {$dataPersister, store, app: {router, i18n}} = useContext()
+    const {markFormAsDirty, markFormAsNotDirty, readonly} = $useForm()
+    const nextStepFactory = new UseNextStepFactory()
+    const {id, query}: ToRefs = toRefs(props)
+    const page = new Page(store)
 
     const entry: ComputedRef<AnyJson> = computed(() => {
       return queryHelper.getFlattenEntry(query.value, id.value)
@@ -112,31 +119,43 @@ export default defineComponent({
       repositoryHelper.updateStoreFromField(props.model, entry.value, newValue, field)
     }
 
-    const submit = async () => {
+    const submit = async (next: string|null = null) => {
+      markFormAsNotDirty()
+
       try {
-        markFormAsNotDirty()
-        await $dataPersister.invoke({
+        const response = await $dataPersister.invoke({
           type: QUERY_TYPE.MODEL,
           model: props.model,
-          id: id.value,
+          id: store.state.form.formStatus === FORM_STATUS.EDIT ? id.value : null,
+          idTemp: store.state.form.formStatus === FORM_STATUS.CREATE ? id.value : null,
           query: props.query
         })
 
-        const alert:alert = {
-          type: TYPE_ALERT.SUCCESS,
-          message: i18n.t('saveSuccess') as string
-        }
-        store.commit('page/setAlert', alert)
+        page.addAlerts(TYPE_ALERT.SUCCESS, [i18n.t('saveSuccess') as string])
+        nextStep(next, response.data)
       } catch (error) {
-        const alert:alert = {
-          type: TYPE_ALERT.ALERT,
-          message: error.message
+        if (error.response.status === 422) {
+          if(error.response.data['violations']){
+            const violations:Array<string> = []
+            const fields:Array<string> = []
+            for(const violation of error.response.data['violations']){
+              violations.push(i18n.t(violation['message']) as string)
+              fields.push(violation['propertyPath'])
+            }
+
+            new Form(store).addViolations(fields)
+            page.addAlerts(TYPE_ALERT.ALERT, violations)
+          }
         }
-        store.commit('page/setAlert', alert)
       }
     }
 
-    const showDialog:ComputedRef<boolean> = computed(() => {
+    const nextStep = (next: string|null, response: AnyJson) =>{
+      if(next === null) return
+      nextStepFactory.invoke(props.submitActions[next], response)[next]()
+    }
+
+    const showDialog: ComputedRef<boolean> = computed(() => {
       return store.state.form.showConfirmToLeave
     })
 
@@ -146,10 +165,10 @@ export default defineComponent({
 
     const saveAndQuit = async () => {
       await submit()
-      goEvenUnsavedData()
+      quitForm()
     }
 
-    const goEvenUnsavedData = () => {
+    const quitForm = () => {
       markFormAsNotDirty()
       store.commit('form/setShowConfirmToLeave', false)
 
@@ -163,23 +182,27 @@ export default defineComponent({
       }
     }
 
+    const actions = computed(()=>{
+      return _.keys(props.submitActions)
+    })
+
     return {
       submit,
       updateRepository,
-      properties,
       readonly,
       showDialog,
       entry,
-      goEvenUnsavedData,
+      quitForm,
       closeDialog,
-      saveAndQuit
+      saveAndQuit,
+      actions
     }
   }
 })
 </script>
 
 <style scoped>
-  .btnActions {
-    text-align: right;
-  }
+.btnActions {
+  text-align: right;
+}
 </style>

+ 11 - 0
components/Ui/SubList.vue

@@ -8,6 +8,13 @@
     />
     <div v-else>
       <slot name="list.item" v-bind="{items}" />
+
+      <v-btn v-if="newLink" class="ot_white--text ot_green float-right">
+        <NuxtLink :to="newLink" class="no-decoration">
+          <v-icon>fa-plus-circle</v-icon>
+          <span>{{$t('add')}}</span>
+        </NuxtLink>
+      </v-btn>
     </div>
     <slot />
   </main>
@@ -45,6 +52,10 @@ export default defineComponent({
       required: false,
       default: 'text'
     },
+    newLink: {
+      type: String,
+      required: false
+    }
   },
   setup (props) {
     const { rootModel, rootId, model, query }: ToRefs = toRefs(props)

+ 1 - 0
config/nuxtConfig/plugins.js

@@ -9,6 +9,7 @@ export default {
     '~/plugins/Data/dataPersister',
     '~/plugins/Data/dataProvider',
     '~/plugins/Data/dataDeleter',
+    '~/plugins/vuexOrm.js',
     '~/plugins/phone-input'
   ]
 }

+ 2 - 0
pages/organization/index.vue

@@ -77,6 +77,7 @@ Contient toutes les informations sur l'organization courante
                     :root-id="id"
                     :model="models.OrganizationAddressPostal"
                     loaderType="image"
+                    newLink="/organization/address/new"
                   >
                     <template #list.item="{items}">
                       <v-container fluid>
@@ -133,6 +134,7 @@ Contient toutes les informations sur l'organization courante
                     :root-id="id"
                     :model="models.ContactPoint"
                     loaderType="image"
+                    newLink="/organization/contact_points/new"
                   >
                     <template #list.item="{items}">
                       <v-container fluid>

+ 11 - 0
plugins/vuexOrm.js

@@ -0,0 +1,11 @@
+import VuexORM from '@vuex-orm/core'
+
+const plugin = {
+  install (store, components, options) {
+    components.Query.prototype.getAllRelations = function () {
+      return _.keys(this.eagerLoad);
+    }
+  }
+}
+
+VuexORM.use(plugin)

+ 13 - 2
services/serializer/normalizer/model.ts

@@ -5,6 +5,7 @@ import { QUERY_TYPE } from '~/types/enums'
 import { repositoryHelper } from '~/services/store/repository'
 import { Item } from '@vuex-orm/core'
 import {queryHelper} from "~/services/store/query";
+import {$objectProperties} from "~/services/utils/objectProperties";
 
 /**
  * @category Services/serializer/normalizer
@@ -41,7 +42,7 @@ class Model extends BaseNormalizer {
 
     let data = item.$toJson()
 
-    if(Model.isPostQuery(args)) data = Model.sanitizeBeforePost(data)
+    if(Model.isPostQuery(args)) data = Model.sanitizeBeforePost(data, args.query ? args.query.getAllRelations() : [])
 
     return _.omit(data, 'originalState')
   }
@@ -57,8 +58,18 @@ class Model extends BaseNormalizer {
   /**
    * Opération de nettoyage avant un POST
    * @param data
+   * @param relations
    */
-  public static sanitizeBeforePost(data:AnyJson): AnyJson{
+  public static sanitizeBeforePost(data:AnyJson, relations: Array<string>): AnyJson{
+    if(relations){
+      data = $objectProperties.cloneAndFlatten(data)
+      for(const relation of relations){
+        delete data[`${relation}.id`]
+        delete data[`${relation}.@id`]
+      }
+      data = $objectProperties.cloneAndNest(data)
+    }
+
     delete data.id
     return data
   }

+ 23 - 0
services/store/page.ts

@@ -0,0 +1,23 @@
+import {Store} from "vuex";
+import {TYPE_ALERT} from "~/types/enums";
+
+export default class Page {
+  private store
+
+  constructor(store:Store<any>) {
+    this.store = store
+  }
+
+  /**
+   * Ajout des alerts dans le store
+   * @param type
+   * @param alerts
+   */
+  addAlerts(type: TYPE_ALERT, alerts: Array<string>){
+    const alert = {
+      type: type,
+      message: alerts.join(' - ')
+    }
+    this.store.commit('page/setAlert', alert)
+  }
+}

+ 4 - 4
services/store/query.ts

@@ -11,10 +11,10 @@ class Query {
    * Récupération de l'Item souhaité
    *
    * @param {VuexQuery} query
-   * @param {number} id
+   * @param {number|string} id
    * @return {Item} l'Item
    */
-  public getItem (query: VuexQuery, id: number): Item {
+  public getItem (query: VuexQuery, id: number|string): Item {
     const item = query.find(id)
     if (!item || typeof item === 'undefined') { throw new Error('item not found') }
     return item
@@ -46,10 +46,10 @@ class Query {
    * Récupération de l'Item souhaité puis transformation en JSON aplati
    *
    * @param {VuexQuery} query
-   * @param {number} id
+   * @param {number|string} id
    * @return {AnyJson} réponse
    */
-  public getFlattenEntry (query: VuexQuery, id: number): AnyJson {
+  public getFlattenEntry (query: VuexQuery, id: number|string): AnyJson {
     return $objectProperties.cloneAndFlatten(this.getItem(query, id) as AnyJson)
   }
 

+ 3 - 3
services/store/repository.ts

@@ -83,10 +83,10 @@ class Repository {
    * Récupération de l'Item du Model souhaité
    *
    * @param {Model} model
-   * @param {number} id
+   * @param {number|string} id
    * @return {Item} l'Item
    */
-  public findItemFromModel (model: typeof Model, id: number): Item {
+  public findItemFromModel (model: typeof Model, id: number|string): Item {
     const repository = this.getRepository(model)
     const item = repository.find(id)
     if (!item || typeof item === 'undefined') { throw new Error('Item not found') }
@@ -117,7 +117,7 @@ class Repository {
    * @param {Model} model
    * @param {number} id
    */
-  public deleteItem (model: typeof Model, id: number) {
+  public deleteItem (model: typeof Model, id: number|string) {
     const repository = this.getRepository(model)
     repository.destroy(id)
   }

+ 14 - 1
store/form.ts

@@ -1,12 +1,25 @@
-import { formState } from '~/types/interfaces'
+import {formState} from '~/types/interfaces'
+import {FORM_STATUS} from "~/types/enums";
 
 export const state = () => ({
+  formStatus: FORM_STATUS.EDIT,
+  violations: [],
+  readonly: false,
   dirty: false,
   showConfirmToLeave: false,
   goAfterLeave: null
 })
 
 export const mutations = {
+  setViolations (state: formState, violations: Array<string>) {
+    state.violations = violations
+  },
+  setReadOnly (state: formState, readonly: boolean) {
+    state.readonly = readonly
+  },
+  setFormStatus (state: formState, formStatus: FORM_STATUS) {
+    state.formStatus = formStatus
+  },
   setDirty (state: formState, dirty: boolean) {
     state.dirty = dirty
   },

+ 8 - 0
types/enums.ts

@@ -1,3 +1,7 @@
+export const enum FORM_STATUS {
+  CREATE = 'CREATE',
+  EDIT = 'EDIT'
+}
 export const enum HTTP_METHOD {
   POST = 'POST',
   PUT = 'PUT',
@@ -52,3 +56,7 @@ export const enum ALERT_STATE_COTISATION {
   ADVERTISINGINSURANCE= 'ADVERTISINGINSURANCE'
 }
 
+export const enum SUBMIT_TYPE {
+  SAVE = 'save',
+  SAVE_AND_BACK= 'save_and_back'
+}

+ 10 - 1
types/interfaces.d.ts

@@ -5,7 +5,7 @@ import { Context } from '@nuxt/types/app'
 import DataPersister from '~/services/data/dataPersister'
 import DataProvider from '~/services/data/dataProvider'
 import DataDeleter from '~/services/data/dataDeleter'
-import {ABILITIES, GENDER, METADATA_TYPE, QUERY_TYPE, TYPE_ALERT} from '~/types/enums'
+import {ABILITIES, FORM_STATUS, GENDER, METADATA_TYPE, QUERY_TYPE, SUBMIT_TYPE, TYPE_ALERT} from '~/types/enums'
 
 /**
  * Upgrade du @nuxt/types pour TypeScript
@@ -19,6 +19,12 @@ declare module '@nuxt/types' {
   }
 }
 
+declare module '@vuex-orm/core' {
+  interface Query{
+    getAllRelations: () => Array<string>
+  }
+}
+
 interface ItemMenu {
   title: string,
   icon?: string,
@@ -49,6 +55,9 @@ interface AbilitiesType {
 }
 
 interface formState {
+  violations: Array<string>,
+  readonly: boolean,
+  formStatus: FORM_STATUS,
   dirty: boolean,
   showConfirmToLeave: boolean,
   goAfterLeave: string

+ 1 - 1
types/types.d.ts

@@ -1,4 +1,4 @@
-import {OrderBy, OrderDirection, Query, Repository} from '@vuex-orm/core'
+import {OrderDirection, Query, Repository} from '@vuex-orm/core'
 
 export type RepositoryOrQuery<R extends Repository = Repository, Q extends Query = Query> = R | Q;