View the demoInstall the AppView the codebase
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:
But, before any of that, we need to get our Cosmic bucket ready to store and serve data.
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. Datewill 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.
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:
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.
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
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:
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'.
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:
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>
)
}
}
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 AppBar which acts as the primary toolbar
A Drawer, which is opened from the AppBar's primary button and serves as the app's navigation, following Material Design.
Within the Drawer, MenuItems to display links
A Card as the primary content container
A Stepper 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 if state.loading is true and the last two will be disabled as long as the user hasn't filled out the previous step.
Nested in the Stepper, three Steps which contain StepButton and StepContentcomponents.
In the first Step, we'll use a DatePicker to let the user choose an appointment date. Unavailable days will be disabled according to the return value of checkDisableDate(). Selection of a date will be handled with handleSetAppointmentDate()
In the second Step, 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 a SelectField for the filter and a RadioButtonGroup to hold the time slot buttons. We need extra logic to render the radio buttons, so will do that in the renderAppointmentTimes() method. That will return a set of RadioButtons.
The last Step will ask the user to input their name, email address, and phone number using TextFieldcomponents. A RaisedButton will be used a submit button to open the confirmation Dialog. The users inputted phone number and email address will be validated with validatePhone() and validateEmail() respectively.
Finally, we’ll include a convenient SnackBar to display notifcations about the loading state and submission at the bottom of the page.All in all, after having written out the render() method, our app will look like this:
// ./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.
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.configsand response.appointments in handleFetch(). We also use componentWillMount to start tracking the window width with resize().
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...
}
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(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
})
}
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 })
}
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 })
}
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})
}
We need to validate the user’s inputted email address, phone number, and we need to feed the DatePickercomponent 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
}
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
}
}
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 })
})
}
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>
)
}
}
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:
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
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/appointmentsrespectively. 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.
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 '}))
})
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 })
})
})
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 })
})
})
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'))
)
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.
Now go ahead and deploy and add a few sample appointments that we can use to test our 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.
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 lodash and query-string. We'll use lodash for filtering data and query-string as a convenient way to get our Cosmic keys, which Cosmic supplies as url parameters.
Likewise, webpack.config.js, .gitignore, and .babelrc will all be entirely the same as in Part 1.
index.js won't change either besides a new scheme for config variables:
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:
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
}
Our Extension needs to have the following functions to get it working the way we need it to:
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
)
}
}
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>
)
}
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.
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')]
}
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
}
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})
}
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>
)
}
}
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.