Learn FeathersJS by Building a Simple CRM - Client App

Here we see how to build a real-world client application for your FeathersJS backend. We will use Vue + Feathers-Vuex and quickly create the frontend app.

Building the Client Application

Previously you saw how we could use Feathers to quickly build a backend application. The beauty of feathers is not only that it is quick to build, but it is also universal.

We can use FeathersJS and Feathers-Vuex (our choice of client storage) to conjure up a client application that totally blows away our users.

Our choice of technology for building client -

  1. Vue + Feathers-Vuex
  2. Vuetify

Client: Get Started

This is what we will build for the client..

feathersjs-vuex-sample-client-app

Let’s get started.

All great things in life starts with Vue CLI.

But, we don’t have the time and this is not a Vue tutorial. So, it’s time to work the magic and improve that statement.

So, we change the above quote..

A few great things start with boilerplates.

Unfortunately, again, we don’t quite have a starter-friendly boilerplate. We can either start from our vue-vuetify-booster boilerplate, or start afresh.

Either ways your Vue application should be setup in the client folder that is adjacent to the server folder where FeathersJS server resides. We will get our design inspiration from the above boilerplate.

  • Panel / Subpanel etc. are layouts defined in that boilerplate - we will reuse them
  • There are a few useful ways shortcuts to show snackbar, alerts etc, which we carry forward to this project
  • The home page, toolbar and navigation drawers will remain largely unchanged except for the links

See the Github repository for Featherlight CRM when in doubt.

At a high level, the client package.json has -

  1. Vue + Vuetify
  2. FeathersJS recommended packages. We will use communication over socket.io (alternatively we can use REST, but real-time using socket is more exciting)
    npm i @feathersjs/feathers @feathersjs/rest-client @feathersjs/authentication-client @vue/composition-api feathers-vuex feathers-hooks-common --save
    

Add/modify transpileDependencies line to your vue.config.js -

module.exports = {
  // code

  transpileDependencies: ["vuetify", "feathers-vuex"],

  // more code
};

Add a new file feathers-client.js to implement feathers client -

import feathers from "@feathersjs/feathers";
import auth from "@feathersjs/authentication-client";
import feathersVuex from "feathers-vuex";
import socketio from "@feathersjs/socketio-client";
import io from "socket.io-client";
import { iff, discard } from "feathers-hooks-common";

const socket = io("http://localhost:3030", { transports: ["websocket"] });

const feathersClient = feathers()
  .configure(socketio(socket))
  .configure(auth({ storage: window.localStorage }))
  .hooks({
    before: {
      all: [
        iff(
          (context) => ["create", "update", "patch"].includes(context.method),
          discard("__id", "__isTemp")
        ),
      ],
    },
  });

export default feathersClient;

// Setting up feathers-vuex
const {
  makeServicePlugin,
  makeAuthPlugin,
  BaseModel,
  models,
  FeathersVuex,
} = feathersVuex(feathersClient, {
  serverAlias: "api", // optional for working with multiple APIs (this is the default value)
  idField: "id", // Must match the id field in your database table/collection
  whitelist: ["$regex", "$options"],
});

export { makeAuthPlugin, makeServicePlugin, BaseModel, models, FeathersVuex };

You should now be able to run the application without issues..

npm run serve

Router

Not a lot of interesting things here.. We just define a dynamic home to redirect users to dashboard or to the generic home page. Everything else is pretty standard.

Note that we have not implemented any navigation guards - but that is easy enough to do by uncommenting the requireAuth function and using that for the routes that need prior authentication.

import Vue from "vue";

import store from "../store/index";

import VueRouter from "vue-router";
import Home from "../views/Home.vue";

Vue.use(VueRouter);

// function requireAuth(to, from, next) {
//   if (store.state.auth.accessToken) next();
//   else next("/login");
// }

function dynamicHome(to, from, next) {
  // this should be ideally replaced by store.getters["auth/isAuthenticated"]
  // .. in newer versions
  if (store.state.auth.accessToken) next("/dashboard");
  else next();
}

function logout(to, from, next) {
  store.dispatch("auth/logout");
  next("/login");
}

const routes = [
  {
    path: "/",
    name: "home",
    component: Home,
    beforeEnter: dynamicHome,
  },
  {
    path: "/signup",
    name: "signup",
    component: () =>
      import(/* webpackChunkName: "register" */ "../views/Register.vue"),
  },
  {
    path: "/login",
    name: "login",
    component: () =>
      import(/* webpackChunkName: "login" */ "../views/Login.vue"),
  },
  {
    path: "/logout",
    name: "logout",
    beforeEnter: logout,
  },
  {
    path: "/terms",
    name: "Terms",
    component: () =>
      import(/* webpackChunkName: "terms" */ "../views/Terms.vue"),
  },
  {
    path: "/dashboard",
    name: "Dashboard",
    component: () =>
      import(/* webpackChunkName: "dashboard" */ "../views/Dashboard.vue"),
    // beforeEnter: requireAuth
  },
  {
    path: "/contacts",
    name: "Contact",
    component: () => import("../views/Contact.vue"),
  },
  {
    path: "/activities",
    name: "Activity",
    component: () => import("../views/Activity.vue"),
  },
  {
    path: "/contact-us",
    name: "Contact Us",
    component: () =>
      import(/* webpackChunkName: "contact-us" */ "../views/ContactUs.vue"),
  },
  {
    path: "/about",
    name: "About",
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () =>
      import(/* webpackChunkName: "about" */ "../views/About.vue"),
  },
  {
    // catch all 404
    path: "*",
    component: () => import("../views/NotFound.vue"),
  },
];

const router = new VueRouter({
  mode: "history",
  base: process.env.BASE_URL,
  routes,
});

export default router;

Vuex

Add a new folder called store/services and create users.js.

import feathersClient, {
  makeServicePlugin,
  BaseModel,
} from "../../feathers-client";

class User extends BaseModel {
  constructor(data, options) {
    super(data, options);
  }
  // Required for $FeathersVuex plugin to work after production transpile.
  static modelName = "User";
  // Define default properties here
  static instanceDefaults() {
    return {
      email: "",
      password: "",
      name: "",
    };
  }
}
const servicePath = "users";
const servicePlugin = makeServicePlugin({
  Model: User,
  service: feathersClient.service(servicePath),
  servicePath,
});

// Setup the client-side Feathers hooks.
feathersClient.service(servicePath).hooks({
  before: {
    all: [],
    find: [],
    get: [],
    create: [],
    update: [],
    patch: [],
    remove: [],
  },
  after: {
    all: [],
    find: [],
    get: [],
    create: [],
    update: [],
    patch: [],
    remove: [],
  },
  error: {
    all: [],
    find: [],
    get: [],
    create: [],
    update: [],
    patch: [],
    remove: [],
  },
});

export default servicePlugin;

As you see - this is has the familiar structure of FeathersJS. We can fine tune this and change app behaviour, but will leave it that for now.

Create a similar file for contacts - we will use the name contactsPlugin instead of servicePlugin. Rest remains the same.

We will register these plugins in the store.

import Vue from "vue";
import Vuex from "vuex";

import { FeathersVuex } from "../feathers-client";

import snackbar from "./snackbar";
import pref from "./pref";
import alert from "./alert";

import auth from "./auth";
import servicePlugin from "./services/users";
import contactsPlugin from "./services/contacts";

Vue.use(Vuex);
Vue.use(FeathersVuex);

export default new Vuex.Store({
  namespaced: true,
  name: "global",

  modules: {
    snackbar,
    pref,
    alert,
    auth,
    servicePlugin,
    contactsPlugin,
  },
  plugins: [auth, servicePlugin, contactsPlugin],

  state: {},
  getters: {
    isLoggedIn(state, getters, rootState) {
      return !!rootState.auth.accessToken;
    },
  },
});

You will see a few modules not used by feathers - it is just to make the application more pleasing. You can ignore them.

Views

Let us create the Contact view. This is simple and straight forward - we just render the list component here.

<template>
  <Panel icon="mdi-book-plus" title="Contacts">
    <template slot="toolbar-items"></template>
    <template slot="content">
      <ContactList />
    </template>
  </Panel>
</template>

<script>
import Panel from "../components/layouts/Panel";
import ContactList from "../components/ContactList";

export default {
  components: {
    Panel,
    ContactList
  }
};
</script>

Contact View - List Component

Next, let us create components/ContactList.vue.

<template>
  <PanelListMain>
    <template slot="toolbar-items">
      <span class="subtitle-2">Contact List</span>
      <v-spacer></v-spacer>
      <v-btn @click="newRecord" small outlined>
        <v-icon small>mdi-plus</v-icon>New
      </v-btn>
    </template>
    <template slot="content">
      <v-row dense>
        <v-col cols="12">
          <v-card flat color="transparent" height="100%" style="overflow:auto">
            <v-card-title>
              <v-spacer></v-spacer>

              <v-text-field
                v-model="srchLastName"
                prepend-icon="mdi-magnify"
                label="Search Last Name"
                single-line
              ></v-text-field>
            </v-card-title>

            <v-data-table
              :headers="headers"
              :items="contacts"
              hide-default-footer
            >
              <template v-slot:item="props">
                <tr>
                  <td>{{ props.item.first_name }}</td>
                  <td>{{ props.item.last_name }}</td>
                  <td>{{ props.item.title }}</td>
                  <td>{{ props.item.status_cd }}</td>
                  <td>{{ props.item.email }}</td>
                  <td>{{ props.item.phone }}</td>
                  <td>
                    <v-icon color="success" @click="editRecord(props.item)"
                      >mdi-pencil</v-icon
                    >
                  </td>
                </tr>
              </template>
            </v-data-table>
          </v-card>
        </v-col>
        <!-- <v-col cols="12" class="text-md-right pt-2">
          <v-pagination
            v-model="contacts.page"
            :total-visible="7"
            :length="contacts.lastPage"
            @input="changePage"
            justify="end"
          ></v-pagination>
        </v-col> -->
      </v-row>

      <ContactEdit
        v-if="detailDialog"
        v-model="detailDialog"
        :currentItem="currentItem"
      />
    </template>
  </PanelListMain>
</template>

<script>
  import { makeFindMixin } from "feathers-vuex";
  import PanelListMain from "./layouts/PanelListMain";

  export default {
    data() {
      return {
        detailDialog: false,
        headers: [
          { text: "First Name", value: "first_name" },
          { text: "Last Name", value: "last_name" },
          { text: "Title", value: "title" },
          { text: "Status", value: "status_cd", sortable: false },
          { text: "Email", value: "email", sortable: false },
          { text: "Phone", value: "phone", sortable: false },
          { text: "Actions", value: "actions", sortable: false },
        ],
        srchLastName: "",
        currentItem: {},
      };
    },

    computed: {
      contactsParams() {
        return { query: this.search };
      },
      search() {
        const srchStr = {};
        if (this.srchLastName) srchStr["last_name"] = this.srchLastName;

        return srchStr;
      },
    },
    mixins: [makeFindMixin({ service: "contacts" })],

    methods: {
      // changePage(page) {
      //   this.fetchContact({
      //     page: page,
      //     query: this.search,
      //   });
      // },

      editRecord(item) {
        this.currentItem = item;
        this.detailDialog = true;
      },

      newRecord() {
        this.currentItem = {};
        this.detailDialog = true;
      },
    },

    components: {
      PanelListMain,
      ContactEdit: () => import("./ContactEdit"),
    },
  };
</script>

There’s a lot going on, but we did not quite put in anything special.

To start with, we introduced ..

import { makeFindMixin } from "feathers-vuex";

// ...

mixins: [makeFindMixin({ service: "contacts" })],

The above code imported and used a feathers provided mixin that makes our coding easier and more generic. We have indicated our intent to use the contacts service - this refers to the service file that we created earlier, which also automatically “maps” (not exactly, but bear with me) to the contacts service in the backend Feathers.

We create a computed variable which can be used to pass params to the mixin -

  contactsParams() {
      return { query: this.search };
  },

By doing just this, we have access to the contacts list. This is the list of contacts fetched from the backend and rendered as a table.

We can change search to pass search criteria. User enters search, and we just pass it as a param in a function.

computed: {
  search() {
      const srchStr = {};
      if (this.srchLastName) srchStr["last_name"] = this.srchLastName;
      return srchStr;
  },
}

Again.. let me reiterate it for you.

All we did was to add a service, introduced boilerplate code for feathers-client.js and pulled in the mixin. This resulted in the contacts being pulled from the server / from our database.

In addition to what has been outlined so far -

  1. We can add functionality with simple param changes on client side and by adding hooks/custom services on server - again a lot of functionality with less code
  2. The app has real-time functions - any changes (on server through API calls / by other users) are published to clients (all clients with default configuration) and our browser starts showing those changes

What we did not do -

  • use REST calls with something like fetch/ Axios
  • manage variables in components / Vuex
  • create logic to search - on client and server

That’s a lot of work saved.

Contact Edit

Let’s keep going.

We will add a reference to a new component in ContactList to enable us to edit contacts.

<ContactEdit
  v-if="detailDialog"
  v-model="detailDialog"
  :currentItem="currentItem"
/>

Also, add methods to display this detail component on click of New or Edit buttons.

methods: {
  editRecord(item) {
    this.currentItem = item;
    this.detailDialog = true;
  },

  newRecord() {
    this.currentItem = {};
    this.detailDialog = true;
  },
}

We set the current item and send it off to ContactEdit.

There are multiple, better ways to invoke ContactEdit to create more elegant/succinct code - we just used on of the options.

ContactEdit has some interesting things going on-

<template>
  <v-dialog v-model="showDialog" persistent max-width="1100px">
    <SubPanel>
      <template slot="toolbar-items">
        <span class="subtitle-2">New/Edit Contact</span>
      </template>

      <template slot="content" v-if="item">
        <FeathersVuexFormWrapper :item="item" watch>
          <template v-slot="{ clone, save, reset, remove }">
            <v-form ref="form" v-model="validInput">
              <v-card flat>
                <v-card-text>
                  <v-row>
                    <v-col cols="12" md="3">
                      <v-text-field v-model="item['first_name']">
                      </v-text-field>
                    </v-col>
                    <v-col cols="12" md="3">
                      <v-text-field v-model="item['last_name']" required>
                      </v-text-field>
                    </v-col>
                    <v-col cols="12" md="3">
                      <v-select
                        :items="['active', 'inactive']"
                        label="Status"
                        v-model="item['status_cd']"
                        required
                      ></v-select>
                    </v-col>
                  </v-row>
                </v-card-text>
                <v-card-actions>
                  <v-spacer></v-spacer>
                  <v-btn outlined @click="closeDialog">Cancel</v-btn>
                  <v-btn color="primary" @click="saveRecord">
                    Save
                  </v-btn>
                </v-card-actions>
              </v-card>
            </v-form>
          </template>
        </FeathersVuexFormWrapper>
      </template>
    </SubPanel>
  </v-dialog>
</template>

<script>
  import { mapMutations } from "vuex";
  import SubPanel from "./layouts/SubPanel";

  import { FeathersVuexFormWrapper } from "feathers-vuex";

  export default {
    data() {
      return {
        validInput: true,
        item: null,
      };
    },
    props: {
      value: Boolean,
      currentItem: {
        type: Object,
        required: true,
      },
    },

    mounted() {
      const { Contact } = this.$FeathersVuex.api;

      const contactModel = new Contact();
      this.item =
        !this.currentItem || !this.currentItem["id"]
          ? contactModel
          : Contact.getFromStore(this.currentItem["id"]);
    },
    computed: {
      showDialog: {
        get() {
          return this.value;
        },
        set(value) {
          this.$emit("input", value);
        },
      },
    },

    components: {
      SubPanel,
    },

    methods: {
      ...mapMutations("snackbar", ["setSnack"]),

      saveRecord() {
        if (this.$refs.form.validate()) {
          if (!this.item["id"]) {
            this.item.save().catch((e) => {
              this.showError(e);
            });
          } else this.item.update();

          this.closeDialog();
        }
      },

      closeDialog() {
        this.showDialog = false;
      },

      showError(e) {
        // you can do other housekeeping here.
        this.setSnack({ message: "Error saving contact.", color: "error" });
        console.error("Error saving record", e);
      },
    },
  };
</script>

When the component loads, we specify whether we are creating a new record or editing an existing record.

mounted() {
    const { Contact } = this.$FeathersVuex.api;

    const contactModel = new Contact();
    this.item =
      !this.currentItem || !this.currentItem["id"]
        ? contactModel
        : Contact.getFromStore(this.currentItem["id"]);
},

We fold our form within -

<FeathersVuexFormWrapper :item="item" watch>
  <template v-slot="{ clone, save, reset, remove }"> </template>
</FeathersVuexFormWrapper>

This code block does quite a few things -

  1. Automatically manage vuex best practices to clone our storage variable (item in our case) when necessary. We also indicated support saving, resetting form and removing records
  2. Feathers client automatically maps the record received from the list to the context of the Contact service (either a new record or find existing record from store)

Finally, we introduce code to save record -

      saveRecord() {
        if (this.$refs.form.validate()) {
          if (!this.item["id"]) {
            this.item.save().catch((e) => {
              this.showError(e);
            });
          } else this.item.update();

          this.closeDialog();
        }
      },

This code block validates form, and invokes save or update methods supported by our feathers-client to send data to our server. Server Feathers knows the operations from the invoked services and carries out those operations on server.

You may also observe that we did not quite use auth here, but it is implied in the background. Feathers figures out that for us. However, you will observe that we have created the auth module (all of two lines), included it as a plugin in the store, and created components for user registration and login.

That is it - you now have a full fledged.. oh wait.

Remember our previous statement? CRM application is not contacts alone - all of us and our neighbour’s dog know that activities make CRM application “whole”.

That said - I don’t want to take away that opportunity of creating a brand new service and Vuex plugin from you. Like the seers of yore, I just point to the direction and you execute all magic, eh?

Finis!

So here we are then.. A complete.. , er.., 50% complete “totally real-world application” built using FeathersJS on server and client

It is time for you to spread your wings and fly to newer heights and into the next telephone pole with this knowledge.

I mentioned earlier that the code used here is available on Github - please don’t forget to star the repository. Those stars are what I count sometimes to go to sleep - more the merrier.

Enjoy.

comments powered by Disqus