---
title: Connect admin UI extensions
description: >-
  Learn how to use admin actions and blocks together to create cohesive features
  in the Shopify admin.
source_url:
  html: >-
    https://shopify.dev/docs/apps/build/admin/actions-blocks/connect-admin-extensions
  md: >-
    https://shopify.dev/docs/apps/build/admin/actions-blocks/connect-admin-extensions.md
api_name: admin-extensions
---

# Connect admin UI extensions

This guide is the third part of a five-part tutorial series that describes how to build UI extensions that display as actions and blocks in Shopify admin. Before starting this guide, you'll need to build or copy code from the [admin action UI extension](https://shopify.dev/docs/apps/build/admin/actions-blocks/build-admin-action) and [admin block UI extension](https://shopify.dev/docs/apps/build/admin/actions-blocks/build-admin-block) guides.

![A buyer clicking the edit button on an issue in the admin block UI extension, editing it, and saving it. The edited data appears on the block.](https://shopify.dev/assets/assets/admin/admin-actions-and-block/connect-action-and-block/block-action-connected-5TPiu3fD.gif)

At this point in the tutorial series, you can create new issues using an [admin action](https://shopify.dev/docs/apps/build/admin/actions-blocks/build-admin-action) and you can view and delete the created issues using an [admin block](https://shopify.dev/docs/apps/build/admin/actions-blocks/build-admin-block). While this is functional, the merchant experience would be better if all of the features could be used in the same location.

Now, you'll modify the block and action so that merchants can create and edit issues directly from the block.

## What you'll learn

In this tutorial, you'll learn how to do the following tasks:

* Update your admin block UI extension to launch admin action UI extensions with an intent, to indicate whether it should edit or create a resource

* Update your admin action UI extension, so that it can listen for the intent and edit the appropriate resource

* Run the UI extensions locally and test them on a dev store

## Requirements

[Tutorials](https://shopify.dev/docs/apps/admin/admin-actions-and-blocks)

You've completed or copied the code from the [admin action tutorial](https://shopify.dev/docs/apps/build/admin/actions-blocks/build-admin-action) and the [admin block tutorial](https://shopify.dev/docs/apps/build/admin/actions-blocks/build-admin-block).

## Project

[View on GitHub](https://github.com/Shopify/example-admin-action-and-block-preact)

## Add a button to open your action form

Add a button that lets merchants create new issues directly from the block so that they don't have to navigate to **More actions** at the top of the page.

This button launches the same UI extension for an admin action that you've already written, so you don't have to duplicate your code.

Access the [navigation](https://shopify.dev/docs/api/admin-extensions/api/block-extension-api) method from the API.

## src/BlockExtension.jsx

```jsx
import { render } from "preact";
import { useEffect, useMemo, useState } from "preact/hooks";


import { updateIssues, getIssues } from "./utils";


export default async () => {
  render(<Extension />, document.body);
}
const PAGE_SIZE = 3;


function Extension() {
  const { data, navigation, i18n } = shopify;


  const [loading, setLoading] = useState(true);
  const [_, setInitialValues] = useState([]);
  const [issues, setIssues] = useState([]);
  const [currentPage, setCurrentPage] = useState(1);


  const productId = data.selected[0].id;
  const issuesCount = issues.length;
  const totalPages = issuesCount / PAGE_SIZE;


  useEffect(() => {
    (async function getProductInfo() {
      // Load the product's metafield of type issues
      const productData = await getIssues(productId);


      setLoading(false);
      if (productData?.data?.product?.metafield?.value) {
        const parsedIssues = JSON.parse(
          productData.data.product.metafield.value,
        );
        setInitialValues(
          parsedIssues.map(({ completed }) => Boolean(completed)),
        );
        setIssues(parsedIssues);
      }
    })();
  }, [productId]);


  const paginatedIssues = useMemo(() => {
    if (issuesCount <= PAGE_SIZE) {
      // It's not necessary to paginate if there are fewer issues than the page size
      return issues;
    }


    // Slice the array after the last item of the previous page
    return [...issues].slice(
      (currentPage - 1) * PAGE_SIZE,
      currentPage * PAGE_SIZE,
    );
  }, [issuesCount, issues, currentPage]);


  const handleChange = async (id, value) => {
    // Update the local state of the extension to reflect changes
    setIssues((currentIssues) => {
      // Create a copy of the array so that you don't mistakenly mutate the state
      const newIssues = [...currentIssues];
      // Find the index of the issue that you're interested in
      const editingIssueIndex = newIssues.findIndex(
        (listIssue) => listIssue.id == id,
      );
      // Overwrite that item with the new value
      newIssues[editingIssueIndex] = {
        // Spread the previous item to retain the values that you're not changing
        ...newIssues[editingIssueIndex],
        // Update the completed value
        completed: value === "completed" ? true : false,
      };
      return newIssues;
    });
  };


  const handleDelete = async (id) => {
    // Create a new array of issues, leaving out the one that you're deleting
    const newIssues = issues.filter((issue) => issue.id !== id);
    // Save to the local state
    setIssues(newIssues);
    // Commit changes to the database
    await updateIssues(productId, newIssues);
  };


  const onSubmit = (event) => {
    // Commit changes to the database
    event.waitUntil(updateIssues(productId, issues));
  };


  const onReset = () => {};


  if (loading) {
    return (
      <s-stack direction="inline">
        <s-spinner />
      </s-stack>
    );
  }


  return (
    <s-admin-block heading={i18n.translate("name")}>
      <s-form id={`issues-form`} onSubmit={onSubmit} onReset={onReset}>
        {issues.length ? (
          <>
            <s-table
              paginate
              hasNextPage={totalPages > currentPage}
              hasPreviousPage={currentPage > 1}
              onNextPage={() => setCurrentPage(currentPage + 1)}
              onPreviousPage={() => setCurrentPage(currentPage - 1)}
            >
              <s-table-header-row>
                <s-table-header listSlot="primary">
                  {i18n.translate("issue-column-heading")}
                </s-table-header>
                <s-table-header>
                  {i18n.translate("status-column-heading")}
                </s-table-header>
                <s-table-header></s-table-header>
                <s-table-header></s-table-header>
              </s-table-header-row>
              <s-table-body>
                {paginatedIssues.map(
                  ({ id, title, description, completed }, index) => {
                    return (
                      <s-table-row key={id}>
                        <s-table-cell>
                          <s-stack direction="block">
                            <s-text>{title}</s-text>
                            <s-text>{description}</s-text>
                          </s-stack>
                        </s-table-cell>
                        <s-table-cell>
                          <s-select
                            labelAccessibilityVisibility="exclusive"
                            label={i18n.translate("status-column-heading")}
                            value={completed ? "completed" : "todo"}
                            onChange={(event) =>
                              handleChange(id, (/** @type {HTMLInputElement} */ (event.target)).value)
                            }
                          >
                            <s-option value="todo">
                              {i18n.translate("option-todo")}
                            </s-option>
                            <s-option value="completed">
                              {i18n.translate("option-completed")}
                            </s-option>
                          </s-select>
                        </s-table-cell>


                        <s-table-cell>
                          <s-button
                            variant="tertiary"
                            icon="edit"
                            accessibilityLabel={i18n.translate(
                              "edit-issue-button",
                            )}
                            onClick={() => {
                              const url = `extension:issue-tracker-action?issueId=${id}`;
                              navigation?.navigate(url);
                            }}
                          />
                        </s-table-cell>


                        <s-table-cell>
                          <s-button
                            icon="delete"
                            accessibilityLabel={i18n.translate(
                              "delete-issue-button",
                            )}
                            onClick={() => handleDelete(id)}
                          />
                        </s-table-cell>
                      </s-table-row>
                    );
                  },
                )}
              </s-table-body>
            </s-table>


            <s-button
              onClick={() => {
                const url = `extension:issue-tracker-action`;
                navigation?.navigate(url);
              }}
            >
              {i18n.translate("add-issue-button")}
            </s-button>
          </>
        ) : (
          <>
            <s-button
              onClick={() => {
                const url = `extension:issue-tracker-action`;
                navigation?.navigate(url);
              }}
            >
              {i18n.translate("add-issue-button")}
            </s-button>
          </>
        )}
      </s-form>
    </s-admin-block>
  );
}
```

Add a button to call `navigation.navigate`, passing your extension handle to the method as `extension:{extension-handle}`. The extension handle is defined in your extension's `shopify.extension.toml` file.

**Note:**

Currently, it's only possible to navigate between UI extensions on the same resource page in admin. For example, you can navigate from a block on the product details page (`admin.product-details.block.render`) to an action on the product details page (`admin.product-details.action.render`).

## src/BlockExtension.jsx

```jsx
import { render } from "preact";
import { useEffect, useMemo, useState } from "preact/hooks";


import { updateIssues, getIssues } from "./utils";


export default async () => {
  render(<Extension />, document.body);
}
const PAGE_SIZE = 3;


function Extension() {
  const { data, navigation, i18n } = shopify;


  const [loading, setLoading] = useState(true);
  const [_, setInitialValues] = useState([]);
  const [issues, setIssues] = useState([]);
  const [currentPage, setCurrentPage] = useState(1);


  const productId = data.selected[0].id;
  const issuesCount = issues.length;
  const totalPages = issuesCount / PAGE_SIZE;


  useEffect(() => {
    (async function getProductInfo() {
      // Load the product's metafield of type issues
      const productData = await getIssues(productId);


      setLoading(false);
      if (productData?.data?.product?.metafield?.value) {
        const parsedIssues = JSON.parse(
          productData.data.product.metafield.value,
        );
        setInitialValues(
          parsedIssues.map(({ completed }) => Boolean(completed)),
        );
        setIssues(parsedIssues);
      }
    })();
  }, [productId]);


  const paginatedIssues = useMemo(() => {
    if (issuesCount <= PAGE_SIZE) {
      // It's not necessary to paginate if there are fewer issues than the page size
      return issues;
    }


    // Slice the array after the last item of the previous page
    return [...issues].slice(
      (currentPage - 1) * PAGE_SIZE,
      currentPage * PAGE_SIZE,
    );
  }, [issuesCount, issues, currentPage]);


  const handleChange = async (id, value) => {
    // Update the local state of the extension to reflect changes
    setIssues((currentIssues) => {
      // Create a copy of the array so that you don't mistakenly mutate the state
      const newIssues = [...currentIssues];
      // Find the index of the issue that you're interested in
      const editingIssueIndex = newIssues.findIndex(
        (listIssue) => listIssue.id == id,
      );
      // Overwrite that item with the new value
      newIssues[editingIssueIndex] = {
        // Spread the previous item to retain the values that you're not changing
        ...newIssues[editingIssueIndex],
        // Update the completed value
        completed: value === "completed" ? true : false,
      };
      return newIssues;
    });
  };


  const handleDelete = async (id) => {
    // Create a new array of issues, leaving out the one that you're deleting
    const newIssues = issues.filter((issue) => issue.id !== id);
    // Save to the local state
    setIssues(newIssues);
    // Commit changes to the database
    await updateIssues(productId, newIssues);
  };


  const onSubmit = (event) => {
    // Commit changes to the database
    event.waitUntil(updateIssues(productId, issues));
  };


  const onReset = () => {};


  if (loading) {
    return (
      <s-stack direction="inline">
        <s-spinner />
      </s-stack>
    );
  }


  return (
    <s-admin-block heading={i18n.translate("name")}>
      <s-form id={`issues-form`} onSubmit={onSubmit} onReset={onReset}>
        {issues.length ? (
          <>
            <s-table
              paginate
              hasNextPage={totalPages > currentPage}
              hasPreviousPage={currentPage > 1}
              onNextPage={() => setCurrentPage(currentPage + 1)}
              onPreviousPage={() => setCurrentPage(currentPage - 1)}
            >
              <s-table-header-row>
                <s-table-header listSlot="primary">
                  {i18n.translate("issue-column-heading")}
                </s-table-header>
                <s-table-header>
                  {i18n.translate("status-column-heading")}
                </s-table-header>
                <s-table-header></s-table-header>
                <s-table-header></s-table-header>
              </s-table-header-row>
              <s-table-body>
                {paginatedIssues.map(
                  ({ id, title, description, completed }, index) => {
                    return (
                      <s-table-row key={id}>
                        <s-table-cell>
                          <s-stack direction="block">
                            <s-text>{title}</s-text>
                            <s-text>{description}</s-text>
                          </s-stack>
                        </s-table-cell>
                        <s-table-cell>
                          <s-select
                            labelAccessibilityVisibility="exclusive"
                            label={i18n.translate("status-column-heading")}
                            value={completed ? "completed" : "todo"}
                            onChange={(event) =>
                              handleChange(id, (/** @type {HTMLInputElement} */ (event.target)).value)
                            }
                          >
                            <s-option value="todo">
                              {i18n.translate("option-todo")}
                            </s-option>
                            <s-option value="completed">
                              {i18n.translate("option-completed")}
                            </s-option>
                          </s-select>
                        </s-table-cell>


                        <s-table-cell>
                          <s-button
                            variant="tertiary"
                            icon="edit"
                            accessibilityLabel={i18n.translate(
                              "edit-issue-button",
                            )}
                            onClick={() => {
                              const url = `extension:issue-tracker-action?issueId=${id}`;
                              navigation?.navigate(url);
                            }}
                          />
                        </s-table-cell>


                        <s-table-cell>
                          <s-button
                            icon="delete"
                            accessibilityLabel={i18n.translate(
                              "delete-issue-button",
                            )}
                            onClick={() => handleDelete(id)}
                          />
                        </s-table-cell>
                      </s-table-row>
                    );
                  },
                )}
              </s-table-body>
            </s-table>


            <s-button
              onClick={() => {
                const url = `extension:issue-tracker-action`;
                navigation?.navigate(url);
              }}
            >
              {i18n.translate("add-issue-button")}
            </s-button>
          </>
        ) : (
          <>
            <s-button
              onClick={() => {
                const url = `extension:issue-tracker-action`;
                navigation?.navigate(url);
              }}
            >
              {i18n.translate("add-issue-button")}
            </s-button>
          </>
        )}
      </s-form>
    </s-admin-block>
  );
}
```

Add a button to display when there are no issues to show.

## src/BlockExtension.jsx

```jsx
import { render } from "preact";
import { useEffect, useMemo, useState } from "preact/hooks";


import { updateIssues, getIssues } from "./utils";


export default async () => {
  render(<Extension />, document.body);
}
const PAGE_SIZE = 3;


function Extension() {
  const { data, navigation, i18n } = shopify;


  const [loading, setLoading] = useState(true);
  const [_, setInitialValues] = useState([]);
  const [issues, setIssues] = useState([]);
  const [currentPage, setCurrentPage] = useState(1);


  const productId = data.selected[0].id;
  const issuesCount = issues.length;
  const totalPages = issuesCount / PAGE_SIZE;


  useEffect(() => {
    (async function getProductInfo() {
      // Load the product's metafield of type issues
      const productData = await getIssues(productId);


      setLoading(false);
      if (productData?.data?.product?.metafield?.value) {
        const parsedIssues = JSON.parse(
          productData.data.product.metafield.value,
        );
        setInitialValues(
          parsedIssues.map(({ completed }) => Boolean(completed)),
        );
        setIssues(parsedIssues);
      }
    })();
  }, [productId]);


  const paginatedIssues = useMemo(() => {
    if (issuesCount <= PAGE_SIZE) {
      // It's not necessary to paginate if there are fewer issues than the page size
      return issues;
    }


    // Slice the array after the last item of the previous page
    return [...issues].slice(
      (currentPage - 1) * PAGE_SIZE,
      currentPage * PAGE_SIZE,
    );
  }, [issuesCount, issues, currentPage]);


  const handleChange = async (id, value) => {
    // Update the local state of the extension to reflect changes
    setIssues((currentIssues) => {
      // Create a copy of the array so that you don't mistakenly mutate the state
      const newIssues = [...currentIssues];
      // Find the index of the issue that you're interested in
      const editingIssueIndex = newIssues.findIndex(
        (listIssue) => listIssue.id == id,
      );
      // Overwrite that item with the new value
      newIssues[editingIssueIndex] = {
        // Spread the previous item to retain the values that you're not changing
        ...newIssues[editingIssueIndex],
        // Update the completed value
        completed: value === "completed" ? true : false,
      };
      return newIssues;
    });
  };


  const handleDelete = async (id) => {
    // Create a new array of issues, leaving out the one that you're deleting
    const newIssues = issues.filter((issue) => issue.id !== id);
    // Save to the local state
    setIssues(newIssues);
    // Commit changes to the database
    await updateIssues(productId, newIssues);
  };


  const onSubmit = (event) => {
    // Commit changes to the database
    event.waitUntil(updateIssues(productId, issues));
  };


  const onReset = () => {};


  if (loading) {
    return (
      <s-stack direction="inline">
        <s-spinner />
      </s-stack>
    );
  }


  return (
    <s-admin-block heading={i18n.translate("name")}>
      <s-form id={`issues-form`} onSubmit={onSubmit} onReset={onReset}>
        {issues.length ? (
          <>
            <s-table
              paginate
              hasNextPage={totalPages > currentPage}
              hasPreviousPage={currentPage > 1}
              onNextPage={() => setCurrentPage(currentPage + 1)}
              onPreviousPage={() => setCurrentPage(currentPage - 1)}
            >
              <s-table-header-row>
                <s-table-header listSlot="primary">
                  {i18n.translate("issue-column-heading")}
                </s-table-header>
                <s-table-header>
                  {i18n.translate("status-column-heading")}
                </s-table-header>
                <s-table-header></s-table-header>
                <s-table-header></s-table-header>
              </s-table-header-row>
              <s-table-body>
                {paginatedIssues.map(
                  ({ id, title, description, completed }, index) => {
                    return (
                      <s-table-row key={id}>
                        <s-table-cell>
                          <s-stack direction="block">
                            <s-text>{title}</s-text>
                            <s-text>{description}</s-text>
                          </s-stack>
                        </s-table-cell>
                        <s-table-cell>
                          <s-select
                            labelAccessibilityVisibility="exclusive"
                            label={i18n.translate("status-column-heading")}
                            value={completed ? "completed" : "todo"}
                            onChange={(event) =>
                              handleChange(id, (/** @type {HTMLInputElement} */ (event.target)).value)
                            }
                          >
                            <s-option value="todo">
                              {i18n.translate("option-todo")}
                            </s-option>
                            <s-option value="completed">
                              {i18n.translate("option-completed")}
                            </s-option>
                          </s-select>
                        </s-table-cell>


                        <s-table-cell>
                          <s-button
                            variant="tertiary"
                            icon="edit"
                            accessibilityLabel={i18n.translate(
                              "edit-issue-button",
                            )}
                            onClick={() => {
                              const url = `extension:issue-tracker-action?issueId=${id}`;
                              navigation?.navigate(url);
                            }}
                          />
                        </s-table-cell>


                        <s-table-cell>
                          <s-button
                            icon="delete"
                            accessibilityLabel={i18n.translate(
                              "delete-issue-button",
                            )}
                            onClick={() => handleDelete(id)}
                          />
                        </s-table-cell>
                      </s-table-row>
                    );
                  },
                )}
              </s-table-body>
            </s-table>


            <s-button
              onClick={() => {
                const url = `extension:issue-tracker-action`;
                navigation?.navigate(url);
              }}
            >
              {i18n.translate("add-issue-button")}
            </s-button>
          </>
        ) : (
          <>
            <s-button
              onClick={() => {
                const url = `extension:issue-tracker-action`;
                navigation?.navigate(url);
              }}
            >
              {i18n.translate("add-issue-button")}
            </s-button>
          </>
        )}
      </s-form>
    </s-admin-block>
  );
}
```

## Modify the block to launch actions with intents for editing

Add an **Edit** button to each issue displayed by the UI extension's block. When clicked, this button launches the action using the same [navigation API](https://shopify.dev/docs/api/admin-extensions/api/block-extension-api) that you used in the previous part of this tutorial.

Add a button for each issue. This time, when you call navigate for the **Edit** button, you'll also add a URL parameter that specifies the ID of issue as `extension:admin-issue-tracker-action?issueId={id}`.

## src/BlockExtension.jsx

```jsx
import { render } from "preact";
import { useEffect, useMemo, useState } from "preact/hooks";


import { updateIssues, getIssues } from "./utils";


export default async () => {
  render(<Extension />, document.body);
}
const PAGE_SIZE = 3;


function Extension() {
  const { data, navigation, i18n } = shopify;


  const [loading, setLoading] = useState(true);
  const [_, setInitialValues] = useState([]);
  const [issues, setIssues] = useState([]);
  const [currentPage, setCurrentPage] = useState(1);


  const productId = data.selected[0].id;
  const issuesCount = issues.length;
  const totalPages = issuesCount / PAGE_SIZE;


  useEffect(() => {
    (async function getProductInfo() {
      // Load the product's metafield of type issues
      const productData = await getIssues(productId);


      setLoading(false);
      if (productData?.data?.product?.metafield?.value) {
        const parsedIssues = JSON.parse(
          productData.data.product.metafield.value,
        );
        setInitialValues(
          parsedIssues.map(({ completed }) => Boolean(completed)),
        );
        setIssues(parsedIssues);
      }
    })();
  }, [productId]);


  const paginatedIssues = useMemo(() => {
    if (issuesCount <= PAGE_SIZE) {
      // It's not necessary to paginate if there are fewer issues than the page size
      return issues;
    }


    // Slice the array after the last item of the previous page
    return [...issues].slice(
      (currentPage - 1) * PAGE_SIZE,
      currentPage * PAGE_SIZE,
    );
  }, [issuesCount, issues, currentPage]);


  const handleChange = async (id, value) => {
    // Update the local state of the extension to reflect changes
    setIssues((currentIssues) => {
      // Create a copy of the array so that you don't mistakenly mutate the state
      const newIssues = [...currentIssues];
      // Find the index of the issue that you're interested in
      const editingIssueIndex = newIssues.findIndex(
        (listIssue) => listIssue.id == id,
      );
      // Overwrite that item with the new value
      newIssues[editingIssueIndex] = {
        // Spread the previous item to retain the values that you're not changing
        ...newIssues[editingIssueIndex],
        // Update the completed value
        completed: value === "completed" ? true : false,
      };
      return newIssues;
    });
  };


  const handleDelete = async (id) => {
    // Create a new array of issues, leaving out the one that you're deleting
    const newIssues = issues.filter((issue) => issue.id !== id);
    // Save to the local state
    setIssues(newIssues);
    // Commit changes to the database
    await updateIssues(productId, newIssues);
  };


  const onSubmit = (event) => {
    // Commit changes to the database
    event.waitUntil(updateIssues(productId, issues));
  };


  const onReset = () => {};


  if (loading) {
    return (
      <s-stack direction="inline">
        <s-spinner />
      </s-stack>
    );
  }


  return (
    <s-admin-block heading={i18n.translate("name")}>
      <s-form id={`issues-form`} onSubmit={onSubmit} onReset={onReset}>
        {issues.length ? (
          <>
            <s-table
              paginate
              hasNextPage={totalPages > currentPage}
              hasPreviousPage={currentPage > 1}
              onNextPage={() => setCurrentPage(currentPage + 1)}
              onPreviousPage={() => setCurrentPage(currentPage - 1)}
            >
              <s-table-header-row>
                <s-table-header listSlot="primary">
                  {i18n.translate("issue-column-heading")}
                </s-table-header>
                <s-table-header>
                  {i18n.translate("status-column-heading")}
                </s-table-header>
                <s-table-header></s-table-header>
                <s-table-header></s-table-header>
              </s-table-header-row>
              <s-table-body>
                {paginatedIssues.map(
                  ({ id, title, description, completed }, index) => {
                    return (
                      <s-table-row key={id}>
                        <s-table-cell>
                          <s-stack direction="block">
                            <s-text>{title}</s-text>
                            <s-text>{description}</s-text>
                          </s-stack>
                        </s-table-cell>
                        <s-table-cell>
                          <s-select
                            labelAccessibilityVisibility="exclusive"
                            label={i18n.translate("status-column-heading")}
                            value={completed ? "completed" : "todo"}
                            onChange={(event) =>
                              handleChange(id, (/** @type {HTMLInputElement} */ (event.target)).value)
                            }
                          >
                            <s-option value="todo">
                              {i18n.translate("option-todo")}
                            </s-option>
                            <s-option value="completed">
                              {i18n.translate("option-completed")}
                            </s-option>
                          </s-select>
                        </s-table-cell>


                        <s-table-cell>
                          <s-button
                            variant="tertiary"
                            icon="edit"
                            accessibilityLabel={i18n.translate(
                              "edit-issue-button",
                            )}
                            onClick={() => {
                              const url = `extension:issue-tracker-action?issueId=${id}`;
                              navigation?.navigate(url);
                            }}
                          />
                        </s-table-cell>


                        <s-table-cell>
                          <s-button
                            icon="delete"
                            accessibilityLabel={i18n.translate(
                              "delete-issue-button",
                            )}
                            onClick={() => handleDelete(id)}
                          />
                        </s-table-cell>
                      </s-table-row>
                    );
                  },
                )}
              </s-table-body>
            </s-table>


            <s-button
              onClick={() => {
                const url = `extension:issue-tracker-action`;
                navigation?.navigate(url);
              }}
            >
              {i18n.translate("add-issue-button")}
            </s-button>
          </>
        ) : (
          <>
            <s-button
              onClick={() => {
                const url = `extension:issue-tracker-action`;
                navigation?.navigate(url);
              }}
            >
              {i18n.translate("add-issue-button")}
            </s-button>
          </>
        )}
      </s-form>
    </s-admin-block>
  );
}
```

## Modify the action to edit an issue when launched with an intent

Edit your UI extension, so the action checks for the presence of the parameter that you passed.

You'll use the `intents` API, which gives your extension access to information about how it's been launched. When the action extension detects an `issueId` parameter in the launch URL from the API, it edits the issue with the ID that that's been passed. To enable editing, you'll first fetch the issue's existing data, and then use it to populate the form.

## src/ActionExtension.jsx

```jsx
import { render } from "preact";
import { useCallback, useEffect, useState } from "preact/hooks";
import { getIssues, updateIssues } from "./utils";


export default async () => {
  render(<Extension />, document.body);
}


function generateId(allIssues) {
  return !allIssues?.length ? 0 : allIssues[allIssues.length - 1].id + 1;
}


function validateForm({ title, description }) {
  return {
    isValid: Boolean(title) && Boolean(description),
    errors: {
      title: !title,
      description: !description,
    },
  };
}


function Extension() {
  const { close, data, intents, i18n } = shopify;
  const issueId = intents?.launchUrl
    ? new URL(intents?.launchUrl)?.searchParams?.get("issueId")
    : null;
  const [loading, setLoading] = useState(issueId ? true : false);


  const [issue, setIssue] = useState({
    title: "",
    description: "",
    id: issueId,
  });


  const [allIssues, setAllIssues] = useState([]);


  const [formErrors, setFormErrors] = useState(null);
  const { title, description } = issue;
  const isEditing = Boolean(issueId);


  useEffect(() => {
    getIssues(data.selected[0].id).then((issues) => {
      setLoading(false);
      setAllIssues(issues || []);
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);


  const onSubmit = useCallback(async () => {
    const { isValid, errors } = validateForm(issue);
    setFormErrors(errors);


    if (isValid) {
      const newIssues = [...allIssues];


      if (isEditing) {
        // Find the index of the issue that you're editing
        const editingIssueIndex = newIssues.findIndex(
          (listIssue) => listIssue.id == issue.id,
        );
        // Overwrite that issue's title and description with the new ones
        newIssues[editingIssueIndex] = {
          ...issue,
          title,
          description,
        };
      } else {
        // Add a new issue at the end of the list
        newIssues.push({
          id: generateId(allIssues),
          title,
          description,
          completed: false,
        });
      }


      // Commit changes to the database
      await updateIssues(data.selected[0].id, newIssues);
      // Close the modal using the 'close' API
      close();
    }
  }, [issue, data.selected, allIssues, close, isEditing, title, description]);


  useEffect(() => {
    if (issueId) {
      // If opened from the block extension, you find the issue that's being edited
      const editingIssue = allIssues.find(({ id }) => `${id}` === issueId);
      if (editingIssue) {
        // Set the issue's ID in the state
        setIssue(editingIssue);
      }
    }
  }, [issueId, allIssues]);


  if (loading) {
    return <></>;
  }


  return (
    <s-admin-action
      heading={
        isEditing
          ? i18n.translate("edit-issue-heading")
          : i18n.translate("create-issue-heading")
      }
    >
      <s-button slot="primary-action" onClick={onSubmit}>
        {isEditing
          ? i18n.translate("issue-save-button")
          : i18n.translate("issue-create-button")}
      </s-button>
      <s-button slot="secondary-actions" onClick={close}>
        {i18n.translate("issue-cancel-button")}
      </s-button>
      <s-text-field
        value={title}
        error={
          formErrors?.title ? i18n.translate("issue-title-error") : undefined
        }
        onChange={(event) =>
          setIssue((prev) => ({ ...prev, title: (/** @type {HTMLInputElement} */ (event.target)).value }))
        }
        label={i18n.translate("issue-title-label")}
        maxLength={50}
      />
      <s-box padding-block-start="large">
        <s-text-area
          value={description}
          error={
            formErrors?.description
              ? i18n.translate("issue-description-error")
              : undefined
          }
          onChange={(event) =>
            setIssue((prev) => ({ ...prev, description: (/** @type {HTMLInputElement} */ (event.target)).value }))
          }
          label={i18n.translate("issue-description-label")}
          max-length={300}
        />
      </s-box>
    </s-admin-action>
  );
}
```

When the user saves the issue, you'll edit the existing issue instead of creating a new one.

## src/ActionExtension.jsx

```jsx
import { render } from "preact";
import { useCallback, useEffect, useState } from "preact/hooks";
import { getIssues, updateIssues } from "./utils";


export default async () => {
  render(<Extension />, document.body);
}


function generateId(allIssues) {
  return !allIssues?.length ? 0 : allIssues[allIssues.length - 1].id + 1;
}


function validateForm({ title, description }) {
  return {
    isValid: Boolean(title) && Boolean(description),
    errors: {
      title: !title,
      description: !description,
    },
  };
}


function Extension() {
  const { close, data, intents, i18n } = shopify;
  const issueId = intents?.launchUrl
    ? new URL(intents?.launchUrl)?.searchParams?.get("issueId")
    : null;
  const [loading, setLoading] = useState(issueId ? true : false);


  const [issue, setIssue] = useState({
    title: "",
    description: "",
    id: issueId,
  });


  const [allIssues, setAllIssues] = useState([]);


  const [formErrors, setFormErrors] = useState(null);
  const { title, description } = issue;
  const isEditing = Boolean(issueId);


  useEffect(() => {
    getIssues(data.selected[0].id).then((issues) => {
      setLoading(false);
      setAllIssues(issues || []);
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);


  const onSubmit = useCallback(async () => {
    const { isValid, errors } = validateForm(issue);
    setFormErrors(errors);


    if (isValid) {
      const newIssues = [...allIssues];


      if (isEditing) {
        // Find the index of the issue that you're editing
        const editingIssueIndex = newIssues.findIndex(
          (listIssue) => listIssue.id == issue.id,
        );
        // Overwrite that issue's title and description with the new ones
        newIssues[editingIssueIndex] = {
          ...issue,
          title,
          description,
        };
      } else {
        // Add a new issue at the end of the list
        newIssues.push({
          id: generateId(allIssues),
          title,
          description,
          completed: false,
        });
      }


      // Commit changes to the database
      await updateIssues(data.selected[0].id, newIssues);
      // Close the modal using the 'close' API
      close();
    }
  }, [issue, data.selected, allIssues, close, isEditing, title, description]);


  useEffect(() => {
    if (issueId) {
      // If opened from the block extension, you find the issue that's being edited
      const editingIssue = allIssues.find(({ id }) => `${id}` === issueId);
      if (editingIssue) {
        // Set the issue's ID in the state
        setIssue(editingIssue);
      }
    }
  }, [issueId, allIssues]);


  if (loading) {
    return <></>;
  }


  return (
    <s-admin-action
      heading={
        isEditing
          ? i18n.translate("edit-issue-heading")
          : i18n.translate("create-issue-heading")
      }
    >
      <s-button slot="primary-action" onClick={onSubmit}>
        {isEditing
          ? i18n.translate("issue-save-button")
          : i18n.translate("issue-create-button")}
      </s-button>
      <s-button slot="secondary-actions" onClick={close}>
        {i18n.translate("issue-cancel-button")}
      </s-button>
      <s-text-field
        value={title}
        error={
          formErrors?.title ? i18n.translate("issue-title-error") : undefined
        }
        onChange={(event) =>
          setIssue((prev) => ({ ...prev, title: (/** @type {HTMLInputElement} */ (event.target)).value }))
        }
        label={i18n.translate("issue-title-label")}
        maxLength={50}
      />
      <s-box padding-block-start="large">
        <s-text-area
          value={description}
          error={
            formErrors?.description
              ? i18n.translate("issue-description-error")
              : undefined
          }
          onChange={(event) =>
            setIssue((prev) => ({ ...prev, description: (/** @type {HTMLInputElement} */ (event.target)).value }))
          }
          label={i18n.translate("issue-description-label")}
          max-length={300}
        />
      </s-box>
    </s-admin-action>
  );
}
```

## Test the UI extensions

1. Navigate to your app directory.

2. To build and preview your app, either start or restart your server with the following command:

   ## Terminal

   ```bash
   shopify app dev
   ```

3. Press `p` to open the Dev Console.

4. In the extension list for your app, click on the preview link for the issue tracker UI extension.

5. The product details page opens. If you don't have a product in your store, then you need to create one.

6. To find your block, scroll to the bottom of the page. It should display the issues that you've created so far.

7. On one of the issues, click **Edit** button. This should open up an action that's pre-populated with the issue details.

8. Edit the issue and save it. The updated issue should be reflected in the block at the bottom of the page.

![A gif showing a user clicking the edit button on an issue from the block extension, editing it in the action and saving it. The edited data appears on the block.](https://shopify.dev/assets/assets/admin/admin-actions-and-block/connect-action-and-block/block-action-connected-5TPiu3fD.gif)

## src/BlockExtension.jsx

```jsx
import { render } from "preact";
import { useEffect, useMemo, useState } from "preact/hooks";


import { updateIssues, getIssues } from "./utils";


export default async () => {
  render(<Extension />, document.body);
}
const PAGE_SIZE = 3;


function Extension() {
  const { data, navigation, i18n } = shopify;


  const [loading, setLoading] = useState(true);
  const [_, setInitialValues] = useState([]);
  const [issues, setIssues] = useState([]);
  const [currentPage, setCurrentPage] = useState(1);


  const productId = data.selected[0].id;
  const issuesCount = issues.length;
  const totalPages = issuesCount / PAGE_SIZE;


  useEffect(() => {
    (async function getProductInfo() {
      // Load the product's metafield of type issues
      const productData = await getIssues(productId);


      setLoading(false);
      if (productData?.data?.product?.metafield?.value) {
        const parsedIssues = JSON.parse(
          productData.data.product.metafield.value,
        );
        setInitialValues(
          parsedIssues.map(({ completed }) => Boolean(completed)),
        );
        setIssues(parsedIssues);
      }
    })();
  }, [productId]);


  const paginatedIssues = useMemo(() => {
    if (issuesCount <= PAGE_SIZE) {
      // It's not necessary to paginate if there are fewer issues than the page size
      return issues;
    }


    // Slice the array after the last item of the previous page
    return [...issues].slice(
      (currentPage - 1) * PAGE_SIZE,
      currentPage * PAGE_SIZE,
    );
  }, [issuesCount, issues, currentPage]);


  const handleChange = async (id, value) => {
    // Update the local state of the extension to reflect changes
    setIssues((currentIssues) => {
      // Create a copy of the array so that you don't mistakenly mutate the state
      const newIssues = [...currentIssues];
      // Find the index of the issue that you're interested in
      const editingIssueIndex = newIssues.findIndex(
        (listIssue) => listIssue.id == id,
      );
      // Overwrite that item with the new value
      newIssues[editingIssueIndex] = {
        // Spread the previous item to retain the values that you're not changing
        ...newIssues[editingIssueIndex],
        // Update the completed value
        completed: value === "completed" ? true : false,
      };
      return newIssues;
    });
  };


  const handleDelete = async (id) => {
    // Create a new array of issues, leaving out the one that you're deleting
    const newIssues = issues.filter((issue) => issue.id !== id);
    // Save to the local state
    setIssues(newIssues);
    // Commit changes to the database
    await updateIssues(productId, newIssues);
  };


  const onSubmit = (event) => {
    // Commit changes to the database
    event.waitUntil(updateIssues(productId, issues));
  };


  const onReset = () => {};


  if (loading) {
    return (
      <s-stack direction="inline">
        <s-spinner />
      </s-stack>
    );
  }


  return (
    <s-admin-block heading={i18n.translate("name")}>
      <s-form id={`issues-form`} onSubmit={onSubmit} onReset={onReset}>
        {issues.length ? (
          <>
            <s-table
              paginate
              hasNextPage={totalPages > currentPage}
              hasPreviousPage={currentPage > 1}
              onNextPage={() => setCurrentPage(currentPage + 1)}
              onPreviousPage={() => setCurrentPage(currentPage - 1)}
            >
              <s-table-header-row>
                <s-table-header listSlot="primary">
                  {i18n.translate("issue-column-heading")}
                </s-table-header>
                <s-table-header>
                  {i18n.translate("status-column-heading")}
                </s-table-header>
                <s-table-header></s-table-header>
                <s-table-header></s-table-header>
              </s-table-header-row>
              <s-table-body>
                {paginatedIssues.map(
                  ({ id, title, description, completed }, index) => {
                    return (
                      <s-table-row key={id}>
                        <s-table-cell>
                          <s-stack direction="block">
                            <s-text>{title}</s-text>
                            <s-text>{description}</s-text>
                          </s-stack>
                        </s-table-cell>
                        <s-table-cell>
                          <s-select
                            labelAccessibilityVisibility="exclusive"
                            label={i18n.translate("status-column-heading")}
                            value={completed ? "completed" : "todo"}
                            onChange={(event) =>
                              handleChange(id, (/** @type {HTMLInputElement} */ (event.target)).value)
                            }
                          >
                            <s-option value="todo">
                              {i18n.translate("option-todo")}
                            </s-option>
                            <s-option value="completed">
                              {i18n.translate("option-completed")}
                            </s-option>
                          </s-select>
                        </s-table-cell>


                        <s-table-cell>
                          <s-button
                            variant="tertiary"
                            icon="edit"
                            accessibilityLabel={i18n.translate(
                              "edit-issue-button",
                            )}
                            onClick={() => {
                              const url = `extension:issue-tracker-action?issueId=${id}`;
                              navigation?.navigate(url);
                            }}
                          />
                        </s-table-cell>


                        <s-table-cell>
                          <s-button
                            icon="delete"
                            accessibilityLabel={i18n.translate(
                              "delete-issue-button",
                            )}
                            onClick={() => handleDelete(id)}
                          />
                        </s-table-cell>
                      </s-table-row>
                    );
                  },
                )}
              </s-table-body>
            </s-table>


            <s-button
              onClick={() => {
                const url = `extension:issue-tracker-action`;
                navigation?.navigate(url);
              }}
            >
              {i18n.translate("add-issue-button")}
            </s-button>
          </>
        ) : (
          <>
            <s-button
              onClick={() => {
                const url = `extension:issue-tracker-action`;
                navigation?.navigate(url);
              }}
            >
              {i18n.translate("add-issue-button")}
            </s-button>
          </>
        )}
      </s-form>
    </s-admin-block>
  );
}
```

## src/ActionExtension.jsx

```jsx
import { render } from "preact";
import { useCallback, useEffect, useState } from "preact/hooks";
import { getIssues, updateIssues } from "./utils";


export default async () => {
  render(<Extension />, document.body);
}


function generateId(allIssues) {
  return !allIssues?.length ? 0 : allIssues[allIssues.length - 1].id + 1;
}


function validateForm({ title, description }) {
  return {
    isValid: Boolean(title) && Boolean(description),
    errors: {
      title: !title,
      description: !description,
    },
  };
}


function Extension() {
  const { close, data, intents, i18n } = shopify;
  const issueId = intents?.launchUrl
    ? new URL(intents?.launchUrl)?.searchParams?.get("issueId")
    : null;
  const [loading, setLoading] = useState(issueId ? true : false);


  const [issue, setIssue] = useState({
    title: "",
    description: "",
    id: issueId,
  });


  const [allIssues, setAllIssues] = useState([]);


  const [formErrors, setFormErrors] = useState(null);
  const { title, description } = issue;
  const isEditing = Boolean(issueId);


  useEffect(() => {
    getIssues(data.selected[0].id).then((issues) => {
      setLoading(false);
      setAllIssues(issues || []);
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);


  const onSubmit = useCallback(async () => {
    const { isValid, errors } = validateForm(issue);
    setFormErrors(errors);


    if (isValid) {
      const newIssues = [...allIssues];


      if (isEditing) {
        // Find the index of the issue that you're editing
        const editingIssueIndex = newIssues.findIndex(
          (listIssue) => listIssue.id == issue.id,
        );
        // Overwrite that issue's title and description with the new ones
        newIssues[editingIssueIndex] = {
          ...issue,
          title,
          description,
        };
      } else {
        // Add a new issue at the end of the list
        newIssues.push({
          id: generateId(allIssues),
          title,
          description,
          completed: false,
        });
      }


      // Commit changes to the database
      await updateIssues(data.selected[0].id, newIssues);
      // Close the modal using the 'close' API
      close();
    }
  }, [issue, data.selected, allIssues, close, isEditing, title, description]);


  useEffect(() => {
    if (issueId) {
      // If opened from the block extension, you find the issue that's being edited
      const editingIssue = allIssues.find(({ id }) => `${id}` === issueId);
      if (editingIssue) {
        // Set the issue's ID in the state
        setIssue(editingIssue);
      }
    }
  }, [issueId, allIssues]);


  if (loading) {
    return <></>;
  }


  return (
    <s-admin-action
      heading={
        isEditing
          ? i18n.translate("edit-issue-heading")
          : i18n.translate("create-issue-heading")
      }
    >
      <s-button slot="primary-action" onClick={onSubmit}>
        {isEditing
          ? i18n.translate("issue-save-button")
          : i18n.translate("issue-create-button")}
      </s-button>
      <s-button slot="secondary-actions" onClick={close}>
        {i18n.translate("issue-cancel-button")}
      </s-button>
      <s-text-field
        value={title}
        error={
          formErrors?.title ? i18n.translate("issue-title-error") : undefined
        }
        onChange={(event) =>
          setIssue((prev) => ({ ...prev, title: (/** @type {HTMLInputElement} */ (event.target)).value }))
        }
        label={i18n.translate("issue-title-label")}
        maxLength={50}
      />
      <s-box padding-block-start="large">
        <s-text-area
          value={description}
          error={
            formErrors?.description
              ? i18n.translate("issue-description-error")
              : undefined
          }
          onChange={(event) =>
            setIssue((prev) => ({ ...prev, description: (/** @type {HTMLInputElement} */ (event.target)).value }))
          }
          label={i18n.translate("issue-description-label")}
          max-length={300}
        />
      </s-box>
    </s-admin-action>
  );
}
```

### Next steps

[Connect your app's backend\
\
](https://shopify.dev/docs/apps/admin/admin-actions-and-blocks/connect-extension-and-backend)

[Complete the next guide in this tutorial series by connecting to your app's backend.](https://shopify.dev/docs/apps/admin/admin-actions-and-blocks/connect-extension-and-backend)

[Admin UI extension APIs\
\
](https://shopify.dev/docs/api/admin-extensions)

[Learn about admin UI extensions, components, and APIs.](https://shopify.dev/docs/api/admin-extensions)

[Deployment\
\
](https://shopify.dev/docs/apps/deployment/app-versions)

[Learn how to deploy your UI extensions to merchants.](https://shopify.dev/docs/apps/deployment/app-versions)

[Issues\
\
](https://github.com/Shopify/ui-extensions)

[File any issues or feature requests on the UI Extensions GitHub repository.](https://github.com/Shopify/ui-extensions)
