Create a dynamic calendar view for an event-booking use case

Please be sure of your agent type and version and pick the right documentation accordingly.

This is the documentation of the forest-express-sequelize and forest-express-mongoose Node.js agents that will soon reach end-of-support.

forest-express-sequelize v9 and forest-express-mongoose v9 are replaced by @forestadmin/agent v1.

Please check your agent type and version and read on or switch to the right documentation.

Create a dynamic calendar view for an event-booking use case

This example shows you how you can implement a calendar view with a custom workflow involving dynamic API calls.

In our example, we want to manage the bookings for a sports court where:

  • We have a list of court opening dates. Each date can be subject to a price increase if the period is busy. These dates come from a collection called availableDates

  • A list of available slots appears after selecting a date and duration. These available slots come from a smart collection called availableSlots

  • The user can book a specific slot using a smart action calledbook.

How it works

Smart view definition

Learn more about smart views. File template.hbs

This file contains the HTML and CSS needed to build the view.

<style>
  .calendar {
    padding: 20px;
    background: white;
    height:100%;
    width: 50%;
    overflow: scroll;
    color: #415574;
  }
  .wrapper-view {
    display: flex;
    height:800px;
    width:100%;
    background-color: white;
  }
  .right-hand-wrapper {
    width: 40%;
    padding: 3px 30px;
    background-color: white;
    margin-left:30px;
    height: 80%;
    overflow: scroll;
  }
  :root {
    --fc-border-color:#e0e4e8;
    --fc-button-text-color: #415574;
    --fc-button-bg-color: #ffffff;
    --fc-button-border-color: #c8ced7;
    --fc-button-hover-bg-color: rgb(195, 195, 195);
    --fc-button-hover-border-color: #c8ced7;
    --fc-button-active-bg-color: #ffffff;
    --fc-button-active-border-color: #c8ced7;
  }
  .slot-container {
    display: flex;
    margin: 10px 0;
    vertical-align: center;
    padding: 2px 0;
  }
  .slot-value {
    margin: auto 0;
    margin-right: 30px;
    width: 40px;
  }
  .calendar .fc-toolbar.fc-header-toolbar .fc-left {
    font-size: 14px;
    font-weight: bold;
  }
  .calendar .fc-day-header {
    padding: 10px 0;
    background-color: #f7f7f7;
  }
  .calendar .fc-event {
    background-color: #f7f7f7;
    border: 1px solid #ddd;
    color: #555;
    font-size: 14px;
    margin-top: 5px;
  }
  .calendar .fc-daygrid-event {
    background-color: #a2c1f5;
    color: white;
    font-size: 14px;
    border: none;
    padding: 6px;
  }
  .c-beta-radio-button-duration {
    display: flex;
    margin-top: 4px;
    flex-wrap: wrap;
    margin-bottom: -4px;
}
</style>

<div class="wrapper-view">
  <div id='{{calendarId}}' class="calendar"></div>
  <div class="right-hand-wrapper">
    <div class="c-beta-label c-beta-label--top ember-view l-dmb">
      <div class= "c-beta-label__label">Selected date</div>
      <div class = "c-row-value">
        {{#if this.selectedDate}}
          {{this.selectedDate}}
        {{else}}
          None
        {{/if}}
      </div>
    </div>

    <div class="c-beta-label c-beta-label--top ember-view l-dmb">
      <div class= "c-beta-label__label">Duration</div>
      <BetaRadioButton
        @class='c-beta-radio-button-duration'
        @namePath='label'
        @options={{this.durations}}
        @value={{this.selectedDuration}}
        @valuePath='value'
      />
    </div>

    {{#if this.availableSlots}}
      <div class="c-beta-label c-beta-label--top ember-view l-dmb">
        <div class= "c-beta-label__label">Available slots</div>
        {{#each this.availableSlots as |slot|}}
          <div class="slot-container">
            <div class="c-row-value slot-value">{{slot.forest-time}}</div>
            <div class="c-beta-button c-beta-button--primary" onclick={{ action 'triggerSmartAction' this.availableSlotsCollection 'book' slot}}>book</div>
          </div>
        {{/each}}
      </div>
    {{else}}
      {{#if (not this.selectedDate)}}
        Please select a date to see slots available.
      {{else}}
        No slots available, please try another duration or another date.
      {{/if}}
    {{/if}}
  </div>
</div>

File template.js

This file contains all the logic needed to handle events and actions.

import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { scheduleOnce } from '@ember/runloop';
import { observer } from '@ember/object';
import $ from 'jquery';
import SmartViewMixin from 'client/mixins/smart-view-mixin';

export default Component.extend(SmartViewMixin, {
  store: service(),
  conditionAfter: null,
  conditionBefore: null,
  loaded: false,
  calendarId: null,
  selectedAvailability: null,
  selectedDate: null,
  selectedDuration: 1,
  availableSlots: null,
  availableSlotsCollection: null,
  _calendar: null,

  init(...args) {
    this._super(...args);
    this.loadPlugin();
    this.initConditions();
    this.set('durations', [
      {
        label: '1 hour',
        value: 1,
      },
      {
        label: '2 hours',
        value: 2,
      },
      {
        label: '3 hours',
        value: 3,
      },
    ]);
  },

  didInsertElement() {
    this.set(
      'availableSlotsCollection',
      this.store.peekAll('collection').findBy('name', 'availableSlots')
    );
  },

  // update displayed events when new records are retrieved
  onRecordsChange: observer('records.[]', function () {
    this.setEvent();
  }),

  onConfigurationChange: observer(
    'selectedDate',
    'selectedDuration',
    function () {
      this.searchAvailabilities();
    }
  ),

  initConditions() {
    if (this.filters) {
      this.filters.forEach((condition) => {
        if (condition.operator === 'is after') {
          this.set('conditionAfter', condition);
        } else if (condition.operator === 'is before') {
          this.set('conditionBefore', condition);
        }
      });
    }
  },

  loadPlugin() {
    scheduleOnce('afterRender', this, function () {
      this.set('calendarId', `${this.elementId}-calendar`);

      // retrieve fullCalendar script to build the calendar view
      $.getScript(
        'https://cdn.jsdelivr.net/npm/fullcalendar@5.3.0/main.min.js',
        () => {
          this.setEvent();
          const calendarEl = document.getElementById(this.calendarId);
          const calendar = new FullCalendar.Calendar(calendarEl, {
            height: 600,
            allDaySlot: true,
            eventClick: (event, jsEvent, view) => {
              // persist the selected event information when an event is clicked
              this.set('selectedAvailability', event.event);
              const eventStart = event.event.start;
              const selectedDate = `${eventStart.getDate().toString()}/${(
                eventStart.getMonth() + 1
              ).toString()}/${eventStart.getFullYear().toString()}`;
              // persist the selected event's date to be displayed in the view
              this.set('selectedDate', selectedDate);
            },
            // define logic to be triggered when the user navigates between date ranges
            datesSet: (view) => {
              // define params to query the relevant records from the database based on the date range
              const params = {
                filters: JSON.stringify({
                  aggregator: 'and',
                  conditions: [
                    {
                      field: 'date',
                      operator: 'before',
                      value: view.end,
                    },
                    {
                      field: 'date',
                      operator: 'after',
                      value: view.start,
                    },
                  ],
                }),
                'page[number]': 1,
                'page[size]': 31,
                timezone: 'Europe/Paris',
              };

              // query the records from the availableDates collection
              return this.store
                .query('forest-available-date', params)
                .then((records) => {
                  this.set('records', records);
                })
                .catch((error) => {
                  this.set('records', null);
                  alert('We could not retrieve the available dates');
                  console.error(error);
                });
            },
          });

          this.set('_calendar', calendar);
          calendar.render();
          this.set('loaded', true);
        }
      );

      const headElement = document.getElementsByTagName('head')[0];
      const cssLink = document.createElement('link');

      cssLink.type = 'text/css';
      cssLink.rel = 'stylesheet';
      cssLink.href =
        'https://cdn.jsdelivr.net/npm/fullcalendar@5.3.0/main.min.css';
      headElement.appendChild(cssLink);
    });
  },

  // create calendar event objects for each availableDates record
  setEvent() {
    if (!this.records || !this.loaded) {
      return;
    }

    this._calendar.getEvents().forEach((event) => event.remove());

    this.records.forEach((availability) => {
      if (availability.get('forest-opened') === true) {
        const event = {
          id: availability.get('id'),
          title: 'Available',
          start: availability.get('forest-date'),
          allDay: true,
        };

        if (availability.get('forest-pricingPremium') === 'high') {
          event.textColor = 'white';
          event.backgroundColor = '#FB6669';
          event.title = 'Available';
        }
        this._calendar.addEvent(event);
      }
    });
  },

  // retrieve record from the availableSlots collection when an event has been selected
  searchAvailabilities() {
    if (this.selectedAvailability) {
      return this.store
        .query('forest-available-slot', {
          date: this.selectedAvailability.start,
          duration: this.selectedDuration,
        })
        .then((slots) => {
          this.set('availableSlots', slots);
        })
        .catch((error) => {
          this.set('availableSlots', null);
          alert('We could not retrieve the available slots');
          console.error(error);
        });
    }
  },
});

Available dates model

File models/available-dates.js

This file contains the model definition for the collection availableDates. It is located in the models folder, at the root of the admin backend.

module.exports = (sequelize, DataTypes) => {
  const { Sequelize } = sequelize;
  const AvailableDates = sequelize.define(
    'availableDates',
    {
      date: {
        type: DataTypes.DATE,
      },
      opened: {
        type: DataTypes.BOOLEAN,
      },
      pricingPremium: {
        type: DataTypes.STRING,
      },
    },
    {
      tableName: 'available_dates',
      underscored: true,
      timestamps: false,
      schema: process.env.DATABASE_SCHEMA,
    }
  );

  return AvailableDates;
};

Available slots smart collection

To create a smart collection that returns records built from an API call, two files need to be created:

  • a file available-slots.js inside the folder forest to declare the collection

  • a file available-slots.js inside the routes folder to implement the GET logic for the collection

File forest/available-slots.js

This file includes the smart collection definition.

const { collection } = require('forest-express-sequelize');

collection('availableSlots', {
  fields: [
    {
      field: 'startDate',
      type: 'Date',
    },
    {
      field: 'endDate',
      type: 'Date',
    },
    {
      field: 'time',
      type: 'String',
    },
    {
      field: 'maxTimeSlot',
      type: 'Number',
    },
  ],
  segments: [],
});

File routes/available-slots.js

This file includes the logic implemented to retrieve the available slots from an API call and return them serialized to the UI.

const express = require('express');
const {
  PermissionMiddlewareCreator,
  RecordSerializer,
} = require('forest-express-sequelize');
const { availableSlots } = require('../models');

const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator(
  'availableSlots'
);
const recordSerializer = new RecordSerializer({ name: 'availableSlots' });

// Get a list of Available slots
router.get(
  '/availableSlots',
  permissionMiddlewareCreator.list(),
  (request, response, next) => {
    const { date } = request.query;
    const { duration } = request.query;
    return fetch(
      `https://apicallplaceholder/slots/?date=${date}&duration=${duration}`
    )
      .then((response) => JSON.parse(response))
      .then((matchingSlots) => {
        return recordSerializer
          .serialize(matchingSlots)
          .then((recordsSerialized) => response.send(recordsSerialized));
      })
      .catch((error) => {
        console.error(error);
      });
  }
);

module.exports = router;

Book smart action

To create the action to book a slot, two files need to be updated:

  • the file available-slots.js inside the folder forest to declare the action

  • the file available-slots.js inside the routes folder to implement the logic for the action

File forest/available-slots.js

This file includes the smart action definition. The action form is pre-filled with the start and end date. The last step is to select the user associated with this booking.

const { collection } = require('forest-express-sequelize');

collection('availableSlots', {
  actions: [
    {
      name: 'book',
      type: 'single',
      fields: [{
        field: 'start date',
        type: 'Date',
      }, {
        field: 'end date',
        type: 'Date',
      }, {
        field: 'user',
        reference: 'users.id',
      }],
      values: (context) => {
        return {
          'start date': context.startDate,
          'end date': context.endDate,
        };
      },
    },
  ],
...
});

File routes/available-slots.js

This file includes the logic of the smart action. It basically creates a record from the bookings collection with the information passed on by the user input form.

const express = require('express');
const { PermissionMiddlewareCreator } = require('forest-express-sequelize');
const { bookings } = require('../models');

const router = express.Router();
const permissionMiddlewareCreator = new PermissionMiddlewareCreator('availableSlots');

...

router.post('/actions/book', permissionMiddlewareCreator.smartAction(), (request, response) => {
  const attr = request.body.data.attributes.values;
  const startDate = attr['start date'];
  const endDate = attr['end date'];
  bookings.create({
    startDate,
    endDate,
    userIdKey: attr.user,
  }).then(() => response.send({ success: 'successfully created booking' }));
});


module.exports = router;

Last updated