bekwam courses

Vue.js Master / Details Popup Demo

March 17, 2019

This article demonstrates a master / details editing design pattern whereby the updates and additions are made in a modal dialog. The main UI can use the full screen to display a table of records ("master"). A details panel, with a superset of table columns rendered as form fields, is used to modify the table.

The following pair of Balsamiq mockups shows the behavior of the demo app. On the left side, there is a view containing a table of data called the Person List. Selecting an item in the table will highlight it. There are three buttons in the upper right: Add, Edit, Delete. Pressing Delete deletes the selected record. Pressing Add or Edit with an item selected will display a modal dialog called the Person Details Popup. In the Popup, you can make edits to the form to add or update a record, depending the button used to display the modal.

Balsamiq Mockup of Popup Demo
Person List (Left) and Person Details Popup (Right)

This video demonstrates the four operations in the app: add, select, edit, and delete. A few records are added, then I double-back over the additions and edit and delete some.

The demo app is hosted here if you'd like to try the code for yourself.

Design

The following UML diagram shows the structure of the application. There is a single Vue app instance which is a container for two components: Person List and Person Details Popup. Person List is the component supporting the table of records. Person Details Popup is for the single record view. Both are part of the top level HTML document, though Person Details Popup is hidden by Bootstrap unless Add or Edit are pressed. The components share a common Vuex Store. There is also a single function packed in a Utils class called from two different files.

UML Class Diagram
Popup Demo Class Model

Project Setup

The project is created using the Vue CLI. We'll be using Bootstrap, both CSS and JS, to provide the layout, styling, and modal functionality.

  1. > vue create vuejs-popup-demo
  2. Specify "Manually select"
  3. Select Vuex and Linter / Formatter
  4. Select ESLint and Prettier
  5. Select Lint on save
  6. Select dedicated config files
  7. Don't save as preset for later projects
  8. > cd vuejs-popup-demo
  9. > npm install bootstrap popper.js jquery

To test this, run npm run serve. This will bring up a stock Vue.js welcome window. We'll replace that stock code with our own.

App.vue

This listing is for the app instance maintained in the generated App.vue file. It replaces the contents created by Vue CLI.


<template>
  <div id="app" class="container mt-2">
    <person-list/>
    <person-details-popup/>
  </div>
</template>

<script>
import "bootstrap/dist/css/bootstrap.css";

import store from "./store";

import PersonList from "./components/PersonList.vue";
import PersonDetails from "./components/PersonDetails.vue";

export default {
  name: "app",
  store,
  components: {
    "person-list": PersonList,
    "person-details-popup": PersonDetails
  }
};
</script>

The script loads the CSS for Bootstrap, the components PersonList and PersonDetails, and the Vue store. The template is the outermost container of the app annotated with the "container" Boostrap class which turns on a grid-based layout. "mt-2" puts 2 elements of margin on the top.

PersonList.vue

PersonList is a component based on an HTML table of Person records. Bootstrap provides a hover for the table rows through the class table-hover. There is an id comparision function isSelected which applies an additional table-primary class to a row that has been selected. The selection will initiate a Vuex Store model change of the selectedPerson.

The three buttons on the top right -- Add, Edit, Delete -- manipulate the list. In this component, pressing the Delete button will remove the selected Person record. The Add and Edit buttons will display the Person Details Popup. There is some conditional logic preventing an unselected Edit or Delete operation.


<template>
  <div>
    <div class="row align-items-center">
      <div class="col">
        <h1>Person List</h1>
      </div>
      <div class="col col-lg-3">
        <div class="row">
          <div class="col">
            <button class="btn btn-secondary" @click="popup('add')">Add</button>
          </div>
          <div class="col">
            <button
              class="btn btn-secondary"
              @click="popup('edit')"
              :disabled="this.$store.getters.selectedPerson.id == -1"
            >Edit</button>
          </div>
          <div class="col">
            <button
              class="btn btn-secondary"
              @click="deleteRow"
              :disabled="this.$store.getters.selectedPerson.id == -1"
            >Delete</button>
          </div>
        </div>
      </div>
    </div>
    <div class="row">
      <div class="col">
        <table class="table table-hover">
          <thead>
            <tr>
              <th scope="col">Name (Job Title)</th>
              <th scope="col">Age</th>
              <th scope="col">Nickname</th>
              <th scope="col">Employee</th>
            </tr>
          </thead>
          <tbody>
            <tr
              v-for="person in this.$store.getters.persons"
              :key="person.id"
              @click="selectRow(person)"
              :class="{'table-primary' : isSelected(person.id)}"
            >
              <td scope="row">
                {{ person.name }}
                <span v-if="person.jobTitle != ''">({{ person.jobTitle }})</span>
              </td>
              <td>{{ person.age }}</td>
              <td>{{ person.nickname }}</td>
              <td>{{ person.employee }}</td>
            </tr>
          </tbody>
        </table>
      </div>
    </div>
  </div>
</template>

<script>
import "bootstrap/dist/js/bootstrap.min.js";
import "popper.js/dist/popper.min.js";
import "jquery/dist/jquery.min.js";
import { createEmptyPerson } from "@/utils.js";

import JQuery from "jquery";
let $ = JQuery;

export default {
  name: "PersonList",
  methods: {
    selectRow(p) {
      this.$store.dispatch("selectPerson", p);
    },
    isSelected(id) {
      return id == this.$store.getters.selectedPerson.id;
    },
    deleteRow() {
      this.$store.dispatch(
        "deletePerson",
        this.$store.getters.selectedPerson.id
      );
    },
    popup(action) {
      if (action === "add") {
        this.$store.dispatch("selectPerson", createEmptyPerson());
      }

      $("#exampleModal").modal();
    }
  }
};
</script>

The modal function in the popup event handler is a Bootstrap JavaScript function that will toggle the display of the modal dialog.

PersonDetails.vue

PersonDetails is a single-record view of a Person. It is a form with textfields and a check box for manipulating a record. This component was added to the main layout in the App.vue app instance and shown by the sibiling component PersonList. It is a modal dialog that can be canceled using the Close button or the X in the topright. The Save operation will initiate an addPerson or updatePerson operation on the Vuex Store.

In this app, there is a convention. Any record with an id of -1 is a temporary record. It's either a placeholder to fend off undefined errors or a template record for an add operation. This convention allows the same modal view code to be used for both Add and Edit. The id==-1 is a flag used in the Save handler to decide which of the two operations is occurring.


  <template>
  <div
    class="modal fade"
    id="exampleModal"
    tabindex="-1"
    role="dialog"
    aria-labelledby="exampleModalLabel"
    aria-hidden="true"
  >
    <form>
      <div class="modal-dialog modal-dialog-centered" role="document">
        <div class="modal-content">
          <div class="modal-header">
            <h5 class="modal-title" id="exampleModalLabel">Person Details Popup</h5>
            <button type="button" class="close" data-dismiss="modal" aria-label="Close">
              <span aria-hidden="true">×</span>
            </button>
          </div>
          <div class="modal-body">
            <fieldset>
              <input type="hidden" id="id" :value="selectedPerson.id">

              <div class="form-group">
                <label for="name">Name</label>
                <input class="form-control" type="text" id="name" :value="selectedPerson.name">
              </div>

              <div class="form-group">
                <label for="nickname">NickName</label>
                <input
                  class="form-control"
                  type="text"
                  id="nickname"
                  :value="selectedPerson.nickname"
                >
              </div>

              <div class="form-group">
                <label for="jobTitle">Job Title</label>
                <input
                  class="form-control"
                  type="text"
                  id="jobTitle"
                  :value="selectedPerson.jobTitle"
                >
              </div>
              <div class="form-group">
                <label for="Age">Age</label>
                <input class="form-control" type="text" id="age" :value="selectedPerson.age">
              </div>
              <div class="form-group form-check">
                <input
                  class="form-check-input"
                  type="checkbox"
                  id="employee"
                  :checked="selectedPerson.employee"
                >
                <label class="form-check-label" for="Employee">Employee</label>
              </div>
            </fieldset>
          </div>
          <div class="modal-footer">
            <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
            <button
              type="submit"
              class="btn btn-primary"
              @click.prevent="savePerson"
              data-dismiss="modal"
            >Save</button>
          </div>
        </div>
      </div>
    </form>
  </div>
</template>

<script>
import { mapState } from "vuex";

export default {
  name: "PersonDetails",
  methods: {
    savePerson(event) {
      let id = event.target.form["id"].value;

      const p = {
        id: id,
        name: event.target.form["name"].value,
        nickname: event.target.form["nickname"].value,
        jobTitle: event.target.form["jobTitle"].value,
        age: event.target.form["age"].value,
        employee: event.target.form["employee"].checked
      };

      if (id == -1) {
        this.$store.dispatch("addPerson", p);
      } else {
        this.$store.dispatch("updatePerson", p);
      }
    }
  },
  computed: {
    ...mapState({
      selectedPerson: state => state.selectedPerson
    })
  }
};
</script>

This code was customized from the code found in the Bootstrap Docs Modal Section.

Vuex Store

The Vuex Store manages the list of Person records, the currently selected Person, and a counter used for id generation.



import Vue from "vue";
import Vuex from "vuex";
import { createEmptyPerson } from "./utils.js";

Vue.use(Vuex);

export default new Vuex.Store({
  strict: true,
  state: {
    persons: [],
    selectedPerson: createEmptyPerson(),
    nextId: 1
  },
  mutations: {
    ADD_PERSON(state, person) {
      person.id = state.nextId++;
      state.persons.push(person);
      state.selectedPerson = person;
    },
    SELECT_PERSON(state, person) {
      state.selectedPerson = person;
    },
    DELETE_PERSON(state, id) {
      if (state.selectedPerson.id == id) {
        state.selectedPerson = createEmptyPerson();
      }
      state.persons = state.persons.filter(p => p.id != id);
    },
    UPDATE_PERSON(state, person) {
      var store_p = state.persons.find(p => p.id == person.id);
      if (store_p != null) {
        store_p.name = person.name;
        store_p.nickname = person.nickname;
        store_p.jobTitle = person.jobTitle;
        store_p.age = person.age;
        store_p.employee = person.employee;
      }
    }
  },
  actions: {
    addPerson(context, person) {
      context.commit("ADD_PERSON", person);
    },
    selectPerson(context, person) {
      context.commit("SELECT_PERSON", person);
    },
    deletePerson(context, id) {
      context.commit("DELETE_PERSON", id);
    },
    updatePerson(context, person) {
      context.commit("UPDATE_PERSON", person);
    }
  },
  getters: {
    persons(state) {
      return state.persons;
    },
    selectedPerson(state) {
      return state.selectedPerson;
    }
  }
});

utils.js

There is a factory function to create an empty Person record that's used in two different files. While the small snippet of object creation could be replicated in both places, that's a bad practice. I have logic added to the "id==-1" convention and if that would change, it's best done in one place. If you're not inspired to make a Vue component, just throw the function in a blanket utils.js file.


function createEmptyPerson() {
  return {
    id: -1,
    name: "",
    jobTitle: "",
    age: "",
    nickname: "",
    employee: false
  };
}

export { createEmptyPerson };

This article showed a master / detail pattern implemented in Vue and Bootstrap. Two components were loaded in a Vue app instance. The first component, Person List, displays an HTML table with multiple records from a Vuex Store. The second component, Person Details Popup, displays a form for working with a single record. This can be either an add or an update operation. Although there's a v-dialog that integrates well with a Vue app, it's easy to incorporate Bootstrap which may be important for organizations getting started with Vue that already have a strong Bootstrap layout capability.

Resources

The source code for the project is available for download in this zip file.


Headshot of Carl Walker

By Carl Walker

President and Principal Consultant of Bekwam, Inc