📑

React, Polaris, App Bridge, GraphQLを使ったShopify在庫表示アプリを作ってみる

18 min read

この記事はShopifyアドベントカレンダーの8日目の記事です。

はじめに

2021/12/05時点での、Shopifyでのアプリケーションの作成チュートリアルを参考に在庫表示アプリを作ってみます(公式ドキュメント参考)。
公式ドキュメントはクラスコンポーネントを使ってますが、個人的に古い書き方のように感じるので関数コンポーネントをつかった実装に書き換えてます。

公式ドキュメントの内容は定期的に更新されているようなので、困ったら公式ドキュメントを見ることをおすすめします。

Prerequired

shopify cli ツールのインストールをします。

brew tap shopify/shopify
brew install shopify-cli

シンプルなアプリケーションの作成

公式にある一番シンプルなアプリケーションを作成します。

1. nodeでプロジェクトの作成

shopify app create node
cd <APP_NAME>

2. ngrokのインストール

Shopify上からローカルの環境にアクセスするためにngrokを使用します。
ngrokの利用には、トークンを取得する必要があります。

brew install ngrok
# トークン取得する
open https://dashboard.ngrok.com/get-started/your-authtoken
ngrok authtoken <GET_TOKEN>

3. ローカル開発サーバ

ローカルでアプリを起動します。

shopify app serve

下記のような表示が出るので、リンク先に飛んでインストールします。

⭑ To install and start using your app, open this URL in your browser:
https://xxx.ngrok.io/auth?shop=your-shop.myshopify.com

4. 完成

おめでとうございます!
Installが完了して、下記の表示がでてきたら正常にカスタムアプリのインストールが完了しています。

在庫管理機能アプリの作成

公式サイトには引き続き在庫管理機能アプリの作成デモがあります。
これはマーチャントが商品の在庫を管理するためのアプリを作成します。
マーチャントはこのアプリを通して、商品名や価格などの情報を記録します。

1. サンプルデータの追加

下記コマンドを実行して、サンプル商品を作ります。コマンドを実行し終わると5つの商品ができます。

$ shopify populate products

以下のようなエラーがでる場合がある。その場合こちらを参考にしてください。

✗ Command not allowed with current login. Please check your login details with shopify whoami. You may need to request additional permissions for this action

2. 初期UIの作成

管理アプリのUI設計のために、PolarisというUIコンポーネントを使います。Shopify自体がPolarisで作成されている。そのため、Polarisを使うことであたかもShopify標準の画面であるかのように機能を提供することができます。

初期UI作成のため、pages/index.jsを以下のように変更します。

pages/index.js
import React, { useState } from 'react';
import { Page, Layout, EmptyState } from "@shopify/polaris";

const img = "https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg";

const Index = () => {
  // 後ほど使用する
  const [open, setOpen] = useState(false)

  return (
    <Page>
      <Layout>
        <EmptyState // Empty state component
          heading="Discount your products temporarily"
          action={{
            content: "Select products",
            onAction: () => setOpen(true)
          }}
          image={img}
        >
          <p>Select products to change their price temporarily.</p>
        </EmptyState>
      </Layout>
    </Page>
  )
}

export default Index;

変更すると下記のような画面が表示されます。

3. プロダクト一覧を表示する

マーチャント向け機能の実装を簡単にするために、ShopifyはApp Bridgeと呼ばれるライブラリを用意しています。これを使うことでShopify管理画面の標準の機能の動作を簡単に実装することができます。

「Select products」を押すと製品追加モーダルを表示するために、、pages/index.jsを以下のように変更します。

pages/index.js
import React, { useState } from 'react';
import { Page, Layout, EmptyState } from "@shopify/polaris";
import { ResourcePicker, TitleBar } from '@shopify/app-bridge-react';

const img = "https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg";

const Index = () => {
  const [open, setOpen] = useState(false)

  const handleSelection = (resources) => {
    setOpen(false)
    console.log(resources);
  }

  return (
    <Page>
      <TitleBar
        primaryAction={{
          content: 'Select products',
          onAction: () => setOpen(true),
        }}
      />

      <ResourcePicker // Resource picker component
        resourceType="Product"
        showVariants={false}
        open={open}
        onSelection={(resources) => handleSelection(resources)}
        onCancel={() => setOpen(false)}
      />

      <Layout>
        <EmptyState // Empty state component
          heading="Discount your products temporarily"
          action={{
            content: "Select products",
            onAction: () => setOpen(true)
          }}
          image={img}
        >
          <p>Select products to change their price temporarily.</p>
        </EmptyState>
      </Layout>
    </Page>
  )
}

export default Index;

前セクションと主な違いはResoucePickerが追加されたことです。ResoucePickerのPropsのopenプロパティにtrueをセットするとResourcePickerが表示されます。

      <ResourcePicker // Resource picker component
        resourceType="Product"
        showVariants={false}
        open={open}
        onSelection={(resources) => handleSelection(resources)}
        onCancel={() => setOpen(false)}
      />

またタイトルバーも追加しています

      <TitleBar
        primaryAction={{
          content: 'Select products',
          onAction: () => setOpen(true),
        }}
      />

これで「Select products」をクリックすると製品一覧が表示されます。

4. 選択したリソースを表示する

プロダクト一覧を表示することができたので、次にプロダクト一覧で表示された商品を選択し他商品の製品一覧を表示するできるようにしましょう。

実装の前に

これらを動作させるためには、以下の2つをクリアする必要があります。

  1. 選択した商品の保存及び取り出し
  2. 選択した商品情報の取得

順番に説明していきます。

1. 選択肢した商品の保存

おそらく本番で提供するケースだとDB上に保存する必要がありそうですが、今回はデモなのでローカルストレージを使用します。ローカルストレージへのアクセスするためのライブラリ、store-jsを追加します。

store-jsを使って、値を保存及び取り出しを行います。

$ npm install store-js
2. 選択した商品情報の取得

商品一覧から選択しても、取得できる情報はその商品のIDのみです。そのため、取得したIDから商品詳細情報を取得する必要があります。

商品詳細情報を取得のためにGraphQL AdminAPIを利用することができます。

GraphQLでのRequestをするために、graphql-tag及び@shopify/react-graphqlを使います

$ npm install @shopify/react-graphql

公式ドキュメントだとreact-apolloを使っていましたが、hookを使ってアプリケーションを実装したいので、@shopify/react-grpahqlを利用しています。

実装

それでは実際に実装を進めていきます。@shopify/react-graphqlのhookを使えるよう、_app.jsのAppoloProviderを変更します。

pages/_app.js
- import { ApolloProvider } from "react-apollo";
+ import { ApolloProvider } from '@shopify/react-graphql';

プロダクト情報を表示するためのコンポーネントResourceListを作成します。
ResourceList.jsというファイルを用意し、プロダクト情報取得するためのクエリドキュメントを書きます

pages/components/ResourceList.js
import gql from 'graphql-tag';

const GET_PRODUCTS_BY_ID = gql`
  query getProducts($ids: [ID!]!) {
    nodes(ids: $ids) {
      ... on Product {
        title
        handle
        descriptionHtml
        id
        images(first: 1) {
          edges {
            node {
              id
              altText
            }
          }
        }
        variants(first: 1) {
          edges {
            node {
              price
              id
            }
          }
        }
      }
    }
  }
`;

コンポーネントを作成します。
商品の情報はstoreからとってきています。

pages/components/ResourceList.js
import { useQuery } from '@shopify/react-graphql';
import store from "store-js";
import React from "react";
import { Card, ResourceList, Stack, TextStyle, Thumbnail } from "@shopify/polaris";

import gql from 'graphql-tag';

const GET_PRODUCTS_BY_ID = gql`
  query getProducts($ids: [ID!]!) {
    nodes(ids: $ids) {
      ... on Product {
        title
        handle
        descriptionHtml
        id
        images(first: 1) {
          edges {
            node {
              id
              altText
            }
          }
        }
        variants(first: 1) {
          edges {
            node {
              price
              id
            }
          }
        }
      }
    }
  }
`;

const ResourceListWithProducts = () => {
  const { loading, error, data } = useQuery(GET_PRODUCTS_BY_ID, { variables: { ids: store.get('ids') }});

  if (loading) return <p>Loading...</p>;
  if (error) return <p>{error.message}</p>;

  return (
    <Card>
      <ResourceList // Defines your resource list component
        showHeader
        resourceName={{ singular: 'Product', plural: 'Products' }}
        items={data.nodes}
        renderItem={item => {
          const media = (
            <Thumbnail
              source={
                item.images.edges[0]
                  ? item.images.edges[0].node.id
                  : ''
              }
              alt={
                item.images.edges[0]
                  ? item.images.edges[0].node.altText
                  : ''
              }
            />
          );
          const price = item.variants.edges[0].node.price;
          return (
            <ResourceList.Item
              id={item.id}
              media={media}
              accessibilityLabel={`View details for ${item.title}`}
              onClick={() => {
                store.set('item', item);
              }}
            >
              <Stack>
                <Stack.Item fill>
                  <h3>
                    <TextStyle variation="strong">
                      {item.title}
                    </TextStyle>
                  </h3>
                </Stack.Item>
                <Stack.Item>
                  <p>${price}</p>
                </Stack.Item>
              </Stack>
            </ResourceList.Item>
          );
        }}
      />
    </Card>
  );
}

export default ResourceListWithProducts;

これでコンポーネントを作成は完了です。作成したコンポーネントをindex.jsから使用します。

pages/index.js
import React, { useState } from 'react';
import { Page, Layout, EmptyState } from "@shopify/polaris";
import { ResourcePicker, TitleBar } from '@shopify/app-bridge-react';
import ResourceListWithProducts from "./components/ResourceList";
import store from 'store-js';

const img = "https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg";

const Index = () => {
  const [open, setOpen] = useState(false)
  const emptyState = !store.get('ids');

  const handleSelection = (resources) => {
    setOpen(false)
    console.log(resources)
    const idsFromResources = resources.selection.map((product) => product.id);
    store.set('ids', idsFromResources);
  }

  return (
    <Page>
      <TitleBar
        primaryAction={{
          content: 'Select products',
          onAction: () => setOpen(true),
        }}
      />

      <ResourcePicker // Resource picker component
        resourceType="Product"
        showVariants={false}
        open={open}
        onSelection={(resources) => handleSelection(resources)}
        onCancel={() => setOpen(false)}
      />

      {emptyState ? ( // Controls the layout of your app's empty state
        <Layout>
          <EmptyState
            heading="Discount your products temporarily"
            action={{
              content: 'Select products',
              onAction: () => setOpen(true),
            }}
            image={img}
          >
            <p>Select products to change their price temporarily.</p>
          </EmptyState>
        </Layout>
      ) : (
        // Uses the new resource list that retrieves products by IDs
        <ResourceListWithProducts />
      )}
    </Page>
  )
}

export default Index;

以上で完了です。選択した商品の名前と値段を表示することができます

5. 製品の値段を変更する

murationを通して製品の値段を変更するためのコンポーネントを作ります。

最終的に下記のような物を作ります。

「Randomizee prices」ボタンを作ります。

pages/components/ApplyRandomPrices.js
import React, { useState } from "react";
import gql from "graphql-tag";
import { useMutation } from "@shopify/react-graphql";
import { Layout, Button, Banner, Toast, Stack, Frame } from '@shopify/polaris';

const UPDATE_PRICE = gql`
  mutation productVariantUpdate($input: ProductVariantInput!) {
    productVariantUpdate(input: $input) {
      product {
        title
      }
      productVariant {
        id
        price
      }
    }
  }
`;

const ApplyRandomPrices = (props) => {
  const updatePrice = useMutation(UPDATE_PRICE);

  const [hasResults, setHasResults] = useState(false);
  const [error, setError] = useState();

  const showError = error && (
    <Banner status="critical">{error.message}</Banner>
  );

  const showToast = hasResults && (
    <Toast
      content="Successfully updated"
      onDismiss={() => setHasResults(false)}
    />
  );

  const handleClick = async () => {
    for (const variantId in props.selectedItems) {
      const price = Math.random().toPrecision(3) * 10;
      const productVariableInput = {
        id: props.selectedItems[variantId].variants.edges[0].node.id,
        price: price
      };

      try {
        await updatePrice({
          variables: {
            input: productVariableInput
          }
        });
      } catch (e) {
        setError(e)
      }
      
      try {
        props.onUpdate()
        setHasResults(true)
      } catch (e) {
        setError(e)
      }
    }
  };

  return (
    <Frame>
      {showToast}
      <Layout.Section>
        {showError}
      </Layout.Section>

      <Layout.Section>
        <Stack distribution={"center"}>
          <Button
            primary
            textAlign={"center"}
            onClick={() => handleClick()} >
            Randomize prices
          </Button>
        </Stack>
      </Layout.Section>
    </Frame>
  );
};

export default ApplyRandomPrices;

選択された商品のハンドリングするためにResouceListも変更します。

pages/components/ResouceList.js
import { useQuery } from "@shopify/react-graphql";
import store from "store-js";
import React, { useState } from "react";
import { Card, ResourceList, Stack, TextStyle, Thumbnail } from "@shopify/polaris";

import gql from "graphql-tag";
import ApplyRandomPrices from "./ApplyRandomPrices";

const GET_PRODUCTS_BY_ID = gql`
...
`;

const ResourceListWithProducts = () => {
  const [selectedItems, setSelectedItems] = useState([]);
  const [selectedNodes, setSelectedNodes] = useState({});

  const { loading, error, data, refetch } = useQuery(GET_PRODUCTS_BY_ID, { variables: { ids: store.get("ids") } });

  if (loading) return <p>Loading...</p>;
  if (error) return <p>{error.message}</p>;

  const nodesById = {};
  data.nodes.forEach(node => nodesById[node.id] = node);

  const handleSelectionChange = (selectedItems) => {
    const selectedNodes = {};
    selectedItems.forEach(item => selectedNodes[item] = nodesById[item]);

    setSelectedItems(selectedItems)
    setSelectedNodes(selectedNodes)
  }

  const handleClickItem = (itemId) => {
    let index = selectedItems.indexOf(itemId);
    const node = nodesById[itemId];
    if (index === -1) {
      selectedItems.push(itemId)
      selectedNodes[itemId] = node;
    } else {
      selectedItems.splice(index, 1);
      delete selectedNodes[itemId];
    }

    setSelectedItems(selectedItems)
    setSelectedNodes(selectedNodes)
  }

  return (
    <>
      <Card>
        <ResourceList
          showHeader
          resourceName={{ singular: 'Product', plural: 'Products' }}
          items={data.nodes}
          selectable
          selectedItems={selectedItems}
          onSelectionChange={handleSelectionChange}
          renderItem={(item, index) => {
            const media = (
              <Thumbnail
                source={
                  item.images.edges[0]
                    ? item.images.edges[0].node.id
                    : ''
                }
                alt={
                  item.images.edges[0]
                    ? item.images.edges[0].node.altText
                    : ''
                }
              />
            );
            const price = item.variants.edges[0].node.price;
            return (
              <ResourceList.Item
                id={item.id}
		key={[index, price]}
                media={media}
                accessibilityLabel={`View details for ${item.title}`}
                verticalAlignment="center"
                onClick={() => handleClickItem(item.id)}
              >
                <Stack alignment="center">
                  <Stack.Item fill>
                    <h3>
                      <TextStyle variation="strong">
                        {item.title}
                      </TextStyle>
                    </h3>
                  </Stack.Item>
                  <Stack.Item>
                    <p>${price}</p>
                  </Stack.Item>
                </Stack>
              </ResourceList.Item>
            );
          }}
        />
      </Card>
      <ApplyRandomPrices selectedItems={selectedNodes} onUpdate={refetch} />
    </>

  );
};

export default ResourceListWithProducts;

以上で完了です。
商品を選択してRandomize pricesをクリックすると商品が更新されると完了です。

まとめ

Shopifyドキュメント上のカスタムアプリ作成のチュートリアルを一通り紹介しました。

https://shopify.dev/apps/getting-started

ドキュメント内では、クラスコンポーネントを使っていましたのでHookを使ったコードに変更しています。

その他

カスタムアプリの相談等あれば気軽にTwitterまでどうぞ

Discussion

ログインするとコメントできます