Forge-ing Simple Solutions for Confluence: Global Variables

forge-img.png

This is a sequel to the Forge-ing Simple Solutions for Jira article wherein I showcase the use of Forge to come up with solutions for everyday problems. I am trying a different approach here i.e., instead of linking to a different page for the steps, I’ll try to incorporate them here for better visibility. Let me know if this format works better for you.

Good day, community! Ian here from Atlassian’s Developer Experience team.

In this month’s entry, I’m going to share a step-by-step guide on how to use Forge to build a global variable macro that can be used in your respective pages, templates, and other macros. The app is inspired by this feature suggestion (check out the issue’s creation date field!).

"How can I use Forge?" and other FAQs

Before we go through the step-by-step guide, here are answers to some frequently asked questions.

 

 Question Answer 
What is Forge? Forge is a serverless app development platform that is built into Atlassian cloud products like Bitbucket, Confluence, and Jira. It can be utilized to build apps to customize your Atlassian product experience.
Can I use it? Yes! This is part of the cloud product for free. You can start building and installing the app on a site that you are an admin in.
Wait, I am not an admin. How do I install the app? That is fine. You can start your journey by creating a development site – you can create one at https://go.atlassian.com/cloud-dev. Once your app is working as intended, you can share the link with the admin of the site where you want your app to be installed (see this guide for more information).

 

The use case

As teams create Confluence content, such as pages and blogs, certain pieces of information serve as snapshots of the current state. If the goal is to have a historical view of data progression, that's perfectly fine. However, in certain situations, there is a requirement for near real-time information.

For example, a company with 1000 employees uses a Confluence template on its internal pages that mentions the company's name in one of its sections. One day, they merged with a bigger corporation, thus, resulting in a company name change. The existing template is still valid but they need to change every instance of the previous company name. Without the use of a global variable in their template, they have to update every page that uses the template (which could be really time-consuming).

A solution

Not to claim that this is the sole solution for this, but I’d like to think that the concept of global variables can be an easy way to solve this requirement. With global variables, you can have a single source of truth for the value as well as a single place to update them. Once updated, the new value will then be picked up by all the instances of the variable.

Let’s build the app

Before going in head first, I normally break down the requirements into bite-size pieces that have potential value. I say potential as I am open to the possibility that a requirement may change during development – it may be because of an invalid use case initially thought otherwise, or maybe we hit a limitation e.g., time, skill-wise, or platform capability.

The way I see it, there are two blocks of work we need to consider:

  1. As a Confluence user (or admin if we want to restrict it), I need a configuration page, in order to create, edit, and delete global variables.

  2. As a general user, I want to reference the variables on a Confluence page (or any content), so that my page will always have the updated variable values.

With that, it is time to open our favorite Javascript-friendly IDE and CLI; here we go.

Wait, it’s my first time building Forge apps

Don’t sweat it, Atlassian provides comprehensive documentation and resources to help you get started (and succeed) when building Forge-based solutions. To set up your development environment, follow this Getting Started guide.

Step 1: Generate the configuration page

forge-create.png
  1. In your command line, go to the directory where you want to create the app e.g., cd ~/Development

  2. Run the command forge create

  3. Enter a name for your app e.g., My Global Variables

  4. Select the UI Kit category using the arrow keys.

  5. Select the confluence-global-settings template.

  6. After hitting the return/enter key:

    • The app template will be created

    • The app will be registered

    • Three free environments will be created (for you to develop, test, and go live on): development, staging, and production

    • All the necessary dependencies will be installed

Step 2: Populate the configuration page

The forge create command generated a working Forge app with a Text component that says “Hello world!”. It is a good starting point, and we need to add more UI components to meet our app’s requirements.

First, using your favorite text editor or IDE, open the index.jsx file and update the import statement to include the necessary UI kit components that we’ll use for the global variables configuration page.

import ForgeUI, { render, Fragment, Text, GlobalSettings, Table, Head, 
  Cell, Row, Button } from '@forge/ui';

Next, we update App by replacing the existing Text component with a Table; if you're curious why a table, as I anticipate the number of variables to increase, a table is easier to grow while showing different values like variable value and description within the same line.

const App = () => {
  return (
    <Fragment>
      <Table>
        {/* Global vars table header */}
        <Head>
          <Cell>
            <Text>Variable Name</Text>
          </Cell>
          <Cell>
            <Text>Value</Text>
          </Cell>
          <Cell>
            <Text>Description</Text>
          </Cell>
          <Cell>
            <Text></Text>
          </Cell>
        </Head>
        {/* Global vars row values. */}
        {/* This section needs to dynamically grow as new vars gets added*/}
        <Row>
          <Cell>
            <Text>Variable name goes here</Text>
          </Cell>
          <Cell>
            <Text>Variable value</Text>
          </Cell>
          <Cell>
            <Text>Variable description</Text>
          </Cell>
          <Cell>
            <Button text="Edit" appearance="link" onClick={async () => {
              console.log(`Edit button clicked`);
            }}>
            </Button>
            <Button text="Delete" appearance="danger" onClick={async() => {
              console.log(`Delete button clicked`);
            }}>
            </Button>
          </Cell>
        </Row>
      </Table>
      <Text/>
      <Button text="Add Variables" onClick={async () => {
        console.log(`Add variable button clicked`);
      }}>
      </Button>
    </Fragment>
  );
};

Step 3: Deploy and install the app

It’s always a nice feeling to see and validate our progress so let’s do that right now! If you’re thinking, “but we just run a number of commands in the CLI, is this a working app now?”, the short answer is YES 🙂. Even without adding the Table component in the previous step, the app generated by forge create is already a working app.

To deploy and install the app in your instance, follow these steps:

  1. Change to your project directory e.g., cd My-Global-Variables

  2. Run the command forge deploy to package the app files and deploy them to the development environment by default.

  3. Run forge install to install the app in your Confluence Cloud instance. Key in your instance URL {yourInstanceName}.atlassian.net when prompted, and give consent to the read:me scope when asked.

  4. To view the app, go to cog > APPS > My Global Variables (Development). It should look something like this.

configuration-page-initial.png

That felt really easy, doesn’t it? 🙂

Step 4: Finalize the configuration page

Once the UI is done, it is time to implement the variable creation, update, and deletion logic. For this requirement, which is common to most app development, we would typically need to set up a database to store the information. With Forge, there is no need to maintain such storage as the platform offers built-in Storage APIs that we can utilize. I will not go into detail about how Forge’s storage works, but know that data will not be shared with other Forge apps, nor will it be shared with other Confluence instances using your app.

Set up Forge’s Storage API

In your command line interface (e.g, Mac’s Terminal or Windows' CMD), go to your project directory and install the @Forge/api package which contains the Storage APIs.

npm install @forge/api

Open the manifest.yml and add the following scope to permit the use of the Storage APIs.

permissions:
  scopes:
    - storage:app

Lastly, import @Forge/api in your index.jsx.

// We'll specifically use startsWith and storage
import { startsWith, storage } from '@forge/api';

Declare the UI component states

In order to manage the data being rendered in our app, we are going to use UI kit hooks, specifically, useState. To start, we will add useState in the imports.

import ForgeUI, { render, Fragment, Text, GlobalSettings, Table, Head, 
  Cell, Row, Button, useState } from '@forge/ui'; 

Inside App, before the return statement, add the following:

// States if the add or edit modals is open
const [isEditModalOpen, setEditModalOpen] = useState(false);
const [isAddVariableModalOpen, setAddVariableModalOpen] = useState(false);

// will be used to identify which specific row the Edit button was pressed
// "globalVariableName-" is the identifier used to differentiate the key
const [rowKey, setRowKey] = useState("");

// We are setting the default value to the data from storage
const [variables, setVariables] = useState(
  async () => await storage.query()
    .where('key', startsWith('globalVariableName-'))
    .getMany()
);

Update the UI to handle events

In our existing configuration page, we have three buttons and a dynamic table that will grow in size. As this might sound complex to implement, let’s break them down again.

First, let’s implement the dynamic table rows. What we will do is to check if there are any stored variables and if there are, we will add a row component for each one of them. If there are no variables, we simply won’t add a Row component.

{/* Make the table rows dynamic depending on the variables we got */}
{/* Go through the stored variables one by one and add a row */}
{/* If the are no variables stored, no rows will be displayed */}
{ 
  (variables.results) ?
  variables.results.map(variable => 
    <Row>
      {/* Retrieve the values from the variables */}
      <Cell>
        <Text>{variable.value.name}</Text>
      </Cell>
      <Cell>
        <Text>{variable.value.value}</Text>
      </Cell>
      <Cell>
        <Text>{variable.value.description}</Text>
      </Cell>
      <Cell>
        <Button text="Edit" appearance="link" onClick={async () => {
          console.log(`Edit button clicked`);
        }}>
        </Button>
        <Button text="Delete" appearance="danger" onClick={async() => {
          console.log(`Delete button clicked`);
        }}>
        </Button>
      </Cell>
    </Row>      
  )
  :
  {/* Don't add rows when there are no variables */}
  ""
}

Next, we’ll handle the edit button click and display a modal. The modal will have a Form component that contains the variable's information and will allow us to edit them. Submitting the Form will call a function which we will implement in the succeeding sections.

Note: Since we are using ModalDialog, Form, and TextField for the first time, be sure to import it from the @Forge/ui package.

import ForgeUI, { render, Fragment, Text, GlobalSettings, Table, Head, 
  Cell, Row, Button, useState, ModalDialog, Form, TextField } from '@forge/ui';
<Button text="Edit" appearance="link" onClick={async () => {
  setEditModalOpen(true);
  // The identifier used to know which row the Edit button was clicked in
  setRowKey(variable.value.name);
}}>
</Button>
{/* Only open the edit modal for the selected row */}
{isEditModalOpen && rowKey == variable.value.name && 
(
  // Contains a form with textfields that allows us to edit the variable details
  <ModalDialog header={"Edit " + variable.value.name} onClose={() => setEditModalOpen(false)}>
    <Form onSubmit={onEditSubmit}>
      <TextField name="variableName" label="Name" type="string" defaultValue={variable.value.name} autoComplete="off"/>
      <TextField name="variableValue" label="Value" type="string" defaultValue={variable.value.value} autoComplete="off" />
      <TextField name="variableDescription" label="Description" type="string" defaultValue={variable.value.description} autoComplete="off" />
    </Form>
  </ModalDialog>
)}

After the edit button, we’ll now handle the delete button click; we’ll call a soon-to-be-implemented deleteRow function that accepts the variable name as an argument. Under the hood, we’ll use a Storage API call.

<Button text="Delete" appearance="danger" onClick={async() => {
  await deleteRow(variable.value.name);
}}>
</Button>

The last button we need to handle is the add button. Much like the edit variable modal, clicking the add button will open a dialog with a Form that contains empty TextFields that need to be populated.

{/* Add space between the table and the add button */}
<Text/>
<Button text="Add Variables" appearance='warning' onClick={async () => {
  setAddVariableModalOpen(true);
}}>
</Button>
{/* Check if the add button is clicked before opening the modal */}
{isAddVariableModalOpen && (
  <ModalDialog header="Add variable" onClose={() => setAddVariableModalOpen(false)}>
    <Form onSubmit={onAddSubmit}>
      <TextField name="variableName" label="Name" type="string" autoComplete="off" />
      <TextField name="variableValue" label="Value" type="string" autoComplete="off" />
      <TextField name="variableDescription" label="Description" type="string" autoComplete="off" />
    </Form>
  </ModalDialog>
)}

Implement button functionalities and helper functions

Starting with the helper functions, I’ve created two that will aid us with storing the values in Forge’s storage as well as updating the local state. Updating the components' local state is essential to ensure that no stale values will be rendered.

// Sync the local variable states with the stored values
const updateVariableLocalState = async () => {
  const currentStore = await storage.query()
          .where('key', startsWith('globalVariableName-'))
          .getMany();

  setVariables(currentStore);
}

// Save the values in the store 
const storeGlobalVariables = async (formData) => {
  // "globalVariableName-" is the identifier used to differentiate the key
  await storage.set(`globalVariableName-${formData.variableName}`, 
    {
      name: formData.variableName,
      value: formData.variableValue,
      description: formData.variableDescription        
    }
  );

  await updateVariableLocalState();
}

These helper functions will then be used by our onClick and onSubmit event handlers:

const onAddSubmit = async (formData) => {
  await storeGlobalVariables(formData);
  setAddVariableModalOpen(false);
};

const onEditSubmit = async (formData) => {
  await storeGlobalVariables(formData);
  setEditModalOpen(false);
};

const deleteRow = async (variableName) => {
  await storage.delete(`globalVariableName-${variableName}`);
  await updateVariableLocalState();
};

Now, that should complete the global variable’s configuration page. To test it out go to your command line and run the following commands:

  1. forge deploy - to package and deploy the updated app files

  2. forge install --upgrade - since there are changes in the manifest.yml when you added the storage scope, this call is required for the changes to take effect

After running the two commands successfully, it is time to look at your current progress and test it out. The three buttons should work as expected, modal dialogs will be displayed, and the values are now being stored. Great work so far!

configuration-final.png

Note: Some validations were not implemented in this reference app specifically around restricting access to the configuration page, checking permissions who can delete a global variable, variable name format validation, etc.

Step 5: Build an inline macro to display your variables

Since variable storage is done, we need a way to retrieve these values and display them on a page or blog. For us to do this, we can use the Macro UI kit component.

First, we need to update the manifest file (a.k.a. manifest.yml) and add the macro module. Notice that we’ll define two different macro handlers – one is for displaying the macro with an inline layout (so that we can place it neatly in the middle of sentences if desired), and the other is for the macro configuration component.

macro:
  - key: f-global-var-macro
    function: main-macro
    title: Global variable macro
    description: Inserts global variable
    layout: inline
    config:
      function: config-global-var-macro      
function:
  - key: main
    handler: index.run
  - key: main-macro
    handler: index.runMacro
  - key: config-global-var-macro
    handler: index.configMacro

For the next step, we’ll create function components for the macro and macro configuration. Let’s start with the simpler-looking one: the macro config. The configuration will accept the variable name as the unique identifier of our global variables.

const ConfigMacro = () => {
  return (
    <MacroConfig>
      <TextField name="globalVarName" label="Global variable name" />
    </MacroConfig>
  );
};

For the main macro body, we’ll define this:

const AppMacro = () => {
  // Hook that retrieves the macro configuration
  const config = useConfig();

  // Retrieve the variable value from storage
  const [globalVariable] = useState(async () => (config) ? 
    await storage.get(`globalVariableName-${config.globalVarName}`) : 
    undefined);

  return (
    <Fragment>
      <Text>
        { 
          (globalVariable) ? 
          globalVariable.value :
          "[This global variable does not exist.]"
        }         
      </Text>
    </Fragment>
  );
};

The last step is to export the components so they can be referenced by the function handlers.

export const runMacro = render(
  <Macro
    app={<AppMacro />}
  />
);

export const configMacro = render(
  <ConfigMacro />
);

Since we used new UI kit components and a hook – MacroConfig, Macro, and useConfig, be sure to import them.

import ForgeUI, { render, Fragment, Text, GlobalSettings, Table, Head, 
  Cell, Row, Button, useState, ModalDialog, Form, TextField,
  useConfig, MacroConfig, Macro } from '@forge/ui';

With all the changes done, let’s do another round of forge deploy and forge install --upgrade for the changes to take effect in our current installation. Once done, you can create a page and test it out!

Note: For reference, the variables are set in the previous section above.

page-with-macro-referencing-vars.png

Wrap up

In less than 200 lines of code, we are able to customize the Confluence experience by building and adding new functionality that our team needs.

Get the whole codebase by forking this project and let us know what you think!

0 comments

Comment

Log in or Sign up to comment
TAGS
AUG Leaders

Atlassian Community Events