Last updated Jun 1, 2024

Your next quest - Part II

In part one you created your Forge UI kit app using the Jira dashboard gadget template. You then customised the Configuration screen and displayed the configuration data in the app.

Now, you will modify the app to call the OpenWeather Geolocation API using the fetch client from the @forge/api package, and display a list of locations for the user to select from on the app configuration view. You will also learn how to store and access environment variables in Forge.

This is part two in this tutorial. Complete Part one before working through this page.

Save your OpenWeather API key as an environment variable

Using the Forge CLI, you will store the API Key for the OpenWeather API as an environment variable.

If you haven't already, sign up for a free Open weather map account and generate an API key. You won't need to sign up for a paid account, as the API calls this app makes are included in the Free plan.

Environment variables are key-value pairs which can be stored based on your environment. As with other Forge CLI commands, the Forge CLI variables commands act on the development environment unless another environment is specified explicitly. Storing your API key as an environment variable means you don't need to store the key in plain text within your app.

  1. Copy your OpenWeather API key from the OpenWeather API keys page
  2. From your app's top-level directory run the following command, replacing :
    1
    2
    forge variables set --encrypt OPENWEATHER_KEY <your-key>
    
  3. You can run forge variables list at any time from your app's top-level directory to view your variables.

Add the fetch permissions to your manifest

In this section, you will modify your manifest.yml to add the necessary permissions to call the OpenWeather API.

  1. Open the manifest.yml file,

  2. Add the following permissions to the end:

    1
    2
    permissions:
      external:
        fetch:
          backend:
            - api.openweathermap.org
    

    For more information on external permissions, see Permissions - External permissions in the Forge docs.

  3. Save the changes you made to manifest.yml

  4. From your app's top-level directory run forge deploy, the following warning message will appear

    1
    2
    We've detected new scopes or egress URLs in your app.
    Run forge install --upgrade and restart your tunnel to put them into effect.
    
  5. From your app's top-level directory run forge install --upgrade and follow the prompts to upgrade your app installation.

Create a Resolver function to get the Longitude and Latitude coordinates

The OpenWeather Current weather data API needs the longitude and latitude to be supplied, rather than the country and city. OpenWeather provides a Geocoding API which allows you to specify a city, state and country and get a list of cities that match along with their Longitudes and Latitudes.

In this section, you will create a resolver function that will use fetch to call the Geocoding API with the city and country entered by the user and pass that data back to be displayed in your app.

API Calls to external services (such as the OpenWeather API) must be made via a resolver.

  1. Navigate to the src/resolvers directory for your app.
  2. Open the index.js file, the contents should be as follows:
    1
    2
    import Resolver from '@forge/resolver';
    
    const resolver = new Resolver();
    
    resolver.define('getText', (req) => {
      console.log(req);
    
      return 'Hello, world!';
    });
    
    export const handler = resolver.getDefinitions();
    
  3. Import fetch from @forge/api by adding the following to the top of the file, and save your changes
    1
    2
    import { fetch } from '@forge/api'
    
  4. From your app's top-level directory run the following command, and wait for it to install the necessary packages,
    1
    2
    npm install @forge/api
    
  5. In src/resolvers/index.js create a new resolver to call the OpenWeather Geocoding API using fetch:
    1
    2
    resolver.define('getLocationCoordinates', async (req) => {
    
      if(req.payload.location) {
        const config = req.payload.location;
        const url = "https://api.openweathermap.org/geo/1.0/direct?q=" + config.city + "," + 
                      config.country + "&limit=5&appid=" + process.env.OPENWEATHER_KEY;
        const response = await fetch(url)
    
        if(!response.ok) {
          const errmsg = `Error from Open Weather Map Geolocation API: ${response.status} ${await response.text()}`;
          console.error(errmsg)
          throw new Error(errmsg)
        }
    
        const locations = await response.json()
        
        return locations;
      } else {
        return null;
      }
    });
    

Modify the App Configuration view to show the list of Locations

In this section, you'll modify the configuration view for your app so that:

  • The Submit button will initially be hidden when the Configuration view is shown.
  • Once the user enters their City and Country the OpenWeather Geolocation service will be called using the resolver function.
  • The first 5 results will be displayed as radio buttons for the user to select from, and the Submit button will appear.
  • When the user presses submit, the app will store the openweather location rather than the city and country entered by the user.
  • If the user tries to Submit before a location is selected from the radio buttons, an error message will be displayed.

You will be using the form component to make most of these changes. See UI Kit Form Component and UI Kit useForm Hook

  1. Navigate to the src/frontend directory of your app and open index.jsx
  2. Add RadioGroup and ErrorMessage to the imports from @forge/react
  3. Modify the first line of your Edit function to add getValues, and formState which will be used to get the field data entered by the user and to catch errors
    1
    2
    const { handleSubmit, register, getValues, formState } = useForm();
    
  4. Add a new function called getOptions and the following variables to the Edit function:
    1
    2
    const [locationOptions, setLocationOptions] = useState(null);
    const [showOptions, setShowOptions] = useState(false);
    const { errors } = formState;
    
    const getOptions = () => {
      const values = getValues();
    
      if(values.city && values.country){
    
        if(currentCC && (currentCC.city == values.city)&&(currentCC.country == values.country)) {
          // do nothing if the city and country entered by the user hasn't changed
        } else {
          // store the curent city and country to compare for changes later
          currentCC = { 
            city: values.city, 
            country: values.country }
          // refresh locationOptions by calling the OpenWeather Geolocation API
          invoke('getLocationCoordinates', {location: values}).then((val) => { 
            setLocationOptions(val);
            // set showOptions to true - this will be used to display the radio button group, and the submit button
            setShowOptions(true);
          });
        }
      }
    };  
    
  5. Add a new function called locationOption to the Edit function. This will be used to create the radio button options.
    1
    2
    function locationOption(obj, index, array) {
      return { name: "location", label: obj.name + ", " + obj.state + ", " + obj.country, value: index }
    }
    
  6. Next, modify the configureGadget function, to return the selected location, rather than the user entered city and state.
    1
    2
    const configureGadget = (data) => {
      view.submit(locationOptions[data.location])
    }
    
  7. Now, modify the return for the Edit function as follows
    1
    2
    <>
      <Form onSubmit={handleSubmit(configureGadget)}>
        <FormSection>
          <Label>City<RequiredAsterisk /></Label>
          <Textfield {...register("city", { required: true, onChange: getOptions() })} />
          <Label>Country<RequiredAsterisk /></Label>
          <Textfield {...register("country", { required: true })} />
          {showOptions && <Label>Select your location<RequiredAsterisk /></Label>}
          {showOptions && (
              <RadioGroup {...register("location", {required: true})} options={locationOptions.map(locationOption)}/>
            )}
            {errors["location"] && <ErrorMessage>Select a location</ErrorMessage>}
        </FormSection>
        <FormFooter>
          {showOptions && <Button appearance="primary" type="submit">
            Submit
          </Button>}
        </FormFooter>
      </Form>
    </>
    
  8. Finally, update the return in the View function to display the latitude and longitude using the location returned from the Geolocation service,
    1
    2
    <>
      <Text>City: {gadgetConfiguration["name"] ? gadgetConfiguration["name"] : "Edit me"}</Text>
      <Text>Country: {gadgetConfiguration["country"] ? gadgetConfiguration["country"] : "Edit me"}</Text>
      <Text>Lon: {gadgetConfiguration["lon"] ? gadgetConfiguration["lon"] : "Edit me"}</Text>
      <Text>Lat: {gadgetConfiguration["lat"] ? gadgetConfiguration["lat"] : "Edit me"}</Text>
    </>
    
  9. Save and deploy your changes and test they work in your development environment.

Your src/resolvers/index.js should look like this:

1
2
import Resolver from '@forge/resolver';
import { fetch } from '@forge/api'

const resolver = new Resolver();

resolver.define('getText', (req) => {
  console.log(req);

  return 'Hello, world!';
});

resolver.define('getLocationCoordinates', async (req) => {

  if(req.payload.location) {
    const config = req.payload.location;
    const url = "https://api.openweathermap.org/geo/1.0/direct?q=" + config.city + "," + config.country + "&limit=5&appid=" + process.env.OPENWEATHER_KEY;
    const response = await fetch(url)
    if(!response.ok) {
      const errmsg = `Error from Open Weather Map Geolocation API: ${response.status} ${await response.text()}`;
      console.error(errmsg)
      throw new Error(errmsg)
    }
    const locations = await response.json()
    return locations;
  } else {
    return null;
  }
});

export const handler = resolver.getDefinitions();

Your src/frontend/index.jsx should look like this:

1
2
import React, {useEffect, useState} from "react";
import ForgeReconciler, {
  Text,
  useProductContext,
  Textfield,
  Form,
  Button,
  FormSection,
  FormFooter,
  Label,
  RequiredAsterisk,
  useForm,
  RadioGroup,
  ErrorMessage,
} from "@forge/react";
import { invoke, view } from "@forge/bridge";

let currentCC = null;

export const Edit = () => {
  const { handleSubmit, register, getValues, formState } = useForm();
  const [locationOptions, setLocationOptions] = useState(null);
  const [showOptions, setShowOptions] = useState(false);
  const { errors } = formState;

  const getOptions = () => {
    const values = getValues();

    if(values.city && values.country){

      if(currentCC && (currentCC.city == values.city)&&(currentCC.country == values.country)) {
      } else {
        currentCC = { 
          city: values.city, 
          country: values.country }
      
        invoke('getLocationCoordinates', {location: values}).then((val) => { 
          setLocationOptions(val);
          setShowOptions(true);
        });
      }
    }
  };

  const configureGadget = (data) => {
    view.submit(locationOptions[data.location])
  }

  function locationOption(obj, index, array) {
    return { name: "location", label: obj.name + ", " + obj.state + ", " + obj.country, value: index }
  }

  return (
    <>
    <Form onSubmit={handleSubmit(configureGadget)}>
      <FormSection>
        <Label>City<RequiredAsterisk /></Label>
        <Textfield {...register("city", { required: true, onChange: getOptions() })} />
        <Label>Country<RequiredAsterisk /></Label>
        <Textfield {...register("country", { required: true })} />
        {showOptions && <Label>Select your location<RequiredAsterisk /></Label>}
        {showOptions && (
            <RadioGroup {...register("location", {required: true})} options={locationOptions.map(locationOption)}/>
          )}
          {errors["location"] && <ErrorMessage>Select a location</ErrorMessage>}
      </FormSection>
      <FormFooter>
        {showOptions && <Button appearance="primary" type="submit">
          Submit
        </Button>}
      </FormFooter>
    </Form>
    </>
  );
};

const View = () => {
  const [data, setData] = useState(null);
  const context = useProductContext();

  useEffect(() => {
    invoke('getText', { example: 'my-invoke-variable' }).then(setData);
  }, []);

  if (!context) {
    return "Loading...";
  }
  const {
    extension: { gadgetConfiguration },
  } = context;

  return (
    <>
      <Text>City: {gadgetConfiguration["name"] ? gadgetConfiguration["name"] : "Edit me"}</Text>
      <Text>Country: {gadgetConfiguration["country"] ? gadgetConfiguration["country"] : "Edit me"}</Text>
      <Text>Lon: {gadgetConfiguration["lon"] ? gadgetConfiguration["lon"] : "Edit me"}</Text>
      <Text>Lat: {gadgetConfiguration["lat"] ? gadgetConfiguration["lat"] : "Edit me"}</Text>
    </>
  );
};

const App = () => {
  const context = useProductContext();
  if (!context) {
    return "This is never displayed...";
  }

  return context.extension.entryPoint === "edit" ? <Edit /> : <View />;
};

ForgeReconciler.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

Your manifest.yml should look like this:

1
2
modules:
  jira:dashboardGadget:
    - key: weather-gadget-dashboard-gadget-ui-kit-2-hello-world-gadget
      title: weather-gadget
      description: A weather dashboard gadget.
      thumbnail: https://developer.atlassian.com/platform/forge/images/icons/issue-panel-icon.svg
      resource: main-resource
      render: native
      resolver:
        function: resolver
      edit:
        resource: main-resource
        render: native
  function:
    - key: resolver
      handler: index.handler
resources:
  - key: main-resource
    path: src/frontend/index.jsx
app:
  runtime:
    name: nodejs18.x
  id: [YOUR_ID]
permissions:
  external:
    fetch:
      backend:
        - api.openweathermap.org

Next steps

In the next step you will call the OpenWeather current weather API, and display the results using UI Kit layout elements.

Rate this page: