Last updated Jul 27, 2020

Use the App storage API in a Confluence macro

This tutorial uses UI Kit 1 and @forge/cli version 7.1.0. This tutorial won't work if you're using the latest version of @forge/cli.

This tutorial describes how to build an app for sharing definitions for terminology and acronyms across an entire Confluence site. The app uses the App storage API to store definitions, which allows them to be shared between several macros and accessed from the site administration.

In this tutorial you will learn how to persist and retrieve data from the App Storage service and display the results in a table.

A definition is added for the term GraphQL

Before you begin

This tutorial assumes you're already familiar with the basics of Forge development. If this is your first time using Forge, see Getting started first.

To complete this tutorial, you need the latest version of Forge CLI. To update your CLI version, run npm install -g @forge/cli@latest on the command line.

Set up a cloud developer site

An Atlassian cloud developer site lets you install and test your app on Confluence and Jira products set up for you. If you don't have one yet, set it up now:

  1. Go to http://go.atlassian.com/cloud-dev and create a site using the email address associated with your Atlassian account.
  2. Once your site is ready, log in and complete the setup wizard.

You can install your app to multiple Atlassian sites. However, app data won't be shared between separate Atlassian sites, products, or Forge environments.

The limits on the numbers of users you can create are as follows:

  • Confluence: 5 users
  • Jira Service Management: 1 agent
  • Jira Software and Jira Work Management: 5 users

The Atlassian Marketplace doesn't currently support cross-product apps. If your app supports multiple products, you can publish two separate listings on the Marketplace, but your app won't be able to make API calls across different products and instances/installations.

Step 1: Create your app

Create an app based on the Hello world template. Using your terminal complete the following:

  1. Navigate to the directory where you want to create the app.

  2. Create your app by running:

    1
    2
    forge create
    
  3. Enter a name for the app. For example, definitions-macro.

  4. Select the UI Kit category.

  5. Select the Confluence product.

  6. Select the confluence-macro template.

  7. Your app has been created in a directory with the same name as your app, for example definitions-macro. Open the app directory to see the files associated with your app.

  8. Install the latest version of the Forge API package

    1
    2
    npm install --save @forge/api@latest
    
  9. Enable the storage API by adding the storage:app scope to the mainifest.yml file. [Learn more about adding scopes to call an Atlassian REST API] (/platform/forge/add-scopes-to-call-an-atlassian-rest-api/).

    1
    2
    permissions:
      scopes:
        - storage:app
    

You can check your app against step 1 in the tutorial repository.

Step 2: Deploy and install your app

  1. Navigate to the app's top-level directory and deploy your app by running:

    1
    2
    forge deploy
    
  2. Install your app by running:

    1
    2
    forge install
    
  3. Select your Atlassian product using the arrow keys and press the enter key.

  4. Enter the URL for your development site. For example, example.atlassian.net. View a list of your active sites at Atlassian administration.

Once the successful installation message appears, your app is installed and ready to use on the specified site. You can always delete your app from the site by running the forge uninstall command.

Running the forge install command only installs your app onto the selected product. To install onto multiple products, repeat these steps again, selecting another product each time. Note that the Atlassian Marketplace does not support cross-product apps yet.

You must run forge deploy before running forge install in any of the Forge environments.

  1. Start tunneling your app

    1
    2
    forge tunnel
    
  2. Add your new Confluence macro to a page and verify you can see the invocation in the logs.

You can check your app against step 2 in the tutorial repository.

Step 3: Add a configuration dialog to the macro

The configuration dialog for the macro

  1. Add a new component named Config containing a ConfigForm and use the component in the config property of the macro. This configuration form allows a user of the macro to specify a set of definitions to show in a table.

    1
    2
    const Config = () => (
      <ConfigForm>
        <TextArea label="Terms to include (one per  line)" name="terms" isRequired />
      </ConfigForm>
    );
    
    export const run = render(<Macro app={<App />}  config={<Config />} />);
    
  2. Add the useConfig hook to the App component. This hook allows the app to access configuration set by the user in the macro configuration dialog. If there is no list of terms set the macro renders a blank state.

    1
    2
    const App = () => {
      const config = useConfig();
      const terms = config?.terms?.split("\n");
      const definitions = [];
    
      if (!config || !config.terms) {
        return (
          <Fragment>
            <Text>No terms</Text>
          </Fragment>
        );
      }
    
      return (
        <Fragment>
          <Text>{terms.join(",")}</Text>
        </Fragment>
      );
    };
    

You can check your app against step 3 in the tutorial repository.

Step 4: Fetch a list of definitions from storage

Your app makes use of the JavaScript API provided in the @forge/api package to interact with the App Storage service. This package provides methods for reading, writing, and querying data within the App Storage service.

This tutorial starts with reading a list of definitions from the storage service. Initially the results are empty, adding definitions is covered in a later stage.

The app stores key entities based on the term, with the following format:

1
2
interface Term {
  definition: string;
  term: string;
}
  1. Create a new file storage.js for storage-related code.

  2. Create a function getDefinition to load a definition given a term.

    1
    2
    import { storage } from "@forge/api";
    import { useConfig, useState } from "@forge/ui";
    
    // Create a key composed from the term
    function termKey(term) {
      return `term-${term}`;
    }
    
    export async function getDefinition(term) {
      // Fetch a definition from the Storage API
      const value = await storage.get(termKey(term));
    
      // If a value was found, return the definition field
      if (value) {
        return value.definition;
      }
    }
    
  3. Create a function getDefinitions to turn a list of terms into a list of definitions.

    1
    2
    export async function getDefinitions(terms) {
      const pendingDefinitions = terms.map((term) => getDefinition(term));
    
      return await Promise.all(pendingDefinitions);
    }
    

    This function makes use of the Promise.all operation to turn a list of pending promises into a single promise. The resulting promise resolves to an array of definitions when awaited.

  4. You can compose UI Kit hooks together to create custom hooks with a purpose-built API. The definition management logic is a good candidate for consolidating into a hook as it will greatly simplify interacting with storage from the UI Kit code. Add a new hook to the storage.js file named useDefinitions. This custom hook will compose the useConfig and useState hooks to integrate the storage API with a UI Kit macro.

    1
    2
    export function useDefinitions() {
      const config = useConfig() || {};
      const terms = config.terms ? config.terms.split("\n") : [];
      const [definitions] = useState(() => getDefinitions(terms));
    
      return {
        terms,
        definitions,
      };
    }
    
  5. Use this hook API within the App component in the index.jsx file to load the definitions for the provided terms list.

    1
    2
    import { useDefinitions } from "./storage";
    
    const App = () => {
      const { terms, definitions } = useDefinitions();
    
      if (terms.length === 0) {
        return (
          <Fragment>
            <Text>No terms</Text>
          </Fragment>
        );
      }
    
      return (
        <Fragment>
          <Text>{terms.join(",")}</Text>
          <Text>{definitions.join(",")}</Text>
        </Fragment>
      );
    };
    

You can check your app against step 4 in the tutorial repository.

Step 5: Create the definitions table

The app showing configured terms with definitions

In this step, you'll add a table to the definitions macro to show the list of terms side by side with a list of definitions.

It can be useful when building a UI Kit app to group elements together into reusable components. For this app, we will add DefinitionTable, DefinitionHeader, and DefinitionRow components.

  1. Create a new file named definition-table.js.

  2. In this file, add a new component named DefinitionRow - this component will be used to render each row in the definitions table.

    1
    2
    import ForgeUI, { Button, Cell, Head, Row, Table, Text } from "@forge/ui";
    
    // Definition row renders a single row in the definitions table for a specific term
    const DefinitionRow = ({ term, definition }) => {
      // Render a row with the term and definition
      return (
        <Row>
          <Cell>
            <Text>{term}</Text>
          </Cell>
    
          <Cell>
            <Text>{definition}</Text>
          </Cell>
        </Row>
      );
    };
    
  3. Create a component named DefinitionTable in the same file. This component renders a list of definitions in a table.

    1
    2
    // Render a definitions table given a list of terms and definitions
    export const DefinitionTable = ({ terms, definitions }) => {
     // Render a definition row for each term
     const definitionRows = terms.map((term, i) => (
       <DefinitionRow term={term} definition={definitions[i]} />
     ));
    
     // Return a table with the list of definitions
     return (
       <Table>
         <Head>
           <Cell>
             <Text>Acronym</Text>
           </Cell>
           <Cell>
             <Text>Definition</Text>
           </Cell>
         </Head>
         {definitionRows}
       </Table>
     );
    };
    
  4. Add this table to your existing macro in index.js and add an import for the DefinitionTable component.

    1
    2
    import { DefinitionTable } from "./definition-table";
    
    const App = () => {
      const { terms, definitions } = useDefinitions();
    
      if (terms.length === 0) {
        return (
          <Fragment>
            <Text>No terms</Text>
          </Fragment>
        );
      }
    
      return (
        <Fragment>
          <DefinitionTable terms={terms} definitions={definitions} />
        </Fragment>
      );
    };
    

You can check your app against step 5 in the tutorial repository.

Step 6: Allow a user to add a definition

The app showing configured terms with a button to add a definition

At this stage, there's still no data stored for the app. In this step, you'll add the ability to store a definition for a term.

  1. Add a saveDefinition method to the storage.js file.

    1
    2
    async function saveDefinition(term, definition) {
      return await storage.set(termKey(term), { term, definition });
    }
    
  2. Add the updateDefinition method to the useDefinitions hook. This allows the macro to save an update to a definition and refresh the UI Kit state.

    1
    2
    export function useDefinitions() {
      const config = useConfig() || {};
      const terms = config.terms ? config.terms.split("\n") : [];
    
      const [definitions, setDefinitions] = useState(() =>
        getDefinitions(terms)
      );
    
      return {
        terms,
        definitions,
        async updateDefinition(term, definition) {
          await saveDefinition(term, definition);
          const i = terms.indexOf(term);
          if (i >= 0) {
            definitions[i] = definition;
            setDefinitions(definitions);
          }
        },
      };
    }
    
  3. Create a file add-definition.jsx.

  4. Create a dialog in the add-definition.jsx file that enables a user to provide a definition for a term. When the user clicks submit for the dialog, the term onSaveDefinition callback is invoked and then the dialog closed.

    1
    2
    import ForgeUI, { Form, ModalDialog, TextArea } from "@forge/ui";
    
    export const AddDefinition = ({ term, onClose, onSaveDefinition }) => (
      <ModalDialog header={`Add definition for ${term}`} onClose={onClose}>
        <Form
          onSubmit={async ({ definition }) => {
            await onSaveDefinition(term, definition);
            onClose();
          }}
        >
          <TextArea name="definition" label={`Definition for ${term}`} />
        </Form>
      </ModalDialog>
    );
    
  5. Add a button with the text "Add definition" to the DefinitionRow component. This button triggers the AddDefinition dialog.

    1
    2
    const DefinitionRow = ({ term, definition, showAddDefinitionDialog }) => {
      let definitionContent = null;
      if (definition) {
        definitionContent = <Text>{definition}</Text>;
      } else {
        definitionContent = (
          <Button text="Add definition" onClick={showAddDefinitionDialog} />
        );
      }
    
      return (
        <Row>
          <Cell>
            <Text>{term}</Text>
          </Cell>
    
          <Cell>{definitionContent}</Cell>
        </Row>
      );
    };
    
  6. Add a callback showAddDefinitionDialog to the DefinitionTable. This callback triggers displaying the AddDefinition dialog for a specific term.

    1
    2
    export const DefinitionTable = ({
      terms,
      definitions,
      showAddDefinitionDialog,
    }) => {
      // Render a definition row for each term
      const definitionRows = terms.map((term, i) => (
        <DefinitionRow
          term={term}
          definition={definitions[i]}
          showAddDefinitionDialog={() => showAddDefinitionDialog(term)}
        />
      ));
    
      // Return a table with the list of definitions
      return (
        <Table>
          <Head>
            <Cell>
              <Text>Term</Text>
            </Cell>
            <Cell>
              <Text>Definition</Text>
            </Cell>
          </Head>
          {definitionRows}
        </Table>
      );
    };
    
  7. Add both the AddDefinition dialog and useDefinitions hook to the App component. The app uses a useState hook to track the visibility of the edit dialog. When the dialog invokes the onSaveDefinition property the associated definition is stored in the app storage API.

    1
    2
    import { AddDefinition } from "./add-definition";
    
    const App = () => {
      const { terms, definitions, updateDefinition } = useDefinitions();
      const [addingDefinitionToTerm, showAddDefinitionDialog] = useState(
        undefined
      );
    
      if (terms.length === 0) {
        return (
          <Fragment>
            <Text>No terms</Text>
          </Fragment>
        );
      }
    
      return (
        <Fragment>
          {!!addingDefinitionToTerm && (
            <AddDefinition
              onClose={() => showAddDefinitionDialog(undefined)}
              term={addingDefinitionToTerm}
              onSaveDefinition={updateDefinition}
            />
          )}
    
          <DefinitionTable
            terms={terms}
            definitions={definitions}
            showAddDefinitionDialog={showAddDefinitionDialog}
          />
        </Fragment>
      );
    };
    
  8. Stop tunneling your app and deploy it by running:

    1
    2
    forge deploy
    

You can check your app against step 6 in the tutorial repository.

Next steps

At this point your app is able display a list of terms and their associated definitions. The app stores definitions in the storage service, and shares these across all the instances of the macro in the site.

Explore the app storage API in further detail over the following pages:

  • Read about the Storage API which details the JavaScript API.
  • Learn more about how Queries can be run against data stored in the app storage API.
  • View the Limits that apply to apps using the app storage API.

Rate this page: