Use BigCommerce App Extensions to build a size chart app for Catalyst storefronts
Expected time: 30 minutes
This tutorial will show you how to build a size chart app for BigCommerce Catalyst storefronts, using App Extensions.
By the end of this tutorial, you will have:
- Set up a BigCommerce connection
- Stored BigCommerce data in a Gadget database
- Subscribed to BigCommerce webhooks and synced historical data
- Built a BigCommerce App Extension
- Set up a Catalyst storefront that reads size chart info from your Gadget app
Prerequisites
Before starting, you will need:
- A BigCommerce partner account
- A BigCommerce sandbox store
- A BigCommerce Catalyst project set up locally
Step 1: Create a new Gadget app and connect to BigCommerce
You will start by creating a new Gadget app and connecting to BigCommerce.
- Create a new Gadget app at gadget.new, select the BigCommerce app type, and give your app a name.
- Click the Connect to BigCommerce button on your app's home page.
- Create a new BigCommerce app in the BigCommerce Developer Portal.
- Copy the Auth callback URL and Load callback URL from Gadget to your BigCommerce app.
- Select the Products Read-only and App Extensions Manage OAuth scopes and click Update & Close in the BigCommerce app.
- In the BigCommerce Developer Portal, click View Client ID for your new app and copy the Client ID and Client Secret to Gadget, then click Continue.
- In your BigCommerce sandbox store, navigate to Apps → My Draft Apps, hover over your newly added app, click Learn more.
- Click Install.
You now have a full-stack, single-click BigCommerce app in your store control panel. OAuth and frontend sessions are handled, and you can subscribe to BigCommerce webhooks.
Step 2: Add a product data model
You need to store both product data and size charts created by merchants in your Gadget database. You can create data models in Gadget to store this data.
- Right-click on the
api/models/bigcommerce
directory in the Gadget file tree and select Add model. - Name the model
product
and add the following fields and validations:
Field name | Field type | Validations |
---|---|---|
name | string | Required |
sizeChart | json | |
store | belongs to | Required |
bigcommerceId | number | Uniqueness (scoped by store ), Required |
When a model is created and edited Gadget will automatically run the required mutations on the underlying database. A CRUD API will also be automatically generated for your model. Read more about data models in Gadget.
- For the
store
field, select thebigcommerce/store
model as the parent model, so thatbigcommerce/store
has manybigcommerce/product
.
For multi-tenant apps, you may have multiple stores whose resources have the same bigcommerceId
. To avoid conflicts, you can scope the
Uniqueness validation on bigcommerceId
by the store
relationship. This ensures that bigcommerceId
is unique per store. Read more
about multi-tenancy in BigCommerce apps.
Step 3: Add appExtensionId
field to bigcommerce/store
You also want to store the App Extension ID on your bigcommerce/store
model so the extension can be referenced from your app.
Add the following field at api/models/bigcommerce/store/schema
:
Field name | Field type |
---|---|
appExtensionId | string |
Step 4: Subscribe to store/product
webhooks
You can use webhooks to keep your product data in Gadget up to date with data in a store.
- Click the + button next to
api/actions
and enterbigcommerce/handleProductWebhooks.js
. This creates abigcommerce
namespace folder and your new action. - Click the + button in the action's Triggers card and select BigCommerce.
- Select the
store/product/created
,store/product/updated
, andstore/product/deleted
webhook scopes. - (Optional) Remove the Generated API endpoint trigger from the action.
Now this action will run anytime a product is created, updated, or deleted in BigCommerce.
When a product webhook is fired, you want to call the bigcommerce/product
model's actions to create, update, or delete records in the Gadget database.
Notice that the upsert
meta API is used to handle store/product/updated
webhooks. This is
because the product may not yet exist in your database.
- Paste the following code in
api/actions/bigcommerce/handleProductWebhooks.js
:
1import { ActionOptions } from "gadget-server";23export const run: ActionRun = async ({ params, api, connections, trigger }) => {4 // get the BigCommerce API client for the current store5 const bigcommerce = connections.bigcommerce.current!;67 if (trigger.type !== "bigcommerce_webhook") {8 throw new Error("This action can only be triggered by a BigCommerce webhook");9 }1011 // handle store/product/deleted webhooks12 if (trigger.scope === "store/product/deleted") {13 const productRecordToDelete = await api.bigcommerce.product.maybeFindFirst({14 filter: { bigcommerceId: { equals: params.id } },15 select: { id: true },16 });17 if (productRecordToDelete) {18 // if it exists, delete it19 await api.bigcommerce.product.delete(productRecordToDelete.id);20 }21 return;22 }2324 // handle store/product/created and store/product/updated webhooks25 // fetch the product data26 const product = await bigcommerce.v3.get("/catalog/products/{product_id}", {27 path: {28 product_id: params.id,29 },30 });3132 if (!product) {33 throw new Error("Product not found");34 }3536 // upsert product data into the model37 await api.bigcommerce.product.upsert({38 bigcommerceId: product.id,39 store: {40 // get the bigcommerce/store id for the record stored in Gadget41 _link: connections.bigcommerce.currentStoreId,42 },43 name: product.name,44 on: ["bigcommerceId", "store"],45 });46};4748export const options: ActionOptions = {49 triggers: {50 api: false,51 bigcommerce: {52 webhooks: [53 "store/product/created",54 "store/product/updated",55 "store/product/deleted",56 ],57 },58 },59};
1import { ActionOptions } from "gadget-server";23export const run: ActionRun = async ({ params, api, connections, trigger }) => {4 // get the BigCommerce API client for the current store5 const bigcommerce = connections.bigcommerce.current!;67 if (trigger.type !== "bigcommerce_webhook") {8 throw new Error("This action can only be triggered by a BigCommerce webhook");9 }1011 // handle store/product/deleted webhooks12 if (trigger.scope === "store/product/deleted") {13 const productRecordToDelete = await api.bigcommerce.product.maybeFindFirst({14 filter: { bigcommerceId: { equals: params.id } },15 select: { id: true },16 });17 if (productRecordToDelete) {18 // if it exists, delete it19 await api.bigcommerce.product.delete(productRecordToDelete.id);20 }21 return;22 }2324 // handle store/product/created and store/product/updated webhooks25 // fetch the product data26 const product = await bigcommerce.v3.get("/catalog/products/{product_id}", {27 path: {28 product_id: params.id,29 },30 });3132 if (!product) {33 throw new Error("Product not found");34 }3536 // upsert product data into the model37 await api.bigcommerce.product.upsert({38 bigcommerceId: product.id,39 store: {40 // get the bigcommerce/store id for the record stored in Gadget41 _link: connections.bigcommerce.currentStoreId,42 },43 name: product.name,44 on: ["bigcommerceId", "store"],45 });46};4748export const options: ActionOptions = {49 triggers: {50 api: false,51 bigcommerce: {52 webhooks: [53 "store/product/created",54 "store/product/updated",55 "store/product/deleted",56 ],57 },58 },59};
This will handle the product webhook topics and call the required bigcommerce/product
action. The products in your Gadget database will now stay up to date with any BigCommerce stores that have installed your app.
Step 5: Sync product data and set up a BigCommerce App Extension
You still need a way to sync existing product data into your data models. You also need to set up a BigCommerce App Extension.
You can do both of these things together in the api/models/bigcommerce/store/actions/install.js
action. This action is run immediately after your app is installed on store.
Start by creating another global action to handle the data sync using Gadget's built-in background actions.
- Create a new file
syncProducts.js
inapi/actions/bigcommerce
and paste the following code:
1import { ActionOptions } from "gadget-server";23export const run: ActionRun = async ({ params, api, connections }) => {4 // set the batch size to 50 for bulk upsert5 const BATCH_SIZE = 50;67 if (!params.storeHash) {8 throw new Error("Store hash is required");9 }1011 const bigcommerce = await connections.bigcommerce.forStoreHash(params.storeHash);12 // use the API client to fetch all products, and return13 const products = await bigcommerce.v3.list(`/catalog/products`);14 // get the id of the store record in Gadget db15 const store = await api.bigcommerce.store.findFirst({16 filter: {17 storeHash: {18 equals: params.storeHash,19 },20 },21 select: {22 id: true,23 },24 });2526 const productPayload = [];27 // use a for await loop to iterate over the AsyncIterables, add to an array28 for await (const product of products) {29 productPayload.push({30 // use bigcommerceId and store to identify unique records for upsert31 on: ["bigcommerceId", "store"],32 // store the BigCommerce ID33 bigcommerceId: product.id,34 // associate the product with the current store35 store: {36 _link: store.id,37 },38 name: product.name,39 });4041 // enqueue 50 actions at a time42 if (productPayload.length >= BATCH_SIZE) {43 const section = productPayload.splice(0, BATCH_SIZE);44 // bulk enqueue create action45 await api.enqueue(api.bigcommerce.product.bulkUpsert, section, {46 queue: { name: "product-sync" },47 });4849 // delay for a second, don't exceed rate limits!50 await new Promise((r) => setTimeout(r, 1000));5152 // ONLY SYNC 50 PRODUCTS FOR THE TUTORIAL53 break;54 }55 }5657 // enqueue any remaining products58 await api.enqueue(api.bigcommerce.product.bulkUpsert, productPayload, {59 queue: { name: "product-sync" },60 });61};6263export const options: ActionOptions = {64 // 15 minute timeout for the sync65 timeoutMS: 900000,66};6768// accept store hash as action param69export const params = {70 storeHash: { type: "string" },71};
1import { ActionOptions } from "gadget-server";23export const run: ActionRun = async ({ params, api, connections }) => {4 // set the batch size to 50 for bulk upsert5 const BATCH_SIZE = 50;67 if (!params.storeHash) {8 throw new Error("Store hash is required");9 }1011 const bigcommerce = await connections.bigcommerce.forStoreHash(params.storeHash);12 // use the API client to fetch all products, and return13 const products = await bigcommerce.v3.list(`/catalog/products`);14 // get the id of the store record in Gadget db15 const store = await api.bigcommerce.store.findFirst({16 filter: {17 storeHash: {18 equals: params.storeHash,19 },20 },21 select: {22 id: true,23 },24 });2526 const productPayload = [];27 // use a for await loop to iterate over the AsyncIterables, add to an array28 for await (const product of products) {29 productPayload.push({30 // use bigcommerceId and store to identify unique records for upsert31 on: ["bigcommerceId", "store"],32 // store the BigCommerce ID33 bigcommerceId: product.id,34 // associate the product with the current store35 store: {36 _link: store.id,37 },38 name: product.name,39 });4041 // enqueue 50 actions at a time42 if (productPayload.length >= BATCH_SIZE) {43 const section = productPayload.splice(0, BATCH_SIZE);44 // bulk enqueue create action45 await api.enqueue(api.bigcommerce.product.bulkUpsert, section, {46 queue: { name: "product-sync" },47 });4849 // delay for a second, don't exceed rate limits!50 await new Promise((r) => setTimeout(r, 1000));5152 // ONLY SYNC 50 PRODUCTS FOR THE TUTORIAL53 break;54 }55 }5657 // enqueue any remaining products58 await api.enqueue(api.bigcommerce.product.bulkUpsert, productPayload, {59 queue: { name: "product-sync" },60 });61};6263export const options: ActionOptions = {64 // 15 minute timeout for the sync65 timeoutMS: 900000,66};6768// accept store hash as action param69export const params = {70 storeHash: { type: "string" },71};
When run, this will fetch all product data from a BigCommerce store and enqueue upserts using Gadget's background actions. Background actions are used so you get the built-in retry handling to ensure data is reliably added to your database in bulk.
This sample code will break
the product iteration loop after 50 products this is to limit resources used while building this tutorial.
Remove the break
in the snippet to sync all product data for production apps.
Now you can call your sync action and set up a BigCommerce App Extension in the install
action.
You will provide a url for the App Extension that you will hook up to a frontend route soon: "/products/${id}/size-chart"
.
- Paste the following in
api/models/bigcommerce/store/actions/install.js
:
1import { applyParams, save, ActionOptions } from "gadget-server";23export const run: ActionRun = async ({ params, record }) => {4 applyParams(params, record);5 await save(record);6};78export const onSuccess: ActionOnSuccess = async ({9 record,10 logger,11 api,12 params,13}) => {14 if (!params?.store?.storeHash) {15 throw new Error("No store hash found, cannot install app");16 }17 // use internal API to read access token for app extension registration18 const store = await api.internal.bigcommerce.store.findFirst({19 filter: {20 storeHash: { equals: params.store.storeHash },21 },22 select: {23 accessToken: true,24 },25 });2627 // use fetch for GraphQL request (GraphQL not supported by built-in client)28 const response = await fetch(29 `https://api.bigcommerce.com/stores/${record.storeHash}/graphql`,30 {31 method: "POST",32 headers: {33 "Content-Type": "application/json",34 Accept: "application/json",35 "X-Auth-Token": store.accessToken,36 },37 body: JSON.stringify({38 query: `mutation AppExtension($input: CreateAppExtensionInput!) {39 appExtension {40 createAppExtension(input: $input) {41 appExtension {42 id43 context44 model45 url46 label {47 defaultValue48 locales {49 value50 localeCode51 }52 }53 }54 }55 }56 }`,57 variables: {58 // edit input to match your desired App Extension59 input: {60 context: "PANEL",61 model: "PRODUCTS",62 url: "/products/${id}/size-chart",63 label: {64 defaultValue: "Size chart",65 locales: [66 {67 value: "Size chart",68 localeCode: "en-US",69 },70 ],71 },72 },73 },74 }),75 }76 );7778 const jsonResponse = await response.json();7980 if (jsonResponse.errors) {81 logger.error({ errors: jsonResponse.errors }, "Error creating app extension");82 }8384 // save the App Extension id to your bigcommerce/store model85 await api.internal.bigcommerce.store.update(record.id, {86 appExtensionId:87 jsonResponse.data.appExtension.createAppExtension.appExtension.id,88 });8990 // sync existing product data from BigCommerce store to Gadget db91 // use background action so install is not blocked92 await api.enqueue(api.bigcommerce.syncProducts, {93 storeHash: record.storeHash,94 });95};9697export const options: ActionOptions = {98 actionType: "create",99};
1import { applyParams, save, ActionOptions } from "gadget-server";23export const run: ActionRun = async ({ params, record }) => {4 applyParams(params, record);5 await save(record);6};78export const onSuccess: ActionOnSuccess = async ({9 record,10 logger,11 api,12 params,13}) => {14 if (!params?.store?.storeHash) {15 throw new Error("No store hash found, cannot install app");16 }17 // use internal API to read access token for app extension registration18 const store = await api.internal.bigcommerce.store.findFirst({19 filter: {20 storeHash: { equals: params.store.storeHash },21 },22 select: {23 accessToken: true,24 },25 });2627 // use fetch for GraphQL request (GraphQL not supported by built-in client)28 const response = await fetch(29 `https://api.bigcommerce.com/stores/${record.storeHash}/graphql`,30 {31 method: "POST",32 headers: {33 "Content-Type": "application/json",34 Accept: "application/json",35 "X-Auth-Token": store.accessToken,36 },37 body: JSON.stringify({38 query: `mutation AppExtension($input: CreateAppExtensionInput!) {39 appExtension {40 createAppExtension(input: $input) {41 appExtension {42 id43 context44 model45 url46 label {47 defaultValue48 locales {49 value50 localeCode51 }52 }53 }54 }55 }56 }`,57 variables: {58 // edit input to match your desired App Extension59 input: {60 context: "PANEL",61 model: "PRODUCTS",62 url: "/products/${id}/size-chart",63 label: {64 defaultValue: "Size chart",65 locales: [66 {67 value: "Size chart",68 localeCode: "en-US",69 },70 ],71 },72 },73 },74 }),75 }76 );7778 const jsonResponse = await response.json();7980 if (jsonResponse.errors) {81 logger.error({ errors: jsonResponse.errors }, "Error creating app extension");82 }8384 // save the App Extension id to your bigcommerce/store model85 await api.internal.bigcommerce.store.update(record.id, {86 appExtensionId:87 jsonResponse.data.appExtension.createAppExtension.appExtension.id,88 });8990 // sync existing product data from BigCommerce store to Gadget db91 // use background action so install is not blocked92 await api.enqueue(api.bigcommerce.syncProducts, {93 storeHash: record.storeHash,94 });95};9697export const options: ActionOptions = {98 actionType: "create",99};
This action:
- uses a GraphQL request to set up an App Extension
- stores the ID of the App Extension on the
bigcommerce/store
model - kicks off a data sync by calling
api.syncProducts()
For more details on working with App Extensions in Gadget, read our docs.
Running the install action
You have already installed your app on a store. This means that to run this action you need to:
- uninstall the app from your sandbox store
- delete the store record in Gadget at
api/models/bigcommerce/store/data
- reinstall the app on your sandbox store
Note: For production apps, you may want to run this same code in both the bigcommerce/store/install
and bigcommerce/store/reinstall
actions.
Step 6: Access control
All Gadget apps have authorization built in. A role-based access control system allows us to restrict API and data access to merchants and shoppers.
The bigcommerce-app-users
role is automatically assigned to merchants who install your app in BigCommerce. Storefront shoppers will be granted the unauthenticated
role. You can read more about roles in our docs.
You need to grant both merchants and shoppers access to the actions that power size chart frontends:
- Navigate to the
accessControl/permissions
page. - Grant the
bigcommerce-app-users
role access to thebigcommerce/product/
model'sread
andupdate
actions. This will allow merchants to create and save size charts for products. - Grant the
unauthenticated
role access to thebigcommerce/product
model'sread
API. This allows shoppers to read the size charts in a storefront.
Read our data security and multi-tenancy docs to learn how to secure your actions when building apps that will be installed on multiple stores.
Step 7: App Extension Size Chart code
Now you are ready to build your App Extension frontend. Gadget frontends are built with React and all code is located in the web
folder. BigDesign has also been installed for you.
An API client has been set up for you in web/api.js
. While you build, and add model and actions to your app, Gadget will keep this client up to date. You will use this client, along with React hooks from the @gadgetinc/react
package, to call your actions and save changes to size charts.
Start by adding a new route component to your frontend. The next step will be defining this route on the frontend router.
- Create a new file
size-chart.jsx
inweb/routes
and paste the following code:
1import { Box, Button, Flex, FlexItem, Form, H2, Input, Message, Panel, StatefulTable, Text } from "@bigcommerce/big-design";2import { AddIcon, RemoveIcon } from "@bigcommerce/big-design-icons";3import { useAction, useFindFirst } from "@gadgetinc/react";4import { ChangeEvent, FormEvent, memo, useCallback, useEffect, useMemo, useState } from "react";5import { useParams } from "react-router";6import { api } from "../api";78// sample data used when no size chart is present9const SAMPLE_COLUMNS = [{ hash: "column1" }, { hash: "column2" }, { hash: "column3" }, { hash: "column4" }];10const SAMPLE_ITEMS = [11 {12 id: "header",13 column1: "Size",14 column2: "S",15 column3: "M",16 column4: "L",17 },18 {19 id: "row1",20 column1: "Chest",21 column2: '34-36"',22 column3: '37-38"',23 column4: '39-40"',24 },25 {26 id: "row2",27 column1: "Waist",28 column2: '30-32"',29 column3: '33-34"',30 column4: '35-36"',31 },32];3334// buttons for adding or removing columns and rows to table35const TableButtons = ({36 name,37 onAdd,38 onRemove,39 disableRemove,40}: {41 name: string;42 onAdd: () => void;43 onRemove: () => void;44 disableRemove: boolean;45}) => (46 <Flex style={{ width: "250px", padding: "10px" }} flexDirection="row" justifyContent="space-between" alignItems="center">47 <FlexItem flexShrink={1}>48 <Text>{name}</Text>49 </FlexItem>50 <FlexItem>51 <Flex flexDirection="row" alignItems="center" style={{ gap: "4px" }}>52 <FlexItem>53 <Button iconOnly={<RemoveIcon title="remove" />} onClick={() => onRemove()} disabled={disableRemove} type="button" />54 </FlexItem>55 <FlexItem>56 <Button iconOnly={<AddIcon title="add" />} onClick={() => onAdd()} type="button" />57 </FlexItem>58 </Flex>59 </FlexItem>60 </Flex>61);6263// memoized cell that can be edited64const EditableCell = memo(({ initialValue, onSave }: { initialValue: string; onSave: (value: string) => void }) => {65 const [value, setValue] = useState(initialValue);6667 const handleChange = (e: ChangeEvent<HTMLInputElement>) => {68 setValue(e.target.value);69 };7071 const handleBlur = () => {72 if (value !== initialValue) {73 onSave(value);74 }75 };7677 return <Input type="text" value={value} onChange={handleChange} onBlur={handleBlur} />;78});7980// the actual size chart81const SizeChart = ({82 product,83 isErrorVisible,84 setIsErrorVisible,85}: {86 product: {87 bigcommerceId: string;88 id: string;89 name: string;90 sizeChart?: {91 items: any[];92 columns: any[];93 };94 };95 isErrorVisible: boolean;96 setIsErrorVisible: (value: boolean) => void;97}) => {98 // call Gadget action to save size chart99 const [{ data: savedChart, fetching: savingChart, error: chartSaveError }, saveSizeChart] = useAction(api.bigcommerce.product.update);100101 // state for the chart (in BigDesign StatefulTable input format)102 const [columns, setColumns] = useState(product.sizeChart?.columns || SAMPLE_COLUMNS);103104 const [items, setItems] = useState(product.sizeChart?.items || SAMPLE_ITEMS);105106 // state for managing error and success message visibility107 const [isSaveVisible, setIsSaveVisible] = useState(false);108109 // manage message visibility110 useEffect(() => {111 if (!savingChart && savedChart) {112 setIsSaveVisible(true);113 }114 }, [savingChart, savedChart]);115116 useEffect(() => {117 if (chartSaveError) {118 setIsErrorVisible(true);119 }120 }, [chartSaveError]);121122 // update state when a cell is edited123 const handleCellChange = useCallback((itemId: string, columnHash: string, value: string) => {124 setItems((prevItems) => prevItems.map((item) => (item.id === itemId ? { ...item, [columnHash]: value } : item)));125 if (itemId === "header") {126 setColumns((prevColumns) => prevColumns.map((col) => (col.hash === columnHash ? { ...col, header: value } : col)));127 }128 }, []);129130 // render editable (and memoized) cells131 const renderCell = (handler: typeof handleCellChange) => (column: { hash: string }) => {132 return ({ [column.hash]: value, id }: { [key: string]: string }) => (133 <EditableCell key={`${id}-${column.hash}`} initialValue={value || ""} onSave={(newValue) => handler(id, column.hash, newValue)} />134 );135 };136137 // memoized cells prevent re-renders of the whole table when state is updated138 const memoizedRenderCell = useCallback(renderCell(handleCellChange), [handleCellChange]);139140 // columns rendered in the table141 const tableColumns = useMemo(142 () =>143 columns.map((column) => ({144 ...column,145 render: memoizedRenderCell(column),146 })),147 [columns, memoizedRenderCell]148 );149150 // callback for adding a new column151 // edit column and item state152 const addColumn = useCallback(() => {153 const newColumnHash = `column${columns.length + 1}`;154 setColumns((prevColumns) => [...prevColumns, { hash: newColumnHash }]);155 setItems((prevItems) =>156 prevItems.map((item) => ({157 ...item,158 [newColumnHash]: "",159 }))160 );161 }, [columns.length]);162163 // callback for removing a new column164 // edit column and item state165 const removeColumn = useCallback(() => {166 if (columns.length > 2) {167 const updatedColumns = [...columns];168 const removedColumn = updatedColumns.pop();169 setColumns(updatedColumns);170 setItems((prevItems) => prevItems.map(({ [removedColumn.hash]: _, ...rest }) => rest));171 }172 }, [columns]);173174 // edit item state when a row is added175 const addRow = useCallback(() => {176 const newRow = {177 id: `row${items.length}`,178 ...Object.fromEntries(columns.map((col) => [col.hash, ""])),179 };180 setItems((prevItems) => [...prevItems, newRow]);181 }, [columns, items.length]);182183 // edit item state when a row is removed184 const removeRow = useCallback(() => {185 if (items.length > 2) {186 setItems((prevItems) => prevItems.slice(0, -1));187 }188 }, [items.length]);189190 // save the chart191 const onSubmit = useCallback(192 async (event: FormEvent<HTMLFormElement>) => {193 event.preventDefault();194195 setIsSaveVisible(false);196 await saveSizeChart({197 id: product.id,198 sizeChart: { columns, items },199 });200 },201 [items]202 );203204 return (205 <>206 <H2>Size chart for {product.name}</H2>207 {!product.sizeChart && !savedChart && <Text>Sample chart provided. Make edits and save.</Text>}208 {isSaveVisible && <Message header="Success" onClose={() => setIsSaveVisible(false)} messages={[{ text: "Size chart saved" }]} />}209 {isErrorVisible && (210 <Message211 header="Error on save"212 type="error"213 messages={[214 {215 text: `Error: ${chartSaveError?.message}`,216 },217 ]}218 onClose={() => setIsErrorVisible(false)}219 />220 )}221 <Form onSubmit={onSubmit}>222 <StatefulTable columns={tableColumns} items={items} keyField="id" headerless />223 <Box marginTop="medium">224 <TableButtons name="Columns" onAdd={addColumn} onRemove={removeColumn} disableRemove={columns.length <= 2} />225226 <TableButtons name="Rows" onAdd={addRow} onRemove={removeRow} disableRemove={items.length <= 2} />227 </Box>228 <Box marginTop="medium">229 <Button type="submit" disabled={savingChart}>230 Save chart231 </Button>232 </Box>233 </Form>234 </>235 );236};237238export default function () {239 const params = useParams();240 const productId = params.productId!;241 const [isErrorVisible, setIsErrorVisible] = useState(false);242243 const [{ data: product, fetching, error }] = useFindFirst(api.bigcommerce.product, {244 select: { id: true, name: true, sizeChart: true },245 filter: { bigcommerceId: { equals: parseInt(productId) } },246 });247248 if (!product) {249 return <Text>Product not found</Text>;250 }251252 return (253 <Panel>254 {error && (255 <Message256 header="Error fetching chart"257 type="error"258 messages={[259 {260 text: `Error: ${error.message}`,261 },262 ]}263 onClose={() => setIsErrorVisible(false)}264 />265 )}266 {fetching ? (267 <Text>Loading...</Text>268 ) : (269 <SizeChart270 product={{271 bigcommerceId: productId,272 id: product.id,273 name: product.name,274 sizeChart: product.sizeChart as any,275 }}276 isErrorVisible={isErrorVisible}277 setIsErrorVisible={setIsErrorVisible}278 />279 )}280 </Panel>281 );282}
1import { Box, Button, Flex, FlexItem, Form, H2, Input, Message, Panel, StatefulTable, Text } from "@bigcommerce/big-design";2import { AddIcon, RemoveIcon } from "@bigcommerce/big-design-icons";3import { useAction, useFindFirst } from "@gadgetinc/react";4import { ChangeEvent, FormEvent, memo, useCallback, useEffect, useMemo, useState } from "react";5import { useParams } from "react-router";6import { api } from "../api";78// sample data used when no size chart is present9const SAMPLE_COLUMNS = [{ hash: "column1" }, { hash: "column2" }, { hash: "column3" }, { hash: "column4" }];10const SAMPLE_ITEMS = [11 {12 id: "header",13 column1: "Size",14 column2: "S",15 column3: "M",16 column4: "L",17 },18 {19 id: "row1",20 column1: "Chest",21 column2: '34-36"',22 column3: '37-38"',23 column4: '39-40"',24 },25 {26 id: "row2",27 column1: "Waist",28 column2: '30-32"',29 column3: '33-34"',30 column4: '35-36"',31 },32];3334// buttons for adding or removing columns and rows to table35const TableButtons = ({36 name,37 onAdd,38 onRemove,39 disableRemove,40}: {41 name: string;42 onAdd: () => void;43 onRemove: () => void;44 disableRemove: boolean;45}) => (46 <Flex style={{ width: "250px", padding: "10px" }} flexDirection="row" justifyContent="space-between" alignItems="center">47 <FlexItem flexShrink={1}>48 <Text>{name}</Text>49 </FlexItem>50 <FlexItem>51 <Flex flexDirection="row" alignItems="center" style={{ gap: "4px" }}>52 <FlexItem>53 <Button iconOnly={<RemoveIcon title="remove" />} onClick={() => onRemove()} disabled={disableRemove} type="button" />54 </FlexItem>55 <FlexItem>56 <Button iconOnly={<AddIcon title="add" />} onClick={() => onAdd()} type="button" />57 </FlexItem>58 </Flex>59 </FlexItem>60 </Flex>61);6263// memoized cell that can be edited64const EditableCell = memo(({ initialValue, onSave }: { initialValue: string; onSave: (value: string) => void }) => {65 const [value, setValue] = useState(initialValue);6667 const handleChange = (e: ChangeEvent<HTMLInputElement>) => {68 setValue(e.target.value);69 };7071 const handleBlur = () => {72 if (value !== initialValue) {73 onSave(value);74 }75 };7677 return <Input type="text" value={value} onChange={handleChange} onBlur={handleBlur} />;78});7980// the actual size chart81const SizeChart = ({82 product,83 isErrorVisible,84 setIsErrorVisible,85}: {86 product: {87 bigcommerceId: string;88 id: string;89 name: string;90 sizeChart?: {91 items: any[];92 columns: any[];93 };94 };95 isErrorVisible: boolean;96 setIsErrorVisible: (value: boolean) => void;97}) => {98 // call Gadget action to save size chart99 const [{ data: savedChart, fetching: savingChart, error: chartSaveError }, saveSizeChart] = useAction(api.bigcommerce.product.update);100101 // state for the chart (in BigDesign StatefulTable input format)102 const [columns, setColumns] = useState(product.sizeChart?.columns || SAMPLE_COLUMNS);103104 const [items, setItems] = useState(product.sizeChart?.items || SAMPLE_ITEMS);105106 // state for managing error and success message visibility107 const [isSaveVisible, setIsSaveVisible] = useState(false);108109 // manage message visibility110 useEffect(() => {111 if (!savingChart && savedChart) {112 setIsSaveVisible(true);113 }114 }, [savingChart, savedChart]);115116 useEffect(() => {117 if (chartSaveError) {118 setIsErrorVisible(true);119 }120 }, [chartSaveError]);121122 // update state when a cell is edited123 const handleCellChange = useCallback((itemId: string, columnHash: string, value: string) => {124 setItems((prevItems) => prevItems.map((item) => (item.id === itemId ? { ...item, [columnHash]: value } : item)));125 if (itemId === "header") {126 setColumns((prevColumns) => prevColumns.map((col) => (col.hash === columnHash ? { ...col, header: value } : col)));127 }128 }, []);129130 // render editable (and memoized) cells131 const renderCell = (handler: typeof handleCellChange) => (column: { hash: string }) => {132 return ({ [column.hash]: value, id }: { [key: string]: string }) => (133 <EditableCell key={`${id}-${column.hash}`} initialValue={value || ""} onSave={(newValue) => handler(id, column.hash, newValue)} />134 );135 };136137 // memoized cells prevent re-renders of the whole table when state is updated138 const memoizedRenderCell = useCallback(renderCell(handleCellChange), [handleCellChange]);139140 // columns rendered in the table141 const tableColumns = useMemo(142 () =>143 columns.map((column) => ({144 ...column,145 render: memoizedRenderCell(column),146 })),147 [columns, memoizedRenderCell]148 );149150 // callback for adding a new column151 // edit column and item state152 const addColumn = useCallback(() => {153 const newColumnHash = `column${columns.length + 1}`;154 setColumns((prevColumns) => [...prevColumns, { hash: newColumnHash }]);155 setItems((prevItems) =>156 prevItems.map((item) => ({157 ...item,158 [newColumnHash]: "",159 }))160 );161 }, [columns.length]);162163 // callback for removing a new column164 // edit column and item state165 const removeColumn = useCallback(() => {166 if (columns.length > 2) {167 const updatedColumns = [...columns];168 const removedColumn = updatedColumns.pop();169 setColumns(updatedColumns);170 setItems((prevItems) => prevItems.map(({ [removedColumn.hash]: _, ...rest }) => rest));171 }172 }, [columns]);173174 // edit item state when a row is added175 const addRow = useCallback(() => {176 const newRow = {177 id: `row${items.length}`,178 ...Object.fromEntries(columns.map((col) => [col.hash, ""])),179 };180 setItems((prevItems) => [...prevItems, newRow]);181 }, [columns, items.length]);182183 // edit item state when a row is removed184 const removeRow = useCallback(() => {185 if (items.length > 2) {186 setItems((prevItems) => prevItems.slice(0, -1));187 }188 }, [items.length]);189190 // save the chart191 const onSubmit = useCallback(192 async (event: FormEvent<HTMLFormElement>) => {193 event.preventDefault();194195 setIsSaveVisible(false);196 await saveSizeChart({197 id: product.id,198 sizeChart: { columns, items },199 });200 },201 [items]202 );203204 return (205 <>206 <H2>Size chart for {product.name}</H2>207 {!product.sizeChart && !savedChart && <Text>Sample chart provided. Make edits and save.</Text>}208 {isSaveVisible && <Message header="Success" onClose={() => setIsSaveVisible(false)} messages={[{ text: "Size chart saved" }]} />}209 {isErrorVisible && (210 <Message211 header="Error on save"212 type="error"213 messages={[214 {215 text: `Error: ${chartSaveError?.message}`,216 },217 ]}218 onClose={() => setIsErrorVisible(false)}219 />220 )}221 <Form onSubmit={onSubmit}>222 <StatefulTable columns={tableColumns} items={items} keyField="id" headerless />223 <Box marginTop="medium">224 <TableButtons name="Columns" onAdd={addColumn} onRemove={removeColumn} disableRemove={columns.length <= 2} />225226 <TableButtons name="Rows" onAdd={addRow} onRemove={removeRow} disableRemove={items.length <= 2} />227 </Box>228 <Box marginTop="medium">229 <Button type="submit" disabled={savingChart}>230 Save chart231 </Button>232 </Box>233 </Form>234 </>235 );236};237238export default function () {239 const params = useParams();240 const productId = params.productId!;241 const [isErrorVisible, setIsErrorVisible] = useState(false);242243 const [{ data: product, fetching, error }] = useFindFirst(api.bigcommerce.product, {244 select: { id: true, name: true, sizeChart: true },245 filter: { bigcommerceId: { equals: parseInt(productId) } },246 });247248 if (!product) {249 return <Text>Product not found</Text>;250 }251252 return (253 <Panel>254 {error && (255 <Message256 header="Error fetching chart"257 type="error"258 messages={[259 {260 text: `Error: ${error.message}`,261 },262 ]}263 onClose={() => setIsErrorVisible(false)}264 />265 )}266 {fetching ? (267 <Text>Loading...</Text>268 ) : (269 <SizeChart270 product={{271 bigcommerceId: productId,272 id: product.id,273 name: product.name,274 sizeChart: product.sizeChart as any,275 }}276 isErrorVisible={isErrorVisible}277 setIsErrorVisible={setIsErrorVisible}278 />279 )}280 </Panel>281 );282}
This frontend route:
- gets the
productId
from the params - loads existing product data, including a size chart if it already exists
- renders a dynamic table component that can be customized
- cells are memoized to prevent redraws on
Input
updates - state is managed with
useState
hooks
- cells are memoized to prevent redraws on
- saves a size chart as json by calling the
api.bigcommerce.product.update
action
Gadget provides the @gadgetinc/react
library which contains useful hooks and tools for building React frontends. The
useFindMany
, useAction
, and useActionForm
hooks are used to fetch data, call actions, and manage form state, respectively.
- Now add the route definition to the frontend router in
web/components/App.jsx
:
1// .. additional imports2import SizeChart from "../routes/size-chart";34// add the new route in the App component5function App() {6 const router = createBrowserRouter(7 createRoutesFromElements(8 <Route path="/" element={<Layout />}>9 <Route index element={<Index />} />10 <Route path="*" element={<Error404 />} />11 {/** add the size chart route */}12 <Route path="/products/:productId/size-chart" element={<SizeChart />} />13 </Route>14 )15 );1617 return <RouterProvider router={router} />;18}
1// .. additional imports2import SizeChart from "../routes/size-chart";34// add the new route in the App component5function App() {6 const router = createBrowserRouter(7 createRoutesFromElements(8 <Route path="/" element={<Layout />}>9 <Route index element={<Index />} />10 <Route path="*" element={<Error404 />} />11 {/** add the size chart route */}12 <Route path="/products/:productId/size-chart" element={<SizeChart />} />13 </Route>14 )15 );1617 return <RouterProvider router={router} />;18}
Testing your App Extension
Now you can test your App Extension. Open up the Size chart extension for a product in your store.
Make sure you select one of the 50 products that was synced to your Gadget database. You can see what products were synced at api/models/bigcommerce/product/data
.
You can modify the number of rows and columns in the chart, and edit the chart contents/measurements.
Once you are done, click Save chart and the chart config will be saved to Gadget as JSON.
Step 8: Draw size chart in Catalyst product page
The last step is rendering the custom size charts on the product pages of a Catalyst storefront.
You need to install a copy of your API client into your Catalyst project to read size chart data from your Gadget models.
Your API client package will have the format @gadget-client/<YOUR-APP-SLUG>
.
- Set up a Catalyst project and start your dev server. This can be an existing storefront or a new project.
cd
into your Catalyst project'score
folder.- Install your Gadget API client:
pnpm install @gadget-client/example-app
You may also need to register the Gadget NPM repository.
npm config set @gadget-client:registry https://registry.gadget.dev/npm
- Create a new file
gadget.ts
incore/
, then initialize and export your client:
import { Client } from "@gadget-client/example-app";export const api = new Client();
- Create a
size-chart.jsx
file incore/app/[locale]/(default)/product/[slug]/_components
and paste the following code:
1import React from "react";2import { api } from "../../../../../../gadget";34// Define types for the size chart data5interface SizeChartItem {6 id: string;7 [key: string]: string;8}910interface SizeChartData {11 items: SizeChartItem[];12 columns: { hash: string }[];13}1415// SizeChart component16export const SizeChart: React.FC<{ productId: number }> = async ({ productId }) => {17 // fetch size chart for product from Gadget API18 const response = await api.bigcommerce.product.findFirst({19 filter: {20 bigcommerceId: {21 equals: productId, // select the size chart for this product22 },23 store: {24 storeHash: {25 equals: process.env.BIGCOMMERCE_STORE_HASH, // for multi-tenant apps, select the size chart for this store26 },27 },28 },29 select: {30 sizeChart: true, // only return the size chart field31 },32 });3334 const { sizeChart } = response.toJSON();3536 // no size chart available, return nothing!37 if (!sizeChart) {38 return null;39 }4041 /// map json response to SizeChartData type42 const data: SizeChartData = {43 items: (sizeChart as any).items as SizeChartItem[],44 columns: (sizeChart as any).columns as { hash: string }[],45 };4647 return (48 <>49 <h2 className="mb-4 text-xl font-bold md:text-2xl">Size chart</h2>50 <table className="table-auto border-collapse border border-gray-300">51 <thead>52 <tr>53 {data.columns.map((column) => (54 <th key={column.hash} className="border border-gray-300 p-2">55 {data.items[0] && data.items[0][column.hash as keyof SizeChartItem]}56 </th>57 ))}58 </tr>59 </thead>60 <tbody>61 {data.items.slice(1).map((item) => (62 <tr key={item.id}>63 {Object.entries(item)64 .filter(([key, _value]) => key !== "id")65 .map(([key, value], index) => (66 <td key={`${key}_${index}`} className="border border-gray-300 p-2">67 {value}68 </td>69 ))}70 </tr>71 ))}72 </tbody>73 </table>74 </>75 );76};
1import React from "react";2import { api } from "../../../../../../gadget";34// Define types for the size chart data5interface SizeChartItem {6 id: string;7 [key: string]: string;8}910interface SizeChartData {11 items: SizeChartItem[];12 columns: { hash: string }[];13}1415// SizeChart component16export const SizeChart: React.FC<{ productId: number }> = async ({ productId }) => {17 // fetch size chart for product from Gadget API18 const response = await api.bigcommerce.product.findFirst({19 filter: {20 bigcommerceId: {21 equals: productId, // select the size chart for this product22 },23 store: {24 storeHash: {25 equals: process.env.BIGCOMMERCE_STORE_HASH, // for multi-tenant apps, select the size chart for this store26 },27 },28 },29 select: {30 sizeChart: true, // only return the size chart field31 },32 });3334 const { sizeChart } = response.toJSON();3536 // no size chart available, return nothing!37 if (!sizeChart) {38 return null;39 }4041 /// map json response to SizeChartData type42 const data: SizeChartData = {43 items: (sizeChart as any).items as SizeChartItem[],44 columns: (sizeChart as any).columns as { hash: string }[],45 };4647 return (48 <>49 <h2 className="mb-4 text-xl font-bold md:text-2xl">Size chart</h2>50 <table className="table-auto border-collapse border border-gray-300">51 <thead>52 <tr>53 {data.columns.map((column) => (54 <th key={column.hash} className="border border-gray-300 p-2">55 {data.items[0] && data.items[0][column.hash as keyof SizeChartItem]}56 </th>57 ))}58 </tr>59 </thead>60 <tbody>61 {data.items.slice(1).map((item) => (62 <tr key={item.id}>63 {Object.entries(item)64 .filter(([key, _value]) => key !== "id")65 .map(([key, value], index) => (66 <td key={`${key}_${index}`} className="border border-gray-300 p-2">67 {value}68 </td>69 ))}70 </tr>71 ))}72 </tbody>73 </table>74 </>75 );76};
This component:
- uses your
api
client to read product size chart data - renders a size chart, if data is returned from Gadget
- Import the
SizeChart
component into your product page atcore/app/[locale]/(default)/product/[slug]/page.tsx
and render the chart:
1// .. other imports2import { SizeChart } from "./_components/size-chart";34// .. interface, type, and function definitions56export default async function Product({ params: { locale, slug }, searchParams }: Props) {7 return (8 <>9 {/* other components such as Breadcrumbs */}1011 <div className="mb-12 mt-4 lg:grid lg:grid-cols-2 lg:gap-8">12 {/* other components such as Gallery and Details */}13 <div className="lg:col-span-2">14 <Description product={product} />1516 {/* other components such as Reviews */}1718 <Suspense fallback={t("loading")}>19 <SizeChart productId={product.entityId} />20 </Suspense>21 </div>22 </div>2324 {/* rest of the page */}25 </>26 );27}
1// .. other imports2import { SizeChart } from "./_components/size-chart";34// .. interface, type, and function definitions56export default async function Product({ params: { locale, slug }, searchParams }: Props) {7 return (8 <>9 {/* other components such as Breadcrumbs */}1011 <div className="mb-12 mt-4 lg:grid lg:grid-cols-2 lg:gap-8">12 {/* other components such as Gallery and Details */}13 <div className="lg:col-span-2">14 <Description product={product} />1516 {/* other components such as Reviews */}1718 <Suspense fallback={t("loading")}>19 <SizeChart productId={product.entityId} />20 </Suspense>21 </div>22 </div>2324 {/* rest of the page */}25 </>26 );27}
For more information on building with Catalyst storefronts, read our docs.
Test it out
You have just built a full stack size chart app, congrats!
You can preview your custom size charts on the storefront by navigating to a product page for which you have built and saved a size chart.
Next steps
- Join Gadget's developer community on Discord
You can extend and customize this app. Some possibilities include:
- importing existing size charts as a CSV into your Gadget database using an action
- displaying the size chart in a model on the storefront