One of Nodewood's core philosophies is that related code should be kept together, and unrelated code should be kept far apart. Jamming all your Controllers together is just like jamming all your CSS together -- sure, they share similar broad functionality, but in practice they're largely unrelated and just get in your way when looking for something specific. Web components have long since moved on from this faux-"separation of concerns," allowing you to mix relevant HTML, JS, and CSS all in a similar file, and it only makes sense that we adopt a similar methodology for the rest of our code.

Nodewood allows you to explicitly create a Feature, which creates an entry in app/features/YOUR_FEATURE_HERE. This directory is like a miniature Nodewood installation of its own, containing api, ui, and lib folders for back-end, front-end, and common code, respectively. This means that when you create a Controller or Validator or Page for your new feature, it all gets located inside this one folder, making it easy to create a mental map and navigate between all the files related to that feature.

In this article, we're going to create a simple Feature in an already set-up installation of Nodewood, going in-depth into the various Nodewood features and capabilities.  We won't cover everything Nodewood does, but we will cover the basics to a high degree of detail.

Why Not Build Another CSM App?

There are plenty of Customer Service Management apps out there, but there could probably stand to be a few more, so let's build one. The first thing a CSM app needs is the ability to M those Cs, right? So the simple feature we're going to build today is the Customer Management Feature, or for short, the customers feature. This article assumes that a Nodewood project is already set up, but for more information on that, you can always check out the documentation on how to install Nodewood.

Creating the customers feature

Nodewood comes with a helper CLI that can be used to create new projects, start the development VM and API/UI servers, and create generate project files. From the project root, we can use it to create a new feature folder, including example files for common use cases:

$ nodewood add feature customers
  _   _               _                                         _
 | \ | |   ___     __| |   ___  __      __   ___     ___     __| |
 |  \| |  / _ \   / _` |  / _ \ \ \ /\ / /  / _ \   / _ \   / _` |
 | |\  | | (_) | | (_| | |  __/  \ V  V /  | (_) | | (_) | | (_| |
 |_| \_|  \___/   \__,_|  \___|   \_/\_/    \___/   \___/   \__,_|

CLI Version 0.5.2

Feature created at:
/Users/dan/sample/app/features/customers
Controller and tests created at:
/Users/dan/sample/app/features/customers/api/controllers/CustomersController.js
/Users/dan/sample/app/features/customers/api/controllers/__tests__/CustomersController.test.js
Service created at:
/Users/dan/sample/app/features/customers/api/services/CustomersService.js
Page created at:
/Users/dan/sample/app/features/customers/ui/pages/CustomersPage.vue
Route for customers added to init.js.
Dialog created at:
/Users/dan/sample/app/features/customers/ui/dialogs/CustomerDialog.vue
Store created at:
/Users/dan/sample/app/features/customers/ui/stores/CustomerStore.js
Store for customers added to init.js.
Validator created at:
/Users/dan/sample/app/features/customers/lib/validators/CustomerValidator.js
Model created at:
/Users/dan/sample/app/features/customers/lib/models/CustomerModel.js
Migration created at:
/Users/dan/sample/app/migrations/20200625205222_customers.js

After editing, make sure to run migrations with yarn migrate and restart your API server.

To enable your new feature, add 'customers' to the 'features' array in 'app/config/app.js'.
To add your feature to the sidebar, add an entry for it to the 'sidebar' array in 'app/config/app.js'.

This command creates the customers feature at app/features/customers, and fills it full of a bunch of the aforementioned example files. You may notice that some files are plural, while other files are singular. This is intentional - the rule of thumb is that singular files are meant to deal with singular instances of your data, plural files are meant to deal with multiple instances.

When starting your feature, you may prefer to start from just a skeleton file structure and build on top of it, instead of having example files all over the place to modify or remove. You can work this way as well, by adding --no-examples to the above command, and the same directory structure will be created with only some .gitkeep files in the folders to preserve directory structure.

We'll get into the files later in this article, but it can help to take a moment to explore the folders created by this command: api, ui, and lib folders are all created inside the features/customers folder. These folders mirror the main folders inside of the parent app folder, and they are for holding your back-end, front-end, and common code, respectively. Co-locating all the code for a single feature together like this helps you organize and compartmentalize the application, making it a lot easier to maintain later. You know that the Controller that generates the API that is consumed by the Store in your UI is essentially right next to it, and is thus easy to find.

Before we continue on, we're going to follow the final instructions given in that command - enabling the feature in the features array, and adding it to our application's sidebar:

app/config/app.js

  ...
  features: {
    app: [
      'customers',
    ],
  },

  ...

  sidebar: [{
    name: 'Home',
    path: '/',
    icon: 'fa-home',
  }, {
    name: 'Customers',
    path: '/customers',
    icon: 'fa-users',
  }],
  ...

Building The API

You can build your app any way you like, but I prefer to start with getting the API functional and tested first. That way, when you're writing your UI, you have a fairly reliable contract to write against, and you know quite well what data you'll have available to work with. Plus, it gives you an opportunity to write the Models and Validators that you'll use later, but in a simplified context that makes them easier to understand.

Creating and running the database migration

Migrations aren't created in the features folder, but grouped in the app/migrations folder, and you should notice one in there named 20200625205222_customers.js or similar. The first part of the migration is a timestamp so that similarly-named migrations never collide.

Nodewood uses Knex.js to make it easy to perform migrations on your database. The default template for a migration just adds an auto-incrementing id field and created_at and updated_at timestamps, so let's add some more fields that we'll need for keeping track of customers:

exports.up = async knex => {
  await knex.schema.createTable('customers', table => {
    table.increments();
    table.timestamps(true, true);
    table.string('name').notNullable();
    table.string('email').notNullable();
    table.string('phone_number').notNullable();
    table.string('notes');

    table.unique('email');
  })
}

exports.down = async knex => knex.schema.dropTable('customers');

To run these migrations, run nodewood migrate:

vagrant@nodewood:~/code$ nodewood migrate
  _   _               _                                         _
 | \ | |   ___     __| |   ___  __      __   ___     ___     __| |
 |  \| |  / _ \   / _` |  / _ \ \ \ /\ / /  / _ \   / _ \   / _` |
 | |\  | | (_) | | (_| | |  __/  \ V  V /  | (_) | | (_) | | (_| |
 |_| \_|  \___/   \__,_|  \___|   \_/\_/    \___/   \___/   \__,_|

CLI Version 0.5.2

Using environment: development
Batch 2 run: 1 migrations

This runs the migrations against the main development database. In order to run them against the test database (since we will be writing tests!), run nodewood migrate test.

You may notice the username in the above example is vagrant@nodewood. This is because we are running these commands on the Nodewood Development VM (accessible by typing nodewood vm from the root of your project). You don't have to run Nodewood on a VM, but it's provided as a convenience so that you don't have to install and maintain a database server/etc on your development machine directly.

Creating the Model

Next, we'll want to create the CustomerModel. Models are a little different in Nodewood, in that they aren't ORM models - in fact there are no database access methods in them at all! They're designed to be shared between the front-end and back-end, so having database access methods on them would mean bundling and sending all kinds of useless code in your UI package. Instead, database access is handled in API Services, which we'll get into later.

For now, open up app/features/customers/lib/models/CustomerModel.js, so we can look at the example code provided by the CLI.  This will be our starting place that we can build from. There are three important parts to note. First is the FIELDS declaration:

static FIELDS = {
  id: new FieldNumber({ label: 'ID' }),
  name: new FieldString({ label: 'Name' }),
};

This is the section where you list the fields you intend to display for your customer. For example, the UserModel that comes with Nodewood doesn't have a password field, since that field is never actually displayed to the customer. We'll get into how Fields work later, but in short, they're helpers designed to make it easier for you to display model data.

Next is the constructor() function:

constructor({ id, name } = {}) {
  this.id = id;
  this.name = name;
}

This allows us to build a CustomerModel by just passing an Object to the constructor, and allowing it to fill in all of its fields.  This means that we can take a database row, or form data, and build a new model just by passing that Object into the constructor, and then we can work with a full model, regardless of whether we're in the API or UI. We can define calculated fields here as well, like a oneYearAnniversary that is never stored in the database, but is calculated based on the created_at field.

Finally, we have the toJSON() function:

toJSON() {
  return {
    id: this.id,
    name: this.name,
  };
}

This allows us to transform the CustomerModel back into an Object. This is useful because in order to pass the CustomerModel to the database or via the API, we want to be able to represent it as a JSON Object. The flow of data and transformations tends to look something like this:

  • Server: Load from database (convert from JSON Object to CustomerModel)
  • Server: Do some work
  • Server: Output through API (convert from CustomerModel to JSON Object)
  • Client: Load from API into Vuex Store (convert from JSON Object to CustomerModel)
  • Client: Do some work
  • Client: Submit to API (convert from CustomerModel to JSON Object)
  • Server: Load from request (convert from JSON Object to CustomerModel)
  • Server: Do some work
  • Server: Save to database (convert from CustomerModel to JSON Object)

Once you have the constructor() and toJSON() functions set up, however, you never have to worry about these transformations. They just happen at the appropriate boundaries, and you only ever have to worry about working with CustomerModels.

Before moving on, we'll want to update our CustomerModel so that it has the fields we've defined in our migration:

class CustomerModel {
  static FIELDS = {
    id: new FieldNumber({ label: 'ID' }),
    name: new FieldString({ label: 'Name' }),
    email: new FieldString({ label: 'Email' }),
    phoneNumber: new FieldString({ label: 'Phone Number' }),
    notes: new FieldString({ label: 'Notes' }),
  }

  constructor({ id, name, email, phone_number, notes } = {}) {
    this.id = id
    this.name = name
    this.email = email
    this.phoneNumber = phone_number
    this.notes = notes
  }

  toJSON() {
    return {
      id: this.id,
      name: this.name,
      email: this.email,
      phone_number: this.phoneNumber,
      notes: this.notes,
    }
  }
}

You may notice that phone_number in the argument list for the constructor is transformed from snake_case to camelCase. This is because most standards for databases and JSON APIs tend to require snake_case, while most linters require variable names be camelCase. The transformation that happens in these two functions means you don't have to worry about violating either principle, but can use the form that makes sense in the situation where it makes sense.

Listing customers

Okay, that's a lot of talking and setup. Let's do something with it! Let's add the ability to get a list of customers! We're going to add the controller action and some tests for it, to make sure it works.

Actually, though, we're not. Examples for this case have already been added when we created the feature, so we're just going to modify them to make sure they work for our use case. If we run the tests as they are (with yarn test CustomersController), we'll get the following error: null value in column "email" violates not-null constraint. This is because the test customers that are added to test the list endpoint at /api/customers by default are only added with some test names, and we've defined email (as well as phone_number) as non-nullable, so we have to add those fields:

await db.customers.insert([
  {
    id: 2,
    name: 'name2',
    email: 'name2@email.com',
    phone_number: '1112223333',
  },
  {
    id: 3,
    name: 'name3',
    email: 'name3@email.com',
    phone_number: '2223334444',
  },
])

Run the tests one more time, and it should all pass! Now we have an endpoint for listing our customers, and our CustomerModel handles serializing and deserializing that data. To check out the snapshots we created and the format of the data we're outputting via our API, look at app/features/customers/api/controllers/__tests__/__snapshots__/CustomersController.test.js.snap. You'll notice this snapshot is one folder away from the test it's for, which is one folder away from the controller it's for. This makes it very easy to locate related files when trying to update and maintain your code later.

Creating the Validator

Before we start adding functionality to our API, we're going to set up our Validator. You always want to make sure you're validating your data before saving it to the database, and Nodewood's Validator class makes that super-easy. It takes advantage of the validator npm package to allow you to define rules for form fields without needing to write a whole lot of code.

It's important to note that form fields are different from Model fields! You may never want to display a password field, but you will want to validate it, so it makes sense to create a rule for validating it. This also means that rule fields in a Validator will be snake_cased, because they will be running not against a model, but against input from an API or form, which will be a plain Object.

By default, the example CustomerValidator only has a rule for validating that a name isn't empty, but let's beef that up a bit:

class CustomerValidator extends Validator {
  constructor(fields) {
    super(fields)

    this.rules = {
      name: [
        new Rule('isEmpty', {
          code: ERROR_EMPTY,
          title: 'You must enter a name.',
        }),
      ],
      email: [
        new Rule('isEmpty', {
          code: ERROR_EMPTY,
          title: 'You must enter an email address.',
        }),
        new NotRule('isEmail', {
          code: ERROR_INVALID_EMAIL,
          title: 'You must enter a valid email address.',
        }),
      ],
      phone_number: [
        new Rule('isEmpty', {
          code: ERROR_EMPTY,
          title: 'You must enter a phone number.',
        }),
        new NotRule('isMobilePhone', {
          code: ERROR_INVALID_PHONE,
          title: 'You must enter a valid phone number.',
        }),
      ],
      notes: [
        new NotRule(
          'isLength',
          {
            code: ERROR_MAX_LENGTH,
            title: `Note must not be more than ${NOTES_MAX_LENGTH} characters.`,
            meta: { minLength: NOTES_MAX_LENGTH },
          },
          [{ max: NOTES_MAX_LENGTH }]
        ),
      ],
    }
  }
}

We'll get into actually using this Validator in just a second, but in essence, for every form field you plan on validating, you create a list of Rules and NotRules to validate on. For a Rule, a field fails validation if the validation rule named by the first argument is true, and for a NotRule, the field fails validation if it is false. The second argument is a JSON-API Error Object that is used to trigger a 400 error with that information provided in the body. Finally, the third argument is an array of options, if the validator rule requires them.

This configuration is pretty simple to write and maintain, and will be used for both our API validation as well as UI form validation, which drastically cuts down on time spent validating fields across our app.

Adding customers

We're going to want to be able to add new customers, so let's build out the API for that. We're going to write our tests first, since that's a great way to force you to think about the contract for the API that you're creating. Let's add the following code to our test file:

describe('POST /customers', () => {
  it('should validate the request', async () => {
    const response = await agent
      .post('/api/customers')
      .set('Cookie', COOKIE_JWT_USER_ID_1)
      .set('X-CSRF-TOKEN', CSRF_TOKEN_USER_ID_1)
      .send({
        name: '',
        email: 'abc',
        phone_number: 'def',
        notes: fill(Array(501), 'a').join(''),
      })
      .expect(400)

    expect(response.body).toMatchSnapshot()
  })
})

This sends a POST request to our customers endpoint with a bunch of data that should fail the validator rules we just set up. So let's modify our controller to respond to this endpoint.

First, add the following function to the controller:

async add(req, res) {
  await this.withTransaction(async (tx) => {
    this.validate(req.body, new CustomerValidator(ADD_FORM_FIELDS));
  });
}

Then add the following line to the constructor:

this.router.post('/customers', this.add.bind(this))

Finally, we need to create that ADD_FORM_FIELDS constant we reference in the add() function. Add this to the exports in our CustomerValidator, and import it alongside the validator in the controller:

ADD_FORM_FIELDS: [
  'name',
  'email',
  'phone_number',
  'notes',
],

So what's going on here? This seems a little opaque maybe, because there's that magic function this.validate() in the controller. This is just a helper function defined in the parent Controller class that uses your validator to validate the form. The validator only validates the fields you pass into the constructor, so if a malicious user alters the request and attempts to pass garbage in other fields, they're ignored.

Then, if this validation fails, a Standard400Error is thrown containing the error objects we defined in the CustomerValidator rules. All Standard4xxErrors are caught in a top-level error handler and transformed into a JSON API-compliant error response with the appropriate HTTP status code.

If you run this test, you should see it pass, and if you explore the snapshot, you should see the list of errors being generated here:

exports[`CustomersController POST /customers should validate the request 1`] = `
Object {
  "errors": Array [
    Object {
      "code": "ERROR_EMPTY",
      "source": Object {
        "parameter": "name",
      },
      "title": "You must enter a name.",
    },
    Object {
      "code": "ERROR_INVALID_EMAIL",
      "source": Object {
        "parameter": "email",
      },
      "title": "You must enter a valid email address.",
    },
    Object {
      "code": "ERROR_INVALID_PHONE",
      "source": Object {
        "parameter": "phone_number",
      },
      "title": "You must enter a valid phone number.",
    },
    Object {
      "code": "ERROR_MAX_LENGTH",
      "meta": Object {
        "maxLength": 500,
      },
      "source": Object {
        "parameter": "notes",
      },
      "title": "Note must not be more than 500 characters.",
    },
  ],
}
`

A hot tip for making it easier to confirm that your error responses are what you expect without needing to review the snapshot file every time is to use .toBe() at first, until you can confirm that the response is what you expect (since any error response won't match a null value), then change to .toMatchSnapshot() and write the snapshot.

Okay, so next we want to actually save the user record, provided it passes validation. Let's write the test first:

it('should insert a customer', async () => {
  const response = await agent
    .post('/api/customers')
    .set('Cookie', COOKIE_JWT_USER_ID_1)
    .set('X-CSRF-TOKEN', CSRF_TOKEN_USER_ID_1)
    .send({
      name: 'name',
      email: 'abc@def.com',
      phone_number: '1112223333',
      notes: '',
    })
    .expect(200)

  expect(response.body).toMatchSnapshot()

  const customer = await db.customers.findOne({ email: 'abc@def.com' })
  expect(customer).toMatchSnapshot({
    created_at: expect.any(Date),
    updated_at: expect.any(Date),
  })
})

And then let's modify the controller function:

async add(req, res) {
  await this.withTransaction(async (tx) => {
    this.validate(req.body, new CustomerValidator(ADD_FORM_FIELDS));
    const customer = await this.customersService.insert(tx, req.body, ADD_FORM_FIELDS);

    res.json({
      data: { customer },
    });
  });
}

Running that should pass and should save snapshots containing the successfully-saved customer. The interesting thing here is the introduction of the CustomersService, but there's quite a bit to talk about there.

Remember earlier, when we talked about how Nodewood Models aren't ORM-style models, and how database access happens somewhere else? Well, pretty obviously, we're using thin controllers here, so that can't be where. This is where Services come in.

Services are where persistence, interaction with 3rd-parties, and broader tasks take place. By default, a Service's constructor accepts a configured MassiveJS instance and a configured Nodemailer instance, and the parent Service class has convenience functions defined for doing common database and email tasks.

insert() is one of these convenience functions. It accepts three parameters: a transaction, the fields to insert, and an array of the names of the fields to insert. This final parameter may seem a bit repetitive, but if you look at how it's being used in the example code, the reasoning should become clear: convenience. We can simply pass the entire request body in for the second parameter, and use our pre-defined list of fields we are expecting for this form as the third field. This is another security measure, as it means that malicious users can't simply pass in extra data to our API, hoping to modify fields they shouldn't have access to (admin: true, for example).

If you need to perform more-complex tasks, you can define your own functions in your own services, and call them from the Controller. Carefully-done, this also makes your service functions composeable. For example, you might have a task for sending a customer a report email. If that's a function of the CustomersService, you can call that function from multiple controller actions in multiple situations and reduce the need for duplicated code.

There's one final thing we need to do for adding users before we can move on: email addresses are unique, so we need to add a check for attempting to add a user with a duplicate email address. First, the test:

it('should error on duplicate email', async () => {
  await db.customers.insert([
    { name: 'name2', email: 'abc@def.com', phone_number: '1112223333' },
  ])

  const response = await agent
    .post('/api/customers')
    .set('Cookie', COOKIE_JWT_USER_ID_1)
    .set('X-CSRF-TOKEN', CSRF_TOKEN_USER_ID_1)
    .send({
      name: 'name',
      email: 'abc@def.com',
      phone_number: '1112223333',
      notes: '',
    })
    .expect(400)

  expect(response.body).toMatchSnapshot()
})

Then, we need to add a new method to our service. We can't (directly) use the helper anymore, since we need to catch this duplicate error, so we'll add a method that wraps the call in a try/catch that specifically listens for Postgres's unique conflict code:

async add(tx, fields) {
  try {
    return await this.insert(tx, fields, ADD_FORM_FIELDS);
  }
  catch (error) {
    if (error.code === POSTGRES_ERROR_UNIQUE_VIOLATION) {
      throw new Standard400Error([{
        code: ERROR_UNIQUE,
        title: 'You already have a customer with that email address.',
        source: { parameter: 'email' },
      }]);
    }

    throw error;
  }
}

And then we can change our controller action to call this method instead:

const customer = await this.customersService.add(tx, req.body)

Since the new method throws a Standard400Error, the top-level error handler will automatically display it correctly, no need for any further changes to the controller. Run the tests, confirm the snapshot is written correctly, and we're good! In a relatively short number of lines of code, we're validating our form, adding new customers, and ensuring that email addresses don't clash. Plus, it's all done in a very modular and re-usable fashion, and we're going to re-use some of that right now.

Updating customers

Once a customer has been added, you may need to update some information about them, like updating the Notes field or changing their name. We'll assume that there's a more-complex process for updating email addresses (since you may want to perform confirmation that they own that address), so for now we'll omit that field for customer updates.

Thankfully, given that we've already set up a lot of our common objects, adding an update action to our controller is quite simple. We'll leave the tests as an exercise for the reader, so all we need to do is modify the CustomersController. Add the following line to your constructor:

this.router.put('/customers/:id(\\d+)', this.update.bind(this))

And then add this method to the class:

async update(req, res) {
  await this.withTransaction(async (tx) => {
    this.validate(req.body, new CustomerValidator(UPDATE_FORM_FIELDS));
    const customer = await this.customersService.update(
      tx,
      req.params.id,
      req.body,
      UPDATE_FORM_FIELDS,
    );

    res.json({
      data: { customer },
    });
  });
}

We'll leave the tests as an exercise for the reader, because this should appear very familiar, and thus the tests will be very similar. update() is just a helper service function that lets you update a table row. Pass the ID of the row to update, the fields to update, and an array of the field names to use, for security. That array of field names is defined in the CustomerValidator, as before, just sans the email field. Like I said, once you've set everything up, re-using that existing code is very easy! So let's do it again!

Deleting customers

We're also going to want to delete customers. You can probably guess how that's going to go down, but just to confirm your guess, here's the route to add to the constructor:

this.router.delete('/customers/:id(\\d+)', this.delete.bind(this))

And then add the function to the controller:

async delete(req, res) {
  await this.withTransaction(async (tx) => {
    await this.customersService.delete(req.params.id);
    res.sendStatus(204);
  });
}

Easy.

Building the UI

With the back-end API completed and our shared objects configured, it's time to move onto building the UI. There are a bunch of new concepts to go over here (especially if you're new to Vue, Vue Router, and Vuex), but you'll also see how much time we'll save because we have Models and Validators already set up.

Listing customers

The first thing we'll want to do is to be able to list our customers, and the first thing we'll need to be able to do is to load that list of customers from the API. This means we'll have to set up our Vuex Store, located at app/features/customers/ui/stores/CustomerStore.js. That said, if you open up that file, you'll notice it's already set up with some code for listing customers!

module.exports = {
  namespaced: true,

  state: {
    currentPage: [],
    totalPages: 1,
  },

  mutations: {
    saveList(state, { customers, pages }) {
      state.currentPage = customers
      state.totalPages = pages
    },
  },

  actions: {
    async getList({ commit }, { page = 1, per = 20, search = '' } = {}) {
      const {
        body: { data, meta },
      } = await delayMin(
        500,
        request.get('/api/customers').query({ page, per, search })
      )

      commit('saveList', {
        customers: data.customers.map(customer => new CustomerModel(customer)),
        pages: meta.pages,
      })
    },
  },
}

If you're not familiar with Vuex yet, I recommend you do a bit of reading, as it'll make this a lot easier to understand. But the basics are that you can set up a collection of Stores that hold state for your app. Then, inside your Vue components, you can bind template data to the values in these stores so that when one component causes store data to change, all components relying on that data update.

In this example code, there's one action defined: getList(). This queries our API for a page of customer data, then saves it to the state in the store, which will propagate to any components bound to that state. One interesting thing to note is that the API call is wrapped in a delayMin function. That's a helper function that will wait either a specified number of milliseconds (in this case 500), or until the API call completes, whichever is longer. Why wait longer? Well, this table has a loading state, and if you don't display it for long enough, it flashes by too long to recognize and looks like "flicker," like there's something wrong with your app. You don't have to have this delay, but you'll want to be careful how you design without it, so that you don't have that weird flicker throughout your app.

From here, let's check out app/features/customers/ui/init.js. There are two functions of interest to us in there. First, there's initStores():

async initStores(store) {
  await store.registerModule(
    'Customers',
    await import(/* webpackChunkName: "customers" */ '#features/customers/ui/stores/CustomerStore'),
  );

  // DO NOT REMOVE: Generated stores will be added above this line
}

Since Webpack wants to know exactly what files you're including in the package, you need to explicitly define them, which we do here. This function registers all the Stores in your feature, and gives them a chunk name that allows Webpack to intelligently split your code to make it quick to load. If you're using the Nodewood CLI to add features and files, it will automatically add generated Stores to this init function (provided you don't remove the line that says "DO NOT REMOVE"), so for the most part, you won't have to worry about this.

Next is initRoutes():

async initRoutes(router) {
  router.addRoutes([
    {
      path: '/customers',
      name: 'customers',
      component: () => import(/* webpackChunkName: "customers" */ '#features/customers/ui/pages/CustomersPage'),
    },

    // DO NOT REMOVE: Generated routes will be added above this line
  ]);
}

This defines the routes used by your feature. In this case, we're telling Vue Router that when users hit /customers, display CustomersPage in the router-view. If you add new Pages using the Nodewood CLI, they'll again be added automatically to this list, but you may need to come back here and adjust the path. The CLI guesses at what you want the path to be based on the page name, but it's frequently not going to be exactly what you want, and you'll have to adjust.

So then let's check out that CustomersPage defined in that route. There's a lot of code generated by the CLI here, so let's check it out bit-by-bit. First up, the component template:

<template>
  <div class="card">
    <table class="w-full wood-table">
      <thead>
        <tr>
          <template v-for="(customerField, key) in customerFields">
            <th
              :key="key"
              :class="customerField.labelClass()"
            >
              <span :class="customerField.labelInnerClass()">
                {{ customerField.label() }}
              </span>
            </th>
          </template>
        </tr>
      </thead>
      <tbody>
        <tr
          v-for="customer in currentPage"
          :key="customer.id"
        >
          <template v-for="(customerField, key) in customerFields">
            <td
              :key="key"
              :class="customerField.valueClass(customer[key])"
            >
              <span :class="customerField.valueInnerClass(customer[key])">
                {{ customerField.value(customer[key]) }}
              </span>
            </td>
          </template>
        </tr>

        <div
          v-if="$isLoading('getList')"
          class="loading-overlay"
        />

        <tr v-else-if="currentPage.length === 0">
          <td
            :colspan="Object.keys(customerFields).length"
            class="text-center"
          >
            No customers.
          </td>
        </tr>
      </tbody>
    </table>

    <pagination-row
      :page="page"
      :pages="totalPages"
      :per="per"
      @changePage="changePage"
      @changePer="changePer"
    />
  </div>
</template>

The big feature here is the table that automatically fills header fields from your CustomerModel and data fields from models loaded from your store.  customerFields is just a computed function that returns the Model's FIELDS value, sans the id field, since we don't want to display that in this table.  (Unless you do, in which case you can remove the omit() function):

  computed: {
    ...mapState('Customers', [
      'currentPage',
      'totalPages',
    ]),

    customerFields() {
      return omit(CustomerModel.FIELDS, 'id');
    },
  },

You'll also note that computed is where we map the state from our CustomersStore earlier.  This exposes currentPage and totalPages from that Store's state into the template we use above.

Moving a little further into that template, there are a bunch of interesting functions:

  • labelClass()
  • labelInnerClass()
  • valueClass()
  • valueInnerClass()

These come from our FIELDS object on the Model - we didn't define anything interesting on them in this example, but if you wanted one of your fields to display centred, you could define the field like so:

name: new FieldString({ label: 'Name', valueClasses: ['text-center'] }),

If you're wondering where these classes come from, it's because Nodewood uses Tailwind CSS, a utility-first CSS framework that makes designing and testing UIs much simpler.

Displaying the field values has a bit of magic around it as well:

{{ customerField.value(customer[key]) }}

The fields you define have formatters that run when you pass data through the value() function.  For example, FieldNumber can be used to display formatted numbers or currencies, FieldBoolean can be used to disply a Yes/No value in an easy-to-read manner, and FieldEnum can be used to display a list of values with appropriate labels.

Near the bottom of the table, there's a loading overlay that dims the table while it's loading, so that the user has an indication of the state of the app.  But that $isLoading() helper might not be something you've seen before.  It comes from vue-loadable, and all you need to do to be able to "watch" your store functions like that is wrap them like so:

methods: {
  ...mapLoadableMethods(
    mapActions('Customers', [
      'getList',
    ]),
  ),

The rest of the component is just calling our Store's getList() to populate the table and when changing pagination options.  You get a lot for free, but you can also expand and modify the basic table you get in the example, and we will in the next few sections!

Adding customers

That table is looking pretty empty, right?  One way to see what it looks like with actual customers in it is to muck around with the database directly, but there are more robust ways as well - like we could add a dialog that allows us to add new customers from the UI.  We already built an API endpoint for this, so why not put it to use?

First up is adding a new action to the CustomersStore:

async addCustomer({ commit }, { name, email, phone_number, notes }) {
  const { body: { data } } = await delayMin(
    500,
    request.post('/api/customers').send({
      name,
      email,
      phone_number,
      notes,
    }),
  );

  commit('addCustomer', { customer: new CustomerModel(data.customer) });
},

This should look familiar by now.  We're passing in the customer information and posting it to our "add customer" endpoint.  Then we commit a mutation to add the customer to our current page of customers, which we need to also write:

addCustomer(state, { customer }) {
  state.currentPage.unshift(customer);
},

While this doesn't exactly mirror the sorting order that customers will appear in in the database, it does highlight the newly-created customer at the very top of the page you're currently on, making it easy to find.

Now we need to modify our CustomersPage to add a button that lets us add a new customer.  Above the table, add:

<div class="flex flex-row-reverse mb-4">
  <button @click="$refs.addCustomerDialog.openDialog()">
    Add Customer
  </button>
</div>

We'll come back to the click function of that button in a moment.  One of the example files created when we created this feature was app/features/customers/ui/dialogs/CustomerDialog.vue.  The CLI tried to guess a good name for this dialog, but since it didn't know how we were going to use it, it only came close.  Let's rename it to AddCustomerDialog.vue for now.  We'll customize that dialog in a moment, but right now, we need to attach it to our page.  We have to modify our script section as follows:

import AddCustomerDialog from '#features/customers/ui/dialogs/AddCustomerDialog';

export default {
  name: 'CustomersPage',

  components: {
    PaginationRow,
    AddCustomerDialog,
  },

This imports the dialog and adds it to the list of components our CustomersPage knows about.  Then we need to add an instance of it to our component template.  You can add the following anywhere on the page, but I like to place dialogs all together near the bottom of the template, to keep easier track of them:

<add-customer-dialog ref="addCustomerDialog" />

There's that ref we were referencing earlier!  Essentially, what that button's click action does is that it calls openDialog() on the instance of the AddCustomerDialog we've just added to the page.  Nodewood Dialogs are actually vue-js-modals with some extra helper functions to abstract and simplify common use cases.

If you try loading the page and clicking the "Add Customer" button, you'll see that it opens the dialog, though there's nothing really interesting in it so far.  So let's build out that dialog and wrap this section up.  We'll start by checking out the component template:

<template>
  <modal
    name="customer-dialog"
    width="40%"
    height="480"
  >
    <div class="dialog-wrapper padded">
      <h1
        slot="title"
        class="dialog-title"
      >
        Add Customer
      </h1>

      <div class="dialog-body pr-3">
        <text-input
          v-model="form.name"
          input-id="name"
          label="Name"
          class="mb-4"
          :error-text="nameErrorText"
          :disabled="$isLoading('addCustomer')"
        />

        <text-input
          v-model="form.email"
          input-id="email"
          label="Email Address"
          class="mb-4"
          :error-text="emailErrorText"
          :disabled="$isLoading('addCustomer')"
        />

        <text-input
          v-model="form.phone_number"
          input-id="phone_number"
          label="Phone Number"
          class="mb-4"
          :error-text="phoneNumberErrorText"
          :disabled="$isLoading('addCustomer')"
        />

        <text-input
          v-model="form.notes"
          input-id="notes"
          label="Notes"
          :error-text="notesErrorText"
          :disabled="$isLoading('addCustomer')"
        />
      </div>

      <div class="dialog-buttons">
        <button
          class="btn"
          :disabled="$isLoading('addCustomer')"
          @click="closeDialog"
        >
          Close
        </button>

        <button
          class="btn ml-4"
          :disabled="$isLoading('addCustomer')"
          @click="submitDialog"
        >
          Add Customer
        </button>
      </div>
    </div>
  </modal>
</template>

A lot of this will be pretty familiar, or will become obvious once we start looking at the script part of the component.  Our friend $isLoading is back, disabling form components while the form is being submitted, but this is the first time we've seen a <text-input /> component.  This is a built-in Nodewood component that offers a styled text input that also handles changing style and displaying error text when an error is detected for it.  That error text is supplied from a helper function we'll define shortly in our script, so we'll come back to that.  Speaking of our script, let's start checking that out:

data: () => ({
  form: {
    name: '',
    email: '',
    phone_number: '',
    notes: '',
  },
  apiErrors: {
    name: [],
    email: [],
    phone_number: [],
    notes: [],
  },
}),

The data section of our component defines the form values and api error collections for those form values that we'll use in the <text-input /> components above.  Speaking of api error collections, though, let's check out how those are transformed into the error-text we spoke of earlier:

import { fieldErrorText } from '#ui/lib/forms';
import {
  CustomerValidator,
  ADD_FORM_FIELDS,
} from '#features/customers/lib/validators/CustomerValidator';

const validator = new CustomerValidator(ADD_FORM_FIELDS);

// ...

computed: {
  nameErrorText: fieldErrorText('name', validator),
  emailErrorText: fieldErrorText('email', validator),
  phoneNumberErrorText: fieldErrorText('phone_number', validator),
  notesErrorText: fieldErrorText('notes', validator),
},

We use a helper to translate validator & api error text into a string that is displayed when set.  And that's it for validation!  Because we've already set up our Validator, we don't need to write any complex validation code here - a simple helper translates all our existing validation code into simple text for us.  And if we decide to change the Rules in our Validator, those changes will carry forth both to the API and to any UI that uses it, no extra changes necessary.

So let's finally get to the meat of this dialog, adding the customer:

  methods: {
    ...mapLoadableMethods(
      mapActions('Customers', [
        'addCustomer',
      ]),
    ),
    
    async submitDialog() {
      this.apiErrors = {
        name: [],
        email: [],
        phone_number: [],
        notes: [],
      };

      await this.addCustomer(this.form);

      this.closeDialog();
      this.$toasted.show('Customer added.', {
        type: 'success',
        icon: 'check-circle',
      });
      
      this.form = {
        name: '',
        email: '',
        phone_number: '',
        notes: '',
      };
    },
  },

Here, we're mapping our addCustomer function from our CustomersStore to our local methods, then using it inside submitDialog().  First, we clear out any existing apiErrors from previous calls, attempt to add the customer by passing along the form, and then if that succeeds (i.e. if it doesn't catch an error that is handled by our global error handler), we close the dialog, display a success toast, and clear the form for the next customer to be added.

Now that might seem like a lot of code to add, but it's important to keep in mind that the CLI writes a lot of that for you when you use it to create a new dialog, and your CustomerValidator and Nodewood's global error handlers mean you don't have to write a lot of validation and error-handling code.

Vue is, at times, verbose, and that's what we're running into here.  But the common, tedious parts have been abstracted out into a pattern, and what's left to add and modify is pretty simple to implement and maintain.

Updating customers

We'll definitely want to be able to update customers in our list, so let's build that now.  Some of this will be very similar to what we just did (we're going to use a dialog and make some changes to our Vuex store), but we're also going to mix it up a little.

So first, let's update the aforementioned store with an update action:

async updateCustomer({ commit }, { id, name, phone_number, notes }) {
  const { body: { data } } = await delayMin(
    500,
    request.put(`/api/customers/${id}`).send({ name, phone_number, notes }),
  );

  commit('updateCustomer', { customer: new CustomerModel(data.customer) });
},

Nearly identical to when we added a customer, we're just not sending the email address.  The mutation is different, though:

updateCustomer(state, { customer }) {
  state.currentPage = state.currentPage.map((stateCustomer) => {
    if (stateCustomer.id === customer.id) {
      stateCustomer.name = customer.name;
      stateCustomer.phoneNumber = customer.phoneNumber;
      stateCustomer.notes = customer.notes;
    }

    return stateCustomer;
  });
},

All we're doing here is updating the current page in-place, replacing the customer data for the updated customer.  Still nothing funky.  So let's move on to the CustomersPage.

We're going to need a way of interacting with each customer row individually, so something like a button for each row would be good.  But this isn't going to be the last action we want to add, and we might want to add a lot of actions!  A bunch of buttons isn't going to cut it.  So instead, we'll use Nodewood's <dropdown-menu /> component.  First, we modify the thead by adding one more header column at the end:

<th>
  Actions
</th>

Then we modify the repeating tr in the tbody by adding another column:

<td class="text-center">
  <dropdown-menu
    menu-button-classes="card-xs"
    :menu-dropdown-classes="'right-0'"
  >
    <template v-slot:menu-text>
      Actions
    </template>
    <dropdown-menu-item
      class="whitespace-no-wrap"
      :click="() => $refs.editCustomerDialog.openDialog(customer)"
    >
      Edit
    </dropdown-menu-item>
  </dropdown-menu>
</td>

The properties for this component are reasonably self-explanatory, but that click function deserves a bit of a second look.  It's very similar to the one we're using to open the AddCustomerDialog, except we're passing a customer into it.  That's how we'll tell the dialog the customer information to display and let us change.

One small change we have to make is to the "no customers" row that is displayed when there are (shocker) no customers to display.  Since its length in columns is calculated based on the amount of customer fields we're displaying, and we just added a column that isn't part of those fields, we need to drop a spicy little + 1 to that calculation, or it'll look weird:

<tr v-else-if="currentPage.length === 0">
  <td
    :colspan="Object.keys(customerFields).length + 1"
    class="text-center"
  >
    No customers.
  </td>
</tr>

We add the dialog and the ref as before, but this time there's no pre-existing dialog to modify.  So instead we'll use the CLI to add a skeleton of a dialog to start from:

 $ nodewood add dialog customers EditCustomer
  _   _               _                                         _
 | \ | |   ___     __| |   ___  __      __   ___     ___     __| |
 |  \| |  / _ \   / _` |  / _ \ \ \ /\ / /  / _ \   / _ \   / _` |
 | |\  | | (_) | | (_| | |  __/  \ V  V /  | (_) | | (_) | | (_| |
 |_| \_|  \___/   \__,_|  \___|   \_/\_/    \___/   \___/   \__,_|

CLI Version 0.5.2

Dialog created at:
/Users/dan/sample/app/features/customers/ui/dialogs/EditCustomerDialog.vue

For this dialog, we're going to crib heavily from the AddCustomerDialog, basically copy/pasting to start and replacing "Add" with "Edit" and stripping out every "email" field or reference we find.  Consequently, there's only really one interesting function to look at, one of which being the openDialog() function we highlighted earlier:

openDialog(customer) {
  this.id = customer.id;
  this.form.name = customer.name;
  this.form.phone_number = customer.phoneNumber;
  this.form.notes = customer.notes;

  this.$modal.show('edit-customer-dialog');
},

We're passing the customer into the dialog when we open it so that we can pre-populate our form data (and save the ID for later).  (We can actually copy this technique and apply it to our AddCustomerDialog, which fixes a bug: if you fill in customer data and then close the dialog, that data will still be there when you open the form again.)

Event the submitDialog function is the same as before, with the addCustomer() store call replaced with:

await this.updateCustomer({ id: this.id, ...this.form });

We pass the form as before, but add the ID of the customer that we saved earlier.  The store action is expecting that so that it can build the URL for updating this customer.

Again, there's no need to build in manual validation, since our CustomerValidator is handling all the validation for this form (though you'll need to use UPDATE_FORM_FIELDS instead of ADD_FORM_FIELDS, or it won't let you submit due to an empty email address).

Save it all, load it in your browser, click the dropdown next to your customer, and edit to your heart's content.

Deleting customers

Finally, we'll want to add the ability to delete a customer from the UI.  Nothing fancy here, we've pretty much seen it all before.  The Store action:

async deleteCustomer({ commit }, { id }) {
  await delayMin(
    500,
    request.delete(`/api/customers/${id}`),
  );

  commit('deleteCustomer', { id });
},

The mutation:

deleteCustomer(state, { id }) {
  state.currentPage = state.currentPage.filter((stateCustomer) => stateCustomer.id !== id);
},

Add a new dropdown menu item to the rows on the page (you'll need to import the dialog and add the ref to the page as before):

<dropdown-menu-item
  class="whitespace-no-wrap"
  :click="() => $refs.deleteCustomerDialog.openDialog(customer)"
>
  Delete
</dropdown-menu-item>

Create the DeleteCustomerDialog:

$ nodewood add dialog customers DeleteCustomer
  _   _               _                                         _
 | \ | |   ___     __| |   ___  __      __   ___     ___     __| |
 |  \| |  / _ \   / _` |  / _ \ \ \ /\ / /  / _ \   / _ \   / _` |
 | |\  | | (_) | | (_| | |  __/  \ V  V /  | (_) | | (_) | | (_| |
 |_| \_|  \___/   \__,_|  \___|   \_/\_/    \___/   \___/   \__,_|

CLI Version 0.5.2

Dialog created at:
/Users/dan/sample/app/features/customers/ui/dialogs/DeleteCustomerDialog.vue

This dialog is much simpler.  A vastly reduced in scope body:

<div class="dialog-body pr-3">
  Are you sure you want to delete "{{ name }}"?
</div>

A tiny openDialog():

openDialog(customer) {
  this.id = customer.id;
  this.name = customer.name;

  this.$modal.show('delete-customer-dialog');
},

And an itty-bitty submitDialog():

async submitDialog() {
  await this.deleteCustomer({ id: this.id });

  this.closeDialog();
  this.$toasted.show('Customer deleted.', {
    type: 'success',
    icon: 'check-circle',
  });
},

Easy-peasy. Kick it on up in your browser, choose "Delete" from the dropdown for a row, confirm with the "Delete" button, and watch that customer disappear into the aether.

tl;dr

This was, admittedly, a fairly lengthy article for creating a "simple feature." You can always skim and come back later if it's too in-depth, but if it's too light on details, you're SOL. (Prime example, if you don't know what "SOL" stands for, well, you're SOL, this article is too light on that detail.)

For comparison, once you are familiar with the Nodewood concepts explained above, you can reduce the steps down to a simple bulleted list:

  • nodewood add feature customers
  • Complete & run migration
  • Add fields to Model
  • Add fields to Validator
  • Add Controller methods
  • Add Controller tests (or not, I'm not your Dad)
  • Fill out Vuex Store
  • Add table to Customers Page & hook up to store
  • Add New Customer Dialog & hook up to store
  • Add Update Customer Dialog & hook up to store
  • Add Delete Customer Dialog & hook up to store

This still may not be as simple or as quick as using a NoCode tool, but, well, it's not supposed to be. For projects over a certain complexity or attempting to do things not covered by the built-in functionality of the tool, you're going to need to write code. And when you write code, you're going to want to write maintainable code that is easy to modify and extend later. Nodewood is designed for that case: where you know you're going to want to build something ambitious, but you still want to get it done quickly and you want to make sure that you're building on top of a solid foundation.

If you want to examine the code in this article closer, I've provided it here as a zip file. Note that this is only the code for the feature, and it's only really useful for educational purposes.  If you want to actually run it anywhere, you'll need a Nodewood installation to place it into!

Build your next app with Nodewood!