GROWIでBacklogのデータ表示するプラグインを作りました

[オープンソースのWikiであるGROWI](https://growi.org/ja/)にはプラグイン機能が用意されています。自社のデータを表示したり、表示をカスタマイズするのに利用できます。

今回は、GROWIプラグインとして[Backlog](https://backlog.com/)と連携するプラグインを作りました。BacklogにはWiki機能がありますが、このプラグインの場合は逆に、GROWI内にプロジェクト管理の情報を取り込んでいく形です。

![003.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/a0c99c6a-4640-aa57-cac3-5ddcb1a26ee7.jpeg)


## プラグインの動作

現状用意しているのは、プロジェクトの表示と課題の表示機能です。

### プロジェクトの表示

```markdown
::backlog[projects]{host=example.backlog.com}
```

hostにはBacklogのURLを指定します。オプションとして、 `archived` と `all` パラメータが指定できます。

```markdown
::backlog[projects]{host=example.backlog.com archived=true all=true}
```

### 課題の表示

```markdown
::backlog[issues]{host=example.backlog.com}
```

hostにはBacklogのURLを指定します。オプションとして、[課題一覧の取得 \| Backlog Developer API \| Nulab](https://developer.nulab.com/ja/docs/backlog/api/2/get-issue-list/)のパラメータが指定できます。

```markdown
::backlog[issues]{host=example.backlog.com sort=cateogry}
```

## 特徴

本プラグインでは、BacklogのAPIキーを利用します。デフォルトでは、そのAPIキー入力欄が表示されます。設定すると、localStorageにAPIキーが保存されます。

![001.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/fc93f681-05fd-d44c-0efa-81f17bbaa8fd.jpeg)


## プラグインを追加する

利用する際には、GROWIの管理画面の `プラグイン` にて追加してください。URLは `https://github.com/goofmint/growi-plugin-backlog` です。

![Admin](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/f5b3e2d3-ccf3-d536-d59e-7301ea1b2f7e.jpeg)

## コードについて

コードは[goofmint/growi\-plugin\-backlog: GROWI Plugin for Backlog](https://github.com/goofmint/growi-plugin-backlog)にて公開しています。ライセンスはMIT Licenseになります。

最初に `:backlog` というRemark Directiveを処理します。この記述があれば、 `a` タグに変換します。他、 `{}` 内で指定した情報は `title` に設定します。また、 `backlog = true` を追加して、他の `a` タグとの区別をつけています。

```tsx
export const remarkPlugin: Plugin = () => {
  return (tree: Node) => {
    return visit(tree, 'leafDirective', (node: Node) => {
      const n = node as unknown as GrowiNode;
      if (n.name !== 'backlog') return;
      const data = n.data || (n.data = {});
      // Render your component
      const { value: action } = n.children[0];
      data.hName = 'a'; // Tag name
      data.hChildren = [{ type: 'text', value: action || 'projects' }]; // Children
      // Set properties
      data.hProperties = {
        title: JSON.stringify({ ...n.attributes, ...{ backlog: true } }), // Pass to attributes to the component
      };
    });
  };
};
```

`a` タグを表示する際には、APIキーの有無によって処理を分けます。

```tsx
const apiKey = localStorage.getItem(keyName);
if (!apiKey) {
  return (
    <>
      <p>Backlog APIキーを取得してください</p>
      <div className="mb-3">
        <label htmlFor="backlogApiKey" className="form-label">APIキー</label>
        <input
          type="password"
          className="form-control"
          id="backlogApiKey"
          placeholder="APIキー"
        />
      </div>
      <div className="mb-3">
        <button
          type="submit"
          className="btn btn-primary"
          onClick={save}
        >保存</button>
      </div>
    </>
  );
}
```

`save` メソッドでは、APIキーをlocalStorageに保存します。また、画面をリロードします。これは、GROWIプラグインの中では `useState` が使えないためです。

```tsx
const save = () => {
  const { value } = document.querySelector('#backlogApiKey') as HTMLInputElement;
  localStorage.setItem(keyName, value);
  // reload
  window.location.reload();
};
```

### 表示処理について

APIキーがあれば、データを取得して表示します。以下はプロジェクト一覧の例です。

```tsx
const backlog = new backlogjs.Backlog({ host, apiKey });
const getProjects = ({ params }: any) => {
  return backlog.getProjects(params);
};

return (<>
  <Async promiseFn={getProjects} params={params}>
    {({ data, error, isPending }) => {
      if (isPending) return 'Loading...';
      if (error) return `Something went wrong: ${error.message}`;
      if (data) {
        return (<>
          {deleteButton()}
          <table className="table">
            <thead>
              <tr>
                <td>名前</td>
                <td>Wiki</td>
                <td>ファイル共有</td>
                <td>Git</td>
                <td>アーカイブ</td>
              </tr>
            </thead>
            <tbody>
              { data.map((project, i) => (
                <tr
                  key={i}
                >
                  <td>
                    <a
                      href={`https://${host}/projects/${project.id}`} target='_blank'
                    >{ project.name }</a>
                  </td>
                  <td>{ project.useWiki ? '✓' : '' }</td>
                  <td>{ project.useFileSharing ? '✓' : '' }</td>
                  <td>{ project.useGit ? '✓' : '' }</td>
                  <td>{ project.archived ? '✓' : '' }</td>
                </tr>
              ))}
            </tbody>
          </table>
        </>
        );
      }
    }}
  </Async>
</>);
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/6c554b7f-184e-1240-56df-fff2d0d05c06.png)


同じように、課題一覧も表示します。課題の状況に応じて、次のステータスに移動するためのアイコンを表示しています。

```tsx
const getIssues = ({ params }: any) => {
  return backlog.getIssues(params);
};

return (<>
  <Async promiseFn={getIssues} params={params}>
    {({ data, error, isPending }) => {
      if (isPending) return 'Loading...';
      if (error) return `Something went wrong: ${error.message}`;
      if (data) {
        return (<>
          {deleteButton()}
          <table className="table">
            <thead>
              <tr>
                <td>名前</td>
                <td>ステータス</td>
                <td>アサイン</td>
                <td>期限</td>
                <td>アクション</td>
              </tr>
            </thead>
            <tbody>
              { data.map((issue, i) => (
                <tr
                  key={i}
                >
                  <td><a
                    href={`https://${host}/view/${issue.issueKey}`} target='_blank'
                  >
                    { issue.summary }
                  </a></td>
                  <td>{ issue.status.name }</td>
                  <td>{ issue.assignee?.name }</td>
                  <td>{ issue.dueDate ? (new Date(issue.dueDate)).toLocaleDateString() : '' }</td>
                  <td>
                    { issue.status.id === 1 && <>
                      <svg
                        xmlns="http://www.w3.org/2000/svg"
                        onClick={() => changeStatus(issue, 2)}
                        width="24px" height="24px" viewBox="0 0 24 24">
                        <path fill="#fff" d="M14.3 21.7c-.7.2-1.5.3-2.3.3c-5.5 0-10-4.5-10-10S6.5 2 12 2c1.3 0 2.6.3 3.8.7l-1.6 1.6c-.7-.2-1.4-.3-2.2-.3c-4.4 0-8 3.6-8 8s3.6 8 8 8c.4 0 .9 0 1.3-.1c.2.7.6 1.3 1 1.8M7.9 10.1l-1.4 1.4L11 16L21 6l-1.4-1.4l-8.6 8.6zM18 14v3h-3v2h3v3h2v-3h3v-2h-3v-3z" />
                      </svg>
                    </>}
                    { issue.status.id === 2 && <>
                      <svg
                        xmlns="http://www.w3.org/2000/svg"
                        width={24} height={24}
                        onClick={() => changeStatus(issue, 3)}
                        viewBox="0 0 24 24">
                        <path fill="currentColor"
                          d="m9.55 18l-5.7-5.7l1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z"></path>
                      </svg>
                    </>}
                    { issue.status.id === 3 && <>
                      <svg
                        xmlns="http://www.w3.org/2000/svg"
                        width="24px" height="24px"
                        onClick={() => changeStatus(issue, 4)}
                        viewBox="0 0 1024 1024">
                        <path
                          fill="#fff"
                          d={'M280.768 753.728L691.456 167.04a32 32 0 1 1 52.416 36.672L314.24 817.472a32 32 0 0 1-45.44 7.296l-230.4-172.8a32 32 0 0 1 38.4-51.2zM736 448a32 32 0 1 1 0-64h192a32 32 0 1 1 0 64zM608 640a32 32 0 0 1 0-64h319.936a32 32 0 1 1 0 64zM480 832a32 32 0 1 1 0-64h447.936a32 32 0 1 1 0 64z'}
                        />
                      </svg>
                    </>}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </>
        );
      }
    }}
  </Async>
</>);
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/d162d4fa-e64c-40bd-26aa-643faf84ab3e.png)


`changeStatus` 関数は、課題のステータスを変更して、リロードします。

```tsx
const changeStatus = async(issue: backlogjs.Entity.Issue.Issue, statusId: number) => {
  await backlog.patchIssue(issue.id, {
    statusId,
  });
  window.location.reload();
};
```

![スクリーンショット_2025-01-12_15_14_19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/5cd0a1af-361c-a7a6-edd1-0df9e3ad6cd4.png)

## Backlog JS SDKについて

Backlogでは、ブラウザとNode.jsどちらでも使えるSDKが提供されています。本プラグインでも、このSDKを使っています。

[nulab/backlog\-js: Backlog API version 2 client for browser and node\.](https://github.com/nulab/backlog-js)

## GROWIコミュニティについて

プラグインの使い方や要望などがあれば、ぜひGROWIコミュニティにお寄せください。実現できそうなものがあれば、なるべく対応します。他にもヘルプチャンネルなどもありますので、ぜひ参加してください！

[GROWI Slackへの参加はこちらから](https://communityinviter.com/apps/wsgrowi/invite)

## まとめ

GROWIプラグインを使うと、表示を自由に拡張できます。足りない機能があれば、どんどん追加できます。ぜひ、自分のWikiをカスタマイズしましょう。

[OSS開発wikiツールのGROWI | 快適な情報共有を、全ての人へ](https://growi.org/ja/)