Pleasanter x Google 連携 : サイトの設定に対応した Google Form を作成する
2022 個人アドベントカレンダー の記事です。
ゴール
- Google 連携として Google Form と連携する
- サイトの設定を読み込んで、それに対応する Form を作成するサイトを作る
- → アプリケーションとしてのテーブル
設計
作りとして Pleasanter 側のスクリプトが起点となっての連携を考えます。
Cf. 以前には Spreadsheet の GAS からデータを取る処理を作りました。[1][2][3]
しかし Google API を JS 用のアドオンが上手く組み込めない Pleasanter でやるのはつらいので Google Apps Script で doPost のウェブアプリケーションの口を作って、そこにデータを投げ、Google Apps Script で Form を作る構成にしました。
具体的には
- Pleasanter のサーバスクリプトで、サイト名からサイト ID を取得
- 作成前に実行
- 作成されたレコードにプロセスボタンを作り、クライアントスクリプトを実行し、データ構造を Form の質問に適合するよう解釈し、内容項目にペースト
- サイトの情報が、サーバスクリプトでうまく取れないため、やむなくクライアント処理にしました。
- Pleasanter のサーバスクリプトで、Google Apps Script に Post をかける。
- Google Apps Script の Standalone Scripts で doPost で ^ を待ち受け
- パラメータの情報から Form を作成
- 作成した Form の URL を JSON で応答
- レスポンスを分類項目に埋めて、ロック
- フォーム作成済みが分かるように。
- Google Apps Script の Standalone Scripts で doPost で ^ を待ち受け
※ demo サイトで使えるレベルのものを想定しているので、拡張 HTML とか、拡張 SQL とかを使うと実装がうまく統合できるかもです。
実装
レコード作成時に実行されるサーバスクリプト
- サイト名からサイト ID を取得します。
try {
const title = model.ClassA;
const siteId = siteSettings.SiteId(title);
if (siteId === 0) {
context.Error(`no site named ${title}.`);
} else {
model.NumA = siteId;
}
} catch (e) {
context.Log(e.message);
context.Log(e.stack);
}
仕様として、サイト ID が取得できないと 0 になるので[4]、その場合はエラーにし、レコード作成できないようにしています。
(これ同じ名前のサイトが複数あったらどういう動作なんだろう)
補足的に NumA は読取専用、 ClassA は重複禁止にしています。
サイトの構造を内容に埋めるスクリプト
- サーバスクリプトではサイトの設定を読めないように思ったので、やむなくクライアントスクリプトで作成
拡張 SQL をサーバスクリプトから実行するとかは、クラウド環境で動作しないし、オンプレミスでもバージョンアップの妨げでしかないのでやりません。
const getSite = (id) =>
new Promise((resolve, reject) => {
$p.apiGetSite({
id: id,
done: (d) => resolve(d),
fail: (e) => reject(e),
});
});
const main = async () => {
try {
const id = $p.getControl('数値A').val()
? $p.getControl('数値A').val()
: $p.getControl('数値A').text();
console.log(id);
const s = (await getSite(id)).Response.Data.SiteSettings;
const quiz = normalizeItem(s.EditorColumnHash.General, s.Columns);
$p.set($p.getControl('内容'), JSON.stringify(quiz, null, 2));
} catch (e) {
console.log(e);
}
};
const normalizeItem = (items, columns) => {
return items
.map((i) => {
const def = defaultDef(i);
if (def === undefined) return def;
const column = columns.filter((e) => e.ColumnName === i)[0];
if (column === undefined) return def;
if (column.ValidateRequired !== undefined)
def.ValidateRequired = column.ValidateRequired;
if (column.LabelText !== undefined) def.LabelText = column.LabelText;
if (column.ChoicesText !== undefined)
def.ChoicesText = column.ChoicesText;
const types = ['ラジオ', 'チェック', 'プルダウン'];
const typesDic = {
ラジオ: 'MultipleChoice',
チェック: 'Checkbox',
プルダウン: 'List',
};
if (
def.ChoicesText !== '' &&
(column.MultipleSelections || types.includes(column.Description))
) {
def.Type = column.MultipleSelections
? 'Checkbox'
: typesDic[column.Description];
}
if (def.Type === 'ParagraphText' && column.FieldCss !== undefined) {
def.Type = 'Text';
}
return def;
})
.filter((e) => e !== undefined);
};
const defaultDef = (col) => {
if (col.startsWith('Class')) {
return {
Type: 'Text',
ColumName: col,
LabelText: col,
ValidateRequired: false,
ChoicesText: '',
};
}
if (col.startsWith('Description')) {
return {
Type: 'ParagraphText',
ColumName: col,
LabelText: col,
ValidateRequired: false,
ChoicesText: '',
};
}
if (col.startsWith('Date')) {
return {
Type: 'Date',
ColumName: col,
LabelText: col,
ValidateRequired: false,
ChoicesText: '',
};
}
return undefined;
};
Pleasanter のサイト設定は、デフォルト値を JSON に保持しないため、例えば項目名を変更せず、書式をマークダウンにした DescriptionA は Columns に表現がなかったりします。そうした部分はあまり Google Apps Script では考慮したくないので ↓ のかたちの要素の配列に整形。
{
Type: string;
ColumName: string;
LabelText: string;
ValidateRequired: boolean;
ChoicesText: string;
}
ついでに Google Form で表現しやすい項目のみに限定(添付や数値をハンドルしていません)。
このとき分類型は、エディタの説明に「ラジオ」とかくと、Google 側でラジオになるような構成にしています。
ある程度、運用上の制約やテーブルの作りに決めを作れば、数値を ScaleItem の質問にしたり、グリッド型の質問を作ったりも、できなくはなさそう。
作った main はプロセスから実行します。
補足として、このプロセスはロック時には利用できない設定にしています。
更新に連動して、フォームを作るリクエストをするサーバスクリプト
- 更新前で実行させます
try {
const title = model.ClassA;
const body = JSON.parse(model.Body);
httpClient.RequestUri = 'https://script.google.com/macros/s/デプロイID/exec';
httpClient.Content = JSON.stringify({ Title: title, Body: body });
[model.ClassB, model.ClassC, model.ClassD] = JSON.parse(httpClient.Post());
model.Locked = true;
} catch (e) {
context.Log(e.message);
context.Log(e.stack);
}
ロックもかけています。
ロックすることで、^ のクライアントスクリプトを発動させるプロセスボタンを出さないようにし、更新を繰り返してフォームを多重に作らないようにします。
リクエストを受ける Google Apps Script
- doPost でリクエストを受けます。
認証とか面倒だったので、リンクを知っている全員が実行可能なウェブアプリケーションで公開しています。
const doPost = (e) => {
const ret = main(JSON.parse(e.postData.contents));
return ContentService.createTextOutput()
.setMimeType(ContentService.MimeType.JSON)
.setContent(JSON.stringify(ret));
};
const main = (e) => {
const title = e.Title;
const body = e.Body;
const form = new FormApp.create(title);
body.forEach((e) => appendAny(form, e));
const ret = [form.getPublishedUrl(), form.getEditUrl(), form.getSummaryUrl()];
return ret;
};
const appendAny = (form, column) => {
switch (column.Type) {
case 'ParagraphText':
appendParagraphText(form, column);
return;
case 'Text':
appendText(form, column);
return;
case 'Date':
appendDate(form, column);
return;
case 'Checkbox':
appendCheckbox(form, column);
return;
case 'List':
appendList(form, column);
return;
case 'MultipleChoice':
appendMultipleChoice(form, column);
return;
default:
return;
}
};
const appendParagraphText = (form, column) => {
const title = column.LabelText;
const isRequired = column.ValidateRequired;
form.addParagraphTextItem().setTitle(title).setRequired(isRequired);
};
const appendText = (form, column) => {
const title = column.LabelText;
const isRequired = column.ValidateRequired;
form.addTextItem().setTitle(title).setRequired(isRequired);
};
const appendDate = (form, column) => {
const title = column.LabelText;
const isRequired = column.ValidateRequired;
form.addDateItem().setTitle(title).setRequired(isRequired);
};
const appendCheckbox = (form, column) => {
const title = column.LabelText;
const isRequired = column.ValidateRequired;
const list = column.ChoicesText.split('\n');
const item = form.addCheckboxItem().setTitle(title).setRequired(isRequired);
item.setChoices(list.map((e) => item.createChoice(e)));
};
const appendList = (form, column) => {
const title = column.LabelText;
const isRequired = column.ValidateRequired;
const list = column.ChoicesText.split('\n');
const item = form.addListItem().setTitle(title).setRequired(isRequired);
item.setChoices(list.map((e) => item.createChoice(e)));
};
const appendMultipleChoice = (form, column) => {
const title = column.LabelText;
const isRequired = column.ValidateRequired;
const list = column.ChoicesText.split('\n');
const item = form
.addMultipleChoiceItem()
.setTitle(title)
.setRequired(isRequired);
item.setChoices(list.map((e) => item.createChoice(e)));
};
Form に固有の API を Google Apps Script で書けるのでやりやすい。
発展させるとすれば、他の質問にどう対応できるか、といったところでしょうか。
さいごに
Kintone とかに比べて Pleasanter はアドオンというかアプリを作って拡張する仕組みがありません。
ですが、テーブルを使って他のテーブルを操作することで、アプリ的に使うことができるますし、そのテーブルはサイトパッケージで可搬性があるので、ライブラリを取り込むように使えるのではないかと考えていました。
特に今回の例のような場合、フォームに対応付けるテーブルそのものに実装をすると、複数の Form を作っていこうと考えたときに、それぞれに同じコードを作っていかないといけないです。
コードが完成して改修しない、ならまだサイトパッケージで複製すればいいんですが、コードを拡張したり Google Apps Script のエンドポイントを変えたりしたくなったとき、コードが散在してるとつらいですし、かといってオンプレミスでもなければ拡張サーバスクリプトで共有、もできないです。
といったことを考えると、機能性を持つだけの他のテーブルがあると便利なんじゃないかなーと思います。
いろいろ考えてはいたのですが、プラグインとしてのテーブルの実用性のありそうな例が作れて満足しています。
long 型の「サイト ID」を返却します。指定した「タイトル」の「サイト」が見つからない場合には 0 を返却します。
Discussion