Setting Data

To close the loop on working with data in Joystick, now, let's wire up a form to change or "set" data in our database. We're going to add one more page component at /ui/pages/edit_book/index.js, this time with a form that we'll use to make changes to our existing books.

Before we do, though, we're going to add an additional getter to our API for retrieving a single book.

/api/books/getters.js

const getters = {
  books: {
    get: () => {
      return process.databases.mongodb.collection('books').find().toArray();
      // PostgreSQL
      // return process.databases.postgresql.query(`SELECT * FROM books`);
    },
  },
  book: {
    input: {
      book_id: {
        type: 'string',
        required: true,
      }
    },
    get: (input = {}) => {
      return process.databases.mongodb.collection('books').findOne({
        _id: input.book_id,
      });

      /* PostgreSQL
         const [book] process.databases.postgresql.query(`SELECT * FROM books WHERE book_id = $1`, [
           input.book_id
         ]);

         return book;
      */
    },
  },
};

export default getters;

Here, we're following an identical approach to our first getter, books, however this time we've added an input schema alongside of our get() function. Remember, when we defined our update_book setter earlier, we introduced an input validator to ensure the data we're being passed from the client is what we expect. Here, we do the same, however this time we're only concerned with the ID of the book we're trying to edit being passed as book_id.

If we have it, similar to what we saw in our books getter, we make a call to our database to find a book with an _id (or, for PostgreSQL, book_id) matching input.book_id.

Wiring up an edit form

Next, with our getter, we're ready to wire up a page where we can edit our book. We're going to create this at /ui/pages/edit_book/index.js:

/ui/pages/edit_book/index.js

import joystick from "@joystick.js/ui";

const EditBook = joystick.component({
  data: async (api = {}, req = {}) => {
    return {
      book: await api.get('book', {
        input: {
          book_id: req?.params?.book_id,
        }
      })
    };
  },
  css: `
    .form-field {
      margin-bottom: 5px;
    }

    .form-field label {
      display: block;
    }

    form button {
      display: block;
      margin-top: 20px;
    }
  `,
  render: ({ data }) => {
    return `
      <h2>Edit Book</h2>
      <form>
        <div class="form-field">
          <label>Title</label>
          <input type="text" name="title" placeholder="Book Title" value="${data.book.title}" />
        </div>
        <div class="form-field">
          <label>Author</label>
          <input type="text" name="author" placeholder="Book Author" value="${data.book.author}" />
        </div>
        <div class="form-field">
          <label>Status</label>
          <select name="status" value="${data.book.status}">
            <option value="unread" ${data.book.status === 'unread' ? 'selected' : ''}>Unread</option>
            <option value="read" ${data.book.status === 'read' ? 'selected' : ''}>Read</option>
          </select>
        </div>
        <button type="submit">Save Changes</button>
      </form>
    `;
  },
});

export default EditBook;

Above, we're creating a component that renders an HTML form we can use to edit an existing book. If we look close, what we're doing here is fetching or "getting" the book from our database first (we'll wire up a separate getter for this next), calling to the book getter we just defined. What's different from the api.get() call we did in /ui/pages/books/index.js is that we're passing an input field via the options object for our api.get() call.

We're also including a second parameter passed to api.get(), req, or "request." This is the inbound HTTP request (sanitized for use in the browser) that we receive when this page (edit_book) is rendered. On that object, we expect Joystick to provide us with the URL params that were a part of the request (URL params are things like :book_id in one of our routes—dynamic variables embedded within our URL).

Here, we point to req.params.book_id because, as we'll see later, we're going to set up a route on our server at /books/:book_id/edit that will render the page we're defining above.

With this data, back down in our render() function, we're setting the value attribute on each of our inputs to the corresponding field's value on the data.book response from our getter. The idea being that when we load the page, we'll see the current title, author, and status for the book rendered on screen.

Handling a call to set()

Next, our goal is to call to our setter update_book with any changes from our form. To do it, we're going to add in another DOM event listener, this time on the submit event for our <form></form> tag.

/ui/pages/edit_book/index.js

import joystick, { set } from "@joystick.js/ui";

const EditBook = joystick.component({
  data: async (api = {}, req = {}) => { ... },
  css: `...`,
  events: {
    'submit form': async (event, instance) => {
      event.preventDefault();

      instance.validate_form(event.target, {
        rules: {
          title: {
            required: true,
          },
          author: {
            required: true,
          }
        },
        messages: {
          title: {
            required: 'Title is required.',
          },
          author: {
            required: 'Author is required.',
          }
        },
      }).then(() => {
        set('update_book', {
          input: {
            book_id: instance?.url?.params?.book_id,
            status: event.target.status.value,
            title: event.target.title.value,
            author: event.target.author.value,
          }
        }).then(() => {
          location.reload();
        });
      })
    },
  },
  render: ({ data }) => {
    return `
      <h2>Edit Book</h2>
      <form>
        <div class="form-field">
          <label>Title</label>
          <input type="text" name="title" placeholder="Book Title" value="${data.book.title}" />
        </div>
        <div class="form-field">
          <label>Author</label>
          <input type="text" name="author" placeholder="Book Author" value="${data.book.author}" />
        </div>
        <div class="form-field">
          <label>Status</label>
          <select name="status" value="${data.book.status}">
            <option value="unread" ${data.book.status === 'unread' ? 'selected' : ''}>Unread</option>
            <option value="read" ${data.book.status === 'read' ? 'selected' : ''}>Read</option>
          </select>
        </div>
        <button type="submit">Save Changes</button>
      </form>
    `;
  },
});

export default EditBook;

Above, we've added an event listener via the events option on our component definition to listen from a submit event on our <form></form> tag. When this event occurs, first, we want to make sure that we call to event.preventDefault() so that the form doesn't submit via the default action url (because we're using JavaScript to handle the form submission, we don't want to use this).

Just below this, we're introducing something unique to Joystick components: built-in form validation. When we're dealing with user input, to the best of our abilities, it's important to ensure that the input we get from a user is what we expect.

Here, we call to the instance.validate_form() function passing our event.target (in this case, our <form></form>) as the first argument and then an object that defines the rules our user's input must follow along with some error messages to display if they break the rules.

Here, we'll notice that we expect instance.validate_form() to return a promise. If a user fails validation, the error messages we've defined here will display beneath the corresponding input that failed validation. If they pass validation, the .then() callback for the promise will fire.

Inside of that .then() callback, we're calling to the set() method we've imported as a named function from @joystick.js/ui at the top of our file. This set() is independent from the api.set() we hinted at earlier inside of data(). Instead of being a "server safe" set(), here, it's intended to only be used on the client (browser).

To use it, similar to our getter, we pass the name of the setter update_book as the first argument and then an options object containing the input we want to pass. Here, we expect Joystick to provide us access to the current URL (similar to the req object inside of data(), but here, only the URL information) via instance.url. From it, we pull the params.book_id and add it to our input, along with all of the form field values for our book.

Because this will return a promise, too, we add a .then() callback to say when we get a successful response from the setter, call location.reload() to reload the page (we should see the changes we just passed to the server persisting past the page load).

With that, we now have a full pattern for getting and setting data in our Joystick app. To finish up, let's add an extra route for the page we've just defined.

Adding a route for edit_book

Just like we saw before, we want to add a route to our routes object in /index.server.js:

/index.server.js

import joystick from '@joystick.js/node';
import api from './api/index.js';
import books_fixture from './fixtures/books.js';

joystick.app({
  api,
  fixtures: () => {
    books_fixture();
  },
  routes: {
    '/': (req = {}, res = {}) => {
      res.render('ui/pages/index/index.js', {
        layout: 'ui/layouts/app/index.js',
      });
    },
    '/another-page': (req = {}, res = {}) => {
      res.render('ui/pages/index/index.js', {
        layout: 'ui/layouts/app/index.js',
      });
    },
    '/books': (req = {}, res = {}) => {
      res.render('ui/pages/books/index.js', {
        layout: 'ui/layouts/app/index.js',
      });
    },
    '/books/:book_id/edit': (req = {}, res = {}) => {
      res.render('ui/pages/edit_book/index.js', {
        layout: 'ui/layouts/app/index.js',
      });
    },
  },
});

Down at the bottom, just like before, we define our route and assign it a callback function which calls to res.render() to render our page component. Of note, notice that we've included the :book_id param that we anticipated in our component to our route's URL.

Previous

Getting Data

Next

Going Further