Reusable generic form component

I have created a form component (FormPage) that manage the functionalities that all forms have in common: get the data, post the data and display validation messages.

With the `<component :is="…" … /> you can insert a child component that contains the fields.

The component works as intended, you can see a simplified version here: codesandbox sample

I’m new to Vue so I would like to have your opinion on the code and specifically on the method I used to bind the data from the FormPage component to the child component, in this case EditPerson.

FormPage

You can see the <form> tag and the <component /> tag.

Another important thing to note is <div v-if="isDataLoaded">.
This is fundamental to the correct loading of the data and to me it seems a little bit like an hack.
I will return to this topic later.

<template>
  <div>
    <div v-if="isDataLoaded">
      <form @submit.prevent="save">
        <div class="buttons">
          <button type="submit" class="button is-primary">
            <span>Save</span>
          </button>
        </div>
        <div id="validationSummary" class="content has-text-danger">
          <ul>
            <li v-for="(se, index) in summaryErrors" :key="`se-${index}`">{{ se }}</li>
          </ul>
        </div>
        <component :is="component" :initial-model="model" :validations="validations"/>
      </form>
    </div>
    <div v-else>
      <div class="notification">Error!</div>
    </div>
  </div>
</template>

<script>
import api from "../api";
const FormPage = {
  name: "FormPage",
  props: {
    component: {
      type: [Object, Function],
      default: null
    },
    getUrl: {
      type: String,
      default: ""
    },
    postUrl: {
      type: String,
      default: ""
    }
  },
  data() {
    return {
      isDataLoaded: false,
      model: {},
      summaryErrors: [],
      validations: {}
    };
  },
  created() {
    // simulate GET getUrl
    api.getJson(data => {
      this.model = data;
      this.isDataLoaded = true;
    });
  },
  methods: {
    save(event) {
      // simulate POST postUrl
      // error management omitted
      this.validations = {};
      api.postJson(
        this.model,
        () => {
          // success
          this.$emit("success");
        },
        result => {
          this.validations = result.errors;
        }
      );
    }
  }
};

export default FormPage;
</script>

EditPerson

This is the component that contains the fields to edit a person.

It’s very simple, the only thing to note is the initialModel prop, the model in data and the fact that the model is assigned in the created hook.

You can see that is very simple to create the real forms without duplicating all the infrastructure that is always the same.

<template>
  <div>
    <input v-model="model.id" type="hidden">
    <div class="field">
      <label class="label">First name</label>
      <div class="control">
        <input v-model="model.firstName" type="text" class="input">
      </div>
      <span class="help is-danger">{{ validations['firstName'] }}</span>
    </div>
    <div class="field">
      <label class="label">First name</label>
      <div class="control">
        <input v-model="model.lastName" type="text" class="input">
      </div>
      <span class="help is-danger">{{ validations['lastName'] }}</span>
    </div>
  </div>
</template>

<script>
const EditPerson = {
  name: "EditPerson",
  props: {
    initialModel: {
      type: Object,
      default: null
    },
    validations: {
      type: Object,
      default: null
    }
  },
  data() {
    return {
      model: {}
    };
  },
  created() {
    this.model = this.initialModel;
  }
};

export default EditPerson;
</script>

App

This is the view that display the form.

To pass the EditPerson component to the FormPage you import it, assign in a data field and bind as :component="component".

<template>
  <section class="section">
    <div id="app">
      <div class="container">
        <FormPage
          :component="component"
          :get-url="`people/editperson?id=${id}`"
          :post-url="`people/editperson`"
          @success="onSuccess"
        ></FormPage>
      </div>
      <hr>
      <div class="container">{{ person }}</div>
    </div>
  </section>
</template>

<script>
import api from "./api";
import EditPerson from "./components/EditPerson";
import FormPage from "./components/FormPage";

export default {
  name: "App",
  components: {
    FormPage
  },
  data() {
    return {
      id: 0,
      component: EditPerson,
      person: null
    };
  },
  methods: {
    onSuccess() {
      api.getJson(data => {
        this.person = data;
      });
    }
  }
};
</script>

Questions

Q1

In App you can see how I pass the EditPerson component to the FormPage component. Is this the correct and canonical way to do it?

Q2

In the created hook of the FormPage component I call the api to retrieve the data using the passed getUrl. When the promise returns I assign the data to this.model.

this.model is bind to the initialModel prop of the child component

<component :is="component" :initial-model="model" :validations="validations" />

In the child component in the created hook I assign the prop to the child component model.

created() {
    this.model = this.initialModel;
}

The problem is that that this code execute too early, before the promise returns so there’s no data.

The solution I have found is to defer the creation of the child component until the data is ready.
I do it with a v-if directive.

<div v-if="isDataLoaded">
  ....
</div>

This works, but it seems like an hack.
Being new to Vue I don’t know if this is correct or if there’s a better way.

What do you think?

I did some research so I answer my own questions.

Q1

Yes, it’s correct. See Dynamic Components.

Q2

Using v-if to defer component creation is correct.

An alternative is to watch the prop and initialize the model there.

watch: {
  initialModel: {
    handler: function(value) {
      if (value != null) {
        this.model = value;
      }
    }
  }
}

Alternative example

1 Like