TL;DR
Your time is valuable but you canât waste a second of it. People need to see you because work needs to get done and there are collaborations to be made. Instead of letting people communicate with you directly to schedule their use of your timeâââwhich only wastes it moreâââweâll use Cosmic JS to build an appointment scheduler. That way, the people who need to talk to you only have to once.
Cosmic JS is an API-first CMS, meaning it is language independent, database independent, and practically everything-else independent. This is great for a small project like this one because we can extend it quickly with any language or framework in the future and we can define data structures that are only as complex as we need them to be.
Our Appointment Scheduler will let users select a day and a one-hour time slot between 9AM and 5PM to meet with us. Weâll then integrate our app with Twilio to send them a confirmation text that their appointment has been scheduled. Finally, weâll build a Cosmic JS Extension so we can manage the appointments right from within the Cosmic JS dashboard.
(What our appointment scheduler app will look like)
Weâll complete our project in 3 major sections:
- Building out the front-end in React and Webpack, with the help of the Material UI component library
- Wiring it up to a simple Express backend that will serve to make API calls to Twilio and to expose our Appointment objects to the front-end (in the spirit of keeping our Cosmic bucket keys out of our frontend code)
- Building the Extension in React, again using the Material UIÂ library
But, before any of that, we need to get our Cosmic bucket ready to store and serve data.
Part 0: Setting Up Cosmic JS
Weâll use two types of Objects to store our data: one for appointments and one for site configurations. In Cosmic JS, first create the
Appointments
object type with the specified metafields. Then, we'll create a Configs
object type with no default metafields and a Siteobject in which we'll define object-specific metafields.Appointments
Here,
email
and phone
will be the user's metadataâââwe'll use their name as the Appointment object's title. Date
will hold the appointment date in YYYY-DD-MM
format and slot
will be how many hours away the appointment is from 9AM.Configs/Site
Weâre using the
Config
object to define details about the app that we want to be able to change on the fly, rather than having to redeploy for.And with our simple data scheme in place, weâre ready to get building.
Part 1: Building the Front End
1. Boilerplate setup
First, weâll make our appointment-scheduler directory, run yarn init (feel free to use npm), and set up the project structure as follows:
appointment-scheduler
|
|--dist
|--src
|. |--Components
|. |. |--App.js
|. |--index.html
|. |--index.js
|--.babelrc
|--.gitignore
|--package.json
|--webpack.config.js
Then, weâll make our HTML template:
<!-- ./src/index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet">
<title>Appointment Scheduler</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
Next, weâll install the packages we need. Of interest, alongside any standard packages needed to develop a React app in ES6, weâll be using:
for an easy way to make ajax calls in seriesasync
as a useful ajax utilityaxios
to make use of the const { property } = object destructuring patternbabel-preset-stage-3
for a convenient set of React-built Material Design componentsmaterial-ui
for parsing timesmoment
to clear browser-default stylesnormalize.css
- a necessary companion toreact-tap-event-plugin
material-ui
To install everything we need, run
yarn add async axios babel-preset-stage-3 material-ui moment normalze.css react react-dom react-tap-event-plugin
and for our dev-dependencies,
yarn add babel-core babel-loader babel-preset-env babel-preset-react css-loader eslint file-loader html-webpack-plugin path style-loader webpack
Having installed the Babel packages we need, weâll tell Babel to use them in itâs config file and weâll tell git to ignore everyhing we just installed:
- //Â ./.babelrc { âpresetsâ: [âenvâ, âreactâ, âstage-3â] }
- #Â ./.gitignore node_modules
Finally, weâll set up Webpack so everythingâs in place for build time
const path = require(âpathâ) const HtmlWebpackPlugin = require(âhtml-webpack-pluginâ) const webpack = require(âwebpackâ) module.exports = { entry: â./src/index.jsâ, output: { path: path.resolve(âdistâ), filename: âbundle.jsâ, sourceMapFilename: âbundle.map.jsâ }, devtool: âsource-mapâ, devServer: { port: 8080 }, module: { rules: [{ test: /\.js$/, use: { loader: âbabel-loaderâ }, exclude: path.resolve(ânode_modulesâ) }, { test: [/\.scss$/,/\.css$/], loader: [âstyle-loaderâ, âcss-loaderâ, âsass-loaderâ] }, { test: /\.(png|jpg|gif|svg)$/, use: [ { loader: âfile-loaderâ } ] }] }, plugins: [ new HtmlWebpackPlugin({ template: â./src/index.htmlâ, filename: âindex.htmlâ, inject: true, xhtml: true }), new webpack.DefinePlugin({ PRODUCTION: process.env.NODE_ENV === âproductionâ }) ] }
const path = require(âpathâ)
const HtmlWebpackPlugin = require(âhtml-webpack-pluginâ)
const webpack = require(âwebpackâ)
module.exports = {
entry: â./src/index.jsâ,
output: {
path: path.resolve(âdistâ), filename: âbundle.jsâ, sourceMapFilename: âbundle.map.jsâ
},
devtool: âsource-mapâ,
devServer: { port: 8080 },
module: { rules: [{
test: /\.js$/, use: { loader: âbabel-loaderâ },
exclude: path.resolve(ânode_modulesâ)
}, {
test: [/\.scss$/,/\.css$/], loader: [âstyle-loaderâ, âcss-loaderâ, âsass-loaderâ]
}, {
test: /\.(png|jpg|gif|svg)$/, use: [ { loader: âfile-loaderâ } ]
}]
},
plugins: [
new HtmlWebpackPlugin({
template: â./src/index.htmlâ,
filename: âindex.htmlâ,
inject: true,
xhtml: true
}),
new webpack.DefinePlugin({
PRODUCTION: process.env.NODE_ENV === âproductionâ
})
]
}
Weâve configured Webpack to output
bundle.js
, it's source map, and index.html
(according to the template in src
) to dist
on building. You also have the option to use SCSS throughout the project and have access to your Node environment via window.PRODUCTION
at build-time.2. Create an Entry Point
Before we go any further we need to define an entry point for our app. Here, weâll import any necessary global libraries or wrapper components. Weâll also use it as the render point for our React app. This will be our
src/index.js
file and it will look like this:// ./src/index.js
import React from 'react'
import ReactDom from 'react-dom'
import App from './Components/App'
import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider'
import 'normalize.css'
require('./scss/app.scss')
window.React = React
ReactDom.render(
<MuiThemeProvider>
<App />
</MuiThemeProvider>,
document.getElementById('root')
)
// MuiThemeProvider is a wrapper component for MaterialUI's components
3. Work out a Skeleton
Having eveything in place to get our app working, we have a few things to consider and a few choices to make about how we want our app to work before we start building it. To help us think, weâll build out a basic skeleton of how we need it to look:
// ./src/Components/App.js
import { Component } from 'react'
import injectTapEventPlugin from 'react-tap-event-plugin'
injectTapEventPlugin()
export default class App extends Component {
constructor() {
super()
this.state = {
// initial state
}
//method bindings
}
//component methods?
//lifecycle methods
componentWillMount() {
//fetch data from cosmic, watch window width
}
componentWillUnmount() {
//remove window width event listener
}
render() {
//define variables
return (
<div>
</div>
)
}
}
To start, we need to think about what the appâs state will look like. Here are some considerations:
- The app loads data from an external server, so it would be useful to show our userâs when thatâs happening
- Material Design implements a drawer-style navigation, so we need to track when thatâs open
- To confirm the userâs appointment details before submitting, weâll show them a confirmation modal and for other notifactions weâll use Material Designâs snackbar, which displays small notifications at the bottom of the page. Weâll need to track the open state of both of these.
- Our appointment scheduling process weâll take place in three steps: selecting a date, selecting a time slot, and filling out personal information. We need to track which step the user is on, the date and time theyâve selected, their contact details, and we also need to validate their email address and phone number.
- Weâll be loading configuration data and a schedule appointments that weâd benefit from cacheing in the state.
- As our user procedes through the 3 scheduling steps, weâll show a friendly sentence tracking their progress. (Example: âScheduling a 1 hour appointment at 3pm onâŠâ). We need to know if that should be displayed.
- When the user is selecting an appoitment slot, theyâll be able to filter by AM/PM, so we need to track which theyâre looking for.
- Finally, weâll add some responsiveness to our styling and we need to keep track of the screen width.
We then arrive at this as our initial state:
// ./src/Components/App.js
// ...
this.state = {
loading: true,
navOpen: false,
confirmationModalOpen: false,
confirmationTextVisible: false,
stepIndex: 0,
appointmentDateSelected: false,
appointmentMeridiem: 0,
validEmail: false,
validPhone: false,
smallScreen: window.innerWidth < 768,
confirmationSnackbarOpen: false
}
Note that
appointmentMeridiem
takes on 0
or 1
, such that 0 => 'AM'
and 1 =>Â 'PM'
.4. Draft Out Functionality
Weâve defined an initial state for our app, but before we build out a view with Material components weâll find it useful to brainstorm what needs done with our data. Our app will boil down to the following functionality:
- As decided in the previous step, our navitgation will be in a drawer so we need a
method to display/hide ithandleNavToggle()
- The three scheduling steps are revealed to the user in succession upon completing a previous step, so we need a
method to handle the flow of user inputhandleNextStep()
- Weâll user a Material UI date picker to set our appointment date and we need a
method to process data from that component. Likewise, we need ahandleSetAppointmentDate()
andhandleSetAppointmentSlot()
method. We don't want the date picker to show unavailable days (includinghandleSetAppointmentMeridiem()
) so we need to pass atoday
method to it.checkDisableDate()
- In the
lifecycle method we'll fetch our data from our backend, then handle that data with a separatecomponentWillMount()
method. For a fetching error, we'll need ahandleFetch()
method.handleFetchError()
- Upon submitting the appointment data, weâll use
to send it to our backend. We'll need ahandleSubmit()
andvalidateEmail()
method for when the user is filling out contact information.validatePhone()
- The user-friendly string above the form will be rendered in a separate method with
. So will available appointment times and the confirmation modal withrenderConfirmationString()
andrenderAppointmentTimes()
respectively.renderAppointmentConfirmation()
- Finally, weâll use a handy
method to respond to the browser window changing in widthresize()
All in all, unwritten methods included, our
App.js
now looks like this (method bindings included):// ./src/Components/App.js
import { Component } from 'react'
import injectTapEventPlugin from 'react-tap-event-plugin'
injectTapEventPlugin()
export default class App extends Component {
constructor() {
super()
this.state = {
loading: true,
navOpen: false,
confirmationModalOpen: false,
confirmationTextVisible: false,
stepIndex: 0,
appointmentDateSelected: false,
appointmentMeridiem: 0,
validEmail: false,
validPhone: false,
smallScreen: window.innerWidth < 768,
confirmationSnackbarOpen: false
}
//method bindings
this.handleNavToggle = this.handleNavToggle.bind(this)
this.handleNextStep = this.handleNextStep.bind(this)
this.handleSetAppointmentDate = this.handleSetAppointmentDate.bind(this)
this.handleSetAppointmentSlot = this.handleSetAppointmentSlot.bind(this)
this.handleSetAppointmentMeridiem = this.handleSetAppointmentMeridiem.bind(this)
this.handleSubmit = this.handleSubmit.bind(this)
this.validateEmail = this.validateEmail.bind(this)
this.validatePhone = this.validatePhone.bind(this)
this.checkDisableDate = this.checkDisableDate.bind(this)
this.renderAppointmentTimes = this.renderAppointmentTimes.bind(this)
this.renderConfirmationString = this.renderConfirmationString.bind(this)
this.renderAppointmentConfirmation = this.renderAppointmentConfirmation.bind(this)
this.resize = this.resize.bind(this)
}
handleNavToggle() {
}
handleNextStep() {
}
handleSetAppointmentDate(date) {
}
handleSetAppointmentSlot(slot) {
}
handleSetAppointmentMeridiem(meridiem) {
}
handleFetch(response) {
}
handleFetchError(err) {
}
handleSubmit() {
}
validateEmail(email) {
}
validatePhone(phoneNumber) {
}
checkDisableDate(date) {
}
renderConfirmationString() {
}
renderAppointmentTimes() {
}
renderAppointmentConfirmation() {
}
resize() {
}
//lifecycle methods
componentWillMount() {
//fetch data from cosmic, watch window width
}
componentWillUnmount() {
//remove window width event listener
}
render() {
//define variables
return (
<div>
</div>
)
}
}
5. Build Out the View
Having a basic idea of how our app is going to function, we can start building out its UI. Besides a couple of wrappers and a few custom styles, the majority of our app will be constructed with pre-packaged Material UI components.
In order, we need:
- An
which acts as the primary toolbarAppBar
- A
, which is opened from theDrawer
's primary button and serves as the app's navigation, following Material Design.AppBar
- Within the
,Drawer
s to display linksMenuItem
- A
as the primary content containerCard
- A
to break the scheduling process into 3 discreet steps. The active step will be expanded while the others are collapsed. The first step will be disabled ifStepper
is true and the last two will be disabled as long as the user hasn't filled out the previous step.state.loading
- Nested in the
, threeStepper
which containSteps
andStepButton
components.StepContent
- In the first
, we'll use aStep
to let the user choose an appointment date. Unavailable days will be disabled according to the return value ofDatePicker
. Selection of a date will be handled withcheckDisableDate()
handleSetAppointmentDate()
- In the second
, we want the user to be able to pick a time slot for their selected day from the slots available. We also want them to be able to filter times according to AM/PM. We'll use aStep
for the filter and aSelectField
to hold the time slot buttons. We need extra logic to render the radio buttons, so will do that in theRadioButtonGroup
method. That will return a set ofrenderAppointmentTimes()
s.RadioButton
- The last
will ask the user to input their name, email address, and phone number usingStep
components. ATextField
will be used a submit button to open the confirmationRaisedButton
. The users inputted phone number and email address will be validated withDialog
andvalidatePhone()
respectively.validateEmail()
- Finally, weâll include a convenient
to display notifcations about the loading state and submission at the bottom of the page. All in all, after having written out theSnackBar
method, our app will look like this:render()
// ./src/Components/App.js
// .. previous imports
import AppBar from 'material-ui/AppBar'
import Drawer from 'material-ui/Drawer'
import Dialog from 'material-ui/Dialog'
import Divider from 'material-ui/Divider'
import MenuItem from 'material-ui/MenuItem'
import Card from 'material-ui/Card'
import DatePicker from 'material-ui/DatePicker'
import TimePicker from 'material-ui/TimePicker'
import TextField from 'material-ui/TextField'
import SelectField from 'material-ui/SelectField'
import SnackBar from 'material-ui/Snackbar'
import {
Step,
Stepper,
StepLabel,
StepContent,
StepButton
} from 'material-ui/stepper'
import {
RadioButton,
RadioButtonGroup
} from 'material-ui/RadioButton'
import RaisedButton from 'material-ui/RaisedButton';
import FlatButton from 'material-ui/FlatButton'
import logo from './../../dist/assets/logo.svg'
export default class App extends Component {
// ... component methods, lifecycle methods
render() {
const { stepIndex, loading, navOpen, smallScreen, confirmationModalOpen, confirmationSnackbarOpen, ...data } = this.state
const contactFormFilled = data.firstName && data.lastName && data.phone && data.email && data.validPhone && data.validEmail
const modalActions = [
<FlatButton
label="Cancel"
primary={false}
onClick={() => this.setState({ confirmationModalOpen : false})} />,
<FlatButton
label="Confirm"
primary={true}
onClick={() => this.handleSubmit()} />
]
return (
<div>
<AppBar
title={data.siteTitle}
onLeftIconButtonTouchTap={() => this.handleNavToggle() }/>
<Drawer
docked={false}
width={300}
open={navOpen}
onRequestChange={(navOpen) => this.setState({navOpen})} >
<img src={logo}
style={{
height: 70,
marginTop: 50,
marginBottom: 30,
marginLeft: '50%',
transform: 'translateX(-50%)'
}}/>
<a style={{textDecoration: 'none'}} href={this.state.homePageUrl}><MenuItem>Home</MenuItem></a>
<a style={{textDecoration: 'none'}} href={this.state.aboutPageUrl}><MenuItem>About</MenuItem></a>
<a style={{textDecoration: 'none'}} href={this.state.contactPageUrl}><MenuItem>Contact</MenuItem></a>
<MenuItem disabled={true}
style={{
marginLeft: '50%',
transform: 'translate(-50%)'
}}>
{"© Copyright " + moment().format('YYYY')}</MenuItem>
</Drawer>
<section style={{
maxWidth: !smallScreen ? '80%' : '100%',
margin: 'auto',
marginTop: !smallScreen ? 20 : 0,
}}>
{this.renderConfirmationString()}
<Card style={{
padding: '10px 10px 25px 10px',
height: smallScreen ? '100vh' : null
}}>
<Stepper
activeStep={stepIndex}
linear={false}
orientation="vertical">
<Step disabled={loading}>
<StepButton onClick={() => this.setState({ stepIndex: 0 })}>
Choose an available day for your appointment
</StepButton>
<StepContent>
<DatePicker
style={{
marginTop: 10,
marginLeft: 10
}}
value={data.appointmentDate}
hintText="Select a date"
mode={smallScreen ? 'portrait' : 'landscape'}
onChange={(n, date) => this.handleSetAppointmentDate(date)}
shouldDisableDate={day => this.checkDisableDate(day)}
/>
</StepContent>
</Step>
<Step disabled={ !data.appointmentDate }>
<StepButton onClick={() => this.setState({ stepIndex: 1 })}>
Choose an available time for your appointment
</StepButton>
<StepContent>
<SelectField
floatingLabelText="AM or PM"
value={data.appointmentMeridiem}
onChange={(evt, key, payload) => this.handleSetAppointmentMeridiem(payload)}
selectionRenderer={value => value ? 'PM' : 'AM'}>
<MenuItem value={0}>AM</MenuItem>
<MenuItem value={1}>PM</MenuItem>
</SelectField>
<RadioButtonGroup
style={{ marginTop: 15,
marginLeft: 15
}}
name="appointmentTimes"
defaultSelected={data.appointmentSlot}
onChange={(evt, val) => this.handleSetAppointmentSlot(val)}>
{this.renderAppointmentTimes()}
</RadioButtonGroup>
</StepContent>
</Step>
<Step disabled={ !Number.isInteger(this.state.appointmentSlot) }>
<StepButton onClick={() => this.setState({ stepIndex: 2 })}>
Share your contact information with us and we'll send you a reminder
</StepButton>
<StepContent>
<section>
<TextField
style={{ display: 'block' }}
name="first_name"
hintText="First Name"
floatingLabelText="First Name"
onChange={(evt, newValue) => this.setState({ firstName: newValue })}/>
<TextField
style={{ display: 'block' }}
name="last_name"
hintText="Last Name"
floatingLabelText="Last Name"
onChange={(evt, newValue) => this.setState({ lastName: newValue })}/>
<TextField
style={{ display: 'block' }}
name="email"
hintText="[email protected]"
floatingLabelText="Email"
errorText={data.validEmail ? null : 'Enter a valid email address'}
onChange={(evt, newValue) => this.validateEmail(newValue)}/>
<TextField
style={{ display: 'block' }}
name="phone"
hintText="(888) 888-8888"
floatingLabelText="Phone"
errorText={data.validPhone ? null: 'Enter a valid phone number'}
onChange={(evt, newValue) => this.validatePhone(newValue)} />
<RaisedButton
style={{ display: 'block' }}
label={contactFormFilled ? 'Schedule' : 'Fill out your information to schedule'}
labelPosition="before"
primary={true}
fullWidth={true}
onClick={() => this.setState({ confirmationModalOpen: !this.state.confirmationModalOpen })}
disabled={!contactFormFilled || data.processed }
style={{ marginTop: 20, maxWidth: 100}} />
</section>
</StepContent>
</Step>
</Stepper>
</Card>
<Dialog
modal={true}
open={confirmationModalOpen}
actions={modalActions}
title="Confirm your appointment">
{this.renderAppointmentConfirmation()}
</Dialog>
<SnackBar
open={confirmationSnackbarOpen || loading}
message={loading ? 'Loading... ' : data.confirmationSnackbarMessage || ''}
autoHideDuration={10000}
onRequestClose={() => this.setState({ confirmationSnackbarOpen: false })} />
</section>
</div>
)
}
}
Before moving on, notice that, because they take some extra logic to render, weâll be handing the radio buttons for the time slots and the confirmation strings in their own methods.
With the view components in place, our last big step will be to write all of the functionality weâve mapped out.
6. Component Lifecycle Methods
componentWillMount()
The first step in adding functionality will be to write out the
componentWillMount()
method. In componentWillMount()
we'll use axios
to fetch our configuration and appointments data from our backend. Again, we're using our backend as a middleman so we can selectively expose data to our front end and omit things like users' contact information.// ./src/Components/App.js
// previous imports
import async from 'async'
import axios from 'axios'
export default class App extends Component {
constructor() {}
componentWillMount() {
async.series({
configs(callback) {
axios.get(HOST + 'api/config').then(res =>
callback(null, res.data.data)
)
},
appointments(callback) {
axios.get(HOST + 'api/appointments').then(res => {
callback(null, res.data.data)
})
}
}, (err,response) => {
err ? this.handleFetchError(err) : this.handleFetch(response)
})
addEventListener('resize', this.resize)
}
// rest...
}
We use
async
to make our axios
calls in series, and name them so we have access to them as response.configs
and response.appointments
in handleFetch()
. We also use componentWillMount
to start tracking the window width with resize()
.componentWillUnmount()
Practicing good form, weâll remove the event listener in
componentWillUnmount()
.// ./src/Components/App.js
// previous imports
import async from 'async'
import axios from 'axios'
export default class App extends Component {
constructor() {}
componentWillUnmount() {
removeEventListener('resize', this.resize)
}
// rest...
}
7. Processing Data
Having fetched the data we need, weâll process a successful fetch with
handleFetch()
and an error with handleFetchError()
. In handleFetch()
we'll build a schedule of appointmens to store in the state, such that schedule = { appointmentDate: [slots] }
. We also use this method to store the app's configuration data in the state.handleFetch()
handleFetch(response) {
const { configs, appointments } = response
const initSchedule = {}
const today = moment().startOf('day')
initSchedule[today.format('YYYY-DD-MM')] = true
const schedule = !appointments.length ? initSchedule : appointments.reduce((currentSchedule, appointment) => {
const { date, slot } = appointment
const dateString = moment(date, 'YYYY-DD-MM').format('YYYY-DD-MM')
!currentSchedule[date] ? currentSchedule[dateString] = Array(8).fill(false) : null
Array.isArray(currentSchedule[dateString]) ?
currentSchedule[dateString][slot] = true : null
return currentSchedule
}, initSchedule)
for (let day in schedule) {
let slots = schedule[day]
slots.length ? (slots.every(slot => slot === true)) ? schedule[day] = true : null : null
}
this.setState({
schedule,
siteTitle: configs.site_title,
aboutPageUrl: configs.about_page_url,
contactPageUrl: configs.contact_page_url,
homePageUrl: configs.home_page_url,
loading: false
})
}
handleFetchError()
For handling errors, weâll simply show the users an error message in the
SnackBar
.handleFetchError(err) {
console.log('Error fetching data:' + err)
this.setState({ confirmationSnackbarMessage: 'Error fetching data', confirmationSnackbarOpen: true })
}
8. Handle UIÂ Changes
We need to manage the state whenever the user opens the Drawer, moves onto another step, or if the browser width changes. First we'll handle the drawer toggle.
handleNavToggle() {
return this.setState({ navOpen: !this.state.navOpen })
}
Then, as long as the user isnât on the last step, weâll handle incrementing the step.
handleNextStep() {
const { stepIndex } = this.state
return (stepIndex < 3) ? this.setState({ stepIndex: stepIndex + 1}) : null
}
Finally, weâll simply change the state on resize if the window width is less than 768px.
resize() {
this.setState({ smallScreen: window.innerWidth < 768 })
}
9. Handle Setting Appointment Data
When the user selects appointment options in steps one and two, we need three simple setters to change the state to reflect those selections.
handleSetAppointmentDate(date) {
this.handleNextStep()
this.setState({ appointmentDate: date, confirmationTextVisible: true })
}
handleSetAppointmentSlot(slot) {
this.handleNextStep()
this.setState({ appointmentSlot: slot })
}
handleSetAppointmentMeridiem(meridiem) {
this.setState({ appointmentMeridiem: meridiem})
}
10. Handle Validations
We need to validate the userâs inputted email address, phone number, and we need to feed the
DatePicker
component a function to check which days should be disabled. Although naive, we'll be using regex's to check the inputs for simplicities sake.validateEmail(email) {
const regex = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i
return regex.test(email) ? this.setState({ email: email, validEmail: true }) : this.setState({ validEmail: false })
}
validatePhone(phoneNumber) {
const regex = /^(1\s|1|)?((\(\d{3}\))|\d{3})(\-|\s)?(\d{3})(\-|\s)?(\d{4})$/
return regex.test(phoneNumber) ? this.setState({ phone: phoneNumber, validPhone: true }) : this.setState({ validPhone: false })
}
For checking if a date should be disabled, we need to check if the date passed by
DatePicker
is either in state.schedule
or is today.checkDisableDate(day) {
const dateString = moment(day).format('YYYY-DD-MM')
return this.state.schedule[dateString] === true || moment(day).startOf('day').diff(moment().startOf('day')) < 0
}
11. Build the Render Methods for the confirmation strings and the time slot radio buttons
In our
render()
lifecycle method we abstracted out the logic for displaying the dynamic confirmation string above the Card
, the confirmation details we'll show in the confirmation modal, and the radio buttons for selecting a time slot.Starting with the confirmation string, weâll display the parts of it that correspond to inputted data only as itâs entered.
renderConfirmationString() {
const spanStyle = {color: '#00bcd4'}
return this.state.confirmationTextVisible ? <h2 style={{ textAlign: this.state.smallScreen ? 'center' : 'left', color: '#bdbdbd', lineHeight: 1.5, padding: '0 10px', fontFamily: 'Roboto'}}>
{ <span>
Scheduling a
<span style={spanStyle}> 1 hour </span>
appointment {this.state.appointmentDate && <span>
on <span style={spanStyle}>{moment(this.state.appointmentDate).format('dddd[,] MMMM Do')}</span>
</span>} {Number.isInteger(this.state.appointmentSlot) && <span>at <span style={spanStyle}>{moment().hour(9).minute(0).add(this.state.appointmentSlot, 'hours').format('h:mm a')}</span></span>}
</span>}
</h2> : null
}
Then, similarly, weâll let the user verify their data before confirming submission.
renderAppointmentConfirmation() {
const spanStyle = { color: '#00bcd4' }
return <section>
<p>Name: <span style={spanStyle}>{this.state.firstName} {this.state.lastName}</span></p>
<p>Number: <span style={spanStyle}>{this.state.phone}</span></p>
<p>Email: <span style={spanStyle}>{this.state.email}</span></p>
<p>Appointment: <span style={spanStyle}>{moment(this.state.appointmentDate).format('dddd[,] MMMM Do[,] YYYY')}</span> at <span style={spanStyle}>{moment().hour(9).minute(0).add(this.state.appointmentSlot, 'hours').format('h:mm a')}</span></p>
</section>
}
Finally, weâll write the method to render the appointment slot radio buttons. To do this we first have to filter the slots by availabity and by whether AM or PM is selected. Both are simple checks; for the first we see if it exists in
state.schedule
, for the later, we check the meridiem part with moment().format('a')
. To compute the time string in 12 hour format, we add the slot to 9AM in units of hours.renderAppointmentTimes() {
if (!this.state.loading) {
const slots = [...Array(8).keys()]
return slots.map(slot => {
const appointmentDateString = moment(this.state.appointmentDate).format('YYYY-DD-MM')
const t1 = moment().hour(9).minute(0).add(slot, 'hours')
const t2 = moment().hour(9).minute(0).add(slot + 1, 'hours')
const scheduleDisabled = this.state.schedule[appointmentDateString] ? this.state.schedule[moment(this.state.appointmentDate).format('YYYY-DD-MM')][slot] : false
const meridiemDisabled = this.state.appointmentMeridiem ? t1.format('a') === 'am' : t1.format('a') === 'pm'
return <RadioButton
label={t1.format('h:mm a') + ' - ' + t2.format('h:mm a')}
key={slot}
value={slot}
style={{marginBottom: 15, display: meridiemDisabled ? 'none' : 'inherit'}}
disabled={scheduleDisabled || meridiemDisabled}/>
})
} else {
return null
}
}
13. Handle the Form Submission
Once we show the user the confirmation modal, upon final submission weâll send the data to our backend with an
axios
POST. We'll notifty them of either a success or an error.handleSubmit() {
const appointment = {
date: moment(this.state.appointmentDate).format('YYYY-DD-MM'),
slot: this.state.appointmentSlot,
name: this.state.firstName + ' ' + this.state.lastName,
email: this.state.email,
phone: this.state.phone
}
axios.post(HOST + 'api/appointments', )
axios.post(HOST + 'api/appointments', appointment)
.then(response => this.setState({ confirmationSnackbarMessage: "Appointment succesfully added!", confirmationSnackbarOpen: true, processed: true }))
.catch(err => {
console.log(err)
return this.setState({ confirmationSnackbarMessage: "Appointment failed to save.", confirmationSnackbarOpen: true })
})
}
14. Conclusion: Seeing it All Together
Before moving on to building the backend, hereâs what we have as our final product.
// ./src/Components/App.js
import { Component } from 'react'
import injectTapEventPlugin from 'react-tap-event-plugin'
import axios from 'axios'
import async from 'async'
import moment from 'moment'
import AppBar from 'material-ui/AppBar'
import Drawer from 'material-ui/Drawer'
import Dialog from 'material-ui/Dialog'
import Divider from 'material-ui/Divider'
import MenuItem from 'material-ui/MenuItem'
import Card from 'material-ui/Card'
import DatePicker from 'material-ui/DatePicker'
import TimePicker from 'material-ui/TimePicker'
import TextField from 'material-ui/TextField'
import SelectField from 'material-ui/SelectField'
import SnackBar from 'material-ui/Snackbar'
import {
Step,
Stepper,
StepLabel,
StepContent,
StepButton
} from 'material-ui/stepper'
import {
RadioButton,
RadioButtonGroup
} from 'material-ui/RadioButton'
import RaisedButton from 'material-ui/RaisedButton';
import FlatButton from 'material-ui/FlatButton'
import logo from './../../dist/assets/logo.svg'
injectTapEventPlugin()
const HOST = PRODUCTION ? '/' : 'http://localhost:3000/'
export default class App extends Component {
constructor() {
super()
this.state = {
loading: true,
navOpen: false,
confirmationModalOpen: false,
confirmationTextVisible: false,
stepIndex: 0,
appointmentDateSelected: false,
appointmentMeridiem: 0,
validEmail: true,
validPhone: true,
smallScreen: window.innerWidth < 768,
confirmationSnackbarOpen: false
}
this.handleNavToggle = this.handleNavToggle.bind(this)
this.handleNextStep = this.handleNextStep.bind(this)
this.handleSetAppointmentDate = this.handleSetAppointmentDate.bind(this)
this.handleSetAppointmentSlot = this.handleSetAppointmentSlot.bind(this)
this.handleSetAppointmentMeridiem = this.handleSetAppointmentMeridiem.bind(this)
this.handleSubmit = this.handleSubmit.bind(this)
this.validateEmail = this.validateEmail.bind(this)
this.validatePhone = this.validatePhone.bind(this)
this.checkDisableDate = this.checkDisableDate.bind(this)
this.renderAppointmentTimes = this.renderAppointmentTimes.bind(this)
this.renderConfirmationString = this.renderConfirmationString.bind(this)
this.renderAppointmentConfirmation = this.renderAppointmentConfirmation.bind(this)
this.resize = this.resize.bind(this)
}
handleNavToggle() {
return this.setState({ navOpen: !this.state.navOpen })
}
handleNextStep() {
const { stepIndex } = this.state
return (stepIndex < 3) ? this.setState({ stepIndex: stepIndex + 1}) : null
}
handleSetAppointmentDate(date) {
this.handleNextStep()
this.setState({ appointmentDate: date, confirmationTextVisible: true })
}
handleSetAppointmentSlot(slot) {
this.handleNextStep()
this.setState({ appointmentSlot: slot })
}
handleSetAppointmentMeridiem(meridiem) {
this.setState({ appointmentMeridiem: meridiem})
}
handleFetch(response) {
const { configs, appointments } = response
const initSchedule = {}
const today = moment().startOf('day')
initSchedule[today.format('YYYY-DD-MM')] = true
const schedule = !appointments.length ? initSchedule : appointments.reduce((currentSchedule, appointment) => {
const { date, slot } = appointment
const dateString = moment(date, 'YYYY-DD-MM').format('YYYY-DD-MM')
!currentSchedule[date] ? currentSchedule[dateString] = Array(8).fill(false) : null
Array.isArray(currentSchedule[dateString]) ?
currentSchedule[dateString][slot] = true : null
return currentSchedule
}, initSchedule)
for (let day in schedule) {
let slots = schedule[day]
slots.length ? (slots.every(slot => slot === true)) ? schedule[day] = true : null : null
}
this.setState({
schedule,
siteTitle: configs.site_title,
aboutPageUrl: configs.about_page_url,
contactPageUrl: configs.contact_page_url,
homePageUrl: configs.home_page_url,
loading: false
})
}
handleFetchError(err) {
console.log('Error fetching data:' + err)
this.setState({ confirmationSnackbarMessage: 'Error fetching data', confirmationSnackbarOpen: true })
}
handleSubmit() {
const appointment = {
date: moment(this.state.appointmentDate).format('YYYY-DD-MM'),
slot: this.state.appointmentSlot,
name: this.state.firstName + ' ' + this.state.lastName,
email: this.state.email,
phone: this.state.phone
}
axios.post(HOST + 'api/appointments', )
axios.post(HOST + 'api/appointments', appointment)
.then(response => this.setState({ confirmationSnackbarMessage: "Appointment succesfully added!", confirmationSnackbarOpen: true, processed: true }))
.catch(err => {
console.log(err)
return this.setState({ confirmationSnackbarMessage: "Appointment failed to save.", confirmationSnackbarOpen: true })
})
}
validateEmail(email) {
const regex = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i
return regex.test(email) ? this.setState({ email: email, validEmail: true }) : this.setState({ validEmail: false })
}
validatePhone(phoneNumber) {
const regex = /^(1\s|1|)?((\(\d{3}\))|\d{3})(\-|\s)?(\d{3})(\-|\s)?(\d{4})$/
return regex.test(phoneNumber) ? this.setState({ phone: phoneNumber, validPhone: true }) : this.setState({ validPhone: false })
}
checkDisableDate(day) {
const dateString = moment(day).format('YYYY-DD-MM')
return this.state.schedule[dateString] === true || moment(day).startOf('day').diff(moment().startOf('day')) < 0
}
renderConfirmationString() {
const spanStyle = {color: '#00bcd4'}
return this.state.confirmationTextVisible ? <h2 style={{ textAlign: this.state.smallScreen ? 'center' : 'left', color: '#bdbdbd', lineHeight: 1.5, padding: '0 10px', fontFamily: 'Roboto'}}>
{ <span>
Scheduling a
<span style={spanStyle}> 1 hour </span>
appointment {this.state.appointmentDate && <span>
on <span style={spanStyle}>{moment(this.state.appointmentDate).format('dddd[,] MMMM Do')}</span>
</span>} {Number.isInteger(this.state.appointmentSlot) && <span>at <span style={spanStyle}>{moment().hour(9).minute(0).add(this.state.appointmentSlot, 'hours').format('h:mm a')}</span></span>}
</span>}
</h2> : null
}
renderAppointmentTimes() {
if (!this.state.loading) {
const slots = [...Array(8).keys()]
return slots.map(slot => {
const appointmentDateString = moment(this.state.appointmentDate).format('YYYY-DD-MM')
const t1 = moment().hour(9).minute(0).add(slot, 'hours')
const t2 = moment().hour(9).minute(0).add(slot + 1, 'hours')
const scheduleDisabled = this.state.schedule[appointmentDateString] ? this.state.schedule[moment(this.state.appointmentDate).format('YYYY-DD-MM')][slot] : false
const meridiemDisabled = this.state.appointmentMeridiem ? t1.format('a') === 'am' : t1.format('a') === 'pm'
return <RadioButton
label={t1.format('h:mm a') + ' - ' + t2.format('h:mm a')}
key={slot}
value={slot}
style={{marginBottom: 15, display: meridiemDisabled ? 'none' : 'inherit'}}
disabled={scheduleDisabled || meridiemDisabled}/>
})
} else {
return null
}
}
renderAppointmentConfirmation() {
const spanStyle = { color: '#00bcd4' }
return <section>
<p>Name: <span style={spanStyle}>{this.state.firstName} {this.state.lastName}</span></p>
<p>Number: <span style={spanStyle}>{this.state.phone}</span></p>
<p>Email: <span style={spanStyle}>{this.state.email}</span></p>
<p>Appointment: <span style={spanStyle}>{moment(this.state.appointmentDate).format('dddd[,] MMMM Do[,] YYYY')}</span> at <span style={spanStyle}>{moment().hour(9).minute(0).add(this.state.appointmentSlot, 'hours').format('h:mm a')}</span></p>
</section>
}
resize() {
this.setState({ smallScreen: window.innerWidth < 768 })
}
componentWillMount() {
async.series({
configs(callback) {
axios.get(HOST + 'api/config').then(res =>
callback(null, res.data.data)
)
},
appointments(callback) {
axios.get(HOST + 'api/appointments').then(res => {
callback(null, res.data.data)
})
}
}, (err,response) => {
err ? this.handleFetchError(err) : this.handleFetch(response)
})
addEventListener('resize', this.resize)
}
componentWillUnmount() {
removeEventListener('resize', this.resize)
}
render() {
const { stepIndex, loading, navOpen, smallScreen, confirmationModalOpen, confirmationSnackbarOpen, ...data } = this.state
const contactFormFilled = data.firstName && data.lastName && data.phone && data.email && data.validPhone && data.validEmail
const modalActions = [
<FlatButton
label="Cancel"
primary={false}
onClick={() => this.setState({ confirmationModalOpen : false})} />,
<FlatButton
label="Confirm"
primary={true}
onClick={() => this.handleSubmit()} />
]
return (
<div>
<AppBar
title={data.siteTitle}
onLeftIconButtonTouchTap={() => this.handleNavToggle() }/>
<Drawer
docked={false}
width={300}
open={navOpen}
onRequestChange={(navOpen) => this.setState({navOpen})} >
<img src={logo}
style={{
height: 70,
marginTop: 50,
marginBottom: 30,
marginLeft: '50%',
transform: 'translateX(-50%)'
}}/>
<a style={{textDecoration: 'none'}} href={this.state.homePageUrl}><MenuItem>Home</MenuItem></a>
<a style={{textDecoration: 'none'}} href={this.state.aboutPageUrl}><MenuItem>About</MenuItem></a>
<a style={{textDecoration: 'none'}} href={this.state.contactPageUrl}><MenuItem>Contact</MenuItem></a>
<MenuItem disabled={true}
style={{
marginLeft: '50%',
transform: 'translate(-50%)'
}}>
{"© Copyright " + moment().format('YYYY')}</MenuItem>
</Drawer>
<section style={{
maxWidth: !smallScreen ? '80%' : '100%',
margin: 'auto',
marginTop: !smallScreen ? 20 : 0,
}}>
{this.renderConfirmationString()}
<Card style={{
padding: '10px 10px 25px 10px',
height: smallScreen ? '100vh' : null
}}>
<Stepper
activeStep={stepIndex}
linear={false}
orientation="vertical">
<Step disabled={loading}>
<StepButton onClick={() => this.setState({ stepIndex: 0 })}>
Choose an available day for your appointment
</StepButton>
<StepContent>
<DatePicker
style={{
marginTop: 10,
marginLeft: 10
}}
value={data.appointmentDate}
hintText="Select a date"
mode={smallScreen ? 'portrait' : 'landscape'}
onChange={(n, date) => this.handleSetAppointmentDate(date)}
shouldDisableDate={day => this.checkDisableDate(day)}
/>
</StepContent>
</Step>
<Step disabled={ !data.appointmentDate }>
<StepButton onClick={() => this.setState({ stepIndex: 1 })}>
Choose an available time for your appointment
</StepButton>
<StepContent>
<SelectField
floatingLabelText="AM or PM"
value={data.appointmentMeridiem}
onChange={(evt, key, payload) => this.handleSetAppointmentMeridiem(payload)}
selectionRenderer={value => value ? 'PM' : 'AM'}>
<MenuItem value={0}>AM</MenuItem>
<MenuItem value={1}>PM</MenuItem>
</SelectField>
<RadioButtonGroup
style={{ marginTop: 15,
marginLeft: 15
}}
name="appointmentTimes"
defaultSelected={data.appointmentSlot}
onChange={(evt, val) => this.handleSetAppointmentSlot(val)}>
{this.renderAppointmentTimes()}
</RadioButtonGroup>
</StepContent>
</Step>
<Step disabled={ !Number.isInteger(this.state.appointmentSlot) }>
<StepButton onClick={() => this.setState({ stepIndex: 2 })}>
Share your contact information with us and we'll send you a reminder
</StepButton>
<StepContent>
<section>
<TextField
style={{ display: 'block' }}
name="first_name"
hintText="First Name"
floatingLabelText="First Name"
onChange={(evt, newValue) => this.setState({ firstName: newValue })}/>
<TextField
style={{ display: 'block' }}
name="last_name"
hintText="Last Name"
floatingLabelText="Last Name"
onChange={(evt, newValue) => this.setState({ lastName: newValue })}/>
<TextField
style={{ display: 'block' }}
name="email"
hintText="[email protected]"
floatingLabelText="Email"
errorText={data.validEmail ? null : 'Enter a valid email address'}
onChange={(evt, newValue) => this.validateEmail(newValue)}/>
<TextField
style={{ display: 'block' }}
name="phone"
hintText="(888) 888-8888"
floatingLabelText="Phone"
errorText={data.validPhone ? null: 'Enter a valid phone number'}
onChange={(evt, newValue) => this.validatePhone(newValue)} />
<RaisedButton
style={{ display: 'block' }}
label={contactFormFilled ? 'Schedule' : 'Fill out your information to schedule'}
labelPosition="before"
primary={true}
fullWidth={true}
onClick={() => this.setState({ confirmationModalOpen: !this.state.confirmationModalOpen })}
disabled={!contactFormFilled || data.processed }
style={{ marginTop: 20, maxWidth: 100}} />
</section>
</StepContent>
</Step>
</Stepper>
</Card>
<Dialog
modal={true}
open={confirmationModalOpen}
actions={modalActions}
title="Confirm your appointment">
{this.renderAppointmentConfirmation()}
</Dialog>
<SnackBar
open={confirmationSnackbarOpen || loading}
message={loading ? 'Loading... ' : data.confirmationSnackbarMessage || ''}
autoHideDuration={10000}
onRequestClose={() => this.setState({ confirmationSnackbarOpen: false })} />
</section>
</div>
)
}
}
<code class="language-markup">import { Component } from 'react'
import injectTapEventPlugin from 'react-tap-event-plugin'
import axios from 'axios'
import async from 'async'
import moment from 'moment'
import AppBar from 'material-ui/AppBar'
import Drawer from 'material-ui/Drawer'
import Dialog from 'material-ui/Dialog'
import Divider from 'material-ui/Divider'
import MenuItem from 'material-ui/MenuItem'
import Card from 'material-ui/Card'
import DatePicker from 'material-ui/DatePicker'
import TimePicker from 'material-ui/TimePicker'
import TextField from 'material-ui/TextField'
import SelectField from 'material-ui/SelectField'
import SnackBar from 'material-ui/Snackbar'
import {
Step,
Stepper,
StepLabel,
StepContent,
StepButton
} from 'material-ui/stepper'
import {
RadioButton,
RadioButtonGroup
} from 'material-ui/RadioButton'
import RaisedButton from 'material-ui/RaisedButton';
import FlatButton from 'material-ui/FlatButton'
import logo from './../../dist/assets/logo.svg'</code>
<code class="language-markup">injectTapEventPlugin()
const HOST = PRODUCTION ? '/' : 'http://localhost:3000/'</code>
<code class="language-markup">export default class App extends Component {
constructor() {
super()
this.state = {
loading: true,
navOpen: false,
confirmationModalOpen: false,
confirmationTextVisible: false,
stepIndex: 0,
appointmentDateSelected: false,
appointmentMeridiem: 0,
validEmail: true,
validPhone: true,
smallScreen: window.innerWidth < 768,
confirmationSnackbarOpen: false
}</code>
<code class="language-markup"> this.handleNavToggle = this.handleNavToggle.bind(this)
this.handleNextStep = this.handleNextStep.bind(this)
this.handleSetAppointmentDate = this.handleSetAppointmentDate.bind(this)
this.handleSetAppointmentSlot = this.handleSetAppointmentSlot.bind(this)
this.handleSetAppointmentMeridiem = this.handleSetAppointmentMeridiem.bind(this)
this.handleSubmit = this.handleSubmit.bind(this)
this.validateEmail = this.validateEmail.bind(this)
this.validatePhone = this.validatePhone.bind(this)
this.checkDisableDate = this.checkDisableDate.bind(this)
this.renderAppointmentTimes = this.renderAppointmentTimes.bind(this)
this.renderConfirmationString = this.renderConfirmationString.bind(this)
this.renderAppointmentConfirmation = this.renderAppointmentConfirmation.bind(this)
this.resize = this.resize.bind(this)
}</code>
<code class="language-markup"> handleNavToggle() {
return this.setState({ navOpen: !this.state.navOpen })
}</code>
<code class="language-markup"> handleNextStep() {
const { stepIndex } = this.state
return (stepIndex < 3) ? this.setState({ stepIndex: stepIndex + 1}) : null
}</code>
<code class="language-markup"> handleSetAppointmentDate(date) {
this.handleNextStep()
this.setState({ appointmentDate: date, confirmationTextVisible: true })
}</code>
<code class="language-markup"> handleSetAppointmentSlot(slot) {
this.handleNextStep()
this.setState({ appointmentSlot: slot })
}</code>
<code class="language-markup"> handleSetAppointmentMeridiem(meridiem) {
this.setState({ appointmentMeridiem: meridiem})
}</code>
<code class="language-markup"> handleFetch(response) {
const { configs, appointments } = response
const initSchedule = {}
const today = moment().startOf('day')
initSchedule[today.format('YYYY-DD-MM')] = true
const schedule = !appointments.length ? initSchedule : appointments.reduce((currentSchedule, appointment) => {
const { date, slot } = appointment
const dateString = moment(date, 'YYYY-DD-MM').format('YYYY-DD-MM')
!currentSchedule[date] ? currentSchedule[dateString] = Array(8).fill(false) : null
Array.isArray(currentSchedule[dateString]) ?
currentSchedule[dateString][slot] = true : null
return currentSchedule
}, initSchedule)</code>
<code class="language-markup"> for (let day in schedule) {
let slots = schedule[day]
slots.length ? (slots.every(slot => slot === true)) ? schedule[day] = true : null : null
}</code>
<code class="language-markup"> this.setState({
schedule,
siteTitle: configs.site_title,
aboutPageUrl: configs.about_page_url,
contactPageUrl: configs.contact_page_url,
homePageUrl: configs.home_page_url,
loading: false
})
}</code>
<code class="language-markup"> handleFetchError(err) {
console.log('Error fetching data:' + err)
this.setState({ confirmationSnackbarMessage: 'Error fetching data', confirmationSnackbarOpen: true })
}</code>
<code class="language-markup"> handleSubmit() {
const appointment = {
date: moment(this.state.appointmentDate).format('YYYY-DD-MM'),
slot: this.state.appointmentSlot,
name: this.state.firstName + ' ' + this.state.lastName,
email: this.state.email,
phone: this.state.phone
}
axios.post(HOST + 'api/appointments', )
axios.post(HOST + 'api/appointments', appointment)
.then(response => this.setState({ confirmationSnackbarMessage: "Appointment succesfully added!", confirmationSnackbarOpen: true, processed: true }))
.catch(err => {
console.log(err)
return this.setState({ confirmationSnackbarMessage: "Appointment failed to save.", confirmationSnackbarOpen: true })
})
}</code>
<code class="language-markup"> validateEmail(email) {
const regex = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i
return regex.test(email) ? this.setState({ email: email, validEmail: true }) : this.setState({ validEmail: false })
}</code>
<code class="language-markup"> validatePhone(phoneNumber) {
const regex = /^(1\s|1|)?((\(\d{3}\))|\d{3})(\-|\s)?(\d{3})(\-|\s)?(\d{4})$/
return regex.test(phoneNumber) ? this.setState({ phone: phoneNumber, validPhone: true }) : this.setState({ validPhone: false })
}</code>
<code class="language-markup"> checkDisableDate(day) {
const dateString = moment(day).format('YYYY-DD-MM')
return this.state.schedule[dateString] === true || moment(day).startOf('day').diff(moment().startOf('day')) < 0
}</code>
<code class="language-markup"> renderConfirmationString() {
const spanStyle = {color: '#00bcd4'}
return this.state.confirmationTextVisible ? <h2 style={{ textAlign: this.state.smallScreen ? 'center' : 'left', color: '#bdbdbd', lineHeight: 1.5, padding: '0 10px', fontFamily: 'Roboto'}}>
{ <span>
Scheduling a</code>
<code class="language-markup"> <span style={spanStyle}> 1 hour </span></code>
<code class="language-markup"> appointment {this.state.appointmentDate && <span>
on <span style={spanStyle}>{moment(this.state.appointmentDate).format('dddd[,] MMMM Do')}</span>
</span>} {Number.isInteger(this.state.appointmentSlot) && <span>at <span style={spanStyle}>{moment().hour(9).minute(0).add(this.state.appointmentSlot, 'hours').format('h:mm a')}</span></span>}
</span>}
</h2> : null
}</code>
<code class="language-markup"> renderAppointmentTimes() {
if (!this.state.loading) {
const slots = [...Array(8).keys()]
return slots.map(slot => {
const appointmentDateString = moment(this.state.appointmentDate).format('YYYY-DD-MM')
const t1 = moment().hour(9).minute(0).add(slot, 'hours')
const t2 = moment().hour(9).minute(0).add(slot + 1, 'hours')
const scheduleDisabled = this.state.schedule[appointmentDateString] ? this.state.schedule[moment(this.state.appointmentDate).format('YYYY-DD-MM')][slot] : false
const meridiemDisabled = this.state.appointmentMeridiem ? t1.format('a') === 'am' : t1.format('a') === 'pm'
return <RadioButton
label={t1.format('h:mm a') + ' - ' + t2.format('h:mm a')}
key={slot}
value={slot}
style={{marginBottom: 15, display: meridiemDisabled ? 'none' : 'inherit'}}
disabled={scheduleDisabled || meridiemDisabled}/>
})
} else {
return null
}
}</code>
<code class="language-markup"> renderAppointmentConfirmation() {
const spanStyle = { color: '#00bcd4' }
return <section>
<p>Name: <span style={spanStyle}>{this.state.firstName} {this.state.lastName}</span></p>
<p>Number: <span style={spanStyle}>{this.state.phone}</span></p>
<p>Email: <span style={spanStyle}>{this.state.email}</span></p>
<p>Appointment: <span style={spanStyle}>{moment(this.state.appointmentDate).format('dddd[,] MMMM Do[,] YYYY')}</span> at <span style={spanStyle}>{moment().hour(9).minute(0).add(this.state.appointmentSlot, 'hours').format('h:mm a')}</span></p>
</section>
}</code>
<code class="language-markup"> resize() {
this.setState({ smallScreen: window.innerWidth < 768 })
}</code>
<code class="language-markup"> componentWillMount() {
async.series({
configs(callback) {
axios.get(HOST + 'api/config').then(res =>
callback(null, res.data.data)
)
},
appointments(callback) {
axios.get(HOST + 'api/appointments').then(res => {
callback(null, res.data.data)
})
}
}, (err,response) => {
err ? this.handleFetchError(err) : this.handleFetch(response)
})
addEventListener('resize', this.resize)
}</code>
<code class="language-markup"> componentWillUnmount() {
removeEventListener('resize', this.resize)
}</code>
<code class="language-markup"> render() {
const { stepIndex, loading, navOpen, smallScreen, confirmationModalOpen, confirmationSnackbarOpen, ...data } = this.state
const contactFormFilled = data.firstName && data.lastName && data.phone && data.email && data.validPhone && data.validEmail
const modalActions = [
<FlatButton
label="Cancel"
primary={false}
onClick={() => this.setState({ confirmationModalOpen : false})} />,
<FlatButton
label="Confirm"
primary={true}
onClick={() => this.handleSubmit()} />
]
return (
<div>
<AppBar
title={data.siteTitle}
onLeftIconButtonTouchTap={() => this.handleNavToggle() }/>
<Drawer
docked={false}
width={300}
open={navOpen}
onRequestChange={(navOpen) => this.setState({navOpen})} >
<img src={logo}
style={{
height: 70,
marginTop: 50,
marginBottom: 30,
marginLeft: '50%',
transform: 'translateX(-50%)'
}}/>
<a style={{textDecoration: 'none'}} href={this.state.homePageUrl}><MenuItem>Home</MenuItem></a>
<a style={{textDecoration: 'none'}} href={this.state.aboutPageUrl}><MenuItem>About</MenuItem></a>
<a style={{textDecoration: 'none'}} href={this.state.contactPageUrl}><MenuItem>Contact</MenuItem></a></code>
<code class="language-markup"> <MenuItem disabled={true}
style={{
marginLeft: '50%',
transform: 'translate(-50%)'
}}>
{"© Copyright " + moment().format('YYYY')}</MenuItem>
</Drawer>
<section style={{
maxWidth: !smallScreen ? '80%' : '100%',
margin: 'auto',
marginTop: !smallScreen ? 20 : 0,
}}>
{this.renderConfirmationString()}
<Card style={{
padding: '10px 10px 25px 10px',
height: smallScreen ? '100vh' : null
}}>
<Stepper
activeStep={stepIndex}
linear={false}
orientation="vertical">
<Step disabled={loading}>
<StepButton onClick={() => this.setState({ stepIndex: 0 })}>
Choose an available day for your appointment
</StepButton>
<StepContent>
<DatePicker
style={{
marginTop: 10,
marginLeft: 10
}}
value={data.appointmentDate}
hintText="Select a date"
mode={smallScreen ? 'portrait' : 'landscape'}
onChange={(n, date) => this.handleSetAppointmentDate(date)}
shouldDisableDate={day => this.checkDisableDate(day)}
/>
</StepContent>
</Step>
<Step disabled={ !data.appointmentDate }>
<StepButton onClick={() => this.setState({ stepIndex: 1 })}>
Choose an available time for your appointment
</StepButton>
<StepContent>
<SelectField
floatingLabelText="AM or PM"
value={data.appointmentMeridiem}
onChange={(evt, key, payload) => this.handleSetAppointmentMeridiem(payload)}
selectionRenderer={value => value ? 'PM' : 'AM'}>
<MenuItem value={0}>AM</MenuItem>
<MenuItem value={1}>PM</MenuItem>
</SelectField>
<RadioButtonGroup
style={{ marginTop: 15,
marginLeft: 15
}}
name="appointmentTimes"
defaultSelected={data.appointmentSlot}
onChange={(evt, val) => this.handleSetAppointmentSlot(val)}>
{this.renderAppointmentTimes()}
</RadioButtonGroup>
</StepContent>
</Step>
<Step disabled={ !Number.isInteger(this.state.appointmentSlot) }>
<StepButton onClick={() => this.setState({ stepIndex: 2 })}>
Share your contact information with us and we'll send you a reminder
</StepButton>
<StepContent>
<section>
<TextField
style={{ display: 'block' }}
name="first_name"
hintText="First Name"
floatingLabelText="First Name"
onChange={(evt, newValue) => this.setState({ firstName: newValue })}/>
<TextField
style={{ display: 'block' }}
name="last_name"
hintText="Last Name"
floatingLabelText="Last Name"
onChange={(evt, newValue) => this.setState({ lastName: newValue })}/>
<TextField
style={{ display: 'block' }}
name="email"
hintText="[email protected]"
floatingLabelText="Email"
errorText={data.validEmail ? null : 'Enter a valid email address'}
onChange={(evt, newValue) => this.validateEmail(newValue)}/>
<TextField
style={{ display: 'block' }}
name="phone"
hintText="(888) 888-8888"
floatingLabelText="Phone"
errorText={data.validPhone ? null: 'Enter a valid phone number'}
onChange={(evt, newValue) => this.validatePhone(newValue)} />
<RaisedButton
style={{ display: 'block' }}
label={contactFormFilled ? 'Schedule' : 'Fill out your information to schedule'}
labelPosition="before"
primary={true}
fullWidth={true}
onClick={() => this.setState({ confirmationModalOpen: !this.state.confirmationModalOpen })}
disabled={!contactFormFilled || data.processed }
style={{ marginTop: 20, maxWidth: 100}} />
</section>
</StepContent>
</Step>
</Stepper>
</Card>
<Dialog
modal={true}
open={confirmationModalOpen}
actions={modalActions}
title="Confirm your appointment">
{this.renderAppointmentConfirmation()}
</Dialog>
<SnackBar
open={confirmationSnackbarOpen || loading}
message={loading ? 'Loading... ' : data.confirmationSnackbarMessage || ''}
autoHideDuration={10000}
onRequestClose={() => this.setState({ confirmationSnackbarOpen: false })} />
</section>
</div>
)
}
}</code>
Part 2: Building the Backend
1. Installations and Directory Structure
Our backend will be simple. All it needs to do is act as an intermediary between our frontend and Cosmic and handle interfacing with Twilio.
First, weâll get our directory structure in place.
AppointmentScheduler
|
|--public
|--app.js
|--.gitignore
|--package.json
public
will be where we serve our built frontend from and .gitignore
will hide node_modules
.# .gitignore
node_modules
Weâll use the following packages:
for pushing Appointment objects to Cosmicaxios
,body-parser
,cors
,http
,morgan
, andpath
for server-wareexpress
- the official client, for fetching objects from Cosmiccosmicjs
- the official client, for sending confirmation textstwilio
for parsing timesmoment
Run
yarn init
, then edit package.json
with a start script so we can deploy on Cosmic.{
// etc...
"scripts": {
"start": "node app.js"
}
}
Then, before we start working:
yarn add axios body-parser cors cosmicjs express express-session http moment morgan path twilio
2. Outline the Backendâs Structure
Our Express app will be fairly basic as far as configuration and middleware goes. For
Appointment
submissions we'll handle post requests at /api/appointments
. We'll serve our site configs and appointments from /api/config
and /api/appointments
respectively. Finally, since our Frontend is an SPA, we'll serve index.html
from /
and redirect all other requests there.Before getting into much logic, our server will start off looking like this:
const express = require('express')
const path = require('path')
const morgan = require('morgan')
const bodyParser = require('body-parser')
const cors = require('cors')
const config = require('./config')
const http = require('http')
const Cosmic = require('cosmicjs')
const twilio = require('twilio')
const moment = require('moment')
const axios = require('axios')
const config = {
bucket: {
slug: process.env.COSMIC_BUCKET,
read_key: process.env.COSMIC_READ_KEY,
write_key: process.env.COSMIC_WRITE_KEY
},
twilio: {
auth: process.env.TWILIO_AUTH,
sid: process.env.TWILIO_SID,
number: process.env.TWILIO_NUMBER
}
}
const app = express()
const env = process.env.NODE_ENV || 'development'
const twilioSid = config.twilio.sid
const twilioAuth = config.twilio.auth
const twilioClient = twilio(twilioSid, twilioAuth)
const twilioNumber = config.twilio.number
app.set('trust proxy', 1)
app.use(session({
secret: 'sjcimsoc',
resave: false,
saveUninitialized: true,
cookie: { secure: false }
}))
app.use(cors())
app.use(morgan('dev'))
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
app.use(express.static(path.join(__dirname, 'public')))
app.set('port', process.env.PORT || 3000)
app.post('/api/appointments', (req, res) => {
//handle posting new appointments to Cosmic
//and sending a confirmation text with Twilio
})
app.get('/api/config', (req, res) => {
//fetch configs from Cosmic, expose to frontend
})
app.get('/api/appointments', (req, res) => {
//fetch appointments from Cosmic, expose to frontend without personal data
})
app.get('/', (req, res) => {
res.send('index.html')
})
app.get('*', (req, res) => {
res.redirect('/')
})
http.createServer(app).listen(app.get('port'), () =>
console.log('Server running at: ' + app.get('port'))
)
Note: weâll provide all
process.env
variables at deployment with Cosmic. Cosmic-specific variables are supplied automatically.3. Handle Post Requests
Two things need to happen here. Weâll use the official Twilio client to send a text to the user, and weâll use
axios
to make a POST request to the Cosmic JS API. Before doing both, we'll strip the user-inputted phone number of any non-digits and compute the time from the selected slot. We have:
app.post('/api/appointments', (req, res) => {
const appointment = req.body
appointment.phone = appointment.phone.replace(/\D/g,'')
const date = moment(appointment.date, 'YYYY-DD-MM').startOf('day')
const time = date.hour(9).add(appointment.slot, 'hours')
const smsBody = `${appointment.name}, this message is to confirm your appointment at ${time.format('h:mm a')} on ${date.format('dddd MMMM Do[,] YYYY')}.`
//send confirmation message to user
twilioClient.messages.create({
to: '+1' + appointment.phone,
from: twilioNumber,
body: smsBody
}, (err, message) => console.log(message, err))
//push to cosmic
const cosmicObject = {
"title": appointment.name,
"type_slug": "appointments",
"write_key": config.bucket.write_key,
"metafields": [
{
"key": "date",
"type": "text",
"value": date.format('YYYY-DD-MM')
},
{
"key": "slot",
"type": "text",
"value": appointment.slot
},
{
"key": "email",
"type": "text",
"value": appointment.email
},{
"key": "phone",
"type": "text",
"value": appointment.phone //which is now stripped of all non-digits
}
]
}
axios.post(`https://api.cosmicjs.com/v1/${config.bucket.slug}/add-object`, cosmicObject)
.then(response => res.json({ data: 'success' })).catch(err => res.json({ data: 'error '}))
})
4. Expose Site Configs
Weâll simply use
cosmicjs
to get the site-config
object we need for the frontend to display links in the navigation.app.get('/api/config', (req,res) => {
Cosmic.getObject(config, { slug: 'site-config' }, (err, response) => {
const data = response.object.metadata
err ? res.status(500).json({ data: 'error' }) : res.json({ data })
})
})
5. Expose Appointments
Where itâs definitely redundant to expose the site configurations through our backends API, its definitely important that weâre doing it with the
Appointment
objects. First, we can conveniently reorganize the data to expose only what we need, but second, and infinitely more important, we don't publicly expose our users' personal information. We'll use cosmicjs
to fetch all Appointment
objects, but only expose an array of objects with the form { date, slot }
.app.get('/api/appointments', (req, res) => {
Cosmic.getObjectType(config, { type_slug: 'appointments' }, (err, response) => {
const appointments = response.objects.all ? response.objects.all.map(appointment => {
return {
date: appointment.metadata.date,
slot: appointment.metadata.slot
}
}) : {}
res.json({ data: appointments })
})
})
6. The Finished Product
Within minutes, entirely thanks to the simplicity of Express, CosmicJsâs official client, and Twilioâs official client, we have a backend that does everything we wanted it to do and nothing more. Pure zen.
const express = require('express')
const path = require('path')
const morgan = require('morgan')
const bodyParser = require('body-parser')
const cors = require('cors')
const config = require('./config')
const http = require('http')
const Cosmic = require('cosmicjs')
const twilio = require('twilio')
const moment = require('moment')
const axios = require('axios')
const config = {
bucket: {
slug: process.env.COSMIC_BUCKET,
read_key: process.env.COSMIC_READ_KEY,
write_key: process.env.COSMIC_WRITE_KEY
},
twilio: {
auth: process.env.TWILIO_AUTH,
sid: process.env.TWILIO_SID,
number: process.env.TWILIO_NUMBER
}
}
const app = express()
const env = process.env.NODE_ENV || 'development'
const twilioSid = config.twilio.sid
const twilioAuth = config.twilio.auth
const twilioClient = twilio(twilioSid, twilioAuth)
const twilioNumber = config.twilio.number
app.set('trust proxy', 1)
app.use(session({
secret: 'sjcimsoc',
resave: false,
saveUninitialized: true,
cookie: { secure: false }
}))
app.use(cors())
app.use(morgan('dev'))
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
app.use(express.static(path.join(__dirname, 'public')))
app.set('port', process.env.PORT || 3000)
app.post('/api/appointments', (req, res) => {
const appointment = req.body
appointment.phone = appointment.phone.replace(/\D/g,'')
const date = moment(appointment.date, 'YYYY-DD-MM').startOf('day')
const time = date.hour(9).add(appointment.slot, 'hours')
const smsBody = `${appointment.name}, this message is to confirm your appointment at ${time.format('h:mm a')} on ${date.format('dddd MMMM Do[,] YYYY')}.`
//send confirmation message to user
twilioClient.messages.create({
to: '+1' + appointment.phone,
from: twilioNumber,
body: smsBody
}, (err, message) => console.log(message, err))
//push to cosmic
const cosmicObject = {
"title": appointment.name,
"type_slug": "appointments",
"write_key": config.bucket.write_key,
"metafields": [
{
"key": "date",
"type": "text",
"value": date.format('YYYY-DD-MM')
},
{
"key": "slot",
"type": "text",
"value": appointment.slot
},
{
"key": "email",
"type": "text",
"value": appointment.email
},{
"key": "phone",
"type": "text",
"value": appointment.phone //which is now stripped of all non-digits
}
]
}
axios.post(`https://api.cosmicjs.com/v1/${config.bucket.slug}/add-object`, cosmicObject)
.then(response => res.json({ data: 'success' })).catch(err => res.json({ data: 'error '}))
})
app.get('/api/config', (req,res) => {
Cosmic.getObject(config, { slug: 'site-config' }, (err, response) => {
const data = response.object.metadata
err ? res.status(500).json({ data: 'error' }) : res.json({ data })
})
})
app.get('/api/appointments', (req, res) => {
Cosmic.getObjectType(config, { type_slug: 'appointments' }, (err, response) => {
const appointments = response.objects.all ? response.objects.all.map(appointment => {
return {
date: appointment.metadata.date,
slot: appointment.metadata.slot
}
}) : {}
res.json({ data: appointments })
})
})
app.get('/', (req, res) => {
res.send('index.html')
})
app.get('*', (req, res) => {
res.redirect('/')
})
http.createServer(app).listen(app.get('port'), () =>
console.log('Server running at: ' + app.get('port'))
)
Part 3: Build and Deploy
Before we build an extension to manage our Appointments, weâll bundle the frontend and deploy the app to Cosmic so we can have even have some appointments to manage.
In the frontend directory,
appointment-scheduler
, run webpack
to build out into dist
. Then move the contents of dist
to the backend's public folder - AppointmentScheduler/public
. The index.html
that Webpack builds will then be the index.html
we serve from /
.From
AppointmentScheduler
, commit the app to a new Github repo. Then, create a trial Twilio account and within the Cosmic JS dashboard, add the following env
variables from the deploy menu.
- your Twilio auth keyTWILIO_AUTH
- your Twilio sidTWILIO_SID
- the phone number you have associated with your Twilio trial.TWILIO_NUMBER
Now go ahead and deploy and add a few sample appointments that we can use to test our extension.
Part 4. Build the Extension
Cosmic JS letâs you upload SPAâs that you can use to access and manipulate your bucketâs data right within the Cosmic JS dashboard. These are called Extensions and weâll be building one to be able to view a table of all scheduled appointments, as well as providing us with an easy way to delete them.
Just as with the frontend, weâll be using React with Material UI and the steps here will be similar to Part 1.
1. Boilerplate setup
First, make our
appointment-scheduler-extension
directory, run yarn init
, and create the following project structure.appointment-scheduler-extension
|
|--dist
|--src
|. |--Components
|. |. |--App.js
|. |--index.html
|. |--index.js
|--.babelrc
|--.gitignore
|--package.json
|--webpack.config.js
Use the same exact
index.html
template we used for the frontend.<!-- ./src/index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet">
<title>Appointment Scheduler</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
- Weâll be using almost all of the same packages as the frontend. Refer to Part 1 for installations, including adding
andlodash
. We'll usequery-string
for filtering data andlodash
as a convenient way to get our Cosmic keys, which Cosmic supplies as url parameters.query-string
- Likewise,
,Âwebpack.config.js
, andÂ.gitignore
will all be entirely the same as in Part 1..babelrc
won't change either besides a new scheme for config variables:index.js
import React from âreactâ
import ReactDom from âreact-domâ
import App from â./Components/Appâ
import MuiThemeProvider from âmaterial-ui/styles/MuiThemeProviderâ
import QueryString from âquery-stringâ
import ânormalize.cssâ
window.React = React
const url = QueryString.parse(location.search)
const config = {
bucket: {
slug: url.bucket_slug,
write_key: url.write_key,
read_key: url.read_key
}
}
ReactDom.render(
<MuiThemeProvider> <App config={config}/> </MuiThemeProvider>,
document.getElementById(ârootâ)
)
3. Work Out a Skeleton
From here, our extesion and frontend start to diverge.
At itâs barest, our extension will look like this:
import { Component } from 'react'
import injectTapEventPlugin from 'react-tap-event-plugin'
injectTapEventPlugin()
export default class App extends Component {
constructor(props) {
super(props)
// set initial state
// bind component methods
}
// component methods, lifecycle methods
render() {
return (
//Material UI components
)
}
}
Thinking about what we need for an initial state:
- Weâre getting our Cosmic config variables from url parametes in
and we're passing them toindex.js
as props, so we'll need to move those to it's state.App
- Weâll be using a
like we did in the frontend so we need to keep track of its state and message. We'll also be using aSnackBar
and need a similar strategy.DatePicker
- Weâll have a toolbar with a dropdown that lets the user select between listing all appointments for filtering them by day. Weâll track which is being done by assigning
to a state variable when the first is selected,1
for the latter.0
- Weâre loading our appointments data from Cosmic so it will be useful to cache them. Weâll also need to do this separately for filtering appointments by date.
- We can select rows in the table of appointments and need to track which are selected. It will also be useful to track the state of all rows being selected.
Thus, for an initial state, we have:
this.state = {
config: props.config,
snackbarDisabled: false,
snackbarMessage: 'Loading...',
toolbarDropdownValue: 1,
appointments: {},
filteredAppointments: {},
datePickerDisabled: true,
selectedRows: [],
deleteButtonDisabled: true,
allRowsSelected: false
}
4. Draft Out Functionality
Our Extension needs to have the following functions to get it working the way we need it to:
- Fetch data from Cosmic JS in
and handle it in a seperatecomponentWillMount()
(and it's companionhandleFetchMethod
)handleFetchError()
- Change the state when the filter option is changed with
handleToobarDropdownChange()
- Override default Material UI
selections withTable
handleRowSelection()
- Handle deleting
objects withAppointment
handleDelete()
- Feed disabled dates to the
withDatePicker
checkDisableDate()
- Filter appointments with
filterAppointments()
- Render appointments as
s withTableRow
setTableChildren()
Including those in our extensionâs skeleton, we now have:
import { Component } from 'react'
import injectTapEventPlugin from 'react-tap-event-plugin'
injectTapEventPlugin()
export default class App extends Component {
constructor(props) {
super(props)
this.state = {
config: props.config,
snackbarDisabled: false,
snackbarMessage: 'Loading...',
toolbarDropdownValue: 1,
appointments: {},
filteredAppointments: {},
datePickerDisabled: true,
selectedRows: [],
deleteButtonDisabled: true,
allRowsSelected: false
}
}
handleFetchError(err) {
//handle errors fetching data from Cosmic JS
}
handleFetch(response) {
//process data fetched from Cosmic JS
}
handleToolbarDropdownChange(val) {
// set the dropdown value and clear filteredAppointments() if
// "List All" is selected. (State 1).
}
handleRowSelection(rowsToSelect) {
// Table returns 'all' if the select-all button was used, an array of selected
// row numbers, otherwise. We need to make sense of this.
}
handleDelete(selectedRows) {
//send a post request to Cosmic JS's api to get rid of unwanted appointments
}
checkDisableDate(date) {
//feed the DatePicker days based on availability determined by appointments
//retrieved from Cosmic
}
filterAppointments(date) {
//Only show appointments occuring on date
}
setTableChildren(selectedRows = this.state.selectedRows, appointments = this.state.appointments) {
//render a TableRow for each appointment loaded
}
componentWillMount() {
//fetch data immediately
}
render() {
return (
//Material UI components
)
}
}
5. Build Out the View
Like the frontend, weâll use all Material UI components to display our data.
render() {
const { snackbarDisabled, appointments, datePickerDisabled, deleteButtonDisabled, ...data } = this.state
return (
<div style={{ fontFamily: 'Roboto' }}>
<AppBar
showMenuIconButton={false}
title="Appointment Manager"/>
<SnackBar
message={data.snackbarMessage}
open={!snackbarDisabled} />
<Toolbar>
<ToolbarGroup firstChild={true}>
<DropDownMenu
value={data.toolbarDropdownValue}
onChange={(evt, key, val) => this.handleToolbarDropdownChange(val)}>
<MenuItem value={0} primaryText="Filter Appointments By Date" />
<MenuItem value={1} primaryText="List All Appointments" />
</DropDownMenu>
<DatePicker
hintText="Select a date"
autoOk={true}
disabled={datePickerDisabled}
name="date-select"
onChange={(n, date) => this.filterAppointments(date)}
shouldDisableDate={(day) => this.checkDisableDate(day)} />
</ToolbarGroup>
<ToolbarGroup lastChild={true}>
<RaisedButton
primary={true}
onClick={() => this.handleDelete(data.selectedRows)}
disabled={deleteButtonDisabled}
label={`Delete Selected ${data.selectedRows.length ? '(' + data.selectedRows.length + ')' : ''}`} />
</ToolbarGroup>
</Toolbar>
<Table
onRowSelection={rowsToSelect => this.handleRowSelection(rowsToSelect)}
multiSelectable={true} >
<TableHeader>
<TableRow>
<TableHeaderColumn>ID</TableHeaderColumn>
<TableHeaderColumn>Name</TableHeaderColumn>
<TableHeaderColumn>Email</TableHeaderColumn>
<TableHeaderColumn>Phone</TableHeaderColumn>
<TableHeaderColumn>Date</TableHeaderColumn>
<TableHeaderColumn>Time</TableHeaderColumn>
</TableRow>
</TableHeader>
<TableBody
children={data.tableChildren}
allRowsSelected={data.allRowsSelected}>
</TableBody>
</Table>
</div>
)
}
6. Fetch Appointment Data
Unllike with the frontend, we donât have to worry about exposing sensitive data to the public, so we can easily use
cosmicjs
to handle the fetch. We'll do this in componentWillMount()
.componentWillMount() {
Cosmic.getObjectType(this.state.config, { type_slug: 'appointments' }, (err, response) => err ? this.handleFetchError(err) : this.handleFetch(response)
)
}
Weâll handle errors with
handleFetchError()
, which will show the user that an error occured in the SnackBar
.handleFetchError(err) {
console.log(err)
this.setState({ snackbarMessage: 'Error loading data' })
}
If data is successfully returned, weâll process it with
handleFetch()
.handleFetch(response) {
const appointments = response.objects.all ? response.objects.all.reduce((currentAppointments, appointment) => {
const date = appointment.metadata.date
if (!currentAppointments[date]) currentAppointments[date] = []
const appointmentData = {
slot: appointment.metadata.slot,
name: appointment.title,
email: appointment.metadata.email,
phone: appointment.metadata.phone,
slug: appointment.slug
}
currentAppointments[date].push(appointmentData)
currentAppointments[date].sort((a,b) => a.slot - b.slot)
return currentAppointments
}, {}) : {}
this.setState({ appointments, snackbarDisabled: true, tableChildren: this.setTableChildren([], appointments) })
}
From the array of
Appointment
objects our bucket sends, we create a schedule of all loaded appointments, appointments. We then save that to the state and pass it to setTableChildren()
to use in rendering the Table
.7. Handle UIÂ Changes
We need a few simple methods to handle the filter dropdown in the toolbar, selecting rows, filtering appointments, feeding a check for disabling dates to the
DatePicker
. Starting with handling the dropdown filter, 0
maps to filtering the appointments by date, 1
maps to listing all. For listing all, we reset state.filteredAppointments
.handleToolbarDropdownChange(val) {
//0: filter by date, 1: list all
val ? this.setState({ filteredAppointments: {}, datePickerDisabled: true, toolbarDropdownValue: 1 }) : this.setState({ toolbarDropdownValue: 0, datePickerDisabled: false })
}
For handling row selection, we save the selected rows to the state, set the table children based on the rows selected, and enable the delete button if at least one row is selected.
handleRowSelection(rowsToSelect) {
const allRows = [...Array(this.state.tableChildren.length).keys()]
const allRowsSelected = rowsToSelect === 'all'
const selectedRows = Array.isArray(rowsToSelect) ? rowsToSelect : allRowsSelected ? allRows : []
const appointments = _.isEmpty(this.state.filteredAppointments) ? this.state.appointments : this.state.filteredAppointments
const deleteButtonDisabled = selectedRows.length == 0
const tableChildren = allRowsSelected ? this.setTableChildren([], appointments) : this.setTableChildren(selectedRows, appointments)
this.setState({ selectedRows, deleteButtonDisabled, tableChildren })
}
For disabling dates, we only make them active if
state.appointments.date
, where date = 'YYYY-DD-MM'
, exists.checkDisableDate(day) {
return !this.state.appointments[moment(day).format('YYYY-DD-MM')]
}
8. Filtering Appointments and Rendering the Table
When the user changes the filter dropdown to
Filter By Date
they then pick a date from the date picker. Upon choosing a date, the date picker fires filterAppointments()
to set state.filteredAppoitments
to the sub-schedule state.appointments[selectedDate]
and pass that sub-schedule setTableChildren()
.filterAppointments(date) {
const dateString = moment(date).format('YYYY-DD-MM')
const filteredAppointments = {}
filteredAppointments[dateString] = this.state.appointments[dateString]
this.setState({ filteredAppointments, tableChildren: this.setTableChildren([], filteredAppointments) })
}
When
filterAppointments()
(or any other method) calls setTableChildren()
we can optionally pass an array of selected rows and an appointments
object or let it default to state.selectedRows
and state.appointments
. If the appointments are filtered, we sort them by time before rendering.setTableChildren(selectedRows = this.state.selectedRows, appointments = this.state.appointments) {
const renderAppointment = (date, appointment, index) => {
const { name, email, phone, slot } = appointment
const rowSelected = selectedRows.includes(index)
return <TableRow key={index} selected={rowSelected}>
<TableRowColumn>{index}</TableRowColumn>
<TableRowColumn>{name}</TableRowColumn>
<TableRowColumn>{email}</TableRowColumn>
<TableRowColumn>{phone}</TableRowColumn>
<TableRowColumn>{moment(date, 'YYYY-DD-MM').format('M[/]D[/]YYYY')}</TableRowColumn>
<TableRowColumn>{moment().hour(9).minute(0).add(slot, 'hours').format('h:mm a')}</TableRowColumn>
</TableRow>
}
const appointmentsAreFiltered = !_.isEmpty(this.state.filteredAppointments)
const schedule = appointmentsAreFiltered ? this.state.filteredAppointments : appointments
const els = []
let counter = 0
appointmentsAreFiltered ?
Object.keys(schedule).forEach(date => {
schedule[date].forEach((appointment, index) => els.push(renderAppointment(date, appointment, index)))
}) :
Object.keys(schedule).sort((a,b) => moment(a, 'YYYY-DD-MM').isBefore(moment(b, 'YYYY-MM-DD')))
.forEach((date, index) => {
schedule[date].forEach(appointment => {
els.push(renderAppointment(date, appointment, counter))
counter++
})
})
return els
}
9. Deleting Appointments
The last thing we need to handle is letting the user delete appointments, leveraging
cosmicjs
for deleting objects found using lodash
from state.appointments
according to selectedRows
.handleDelete(selectedRows) {
const { config } = this.state
return selectedRows.map(row => {
const { tableChildren, appointments } = this.state
const date = moment(tableChildren[row].props.children[4].props.children, 'M-D-YYYY').format('YYYY-DD-MM')
const slot = moment(tableChildren[row].props.children[5].props.children, 'h:mm a').diff(moment().hours(9).minutes(0).seconds(0), 'hours') + 1
return _.find(appointments[date], appointment =>
appointment.slot === slot
)
}).map(appointment => appointment.slug).forEach(slug =>
Cosmic.deleteObject(config, { slug, write_key: config.bucket.write_key }, (err, response) => {
if (err) {
console.log(err)
this.setState({ snackbarDisabled: false, snackbarMessage: 'Failed to delete appointments' })
} else {
this.setState({ snackbarMessage: 'Loading...', snackbarDisabled: false })
Cosmic.getObjectType(this.state.config, { type_slug: 'appointments' }, (err, response) =>
err ? this.handleFetchError(err) : this.handleFetch(response)
)}
}
)
)
this.setState({ selectedRows: [], deleteButtonDisabled: true})
}
10. Putting It All Together
At this point, including all necessary imports, our completed extension looks like this:
// ./src/Components/App.js
import { Component } from 'react'
import injectTapEventPlugin from 'react-tap-event-plugin'
import axios from 'axios'
import async from 'async'
import _ from 'lodash'
import moment from 'moment'
import Cosmic from 'cosmicjs'
import AppBar from 'material-ui/AppBar'
import FlatButton from 'material-ui/FlatButton'
import RaisedButton from 'material-ui/RaisedButton'
import SnackBar from 'material-ui/SnackBar'
import DropDownMenu from 'material-ui/DropDownMenu'
import MenuItem from 'material-ui/MenuItem'
import DatePicker from 'material-ui/DatePicker'
import {
Toolbar,
ToolbarGroup
} from 'material-ui/Toolbar'
import {
Table,
TableBody,
TableHeader,
TableHeaderColumn,
TableRow,
TableRowColumn,
} from 'material-ui/Table';
injectTapEventPlugin()
export default class App extends Component {
constructor(props) {
super(props)
this.state = {
config: props.config,
snackbarDisabled: false,
snackbarMessage: 'Loading...',
toolbarDropdownValue: 1,
appointments: {},
filteredAppointments: {},
datePickerDisabled: true,
selectedRows: [],
deleteButtonDisabled: true,
allRowsSelected: false
}
this.handleFetchError = this.handleFetchError.bind(this)
this.handleFetch = this.handleFetch.bind(this)
this.handleRowSelection = this.handleRowSelection.bind(this)
this.handleToolbarDropdownChange = this.handleToolbarDropdownChange.bind(this)
this.handleDelete = this.handleDelete.bind(this)
this.checkDisableDate = this.checkDisableDate.bind(this)
this.setTableChildren = this.setTableChildren.bind(this)
}
handleFetchError(err) {
console.log(err)
this.setState({ snackbarMessage: 'Error loading data' })
}
handleFetch(response) {
const appointments = response.objects.all ? response.objects.all.reduce((currentAppointments, appointment) => {
const date = appointment.metadata.date
if (!currentAppointments[date]) currentAppointments[date] = []
const appointmentData = {
slot: appointment.metadata.slot,
name: appointment.title,
email: appointment.metadata.email,
phone: appointment.metadata.phone,
slug: appointment.slug
}
currentAppointments[date].push(appointmentData)
currentAppointments[date].sort((a,b) => a.slot - b.slot)
return currentAppointments
}, {}) : {}
this.setState({ appointments, snackbarDisabled: true, tableChildren: this.setTableChildren([], appointments) })
}
handleToolbarDropdownChange(val) {
//0: filter by date, 1: list all
val ? this.setState({ filteredAppointments: {}, datePickerDisabled: true, toolbarDropdownValue: 1 }) : this.setState({ toolbarDropdownValue: 0, datePickerDisabled: false })
}
handleRowSelection(rowsToSelect) {
const allRows = [...Array(this.state.tableChildren.length).keys()]
const allRowsSelected = rowsToSelect === 'all'
const selectedRows = Array.isArray(rowsToSelect) ? rowsToSelect : allRowsSelected ? allRows : []
const appointments = _.isEmpty(this.state.filteredAppointments) ? this.state.appointments : this.state.filteredAppointments
const deleteButtonDisabled = selectedRows.length == 0
const tableChildren = allRowsSelected ? this.setTableChildren([], appointments) : this.setTableChildren(selectedRows, appointments)
this.setState({ selectedRows, deleteButtonDisabled, tableChildren })
}
handleDelete(selectedRows) {
const { config } = this.state
return selectedRows.map(row => {
const { tableChildren, appointments } = this.state
const date = moment(tableChildren[row].props.children[4].props.children, 'M-D-YYYY').format('YYYY-DD-MM')
const slot = moment(tableChildren[row].props.children[5].props.children, 'h:mm a').diff(moment().hours(9).minutes(0).seconds(0), 'hours') + 1
return _.find(appointments[date], appointment =>
appointment.slot === slot
)
}).map(appointment => appointment.slug).forEach(slug =>
Cosmic.deleteObject(config, { slug, write_key: config.bucket.write_key }, (err, response) => {
if (err) {
console.log(err)
this.setState({ snackbarDisabled: false, snackbarMessage: 'Failed to delete appointments' })
} else {
this.setState({ snackbarMessage: 'Loading...', snackbarDisabled: false })
Cosmic.getObjectType(this.state.config, { type_slug: 'appointments' }, (err, response) =>
err ? this.handleFetchError(err) : this.handleFetch(response)
)}
}
)
)
this.setState({ selectedRows: [], deleteButtonDisabled: true})
}
checkDisableDate(day) {
return !this.state.appointments[moment(day).format('YYYY-DD-MM')]
}
filterAppointments(date) {
const dateString = moment(date).format('YYYY-DD-MM')
const filteredAppointments = {}
filteredAppointments[dateString] = this.state.appointments[dateString]
this.setState({ filteredAppointments, tableChildren: this.setTableChildren([], filteredAppointments) })
}
setTableChildren(selectedRows = this.state.selectedRows, appointments = this.state.appointments) {
const renderAppointment = (date, appointment, index) => {
const { name, email, phone, slot } = appointment
const rowSelected = selectedRows.includes(index)
return <TableRow key={index} selected={rowSelected}>
<TableRowColumn>{index}</TableRowColumn>
<TableRowColumn>{name}</TableRowColumn>
<TableRowColumn>{email}</TableRowColumn>
<TableRowColumn>{phone}</TableRowColumn>
<TableRowColumn>{moment(date, 'YYYY-DD-MM').format('M[/]D[/]YYYY')}</TableRowColumn>
<TableRowColumn>{moment().hour(9).minute(0).add(slot, 'hours').format('h:mm a')}</TableRowColumn>
</TableRow>
}
const appointmentsAreFiltered = !_.isEmpty(this.state.filteredAppointments)
const schedule = appointmentsAreFiltered ? this.state.filteredAppointments : appointments
const els = []
let counter = 0
appointmentsAreFiltered ?
Object.keys(schedule).forEach(date => {
schedule[date].forEach((appointment, index) => els.push(renderAppointment(date, appointment, index)))
}) :
Object.keys(schedule).sort((a,b) => moment(a, 'YYYY-DD-MM').isBefore(moment(b, 'YYYY-MM-DD')))
.forEach((date, index) => {
schedule[date].forEach(appointment => {
els.push(renderAppointment(date, appointment, counter))
counter++
})
})
return els
}
componentWillMount() {
Cosmic.getObjectType(this.state.config, { type_slug: 'appointments' }, (err, response) =>
err ? this.handleFetchError(err) : this.handleFetch(response)
)
}
render() {
const { snackbarDisabled, appointments, datePickerDisabled, deleteButtonDisabled, ...data } = this.state
return (
<div style={{ fontFamily: 'Roboto' }}>
<AppBar
showMenuIconButton={false}
title="Appointment Manager"/>
<SnackBar
message={data.snackbarMessage}
open={!snackbarDisabled} />
<Toolbar>
<ToolbarGroup firstChild={true}>
<DropDownMenu
value={data.toolbarDropdownValue}
onChange={(evt, key, val) => this.handleToolbarDropdownChange(val)}>
<MenuItem value={0} primaryText="Filter Appointments By Date" />
<MenuItem value={1} primaryText="List All Appointments" />
</DropDownMenu>
<DatePicker
hintText="Select a date"
autoOk={true}
disabled={datePickerDisabled}
name="date-select"
onChange={(n, date) => this.filterAppointments(date)}
shouldDisableDate={(day) => this.checkDisableDate(day)} />
</ToolbarGroup>
<ToolbarGroup lastChild={true}>
<RaisedButton
primary={true}
onClick={() => this.handleDelete(data.selectedRows)}
disabled={deleteButtonDisabled}
label={`Delete Selected ${data.selectedRows.length ? '(' + data.selectedRows.length + ')' : ''}`} />
</ToolbarGroup>
</Toolbar>
<Table
onRowSelection={rowsToSelect => this.handleRowSelection(rowsToSelect)}
multiSelectable={true} >
<TableHeader>
<TableRow>
<TableHeaderColumn>ID</TableHeaderColumn>
<TableHeaderColumn>Name</TableHeaderColumn>
<TableHeaderColumn>Email</TableHeaderColumn>
<TableHeaderColumn>Phone</TableHeaderColumn>
<TableHeaderColumn>Date</TableHeaderColumn>
<TableHeaderColumn>Time</TableHeaderColumn>
</TableRow>
</TableHeader>
<TableBody
children={data.tableChildren}
allRowsSelected={data.allRowsSelected}>
</TableBody>
</Table>
</div>
)
}
}
Part 5: Conclusion
Once you run
webpack
in appointment-scheduler-extension
, create extension.json
in dist
to make Cosmic recognize it:// appointment-scheduler-extension/dist/extension.json
{
"title": "Appointment Manager",
"font_awesome_class": "fa-calendar",
"image_url": ""
}
Then, compress
dist
, upload it to Cosmic, and we're ready to start managing appointments.Using Cosmic JS, Twilio, Express, and React, weâve built a modular, easy to extend appointment scheduler to both give others easy access to our time while saving more of it for ourselves. The speed at which weâre able to get our app deployed and the simplicity of managing our data reinforces that it was an obvious choice to use Cosmic JS both for CMS and deployment.
Although our appointment scheduler will definitely save us time in the future, itâs a sure thing that it can never compete with the time Cosmic will save us on future projects.
Matt Cain builds smart web applications and writes about the tech used to build them. You can learn more about him on his portfolio.