bekwam courses

Vue.js Dynamic List

May 25, 2019

This article presents a Vue.js application that manages a dynamic list of text items. The app is used to store a list of URLs which are persisted by a RESTful backened coded in JavaEE. This list example is intended to be used in a larger, more complex form. Since the example defers its save operation, it does not execute a backend call with each manipulation of the list.

In a complex form, the user may work iteratively, filling fields in, reviewing the data, and receiving feedback from the system. Only after everything is in place does the user submit the form. In this example, the user is allowed to manipulate the dynamic list but nothing will be saved until the user is sure of the content. In a typical pattern, the user may enter some data, notice misspellings, and re-order the list prior to saving.

Even if the form isn't complex, there still are benefits to deferring the save operation. The network and the backend system are the biggest source of operational errors in the app. By batching all the backend updates, a single loading screen, progress tracker, and cancel button is needed.

The following video demonstrates the application. The user adds, edits, and removes items from a list. The UI manipulations are contrasted with the data stored in the remote system using a RESTful web services test tool, ReadyAPI.

This UML diagram shows the code modules involved in the solution. The Vue.js code is using the CDN (and not a Webpack and SFCs). It's hosted in IIS. The RESTful backend is coded in JavaEE and deployed in WildFly.

UML Class Diagram
App Instance and RESTful Web Service

The RESTful backend provides four operations.

  1. GET /api/links - Return a JSON array of all the Link records in the database
  2. POST /api/links - Add a Link record to the database; returns the same Link with a DB identifier
  3. DELETE /api/links/{id} - Delete a Link record
  4. PUT /api/links/{id} - Updates a Link record

The GET /api/links call will be made when the app is first accessed, the user presses the Revert button, or the user presses the F5 key. The remaining lists implement the add, edit, and remove functions. They are all used in a Javascript method save() which is invoked by a press of the Save button.

index.html

The following code listing shows the Vue.js index.html file hosted in IIS. It loads the Vue.js libraries and holds a few CSS styles. It then loads the app code which is in main.js.


<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Dynamic List App</title>
<style>
	ul {
		list-style-type: none;
		padding: 0;
	}
	ul > li {
		line-height: 2em;
	}
	.link-box {
		width: 250px; 
		display: inline-block; 
	}
	.hdn {
		visibility: hidden;
	}
	.link-box-selected {
    	background-color: blue;
    	color: white;
	}
	.go {
		color: white;
		background-color: green;
	}	
</style>
</head>
<body>
	<div id="app"></div>
	<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
	<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.18.0/axios.js"></script>
	<script src="main.js"></script>
</body>
</html>

main.js

This is the top of main.js declaration.


const app = new Vue({
  el: "#app",
  data: {
    linkToAdd: null,
    linkSeq: 1,
    links: [],
    pendingDeletions: [],
    dirty: false,
    selectedLinkId: null
  },

linkToAdd is the v-model associated with the text field used to add a new item. linkSeq is a counter that maintains a temporary identifier (more on this laster). links[] is the collection of Link record both from the database and newly-created by the user. pendingDeletions[] is a list of the deletions that need to be made on a save operation. dirty is a flag that there is unsaved work in the form. selectedLinkId is used to toggle the edit action which swaps a label of a textfield.

Template

This is the template for the file. A v-for over a ulis the basis for the UI. Each iteration produces a label / text field pair and a set of buttons for the delete and re-order operations. There is a flag on each item in the label / text field pair so that the user can toggle between read-only (label) and editable views.

  template: `
    <div>
      <h1>Dynamic List Demo</h1>
      <p>Changes to the list won't be saved until you press "Save".</p>
      <ul>
        <li v-for="(link,index) in links" :key="getLinkId(link)">
          <div class="link-box">
            <div v-if="getLinkId(link) != selectedLinkId" @click="selectLink(getLinkId(link))">{{ link.linkHref }}</div>
            <input v-if="getLinkId(link) == selectedLinkId" type="text" 
:value="link.linkHref" @change="editLink(link, index, $event)"   />
          </div>
          <button @click="deleteLink(link)">-</button>
          <button :disabled="index < 1" @click="moveUp(index)">↑</button>
          <button :disabled="index > (links.length-2)" @click="moveDown(index)">↓</button>
        </li>
        <li>
          <div class="link-box">
            <input id="linkToAdd" type="text" v-model="linkToAdd" />
          </div>
          <button :disabled="!linkToAdd" @click="addLink">+</button>
        </li>
        <hr>
        <li>
          <div class="link-box"> </div>
          <button :disabled="!dirty" :class="{ go : dirty }" @click="save">Save</button>
          <button :disabled="!dirty" @click="revert">Revert</button>
        </li>    
      </ul>
    </div>
    `,

The value for the :key is based on a computed value. Items not yet persisted are given a tempId property that distinguishes them from items already existing in the database. When the batched save() operation occurs, different calls are used for the add operation (POST /api/links) and the edit and reorder operations (PUT /api/links{id}).

These functions manipulate the UI by modifying the links[] array. Their using loop indexes or the computed key in the case of the delete function.

Methods

methods: { selectLink(linkId) { this.selectedLinkId = linkId; }, addLink() { this.dirty = true; this.links.push({ tempId: this.linkSeq++, linkHref: this.linkToAdd }); this.linkToAdd = null; }, editLink(link, index, $event) { this.dirty = true; link.linkHref = $event.target.value; Vue.set(this.links, index, link); }, deleteLink(toDelete) { this.dirty = true; if( toDelete.tempId ) { this.links = this.links.filter( link => link.tempId != toDelete.tempId ); } else { this.pendingDeletions.push( toDelete ); this.links = this.links.filter( link => link.linkId != toDelete.linkId ); } }, moveUp(index) { this.dirty = true; let tmp = this.links[index]; Vue.set(this.links, index, this.links[index-1]); Vue.set(this.links, index-1, tmp); }, moveDown(index) { this.dirty = true; let tmp = this.links[index]; Vue.set(this.links, index, this.links[index+1]); Vue.set(this.links, index+1, tmp); },

This is the code for the save function. All of the backend calls are made here. The links[] array is iterated over and all items with a tempId generate a POST /api/links call to add a new link. If there is no tempId, then the record already exists in the database. That results in a PUT /api/links/{linkId} call.

Axios


      save() {

        let sort = 1;
        this.links.forEach( link => {
          link.linkSort = sort++;
          if( link.tempId ) {
            axios
            .post( 
              "http://localhost:8080/vuejs-demo/api/links",
              link
              )
            .then( response => {
              link.linkId = response.data.linkId;
              delete link.tempId 
            })
            .catch( error => console.error("error calling POST /links; " + error));
          } else {
            axios
            .put( 
              "http://localhost:8080/vuejs-demo/api/links/" + link.linkId,
              link
              )
            .catch( error => console.error("error calling PUT /links; " + error));
          }
        });

        this.pendingDeletions.forEach( toDelete => {
          axios
          .delete( 
            "http://localhost:8080/vuejs-demo/api/links/" + toDelete.linkId
            )
          .catch( error => console.error("error calling DELETE /links; " + error));
        });

        this.pendingDeletions = [];
        this.selectedLink = null;
        this.dirty = false;
      },

Deletions are handled in a separate data structure, pendingDeletions. Pressing the - delete button on an item removes it from the DOM. If the item had never been persisted, the operation is finished. Otherwise, the item is removed from the DOM and added to pendingDeletions. The save will issue DELETE /api/links/{id} calls and clear the array when finished.

The add service also correlates the saved record with that in the UI. It sets the list item's linkId with the value from the database. It removes the tempId property.

The remainder of the main.js file is a fetchAll() operations that retrieves all of the Link records in the database. It's called from the Vue.js created() and a dedicated Revert buttton. The Revert function will replace the items in the UI with whatever is stored in the database.


      revert() {
        this.fetchAll();
        this.dirty = false;
      },
      getLinkId: (link) => ((typeof link.tempId!=="undefined")?link.tempId:"0") + "-" + 
        ((typeof link.linkId!=="undefined")?link.linkId:"0"),
      fetchAll() {
        axios
        .get("http://localhost:8080/vuejs-demo/api/links")
        .then( response => this.links = response.data )
        .catch( error => console.error("error calling GET /links; " + error));
      }
    },
    created() {
      this.fetchAll();
    }
});

There is a function getLinkId() which forms the identifier used in the :key attribute. It is of the form tempId - linkId. If there is no tempId (the record is in the database), then 0-linkId will be the start of the computed ID. If there is no linkId (the record has not been saved yet), the form is tempId-0.

If the database needs to reflect the UI at all times, make a RESTful web service call from a DOM event handler. For instance, call an axios.post() from a @change event on a text field. The article presented an alternative that deferred the save operation in order to allow the user to work iteratively over a larger form.

Resources

The code presented in this article is available on GitHub. It is packaged as a JavaEE WAR file which is a zipped-up mixture of Java code for the RESTful backend and static HTML and .js for the Vue.js front-end.

JavaEE Backend

The focus of this article was on the Vue.js frontend, but for those interested, I'll walkthrough the JavaEE code. JavaEE has a reputation for being complex stemming from the early days. However through the years, the amount of code required to implement RESTful web services and ORM persistence has dropped. The JavaEE annotations and persistence may not be familiar, but high school students learn Java as part of AP Comp Sci courses, so it should be readable as a core program.

Domain Object

In JavaEE, a class marked with @Entity is a persisted object. This means that it can be fed into a service object called an Enterprise Java Bean (EJB) and the operations will be persisted to the database. This is the ORM (object relational mapping) as implemented in the Java Persistence Architecture (JPA) which is part of JavaEE.

    
@Entity
public class Link {

    @Id
    @GeneratedValue
    private Long linkId;

    @NotNull
    @Size(max = 255)
    private String linkHref;

    @OrderColumn
    private Integer linkSort;

    public Long getLinkId() {
        return linkId;
    }

    public void setLinkId(Long linkId) {
        this.linkId = linkId;
    }

    public String getLinkHref() {
        return linkHref;
    }

    public void setLinkHref(String linkHref) {
        this.linkHref = linkHref;
    }

    public Integer getLinkSort() {
        return linkSort;
    }

    public void setLinkSort(Integer linkSort) {
        this.linkSort = linkSort;
    }
}    
    

The @Id and @OrderColumn are cues to JPA for the handling of the persistable object. @Id sets a field as a database-generated unique identifier. @NotNull and @Size are used by the Bean Validation feature of JavaEE which ensures that the Link objects are valid for the add and update operations.

Enterprise Java Bean

LinkService is an EJB as configured using the @Stateless annotation. @PersistenceContext is a database connection. With this connection, you can persist and retrieve objects from the database.


@Stateless
public class LinkService {

    @PersistenceContext
    private EntityManager em;

    public List<Link> getAllLinks() {

        String jpql = "SELECT lnk FROM Link lnk ORDER BY lnk.linkSort ASC";

        TypedQuery<Link> q = em.createQuery(jpql, Link.class);

        return q.getResultList();
    }

    public Link addLink(Link link) {
        em.persist(link);
        return link;
    }

    public void removeLink(Long linkId) {
        em.remove( em.find(Link.class, linkId) );
    }

    public void updateLink(Link link) {
        Link fromDB = em.find( Link.class, link.getLinkId() );
        fromDB.setLinkHref( link.getLinkHref() );
        fromDB.setLinkSort( link.getLinkSort() );
    }
}

JPA uses a special object-oriented query language called Java Persistence Query Language (JPQL). This is like SQL but includes an addresses scheme for working with objects rather than tables. You can do simple id lookups with em.find. If you need a more complex query to retrieve your data, you use JPQL.

JAX-RS

Finally, there is the RESTful web service code. This is implemented as part of the JAX-RS feature in JavaEE. In accordance with good practice, these classes are usually small, often delegating all of their work to the service layer. The value that they add is that they map URLs to service calls and can invoke automatic Bean Validation using the @Valid annotation.


@Path("/links")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class LinksResource {

    @EJB
    private LinkService linkService;

    @GET
    public List<Link> getAllLinks() {
        return linkService.getAllLinks();
    }

    @POST
    public Link addLink(@Valid Link link) {
        return linkService.addLink(link);
    }

    @DELETE
    @Path("/{id}")
    public void deleteLink(@PathParam("id") Long linkId) {
        linkService.removeLink(linkId);
    }

    @PUT
    @Path("/{id}")
    public void updateLink(@PathParam("id") Long linkId, @Valid Link link) {
        link.setLinkId(linkId);  // for security
        linkService.updateLink(link);
    }
}

This article wasn't intended to cover JavaEE, but I see a lot of Vue.js deployments referencing server-side code using Laravel. Previous versions of JavaEE were difficult to work with and the deployment to app servers was also tough. These days, it's just a few simple files packed into a few hundred kilobyte WAR file.


Headshot of Carl Walker

By Carl Walker

President and Principal Consultant of Bekwam, Inc