I came across a thread by the Big Chief and this being Friday, thought "maybe a #FreeCodeFriday on this  wouldn't be a bad idea". So, I'll be creating an open-source app to help tailors store client information and measurements. It's open-source so others can extend it to solve other tailor pain points.

Also, I've never really understood all the hue and cry about PWAs. But this is a use case that fits perfectly (I tend not to bother with technologies that I can't find a good use case for - looking at you, GraphQL).

For this app will be entirely self-contained, I'll be using IndexedDB so other than the first load, internet access would not be necessary. This is appropriate as we can't guarantee that tailors would have good internet connectivity. This would also serve as an IndexedDB tutorial for those who aren't aware that their (modern) browsers come bundled with a full-fledged DB.

We'll be covering all steps from installation to deployment, so this would be a long article. Strap in and let's begin.

Installation - commit

$ npx create-react-app tailor
$ cd tailor
$ npm i -S react-bootstrap bootstrap

I use ESLint with (modified) AirBnB's rules for my JS projects. Install with:

$ npm info "eslint-config-airbnb@latest" peerDependencies --json | command sed 's/[\{\},]//g ; s/: /@/g' | xargs npm i -D "eslint-config-airbnb@latest"

Add a lint command to package.json:

"scripts": {
    "lint": "eslint --ext=js .",
    ...
}

Create .eslintrc.js with:

module.exports = {
  'env': {
    'browser': true,
    'jest': true,
    'es6': true,
    'node': true,
  },
  'extends': [
    'airbnb',
  ],
  'rules': {
    'react/prefer-stateless-function': 'off',
    'react/jsx-filename-extension': 'off',
    'no-new': 'off',
    'no-new-wrappers': 'off',
    'no-restricted-globals': 'off',
    'object-shorthand': 'off',
    'operator-linebreak': 'off',
    'no-underscore-dangle': 'off',
    'no-alert': 'off',
    'no-console': 'off',
    'object-curly-newline': 'off',
    'yoda': 'off',
    'comma-dangle': ['error', 'never'],
    'max-len': ['error', 120, 2, { ignoreComments: true }],
    'no-unused-vars': ['warn', { 'vars': 'local', 'args': 'none' }],
    'no-cond-assign': ['error', 'except-parens'],
    'no-nested-ternary': 'off',
    'no-trailing-spaces': 'off',
    'import/prefer-default-export': 'off',
    'linebreak-style': 'off',
    'no-continue': 'off'
  },
  'parserOptions': {
    'ecmaFeatures': {
      'jsx': true,
    }
  }
};

Add the following line to the top of serviceWorker.js and fix the errors reported when you run npm run lint:

/* eslint-disable no-console, no-param-reassign, no-use-before-define */

Install React Router with npm i -S prop-types react-router-dom.

Remove ./src/App.css, src/logo.svg, replace the contents of ./src/index.css with:

@import '~bootstrap/dist/css/bootstrap.min.css';

Setup app structure - commit

Install Redux:

$ npm i -S react-redux redux redux-logger

Replace the contents of ./src/App.js with:

import React from 'react';
import { HashRouter, Link } from 'react-router-dom';
import { Provider } from 'react-redux';
import Navbar, { Brand } from 'react-bootstrap/Navbar';
import Routes from './Routes';
import initStore from './store';

/* eslint-disable react/prop-types */
const App = () => (
  <Provider store={initStore()}>
    <HashRouter>
      <div className="container-fluid">
        <header>
          <Navbar collapseOnSelect expand="lg">
            <Brand as={Link} to="/clients">Tailor</Brand>
          </Navbar>
        </header>
        <main style={{ marginTop: '10px' }}>
          <Routes />
        </main>
      </div>
    </HashRouter>
  </Provider>
);

export default App;

Create ./src/Routes.js:

import React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import Clients from './Clients';
import Client from './Client';

export default () => (
  <Switch>
    <Route exact path="/">
      <Redirect to="/clients" />
    </Route>
    <Route path="/clients" exact component={Clients} />
    <Route path="/clients/:id" exact component={Client} />
  </Switch>
);

Create ./src/Client.js:

import React, { Component } from 'react';
import { object } from 'prop-types';

class Client extends Component {
  static propTypes = {
    match: object.isRequired
  };

  state = {
    client: {}
  };

  componentDidMount() {
    const { props: { match: { params: { id } } } } = this;

    this.setState({ client: { id } });
  }

  render() {
    const { state: { client: { id } } } = this;
    
    return (<p>Client {id}</p>);
  }
}

export default Client;

Create ./src/Clients.js:

import React, { Component } from 'react';

class Clients extends Component {
  render() {
    return (<p>Clients</p>);
  }
}

export default Clients;

Create ./src/store/index.js:

import { createStore, applyMiddleware } from 'redux';
import logger from 'redux-logger';
import reducer from './reducer';

const initStore = () => {
  const initialState = {
    clients: []
  };

  return createStore(reducer, initialState, applyMiddleware(logger));
};

export default initStore;

Create ./src/store/reducer.js:

import ACTIONS from './actions';

const defaultState = { clients: [] };

const clientReducer = (state = defaultState, { type, payload }) => {
  switch (type) {
    case ACTIONS.Types.SAVE_CLIENT: {
      const { clients } = state;

      if (!payload.id) {
        payload.id = clients.length + 1;
        clients.push(payload);
      } else {
        clients.splice(payload.id - 1, 1, payload);
      }

      return { ...state, clients };
    }

    case ACTIONS.Types.DELETE_CLIENT: {
      const { clients } = state;

      clients.splice(payload - 1, 1);

      return { ...state, clients };
    }

    default:
      return state;
  }
};

export default clientReducer;

Create ./src/store/actions.js:

const Types = {
  SAVE_CLIENT: 'SAVE_CLIENT',
  DELETE_CLIENT: 'DELETE_CLIENT'
};

const saveClient = (client) => ({ type: Types.SAVE_CLIENT, payload: client });
const deleteClient = (id) => ({ type: Types.DELETE_CLIENT, payload: id });

export default {
  saveClient,
  deleteClient,
  Types
};

List clients - commit

Update Clients.js:

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { array, func } from 'prop-types';
import ListGroup, { Item } from 'react-bootstrap/ListGroup';
import { Control } from 'react-bootstrap/Form';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import Button from 'react-bootstrap/Button';
import Modal, { Header, Footer, Body, Title } from 'react-bootstrap/Modal';
import ACTIONS from './store/actions';

class Clients extends Component {
  static propTypes = {
    saveClient: func.isRequired,
    deleteClient: func.isRequired,
    clients: array.isRequired
  };

  state = {
    searchText: '',
    localClients: this.props.clients,
    form: {
      name: '',
      gender: 'female',
      phone: ''
    },
    showModal: false
  };

  handleSearchTextChange = (e) => {
    const searchText = e.target.value.trim(),
      localClients = this.props.clients.filter(({ name }) => name.includes(searchText));
    
    this.setState({ searchText, localClients });
  }

  openModal = () => this.setState({ showModal: true });

  closeModal = () => this.setState({ showModal: false });

  createClient = () => {
    this.closeModal();
  };

  render() {
    const { 
      state: { searchText, localClients, showModal },
      openModal,
      closeModal,
      createClient,
      handleSearchTextChange,
      renderClientListItem,
      renderEmptyView
    } = this;

    return (
      <Row>
        <Col sm={{ span: 3, offset: 9 }} className="mb-3">
          <Button size="sm" variant="outline-primary" onClick={openModal}>Create client</Button>

          <Modal show={showModal} onHide={closeModal} animation={false}>
            <Header closeButton>
              <Title>Create client</Title>
            </Header>
            <Body>
              Body goes here...
            </Body>
            <Footer>
              <Button size="sm" variant="outline-secondary" onClick={closeModal}>
                Close
              </Button>
              <Button size="sm" variant="outline-primary" onClick={createClient}>
                Create
              </Button>
            </Footer>
          </Modal>
        </Col>
        <Col sm={12}>
          <ListGroup variant="flush">
            <Item>
              <Control
                type="text"
                placeholder="Search..."
                value={searchText}
                onChange={handleSearchTextChange}
              />
            </Item>
            {localClients.length ?  localClients.map(renderClientListItem) : renderEmptyView()}
          </ListGroup>
        </Col>
      </Row>
    );
  }

  renderClientListItem = ({ id, name }) =>
    (
      <Item key={id}>
        {name}
      </Item>
    );

  renderEmptyView = () => 
    (
      <Item>
        <h3 className="text-muted text-center">No clients</h3>
      </Item>
    );
}

const mapStateToProps = ({ clients }) => ({ clients });

const mapDispatchToProps = (dispatch) => ({
  saveClient: (client) => dispatch(ACTIONS.saveClient(client)),
  deleteClient: (id) => dispatch(ACTIONS.deleteClient(id))
});

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(Clients);

We connect the component with our store, add a search box to filter clients and a modal and the state needed to add new clients.

Create client - commit

To add a new client, we update Clients.js to:

import { Control, Label } from 'react-bootstrap/Form';


class Clients extends Component {
  ...
  state = {
    ...,
    nameRef: React.createRef()
  };

  handleSearchTextChange = (e) => {
    const ...,
      localClients = this.props.clients.filter(({ name }) => name.toLowerCase().includes(searchText.toLowerCase()));
    
    ...
  }
    
  openModal = () => this.setState({ showModal: true }, () => this.state.nameRef.current.focus());

  closeModal = () => this.setState({ showModal: false, form: { name: '', gender: 'female', phone: '' } });

  createClient = () => {
    this.props.saveClient(this.state.form);
    this.closeModal();
  };

  isFormValid = () => {
    const { state: { form: { gender, name, phone } }, props: { clients } } = this;

    return !!name && 
      /^\d{7,11}$/.test(phone.trim()) && 
      ['male', 'female'].includes(gender) && 
      clients.every((c) => c.phone !== phone.trim());
  };

  updateForm = (e) => this.setState({ form: { ...this.state.form, [e.target.name]: e.target.value } });

  render() {
    const { 
      state: { searchText, localClients, showModal, form, nameRef },
      openModal,
      closeModal,
      createClient,
      handleSearchTextChange,
      renderClientListItem,
      renderEmptyView,
      updateForm,
      isFormValid
    } = this;

    return (
      <Row>
        <Col sm={3} className="mb-3">
          ...

          <Modal show={showModal} onHide={closeModal} animation={false}>
            ...
            <Body>
              <Label className="mt-2">Name</Label>
              <Control ref={nameRef} name="name" type="text" placeholder="Name" value={form.name} onChange={updateForm} />

              <Label className="mt-2">Phone number</Label>
              <Control name="phone" type="text" placeholder="Phone number" value={form.phone} onChange={updateForm} />

              <Label className="mt-2">Gender</Label>
              <Control name="gender" as="select" value={form.gender} onChange={updateForm}>
                <option disabled>Select gender</option>
                <option value="female">Female</option>
                <option value="male">Male</option>
              </Control>
            </Body>
            <Footer>
              ...
              <Button disabled={!isFormValid()} size="sm" variant="outline-primary" onClick={createClient}>
                Create
              </Button>
            </Footer>
          </Modal>
        </Col>

        ...
      </Row>
    );
  }

  ...
}

...

In closeModal(), in addition to hiding the modal, we reset the form. We check that the name is non-empty, the phone has the right number of digits and unique and the gender is either male or female in isFormValid().

We update our form state in updateForm() and save the new client in createClient() before closing the modal. In handleSearchTextChange(), we modify our filter function comparison to be case-insensitive.

The nameRef on the name input is so to place the focus on the name when the modal opens.

In ./src/store/reducer.js, add a measurements array when saving a new client:

...
case ACTIONS.Types.SAVE_CLIENT: {
      ...

      if (!payload.id) {
        payload.id = clients.length + 1;
        payload.measurements = [];
        clients.push(payload);
      }
    ...
}
...

Delete client - commit

To delete a client, we modify the client render template in renderClientListItem():

renderClientListItem = ({ id, name }) =>
    (
      <Item key={id} className="d-flex justify-content-between align-items-center">
        <span>{name}</span>
        <button onClick={_ => this.handleDeleteClient(id)} className="close">&times;</button>
      </Item>
    );

Then add a handler:

handleDeleteClient = (id) => {
    const { state: { localClients } } = this;

    if (! confirm('Are you sure?')) return; // eslint-disable-line no-restricted-globals

    this.props.deleteClient(id);

    this.setState({ localClients: localClients.filter((c) => c.id !== id) });
  };

View client - commit

When a client's name is clicked/tapped, we'll open a screen containing the client's details.

In Clients.js, add an onClick listener to the name span:

<span onClick={_ => this.viewClient(id)}>{name}</span>

Then add the handler:

viewClient = (id) => this.props.history.push(`/clients/${id}`);

In Routes.js, update the client route to:

<Route path="/clients/:id" exact render={(props) => <Client {...props} />} />

Update Client.js to:

...
import { connect } from 'react-redux';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import Card, { Body, Title } from 'react-bootstrap/Card';
import ACTIONS from './store/actions';

class Client extends Component {
  static propTypes = {
    match: object.isRequired,
    client: object.isRequired
  };

  render() {
    const { props: { client: { name, gender, phone }, history } } = this,
      buttonStyles = { padding: 0, border: 0, backgroundColor: 'transparent' };

    return (
      <Row>
        <Col sm={12}>
        <Card>
          <Body>
            <Title>
              <button onClick={_ => history.replace('/clients')} style={buttonStyles}>&larr;</button>
              &emsp;
              {name}
            </Title>
            <p className="text-muted">Phone: {phone}</p>
            <p className="text-muted">Gender: {gender}</p>
          </Body>
        </Card>
        </Col>
        <Col sm={12} className="mt-3">
          <Card>
            <Body>
              Measurements will go here...
            </Body>
          </Card>
        </Col>
      </Row>
    );
  }
}

const mapStateToProps = ({ clients }, { match: { params: { id } } }) => 
  ({ client: clients.find((c) => c.id === parseInt(id)) });

const mapDispatchToProps = (dispatch) => ({
  saveClient: (client) => dispatch(ACTIONS.saveClient(client))
});

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(Client);

Update client - commit

First, let's trim the name and phone number of the client before creating them in Clients.js:

createClient = () => {
    const form = this.state.form;
    form.phone = form.phone.trim();
    form.name = form.name.trim();

    this.props.saveClient(form);
    this.closeModal();
 };

Then, let's add code in Client.js to update the client information:

...
import { object, array } from 'prop-types';
import Modal, { Header, Footer, Body as ModalBody, Title as ModalTitle } from 'react-bootstrap/Modal';
import { Control, Label } from 'react-bootstrap/Form';
import ACTIONS from './store/actions';

class Client extends Component {
  static propTypes = {
    ...,
    clients: array.isRequired
  };

  state = {
    clientForm: {},
    showClientModal: false,
    clientNameRef: React.createRef()
  };

  openClientUpdateModal = () => {
    const updatedState = {
      clientForm: { ...this.props.client },
      showClientModal: true
    };

    this.setState(updatedState, () => this.state.clientNameRef.current.focus());
  };

  closeClientModal = () => this.setState({ showClientModal: false, clientForm: {} });

  updateClient = () => {
    const form = this.state.clientForm;
    form.phone = form.phone.trim();
    form.name = form.name.trim();

    this.props.saveClient(form);
    this.closeClientModal();
  };

  isClientFormValid = () => {
    const { state: { clientForm: { gender, id, name, phone } }, props: { clients } } = this;

    return !!name && 
      /^\d{7,11}$/.test(phone.trim()) && 
      ['male', 'female'].includes(gender) && 
      clients.filter((c) => c.id !== id).every((c) => c.phone !== phone.trim());
  };

  updateClientForm = (e) => this.setState({ clientForm: { ...this.state.clientForm, [e.target.name]: e.target.value } });

  render() {
    const { 
      props: { client: { name, gender, phone }, history },
      state: { showClientModal, clientForm, clientNameRef },
      openClientUpdateModal,
      closeClientModal,
      updateClientForm,
      updateClient,
      isClientFormValid
    } = this,
      buttonStyles = { padding: 0, border: 0, backgroundColor: 'transparent' };

    return (
      <Row>
        <Col sm={12}>
        <Card>
          <Body>
            ...
            <p className="text-muted">Gender: {gender}</p>

            <Button variant="outline-primary" onClick={openClientUpdateModal}>Update</Button>

            <Modal show={showClientModal} onHide={closeClientModal} animation={false}>
              <Header closeButton>
                <ModalTitle>Update {name}</ModalTitle>
              </Header>
              <ModalBody>
                <Label className="mt-2">Name</Label>
                <Control ref={clientNameRef} name="name" type="text" placeholder="Name" value={clientForm.name} onChange={updateClientForm} />

                <Label className="mt-2">Phone number</Label>
                <Control name="phone" type="text" placeholder="Phone number" value={clientForm.phone} onChange={updateClientForm} />

                <Label className="mt-2">Gender</Label>
                <Control name="gender" as="select" value={clientForm.gender} onChange={updateClientForm}>
                  <option disabled>Select gender</option>
                  <option value="female">Female</option>
                  <option value="male">Male</option>
                </Control>
              </ModalBody>
              <Footer>
                <Button size="sm" variant="outline-secondary" onClick={closeClientModal}>
                  Close
                </Button>
                <Button disabled={!isClientFormValid()} size="sm" variant="outline-primary" onClick={updateClient}>
                  Update
                </Button>
              </Footer>
            </Modal>
          </Body>
        </Card>
        </Col>
        ...
      </Row>
    );
  }
}

const mapStateToProps = ({ clients }, { match: { params: { id } } }) => 
  ({ client: clients.find((c) => c.id === parseInt(id)), clients });

...

We need our clients list to ensure phone numbers remain unique.

Add, update and delete measurements - commit

We've come to the centerpiece of this app - storing client measurements. Each measurement we'll be storing will have a unit (which will be foot, inch or centimeters), a key (the body part being measured e.g. neck, sleeve etc) and a value (to hold the numeric value measured).

First, update Client.js:

...
import Measurements from './Measurements';

class Client extends Component {
  ...

  deleteMeasurement = (index) => {
    const client = this.props.client;
    client.measurements.splice(index, 1);
    this.props.saveClient(client);
  };

  saveMeasurement = (m, index) => {
    const client = this.props.client;

    if (index !== -1) {
      client.measurements[index] = m;
    } else {
      client.measurements.push(m);
    }

    this.props.saveClient(client);
  };

  render() {
    const { 
      props: { client, history },
      ...,
      isClientFormValid,
      saveMeasurement,
      deleteMeasurement
    } = this,
      ...;

    return (
      <Row>
        <Col sm={12}>
        <Card>
          <Body>
            <Title>
              ...
              {client.name}
            </Title>
            <p className="text-muted">Phone: {client.phone}</p>
            <p className="text-muted">Gender: {client.gender}</p>

            ...

            <Modal show={showClientModal} onHide={closeClientModal} animation={false}>
              <Header closeButton>
                <ModalTitle>Update {client.name}</ModalTitle>
              </Header>
              ...
            </Modal>
          </Body>
        </Card>
        </Col>
        <Col sm={12} className="mt-3">
          <Card>
            <Body>
              <Measurements 
                client={client}
                deleteMeasurement={deleteMeasurement}
                saveMeasurement={saveMeasurement}
                />
            </Body>
          </Card>
        </Col>
      </Row>
    );
  }
}

...

Then, add a new file ./src/Measurements.js with the following code:

import React, { Component } from 'react';
import { func, object } from 'prop-types';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import Card, { Body, Link, Text, Title } from 'react-bootstrap/Card';
import Button from 'react-bootstrap/Button';
import Modal, { Header, Footer, Body as ModalBody, Title as ModalTitle } from 'react-bootstrap/Modal';
import { Control, Label } from 'react-bootstrap/Form';
import ListGroup, { Item } from 'react-bootstrap/ListGroup';

class Measurements extends Component {
  static propTypes = {
    client: object.isRequired,
    deleteMeasurement: func.isRequired,
    saveMeasurement: func.isRequired
  };

  state = {
    form: {},
    showModal: false,
    index: -1
  };

  openModal = (index = -1) => 
    this.setState({
      form: index !== -1 ? 
        { ...this.props.client.measurements[index] } : 
        { unit: 'cm', key: 'Neck', value: 0 },
      showModal: true,
      index
    });

  closeModal = () => this.setState({ showModal: false, form: {}, index: -1 });

  save = () => {
    this.props.saveMeasurement(this.state.form, this.state.index);
    this.closeModal();
  };

  remove = (index) => {
    if (!confirm('Are you sure?')) return; // eslint-disable-line no-restricted-globals

    this.props.deleteMeasurement(index);
    this.forceUpdate();
  }

  isFormValid = () => {
    const { state: { form: { unit, key, value } } } = this;

    return !!unit && !!key && !!value;;
  };

  updateForm = (e) => this.setState({ form: { ...this.state.form, [e.target.name]: e.target.value } });

  render() {
    const { 
      props: { client: { measurements } },
      state: { showModal, form, index },
      openModal,
      closeModal,
      updateForm,
      save,
      isFormValid,
      measurementOptions,
      renderMeasurement,
      renderEmptyView
    } = this;

    return (
      <>
        <Button size="sm" variant="outline-primary" onClick={_ => openModal(-1)}>Add</Button>

        <Modal show={showModal} onHide={closeModal} animation={false}>
          <Header closeButton>
            <ModalTitle>{index === -1 ? 'Create' : 'Update'} measurement</ModalTitle>
          </Header>
          <ModalBody>
            <Label className="mt-2">Measurement</Label>
            <Control name="key" as="select" value={form.key} onChange={updateForm}>
              <option disabled>Select measurement</option>
              {measurementOptions().map((a, i) => <option key={i} value={a}>{a}</option>)}
            </Control>

            <Label className="mt-2">Value</Label>
            <Control name="value" type="number" value={form.value} onChange={updateForm} />

            <Label className="mt-2">Unit</Label>
            <Control name="unit" as="select" value={form.unit} onChange={updateForm}>
              <option disabled>Select unit</option>
              <option value="in">Inch</option>
              <option value="cm">Centimeter</option>
              <option value="ft">Foot</option>
            </Control>
          </ModalBody>
          <Footer>
            <Button size="sm" variant="outline-secondary" onClick={closeModal}>
              Close
            </Button>
            <Button disabled={!isFormValid()} size="sm" variant="outline-primary" onClick={save}>
              Save
            </Button>
          </Footer>
        </Modal>
        <Row className="mt-3">
          <Col>
            <ListGroup variant="flush">
            {measurements.length ?  measurements.map((m, i) => renderMeasurement(m, i)) : renderEmptyView()}
            </ListGroup>
          </Col>
        </Row>
      </>
    );
  }

  renderMeasurement = ({ key, value, unit }, index) => 
  (
    <Card className="mb-2" key={index}>
      <Body>
        <Title>{key}</Title>
        <Text>
          {value} {unit}.
        </Text>
        <Link onClick={_ => this.openModal(index)}>Update</Link>
        <Link onClick={_ => this.remove(index)}>Delete</Link>
      </Body>
    </Card>
  );

  renderEmptyView = () => 
    (
      <Item>
        <h3 className="text-muted text-center">No measurements</h3>
      </Item>
    );

  measurementOptions = () =>
    [
      'Neck',
      'Chest',
      'Waist',
      'Seat',
      'Shirt length',
      'Shoulder width',
      'Arm length',
      'Wrist',
      'Biceps',
      'Hip',
      'Inseam',
      'Sleeve length (jacket)'
    ];
}

export default Measurements;

That's pretty straightforward and provides our measurement creation, update and deletion functionalities. We have to forceUpdate() in remove() as the measurements don't get re-rendered after deletion.

Persisting to database - commit

First, we'll remove idb and install dexie as our IndexedDB client. Since database operations will be asynchronous, we'll also install redux-thunk.

npm un idb; npm i -S dexie redux-thunk

We'll add code that interacts with the database in its own file so it doesn't get tangled with the rest of our app. Create a file ./src/db/index.js and paste in:

import Dexie from 'dexie';

const uuidv4 = require('uuid/v4');

const dbName = 'tailor';
const dbVersion = 1;

const db = new Dexie(dbName);
db.version(dbVersion).stores({ clients: '&id, &phone' });

/* eslint-disable no-return-await */
const getClients = async () => await db.clients.toArray();

const getClient = async (id) => await db.clients.get(id);

const deleteClient = async (id) => await db.clients.delete(id);

const saveClient = async (client) => {
  if (!client.id) {
    client.id = uuidv4();
    client.measurements = [];
  }

  return await db.clients.put(client);
};

export {
  getClient,
  getClients,
  deleteClient,
  saveClient
};

Since we don't need dummy ids and what not, remove those from .src/store/reducer.js:

import ACTIONS from './actions';

const defaultState = { clients: [] };

const clientReducer = (state = defaultState, { type, payload }) => {
  switch (type) {
    case ACTIONS.Types.LOAD_CLIENTS: 
      return { ...state, clients: payload };

    case ACTIONS.Types.SAVE_CLIENT: {
      const { clients } = state, 
        index = clients.findIndex((c) => c.id === payload.id);

      if (index === -1) {
        clients.push(payload);
      } else {
        clients[index] = payload;
      }

      return { ...state, clients };
    }

    case ACTIONS.Types.DELETE_CLIENT: 
      return { ...state, clients: state.clients.filter((c) => c.id !== payload) };

    default:
      return state;
  }
};

export default clientReducer;

After creating our store in ./src/store/index.js, we'll hydrate it with existing data before returning. We'll also apply the thunk middleware to work with our asynchronous database code.

const initStore = () => {
  const store = createStore(reducer, { clients: [] }, applyMiddleware(thunk, logger));
  
  store.dispatch(ACTIONS.loadClients());

  return store;
};

Update ./src/store/actions.js:

import { deleteClient as dbDeleteClient, saveClient as dbSaveClient, getClients, getClient } from '../db';

const Types = {
  LOAD_CLIENTS: 'LOAD_CLIENTS',
  SAVE_CLIENT: 'SAVE_CLIENT',
  DELETE_CLIENT: 'DELETE_CLIENT'
};

const loadClients = () => (dispatch) => getClients()
  .then((payload) => dispatch({ type: Types.LOAD_CLIENTS, payload }));

const saveClient = (client) => (dispatch) => dbSaveClient(client)
  .then((id) => getClient(id)
    .then((payload) => dispatch({ type: Types.SAVE_CLIENT, payload })));

const deleteClient = (id) => (dispatch) => dbDeleteClient(id)
  .then(() => dispatch({ type: Types.DELETE_CLIENT, payload: id }));

export default {
  loadClients,
  saveClient,
  deleteClient,
  Types
};

Since we're no longer using integer ids, we'll remove parseInt in mapStateToProps() of Client.js:


const mapStateToProps = ({ clients }, { match: { params: { id } } }) => 
  ({ client: clients.find((c) => c.id === id), clients });

Let's do away with localClients in Clients.js to simplify state management:

...

class Clients extends Component {
  ...

  state = {
    searchText: '',
    form: {
      name: '',
      gender: 'female',
      phone: ''
    },
    showModal: false,
    nameRef: React.createRef()
  };

  handleSearchTextChange = ({ target: { value }}) => this.setState({ searchText: value.trim() });

  ...
  createClient = () => {
    const form = this.state.form;
    form.phone = form.phone.trim();
    form.name = form.name.trim();

    this.props.saveClient(form).then(() => this.closeModal());
  };
  ...

  updateForm = ({ target: { name, value } }) => this.setState({ form: { ...this.state.form, [name]: value } });

  handleDeleteClient = (id) => {
    if (! confirm('Are you sure?')) return; // eslint-disable-line no-restricted-globals

    this.props.deleteClient(id);
  };

  viewClient = (id) => this.props.history.push(`/clients/${id}`);

  render() {
    const { 
      props: { clients },
      state: { searchText, showModal, form, nameRef },
      ...
    } = this,
      localClients = clients.filter(({ name }) => name.toLowerCase().includes(searchText.toLowerCase()));

    ...
  }

  ...
}

...

Change cursor to pointer over names - commit

When the cursor hovers over the client names, we want to change the cursor to pointer to signify the name is clickable.

Just modify the name span in renderClientListItem() to:

<span style={{ cursor: 'pointer' }} onClick={_ => this.viewClient(id)}>{name}</span>

PWA-ify our app - commit

Our core functionalities are done. Now's the time to PWA-ify our app.

We just need to change line 12 in ./src/index.js to:

serviceWorker.register();

Let's apply the Redux logger middleware only in development. Change the createStore() call in initStore() of ./src/store/index.js to:

const store = createStore(reducer, { clients: [] }, 
    process.env.NODE_ENV === 'development' ? applyMiddleware(thunk, logger) : applyMiddleware(thunk));

Deploy

I'll be deploying this using Docker and serving with Express. You can see the changes required to implement that here.

I'll also orchestrate the deployment using a CI/CD pipeline.