Write React components

The following React components should be copied and pasted into your Cloud9 environment under the frontend/src folder into the specified file names. The files are currently empty. Replace them with the contents shown here. Be sure to save the files as you go.

AllProducts.js - Generates a list of products from previously-retrieved data.

import React, { useEffect } from 'react';
import Product from './Product';
import CreateProduct from './CreateProduct';
import { STATES } from './states';

export default ({
  subscribeToNewProducts,
  subscribeToUpdatedProducts,
  user,
  products,
  updateProduct
}) => {
  useEffect(() => subscribeToNewProducts());
  useEffect(() => subscribeToUpdatedProducts());

  const sortProducts = (products) => {
    const prodArray = Object.entries(products).map(e => e[1]);
    return prodArray.sort((a, b) => {
      return b.history.manufactured.localeCompare(a.history.manufactured);
    });
  };
  const sortedProducts = sortProducts(products);

  return (
    <div>
      <CreateProduct user={user} />
      <table>
      <thead>
        <tr>
          <th>serial number</th>
          <th>current state</th>
          {STATES.map(state => (
            <th key={state}>{state}</th>  
          ))}
        </tr>
      </thead>
      <tbody>
      {sortedProducts.map(product => (
        <Product key={product.id} product={product} user={user} updateProduct={updateProduct} />
      ))}
      </tbody>
      </table>
    </div>
  );
};

AllProductsWithData.js - Retrieves a list of products from the API. This component also receives updates when other users change the state of the data using GraphQL subscriptions.

import React, { useEffect } from 'react';
import { useQuery } from '@apollo/react-hooks';
import gql from 'graphql-tag';

import AllProducts from './AllProducts';

const LIST_PRODUCTS_QUERY = gql`
  query GetProducts {
    products {
      id
      state
      history {
        manufactured
        inspected
        shipped
        stocked
        labeled
        sold
      }
    }
  }
`;

const PRODUCTS_SUBSCRIPTION = gql`
  subscription onProductCreated {
    createdProduct {
      id
      state
      history {
        manufactured
        inspected
        shipped
        stocked
        labeled
        sold
      }
    }
  }
`;

const UPDATES_SUBSCRIPTION = gql`
  subscription onProductUpdated {
    updatedProductState {
      id
      state
      history {
        manufactured
        inspected
        shipped
        stocked
        labeled
        sold
      }
    }
  }
`;


export default ({ user, products, setProducts }) => {
  const { subscribeToMore, loading, data, refetch } = useQuery(LIST_PRODUCTS_QUERY);

  const updateProduct = (product) => {
    const newProducts = Object.assign(products);
    newProducts[product.id] = product;
    setProducts(newProducts);
    refetch();
  };

  const updateProductListFromSubscriptionData = (prev, update) => {
    if (update.data.createdProduct) {
      const newProducts = {};
      prev.products.forEach(p => newProducts[p.id] = p);
      const p = update.data.createdProduct;
      newProducts[p.id] = p;
      setProducts(newProducts);
    }
    if (update.data.updatedProductState) {
      const newProducts = {};
      prev.products.forEach(p => newProducts[p.id] = p);
      const p = update.data.updatedProductState;
      newProducts[p.id] = p;
      setProducts(newProducts);
    }
    return prev;
  };

  useEffect(() => {
    if (data && data.products) {
      const prod = {};
      data.products.forEach(p => prod[p.id] = p);
      setProducts(prod);
    }  
  }, [data, setProducts]);

  if (loading) return (<div className="loader">Loading...</div>);
  
  return (
    <AllProducts
    user={user}
    products={products}
    updateProduct={updateProduct}
    subscribeToNewProducts={() => {
      subscribeToMore({
        document: PRODUCTS_SUBSCRIPTION,
        variables: {},
        updateQuery: (prev, { subscriptionData }) =>
          updateProductListFromSubscriptionData(
            prev,
            subscriptionData
          )
      });
    }}
    subscribeToUpdatedProducts={() => {
      subscribeToMore({
        document: UPDATES_SUBSCRIPTION,
        variables: {},
        updateQuery: (prev, { subscriptionData }) =>
          updateProductListFromSubscriptionData(
            prev,
            subscriptionData
          )
      });
    }}
    />
  );
};

App.js - This is where the app’s root component is stored and connections to AppSync and other configuration data are initialized.

import React, { useState } from 'react';
import awsconfig from './aws-exports';
import { createSubscriptionHandshakeLink } from "aws-appsync-subscription-link";
import { createAuthLink } from "aws-appsync-auth-link";
import { ApolloClient, ApolloLink, InMemoryCache } from '@apollo/client';
import { ApolloProvider } from "react-apollo";
import Auth from '@aws-amplify/auth';
import useAmplifyAuth from './useAmplifyAuth';
import workerNames from './workerNames.json';

import AllProductsWithData from './AllProductsWithData';

Auth.configure(awsconfig);
const getAccessToken = () => {
  return Auth.currentSession().then(session => {
    return session.getAccessToken().getJwtToken();
  });
};

const config = {
  url: awsconfig.aws_appsync_graphqlEndpoint,
  region: awsconfig.aws_appsync_region,
  auth: {
    type: awsconfig.aws_appsync_authenticationType,
    jwtToken: getAccessToken
  },
  disableOffline: true
};

const link = ApolloLink.from([
  createAuthLink(config),
  createSubscriptionHandshakeLink(config)
]);

export const client = new ApolloClient({
  link,
  cache: new InMemoryCache({ addTypename: false })
});

const App = () => {
  const {
    state: { user },
    handleSignout
  } = useAmplifyAuth();

  const [products, setProducts] = useState({});

  return !user ? (
    <header className="signin">
      <div>
        <h1>Supply Chain Manager</h1>
        <div className="signin">
          <button onClick={() => Auth.signIn(workerNames['worker1'], 'Password123')}>Sign in as {workerNames['worker1']}</button>
          <button onClick={() => Auth.signIn(workerNames['worker2'], 'Password123')}>Sign in as {workerNames['worker2']}</button>
        </div>
      </div>
    </header>
  ) : (
    <div className="App">
      <header className="section">
        <div><h1>Supply Chain Manager</h1></div>
        <div>
          <button onClick={handleSignout}>Sign out {user.username}</button>
        </div>
      </header>
      <main className="section">
        <AllProductsWithData user={user} products={products} setProducts={setProducts} />
      </main>
      <footer className="section">
        <div>
          This site generated as part of the&nbsp;
          <a href="https://supply-chain-blockchain.workshops.aws.dev">supply chain workshop</a> for&nbsp;
          <a href="https://aws.amazon.com/managed-blockchain/">Amazon Managed Blockchain</a>
        </div>

      </footer>
    </div>
  );
};

const WithProvider = () => (
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>
);

export default WithProvider;

CreateProduct.js - This component is used by the Supplier worker to manufacture new products.

import React from 'react';
import { useMutation } from '@apollo/react-hooks';
import gql from 'graphql-tag';
import moment from 'moment';

const CREATE_PRODUCT_MUTATION = gql`
mutation($id: ID!) {
  createProduct(id: $id) {
    id
    state
    history {
      manufactured
      inspected
      shipped
      stocked
      labeled
      sold
    }
  }
}`;

const PRODUCTS_QUERY = gql`
  query GetProducts {
    products {
      id
      state
      history {
        manufactured
        inspected
        shipped
        stocked
        labeled
        sold
      }
    }
  }
`;

const UNAMBIGUOUS_ALPHANUMERIC = '123456789ABCDEFGHJKLMNPQRSTUVWXYZ';
function randomString(length, chars) {
  var result = '';
  for (var i = length; i > 0; --i) result += chars[Math.floor(Math.random() * chars.length)];
  return result;
}

export default ({user}) => {
  const userCanManufacture = () => {
    const permissions = user.attributes['custom:permissions'].split('_');
    return permissions.includes('manufacture');
  };
  if (!userCanManufacture()) return '';
  
  const [
    createProduct,
    { loading: mutationLoading, error: mutationError }
  ] = useMutation(
    CREATE_PRODUCT_MUTATION, {
      onCompleted({createProduct}) {
        // console.log('onCompleted', createProduct);
      },
      onError(error) {
        console.log('onError', error);
      }
    }
  );

  const serialNumber = randomString(8, UNAMBIGUOUS_ALPHANUMERIC);
  return (
    <div className="new-product">
      <div className="new-product-left">
        <form
          onSubmit={e => {
            e.preventDefault();
            createProduct({
              variables: { id: serialNumber },
              optimisticResponse: {
                __typename: 'Mutation',
                createProduct: {
                  id: serialNumber,
                  __typename: 'Product',
                  state: 'manufactured',
                  history: {
                    manufactured: moment().toISOString(),
                    inspected: null,
                    shipped: null,
                    stocked: null,
                    labeled: null,
                    sold: null          
                  }
                }
              },
              update: (proxy, { data: { createProduct }}) => {
                const data = proxy.readQuery({query: PRODUCTS_QUERY });
                proxy.writeQuery({query: PRODUCTS_QUERY, data: {
                  ...data,
                  products: [...data.products, createProduct]
                }});
              }
            });          
          }}
        >
          <button className="left" type="submit">Create new product</button>
        </form>
      </div>
      <div className="new-product-right">
        {mutationLoading && <div className="loader">Loading...</div>}
        {mutationError && <p>Error :( Please try again</p>}
      </div>
    </div>
  );
};

index.css - This contains basic style settings for the entire project, which control the look and feel of the interface.

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

body {
  margin: 0;
  padding: 0;
  font-family: sans-serif;
}


header {
  height: 30px;
  display: flex;
  justify-content: space-between;
}

footer {
  font-size: 50%;
}

h1 {
  margin-top: 0;
  clear: both;
}

.section {
  padding: 15px;
}

.signin {
  display: flex;
  justify-content: center;
  padding: 10px;
  grid-gap: 10px;
}

table {
  width: 100%;
  border-collapse:collapse
}

th {
  padding: 5px;
  font-size: 80%;
  font-weight: bold;
  font-variant: small-caps;
}

td {
  min-width: 75px;
  min-height: 40px;
  padding: 5px;
  margin: 5px;
  border-top: 1px solid #eee;
  font-size: 70%;
  font-weight: normal;
}

.serial {
  font-family: "Lucida Console", Monaco, monospace;
}

tbody tr:nth-child(odd) {
  background-color: #eee;
}

.new-product {
  height: 30px;
  float: left;
  display: inline-grid;
  grid-template-columns: auto auto;
  grid-gap: 10px;
}

.left {
  float: left;
}

.loader,
.loader:after {
  border-radius: 50%;
  width: 1em;
  height: 1em;
}
.loader {
  margin: 10px auto;
  margin-top: 0;
  margin-bottom: 0;
  font-size: 10px;
  text-indent: -9999em;
  border-top: 0.5em solid rgba(200,200,200, 0.2);
  border-right: 0.5em solid rgba(200,200,200, 0.2);
  border-bottom: 0.5em solid rgba(200,200,200, 0.2);
  border-left: 0.5em solid #c8c8c8;
  -webkit-transform: translateZ(0);
  -ms-transform: translateZ(0);
  transform: translateZ(0);
  -webkit-animation: load8 1.1s infinite linear;
  animation: load8 1.1s infinite linear;
}
@-webkit-keyframes load8 {
  0% {
    -webkit-transform: rotate(0deg);
    transform: rotate(0deg);
  }
  100% {
    -webkit-transform: rotate(360deg);
    transform: rotate(360deg);
  }
}
@keyframes load8 {
  0% {
    -webkit-transform: rotate(0deg);
    transform: rotate(0deg);
  }
  100% {
    -webkit-transform: rotate(360deg);
    transform: rotate(360deg);
  }
}

Product.js - This component contains logic for rendering individual products in the master list.

import React, { useState } from 'react';
import moment from 'moment';
import UpdateProductState from './UpdateProductState.js';
import { STATES, NEXT_STEP_LOOKUP } from './states';

export default ({product, user, updateProduct}) => {
  const [clickedButton, setClickedButton] = useState('');

  const userCanPerformNextTransitionOnProduct = () => {
    const permissions = user.attributes['custom:permissions'].split('_');
    let canPerform = true;

    if (NEXT_STEP_LOOKUP.hasOwnProperty(product.state)) {
      const transitionStep = NEXT_STEP_LOOKUP[product.state];
      canPerform = permissions.includes(transitionStep);
    }
    return canPerform;
  };

  return (
    <tr>
      <td className="serial">{product.id}</td>
      <td>{product.state}</td>
      {STATES.map(state => (
        <td key={state}>
          <span title={moment(product.history[state]).format('MMMM Do YYYY, h:mm:ss a')}>
            {product.history[state] && moment(product.history[state]).fromNow()}
          </span>
        </td>
      ))}
      <td>
        {userCanPerformNextTransitionOnProduct(product) && 
          <UpdateProductState product={product} updateProduct={updateProduct} clickedButton={clickedButton} setClickedButton={setClickedButton} />
        }
      </td>
    </tr>
  );
};

states.js - This file contains shared code about the product states and possible state transitions.

const STATES = [
  'manufactured',
  'inspected',
  'shipped',
  'stocked',
  'labeled',
  'sold'
];

const NEXT_STEP_LOOKUP = {
  'manufactured': 'inspect',
  'inspected': 'ship',
  'shipped': 'receive',
  'stocked': 'label',
  'labeled': 'sell'
};

const NEXT_STATE_LOOKUP = {
  'manufactured': 'inspected',
  'inspected': 'shipped',
  'shipped': 'stocked',
  'stocked': 'labeled',
  'labeled': 'sold'
};

export {
  STATES,
  NEXT_STEP_LOOKUP,
  NEXT_STATE_LOOKUP
};

UpdateProductState.js - This component is used to initiate a state transition in a product.

import React from 'react';
import { useMutation } from '@apollo/react-hooks';
import gql from 'graphql-tag';
import moment from 'moment';
import { NEXT_STEP_LOOKUP, NEXT_STATE_LOOKUP } from './states';

const UPDATE_PRODUCT_STATE_MUTATION = gql`
mutation($id: ID!, $transition: String!) {
  updateProductState(id: $id, transition: $transition) {
    id
    state
    history {
      manufactured
      inspected
      shipped
      stocked
      labeled
      sold
    }
  }
}`;

export default ({product, updateProduct, clickedButton, setClickedButton}) => {
  let tempProductDuringUpdate = {};
  
  const [
    updateProductState,
    { loading: mutationLoading }
  ] = useMutation(
    UPDATE_PRODUCT_STATE_MUTATION, {
      refetchQueries: ['GetProducts'],
      awaitRefetchQueries: true,
      onCompleted({updateProductState}) {
        // console.log('onCompleted', updateProductState);
      },
      onError(error) {
        if (product.__oldRecord) {
          updateProduct(product.__oldRecord);
        }
      }
    }
  );
  const transition = NEXT_STEP_LOOKUP[product.state];
  const nextState = NEXT_STATE_LOOKUP[product.state];
  return (
    <div>
      {mutationLoading && <div className="loader">Loading...</div>}
      {!(mutationLoading || clickedButton === nextState) &&
        <form
          onSubmit={e => {
            e.preventDefault();
            setClickedButton(nextState);
            let newHistory = Object.assign({}, product.history);
            newHistory[nextState] = moment().toISOString();
            tempProductDuringUpdate = {
              id: product.id,
              state: nextState,
              history: newHistory,
              __oldRecord: product
            };
            updateProductState({
              variables: {
                id: product.id,
                transition: transition
              }
            });
            updateProduct(tempProductDuringUpdate);
          }}
        >
          {transition && (
            <button type="submit">{transition}</button>
          )}
        </form>
      }
    </div>
  );
};