GROWIでフォームを表示するプラグインを作りました

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

今回は、GROWIプラグインとしてフォームを表示するプラグインを作りました。GROWIページで、お問い合わせフォームのような仕組みを実現できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/8f6f91ba-236a-acfe-6ed7-3a074f70aa6c.png)


## プラグインの動作

Remark Directiveとして、以下のように記述します。フォームの内容は[JavaScript Powered Forms and Form\.io SDK](https://formio.github.io/formio.js/app/builder)を使って作成します。

```markdown
:::form[contact]{}
{
    "display": "form",
    "settings": {
        "pdf": {
            "id": "1ec0f8ee-6685-5d98-a847-26f67b67d6f0",
            "src": "https://files.form.io/pdf/5692b91fd1028f01000407e3/file/1ec0f8ee-6685-5d98-a847-26f67b67d6f0"
        }
    },
    "components": [
        {
            "label": "Text Field",
            "applyMaskOn": "change",
            "tableView": true,
            "validateWhenHidden": false,
            "key": "textField",
            "type": "textfield",
            "input": true
        }
    ]
}
:::
```

上記の場合、以下のようなフォームが表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/e0889b50-86ad-e3f0-6c39-4a23ab1f07a8.png)


オプションとして、入力した内容を保存するページのパスと、そのページの読み取り権限を持つグループなどが指定できます。たとえば問合せフォームの場合は、 `path=/contact` と指定して、 `role=Admin` などとして `Admin` グループに所属するユーザーだけが閲覧できる形にすると良いでしょう。

| パラメータ名 | 説明                 |
| ------------ | -------------------- |
| path         | 保存するページのパス |
| role         | 閲覧権限のグループ   |
| message      | 保存後のメッセージ   |
| submit       | 送信ボタンのラベル   |

保存データは、入力値をYAMLに変換した形になります。

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

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

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

## コードについて

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

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

`vist` メソッドで、RemarkのAST `containerDirective` を処理します。2つ目の引数を指定すると、そのディレクティブの場合のみ呼び出されるので便利です。

```tsx
export const remarkPlugin: Plugin = () => {
  return (tree: Node) => {
    visit(tree, 'containerDirective', (node: Node) => {
      const n = node as unknown as GrowiNode;
      if (n.name !== 'form') return;
      const id = (n.children[0] as GrowiNode).children[0].value;
      const value = (n.children[1] as GrowiNode).children.map(ele => ele.value).join('');
      const data = n.data || (n.data = {});
      // Render your component
      try {
        JSON.parse(value);
        data.hName = 'code'; // Tag name
        data.hProperties = { id, class: 'language-form', title: JSON.stringify(n.attributes) }; // Properties
        data.hChildren = [{ type: 'text', value }]; // Children
      }
      catch (err) {
        console.log(err);
        // console.error(err);
        return;
      }
    });
  };
};
```

`code` タグを表示する際には、 `.language-form` の指定があれば、`<growi-form />` で表示します。それ以外の場合は、元々のタグを表示します。

`<growi-form />` タグは、Stehcil.jsを使って作成したWebコンポーネントです。

```tsx
export const helloGROWI = (Tag: React.FunctionComponent<any>): React.FunctionComponent<any> => {
  return ({ children, ...props }) => {
    try {
      if (props.className === 'language-form') {
        const {
          submit, path, role, message,
        } = (props.title && props.title !== '')
          ? JSON.parse(props.title)
          : {
            submit: 'Submit', path: '', role: '', message: '',
          };
        return (
          <>
            <growi-form
              code={children}
              path={path}
              submit={submit}
              role={role}
              message={message}
            >
            </growi-form>
          </>
        );
      }
      // your code here
      // return <>Hello, GROWI!</>;
    }
    catch (err) {
      // console.error(err);
    }
    // Return the original component if an error occurs
    return (
      <Tag {...props}>{children}</Tag>
    );
  };
};
```

## growi-formタグの動作

growi-formタグでは、コンポーネントをロードしたタイミング `connectedCallback` で、GROWIのJavaScript SDKを初期化します。その後、 `getHtml` メソッドでフォームを表示します。

`Formio.create` で、指定されたDOM `<growi-form><div></div></growi-form>` にフォームを表示します。フォームの内容は、Remark Directiveで指定したJSONを使います。フォームの内容には、送信ボタンを追加しています。

```tsx
@Component({
  tag: 'growi-form',
  styleUrls: [
    './growi-form.css',
    '../../../node_modules/formiojs/dist/formio.embed.min.css',
    '../../../node_modules/bootstrap/dist/css/bootstrap.min.css',
  ],
  shadow: true,
})
export class GrowiForm {

  growi: GROWI;

  @Element() el: HTMLElement;

  /**
   * The name
   */
  @Prop() name: string;

  @Prop() path: string;

  @Prop() role: string;

  @Prop() message: string;

  @Prop() submit: string;

  @Event() saved: EventEmitter<string>;

  /**
   * The parametar1
   */
  @Prop() code: string;

  componentDidRender(): void {
    this.getHtml();
    return;
  }

  connectedCallback(): void {
    this.growi = new GROWI({ url: window.location.origin });
  }

  private getHtml = async(): Promise<void> => {
    const { Formio } = window;
    const ele = this.el.shadowRoot.querySelector('div');
    const code = JSON.parse(this.code);
    code.components.push({
      label: this.submit || 'Submit',
      showValidations: false,
      tableView: false,
      key: 'submit',
      type: 'button',
      input: true,
      saveOnEnter: false,
    });
    const form = await Formio.createForm(ele, code, {
      noAlerts: true,
      disableAlerts: true,
      submitMessage: '',
    });
    form.on('submit', async(submission: any) => {
      // 後述
    });
  };

  render(): JSX.Element {
    return (<div></div>);
  }
}
```

### Tips

Formioでは、現状Bootstrap4のみ対応しています。GROWIのBootstrapとはバージョンが合わないため、デザインが適用されません。

Stencil.js（というかWeb Components）では、デザインをコンポーネント内だけに留められるので、本プラグインの中だけBootstrap4になっています。

```js
@Component({
  tag: 'growi-form',
  styleUrls: [
    './growi-form.css',
    '../../../node_modules/formiojs/dist/formio.embed.min.css',
    '../../../node_modules/bootstrap/dist/css/bootstrap.min.css',
  ],
  shadow: true,
})
```


### フォームの送信処理

フォームの送信処理は `form.on('submit')` が呼ばれます。ここでは入力内容を受け取って、GROWIの指定されたページ以下に現在日時をページ名として、新しいページを保存します。

以下の処理でグループを取得 `growi.groups()` していますが、これはAPIトークンを使っての取得はできません。セッションを利用しているので、プラグイン内部でしか現状では使えないメソッドになります。

```typescript
form.on('submit', async(submission: any) => {
  // 入力された内容
  const { data } = submission;
  // ロールを取得
  const role = (await this.growi.groups()).groups.find(group => group.name === this.role);
  // ページを取得
  const page = await this.growi.root();
  const newPage = await page.create({
    name: `${this.path}/${(new Date()).toUTCString()}`,
  });
  // ページの内容
  newPage.contents(`\`\`\`yaml\n${YAML.stringify(data)}\n\`\`\``);
  await newPage.save(role ? {
    grant: 5,
    userRelatedGrantUserGroupIds: [{
      type: 'UserGroup',
      item: role.id,
    }],
  } : null);
  // スピナーが消えないため、強制削除
  const spinner = this.el.shadowRoot.querySelector('i.spinner-border.spinner-border-sm.button-icon-right');
  if (spinner) spinner.remove();
  // メッセージが指定されていれば、表示
  if (this.message && this.message !== '') {
    const alert = document.createElement('div');
    alert.className = 'alert alert-success';
    alert.innerText = this.message;
    form.element.appendChild(alert);
    setTimeout(() => {
      form.resetValue();
      alert.remove();
    }, 5000);
  }
  else {
    form.resetValue();
  }
  // this.saved.emit(newPage.path);
});
```

これで、問い合わせ内容を指定したページ内に保存する流れができあがります。

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

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

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

## まとめ

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

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