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
<a href="https://supply-chain-blockchain.workshops.aws.dev">supply chain workshop</a> for
<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>
);
};