Build your first Figma Plugin

August 15, 2021

Let's create a Figma Plugin from Scratch using the NYTimes: Article Search API.


Notes

Step 01. Setup the Project

In order to simply the tutorial a bit, I've created a basic starter for the plugin we'll create. You can clone it and setup the project. After this step we'll have the basic setup for the app built and we can focus on building out the plugin.

Clone the starter project

# Clone Repo
git clone git@github.com:clintonhalpin/figma-news-api-plugin.git --branch step-1 figma-news && cd $_
 
# Install packages & test that build works
yarn && yarn build

After running this command you shouldn't see any red errors in your console! We can proceed from here.


Step 02. Run your plugin in Figma

The next step would be to test that your app works in Figma. Follow these steps:

  1. In the Menu Bar Click "Plugins > Manage Plugins"
  2. Hit the "+" button next to Create New Plugin
  3. Click "Click to choose a manifest.json file"
  4. In the project we just cloned select the manifest file in the dist folder ex. ~/Desktop/figma-plugin-news-api/dist/manifest.json
  5. Now right click in any figma file Select "Plugins > Development > FigmaNewsApiPlugin"
  6. If everything worked you should see...

Step 2 Screenshot


Step 03. Grab your API Key From NY Times

Before we get started with building out the rest of the application we will need to get an access token which will allow us to make API requests to the NY Times. Follow these steps:

Steps to get API Key

  1. Go to the create account page and fill in details here Create NY Times Developer Account
  2. Confirm the email in your inbox / Sign into your account
  3. Go to the new app page and fill in Name something like "Figma Plugin" Create NY Times App
    • Enable "Article Search API"
  4. Click "Create"
  5. Copy the API Key ex. 2jH4mOVGrKa8YrkBAJ7sF3u9JppLLIvl
  6. Save this to your clipboard for safe keeping

Step 3 Screenshot


Step 04. Create the UI for the App

Create the Form Component

Our Figma App will consist of two forms:

  1. Auth: A Form to Authenticate the user and save the users api key
  2. Search: A Form to Search the NY Times

We can create a simple form component to drive both parts of our app.

In src/components/Form.tsx add the following

import React, { useState } from "react";
 
export const Form = ({ fields, errorMsg, loading, onSubmit }) => {
  const [formFields, setFormFields] = useState({});
  return (
    <div className="p-xxsmall">
      <form
        style={{ opacity: loading ? 0.5 : 1 }}
        onSubmit={(e) => {
          e.preventDefault();
          onSubmit(formFields);
        }}
      >
        {errorMsg && (
          <div className="flex icon--red section-title">
            <span>Error: </span>
            {errorMsg}
          </div>
        )}
        {fields.map((f, i) => {
          if (f.type === "text") {
            return (
              <input
                {...f}
                key={i}
                className="input__field"
                type="text"
                onChange={(e) => {
                  setFormFields({
                    [e.target.name]: e.target.value,
                  });
                }}
              />
            );
          }
        })}
        <div className="pt-xxsmall">
          <button className="button button--primary mb-xxsmall" type="submit">
            Submit
          </button>
        </div>
      </form>
    </div>
  );
};

Create the Utilities file with our API Requests

In order to get our auth form working we'll also need to create the API request to fetch articles from NY Times.

Create a new folder and file and add src/utils/index.ts add the following:

import { NYTArticleSearchApi } from "./../interfaces";
const NYTIMESBASE = `https://api.nytimes.com/svc/search/v2/articlesearch.json`;
 
// Convert an Object to a query string
export function toParams(params) {
  return Object.keys(params)
    .map((key) => key + "=" + params[key])
    .join("&");
}
 
// Make an API Request to the NY Times
export function fetchNyTimesSearch({
  q = "figma",
  sort = "relevance",
  page = 0,
  apiKey,
}): Promise<NYTArticleSearchApi> {
  const urlParams = {
    q: encodeURI(q),
    sort,
    page,
    "api-key": apiKey,
  };
  return new Promise(async (resolve, reject) => {
    fetch(`${NYTIMESBASE}?${toParams(urlParams)}`)
      .then((res) => res.json())
      .then(async (response: NYTArticleSearchApi) => {
        if ("OK" === response.status) {
          resolve(response);
        } else {
          throw new Error("Unable to Authorize!");
        }
      })
      .catch((err) => {
        reject(err);
      });
  });
}

Create the Authorization Form

The first component we will create will be a form to save the API Key we created in Step 3. That component should look like this

In src/components/Auth.tsx add the following

import React, { useState } from "react";
import { fetchNyTimesSearch } from "./../utils/";
import { Form } from "./Form";
 
export const Auth = () => {
  const [loading, setLoading] = useState(false);
  const [errorMsg, setErrorMsg] = useState(null);
  const handleSubmit = async (formFields) => {
    setLoading(true);
    try {
      let response = await fetchNyTimesSearch({
        q: "Test Query",
        ...formFields,
      });
      console.log(response);
    } catch (e) {
      setErrorMsg("Unable to Authorize!");
    }
    setLoading(false);
  };
  return (
    <div>
      <Form
        loading={loading}
        onSubmit={handleSubmit}
        errorMsg={errorMsg}
        fields={[
          {
            name: "apiKey",
            placeholder: "API Key...",
            type: "text",
          },
        ]}
      />
    </div>
  );
};

Create the Search Form

Once our users can login they'll need a form to perform searches

In src/components/Search.tsx add the following

import React, { useState } from "react";
import { fetchNyTimesSearch } from "./../utils/";
import { Form } from "./Form";
 
export const Search = ({ apiKey }) => {
  const [loading, setLoading] = useState(false);
  const [errorMsg, setErrorMsg] = useState(null);
  const handleSubmit = async (formFields) => {
    setLoading(true);
    try {
      let response = await fetchNyTimesSearch({
        apiKey,
        ...formFields,
      });
      console.log(response);
    } catch (e) {
      setErrorMsg("Unable to Authorize!");
    }
    setLoading(false);
  };
  return (
    <div>
      <Form
        loading={loading}
        onSubmit={handleSubmit}
        errorMsg={errorMsg}
        fields={[
          {
            name: "q",
            placeholder: "Search NY Times...",
            type: "text",
          },
        ]}
      />
    </div>
  );
};

Render our Components in App

The step will want to take it to render our new Auth and Search components in our App.

In src/components/App.tsx update to:

import React from "react"
import { Auth } from "./../components/Auth"
import { Search } from "./../components/Search"
 
type IAppProps = {
  pluginData?: {
    local?: {
      apiKey?: string,
    },
  },
}
 
export const App = ({ pluginData }: IAppProps) => {
  return (
    <div>
      <Auth />
      {pluginData && pluginData.local && pluginData.local.apiKey && (
        <Search apiKey={pluginData.local.apiKey} />
      )}
    </div>
  )
}

Wrap Creating the UI

With the code above we should now have all of the UI we need to grab the API key from the user. As well as making API requests to the NY Times API!

Note if you want to try out the App as is an easy way to do it is to use serve on the dist folder

yarn build
npx serve dist
# Open localhost:5000

Additionally I realize there a bunch of steps above. If you are seeing any errors you can checkout the step-2 branch with all of the UI. In your terminal run:

git fetch --all
git checkout step-2

Step 05. Start Interacting with Figma

Save the NYTimes API Key

Currently anytime a user opens the Figma plugin they will have to input the api token. We can bypass this step by storing the API Key in our plugin data. To enable this functionality we will make changes in three files messages.ts, plugin.ts and src/Auth.tsx

Inside of our messages file src/messages.ts let's add a new line for our SAVE_AUTH event and export it

export const SAVE_AUTH = `SAVE_AUTH`;

In our src/utils/index.ts file let's add a function to send JSON messages from anywhere in our UI

export const sendJsonMessage = (type: string, payload: any) => {
  parent.postMessage(
    {
      pluginMessage: {
        type,
        name: `name_${type}`,
        payload: JSON.stringify(payload),
      },
    },
    "*",
  );
};

Inside of src/components/Auth.tsx we will want to add a few things.

import React, { useState } from "react"
import { fetchNyTimesSearch, sendJsonMessage } from "./../utils/"
import { Form } from "./Form"
// ⭐️ New: Import the Message
import { SAVE_AUTH } from "./../messages"
 
export const Auth = () => {
  const [loading, setLoading] = useState(false)
  const [errorMsg, setErrorMsg] = useState(null)
  const handleSubmit = async formFields => {
    setLoading(true)
    try {
      let response = await fetchNyTimesSearch({
        q: "Test Query",
        ...formFields,
      })
      // ⭐️ New: Post a JSON message to the parent frame once a successful response has been recieved
      sendJsonMessage(SAVE_AUTH, formFields)
      console.log(response)
    } catch (e) {
      setErrorMsg("Unable to Authorize!")
    }
    setLoading(false)
  }
  return (
    <div>
      <Form
        loading={loading}
        onSubmit={handleSubmit}
        errorMsg={errorMsg}
        fields={[
          {
            name: "apiKey",
            placeholder: "API Key...",
            type: "text",
          },
        ]}
      />
    </div>
  )
}

To save the response to our plugin we will want to add the following to our plugin.ts file

import { PluginDataManager } from "./pluginData";
import * as messages from "./messages";
 
function main() {
  figma.showUI(__html__, { width: 420, height: 550 });
  const pluginData = new PluginDataManager(figma);
  figma.ui.onmessage = (msg) => {
    /**
     // ⭐️ New: Here is where we recieve the POST from the iframe and process the JSON message
     */
    if (messages.SAVE_AUTH === msg.type) {
      console.log("plugin.ts", msg);
      const body = JSON.parse(msg.payload);
      /**
     // ⭐️ New: Once we recieve the token store it
     // and send a message back to the UI that we have stored it successfully
     */
      pluginData.updateLocalDataField("apiKey", body.apiKey);
    }
  };
}
 
main();

We will add a few lines into our src/components/App.tsx to change the UI once the user is logged in

import React from "react"
import { Auth } from "./../components/Auth"
import { Search } from "./../components/Search"
 
type IAppProps = {
  pluginData?: {
    local?: {
      apiKey?: string
    }
  }
}
 
export const App = ({ pluginData }: IAppProps) => {
  // ⭐️ New: Look for the apiKey to set the user to authorized
  // Show either Auth Or Search Component
  const isAuthorized = pluginData && pluginData.local && pluginData.local.apiKey
  return (
    <div>
      {!isAuthorized && <Auth />}
      {isAuthorized && <Search apiKey={pluginData.local.apiKey} />}
    </div>
  )
}

Now our Figma app will store the users API Key so they don't have to enter it again. If you want to skip the steps above run the following in your project!

git fetch --all
git checkout step-3

Step 06. Fill Data from NYTImes into Figma

Finally with our app setup and UI working we can fill data from the NYTImes into Figma. We will make changes in messages.ts plugin.ts and in src/components/Search.tsx

Start with adding our new FILL_RESULTS message in messages.ts

export const UPDATE_PLUGIN_DATA = "UPDATE_PLUGIN_DATA";
export const SAVE_AUTH = `SAVE_AUTH`;
// ⭐️ New: New Message type
export const FILL_RESULTS = `FILL_RESULTS`;

Then import the message and post it just like we did for auth in src/components/Search.tsx

import React, { useState } from "react"
import { fetchNyTimesSearch, sendJsonMessage } from "./../utils/"
import { Form } from "./Form"
// ⭐️ New: Import message
import { FILL_RESULTS } from "../messages"
 
export const Search = ({ apiKey }) => {
  const [loading, setLoading] = useState(false)
  const [errorMsg, setErrorMsg] = useState(null)
  const handleSubmit = async formFields => {
    setLoading(true)
    try {
      let response = await fetchNyTimesSearch({
        apiKey,
        ...formFields,
      })
      setLoading(false)
      // ⭐️ New: Previously we were just logging the search results
      // Now we want to send them to our plugin.ts
      sendJsonMessage(FILL_RESULTS, {
        response,
      })
    } catch (e) {
      setErrorMsg("Unable to Authorize!")
    }
    setLoading(false)
  }
  return (
    <div>
      <Form
        loading={loading}
        onSubmit={handleSubmit}
        errorMsg={errorMsg}
        fields={[
          {
            name: "q",
            placeholder: "Search NY Times...",
            type: "text",
          },
        ]}
      />
    </div>
  )
}

Just like we did for Authorization, we need to recieve the search results in our plugin and update the UI in plugin.ts

Start recieving the event in our plugin right below the SAVE_AUTH Message add

 figma.ui.onmessage = msg => {
    console.log("msg");
    /**
     * Save apiKey
     */
    if (messages.SAVE_AUTH === msg.type) {
      console.log("plugin.ts", msg);
      let body = JSON.parse(msg.payload);
      pluginData.updateLocalDataField("apiKey", body.apiKey);
    }
 
    /**
     // ⭐️ New: Fill Results
     */
    if (messages.FILL_RESULTS === msg.type) {
      let body = JSON.parse(msg.payload);
      fillArticles(body);
    }
  };
}

Below our main function we can add our "fillArticles" function

/**
 * fillArticles
 * @param data NYTArticleSearchApi
 */
async function fillArticles(data) {
  const articles = data.response.response.docs;
  try {
    let nodes = figma.currentPage.selection;
 
    if (nodes.length === 0) {
      alert("No Layers Selected!");
      return;
    }
 
    let matchingNodes = [];
    for (let i = 0; i < nodes.length; i++) {
      let node = nodes[i];
      if (shouldReplaceText(node)) {
        matchingNodes.push([node, articles[i]]);
      }
      // @ts-ignore
      if (node.children) {
        // @ts-ignore
        let children = loopChildNodes(node.children, articles[i]);
        if (children.length) {
          matchingNodes = [...matchingNodes, ...children];
        }
      }
    }
 
    if (matchingNodes.length === 0) {
      alert(
        'No values to replace, please rename your layers starting with a "#", ex. #title.text',
      );
    }
 
    /**
     * It seems the fastest in Figma to Seperate these actions
     */
    for (let i = 0; i < matchingNodes.length; i++) {
      let node = matchingNodes[i][0];
      let row = matchingNodes[i][1];
      let value = gatherValue(node.name, row);
      replaceText(node, value);
    }
    return;
  } catch (e) {
    console.log(e);
    return true;
  }
}
 
/**
 * shouldReplaceText
 * @param node FigmaNode
 */
function shouldReplaceText(node) {
  return node.name.includes("#");
}
 
/**
 * gatherValue
 * - Given a string ex. "#headline.main" or #keywords[0].value and object (DocsEntity) we match it the value from the object
 * - supporting dot and bracket notation
 * @param name
 * @param row
 * @returns
 */
const gatherValue = (name, row) =>
  name
    .replace("#", "")
    .split(".")
    .reduce(function (obj, prop) {
      if (prop.includes("[") && prop.includes("]")) {
        prop = prop.replace("[").replace("]");
      }
      return obj && obj[prop] ? obj[prop] : "--";
    }, row);
 
/**
 * replaceText
 * - Changing text in figma requires that we "load" all of the fonts
 * - Function handles case where you have multiple fonts loaded in a text layer
 * @param node
 * @param content
 */
async function replaceText(node, content) {
  /**
   * Load ALL fonts in the text
   */
  let len = node.characters.length;
  for (let i = 0; i < len; i++) {
    await figma.loadFontAsync(node.getRangeFontName(i, i + 1));
  }
  /**
   * Once fonts are loaded we can change the text
   */
  node.characters = String(content);
}
 
/**
 * loopChildNodes
 * - Recursively loop and find all matching notes
 * @param nodes
 * @param row
 */
function loopChildNodes(nodes, row) {
  let matches = [];
  for (let i = 0; i < nodes.length; i++) {
    const node = nodes[i];
    if (shouldReplaceText(node)) {
      matches.push([node, row]);
    }
    if (node.children) {
      const nextMatches = loopChildNodes(node.children, row);
      matches = [...matches, ...nextMatches];
    }
  }
  return matches;
}

If you want to skip the steps above run the following in your project!

git fetch --all
git checkout step-4