12月20日日曜日を担当させていただきましたMoriMorityと申します。
初めてのAdvent Calendarへの参加です。勢いで登録しました。
何を書こうかと悩んだのですが、アソシエイトを取得した時の自主開発経験を書くことにします。
kintone認定アソシエイトの存在を知る
研修でkintoneのことを勉強している中で、認定資格があることを教えてもらいました。
さらに当時、テキストの改訂が行われた関係でバウチャー制度も用意されていました。
無料で受けられるのであれば!ということで、「8月にアソシエイト取ります!」と宣言し、テキストを購入。
届いたそこそこ分厚いテキストをわくわく気分で開き、とりあえず練習問題を解いてみることに
4択で択一じゃないものもある時点で嫌な予感はしましたが、とりあえず1章分終了。
早くも問題が発生しました。
困ったポイント①:問題が難しい
・・・全然解けませんでした
標準機能だと思って甘く見すぎてました。初めて知った機能も多い。
しっかりkintoneに触ってないと無理そうでした。
困ったポイント②:解説が見にくい
ほぼすべての参考書がそうだとおもいますが、各章末に問題があり、巻末に解答があるという作りになっています。
問題を解くごとに毎回巻末を見なければいけない上、4択で択一ではないのもあるので、解説は全部見ないといけません。
この調子で問題数はたっぷり138問(選択肢を別の問題だとすると552問)!
正解が少ないのに加えて、これではモチベーションが保てません。
面倒なら面倒じゃないように作ればいいじゃないか
何とかしてもうちょっとテンポよく勉強できないかと考えた結果、
”そもそも4択で択一じゃないなら実質各選択肢〇×で答えられればいいんでしょ?”
と思い立ち、全問題を〇×で答えられるような勉強アプリを作って回数をこなそうという方針に。
せっかくだからkintoneで作れば一石二鳥じゃん!となって作成に取り組みました。
(アソシエイトにカスタマイズ機能はでないので、一石二鳥でもなんでもないですがそれはそれ)
#本アプリの目的
- 全問題〇×で答えられるような学習アプリを作りたい
- なるべくサクサク動くもの
- 進捗状況がわかるようにしたい
- JSカスタマイズについて学習したことの復習をしたい
#作りました
利用したツール・ライブラリ
JSカスタマイズについて学習したことの復習も兼ねたかったので、いろんなツール・ライブラリを積極的に使いました
- @kintone/rest-api-client(レコード操作)
- kintone CLI(開発環境整備)
- kintoneUIComponent(ボタンなどのUI)
- amcharts(グラフの描画)
- SweetAlert2(モダンなダイアログ表示)
特にamchartsはグラフが簡単にイケてる感じにできてよかったです。
kintoneのカスタマイズは公式で様々な記事が出ているので、学習しやすいのがいいところですね
成績管理機能
最終的にはいろいろと機能をつけ足したのですが、中心になる成績の管理機能について。
反復して覚えたかったので、各問題3回連続で正解するまで出題されるようにしようと計画。
各問題の情報を以下のように分けて管理することにしました。
詰まった点
問題を〇×で回答することはそれほど難しくはありませんでしたが、
複数ユーザが利用するとしたときにどのように成績を管理するかという点が問題でした。
別アプリに問題番号を管理しようかとか、サブテーブルで管理しようかとか、API制限や実行速度など考慮して悩んだのですが、
最終的に、個人の成績は別アプリに正解数などを管理し、各問題のステータスは、
各ステータスでユーザ選択フィールドを作成し、ログインユーザの状態を更新することで管理することにしました。
具体的な方法(コーディングなど)
0.ユーザ登録時に全レコードに参加者を登録します
ドメインの全ユーザを登録すると確実に重くなると思ったので、ユーザ登録機能をつけました。
ユーザ登録ボタンを押すと、全レコードに参加者として登録されます。
(Promiseの処理を理解するのが難しかったです・・・)
user_register : function(client, loginuser, SPINNER) {
return client.record.getAllRecords({
app: kintone.app.getId(),
})
.then(function(records) {
var result_body = {
app: dojo_config.dojo_result_appid,
record: {
user: {
value : [{
code: loginuser.code,
name: loginuser.name
}]
},
未実施: {value: records.length}
}
}
var registered_users = records[0]['参加者'].value;
registered_users.push({code: loginuser.code, name: loginuser.name})
var body = {
app: kintone.app.getId(),
records: []
};
Object.keys(records).forEach(function(k) {
var notanswerusers = records[k]['未実施'].value;
notanswerusers.push({code: loginuser.code, name: loginuser.name});
body.records.push({
id: records[k].$id.value,
record: {
未実施: {
value: notanswerusers
},
参加者: {
value: registered_users
}
}
});
})
return client.record.updateAllRecords(body)
.then(function(resp) {
return kintone.api(kintone.api.url('/k/v1/record', true), 'POST', result_body)
.then(function() {
console.log(resp);
SPINNER.hide();
$swal.fire({
title: '登録完了',
icon: 'success',
text: 'ユーザ登録が完了しました',
allowOutsideClick: false
})
.then(function() {
location.reload();
return;
})
})
})
})
.catch(function(err) {
console.log(err);
SPINNER.hide();
$swal.fire({
title: 'エラー!',
icon: 'error',
text: 'ユーザ登録に失敗しました',
allowOutsideClick: false
})
.then(function() {
location.reload();
return;
})
})
},
1.回答した時のユーザの情報をレコードから確認します
問題を回答した時に、該当する問題でユーザがどのような状態になっているのか、
ログイン名を総当たりで検索することで見つけています。
(線形探索のためおそらくここで時間がかかると思うのですが、ユーザが最大でも20名程度だと予想されたので、これでも行けると判断しました)
// 該当問題のユーザの回答状況をチェックする
var check_status = function(user, question) {
var status = {};
var new_question_idx = question['未実施'].value.map(function(e) {return e.code}).indexOf(user.code);
var miss_question_idx = question['間違い'].value.map(function(e) {return e.code}).indexOf(user.code);
var once_question_idx = question['正解1'].value.map(function(e) {return e.code}).indexOf(user.code);
var twice_question_idx = question['正解2'].value.map(function(e) {return e.code}).indexOf(user.code);
var comp_question_idx = question['正解3'].value.map(function(e) {return e.code}).indexOf(user.code);
if (new_question_idx >= 0) {
status = {status: '未実施', id: new_question_idx};
} else if (miss_question_idx >= 0) {
status = {status: '間違い', id: miss_question_idx};
} else if (once_question_idx >= 0) {
status = {status: '正解1', id: once_question_idx};
} else if (twice_question_idx >= 0) {
status = {status: '正解2', id: twice_question_idx};
} else if (comp_question_idx >= 0){
status = {status: '正解3', id: comp_question_idx};
} else {
status = {status: 'error', id: 0};
}
return status;
}
2.正解したかどうかと照らし合わせてレコードを更新するパラメータを作ります
後はレコード更新APIのパラメータを準備するだけです。ユーザ選択フィールドが配列なのが
少し面倒でしたが、順番は関係ないので、前のステータスのフィールドからログインユーザを削除し、
回答後のステータスのフィールドに追加しています。
// 過去の回答ステータスからログインユーザを削除
var now_user = question[status.status].value;
now_user.splice(status.id, 1);
var reload_user = now_user;
body.record[status.status] = {};
body.record[status.status].value = reload_user;
// 新しい回答状況を判別
if (select !== question_answer) {
var miss_users = question['間違い'].value;
new_status = '間違い';
miss_users.push(user);
body.record['間違い'] = {};
body.record['間違い'].value = miss_users;
answer_rate_data.miss_users += 1;
} else {
if (status.status === "未実施" || status.status === "間違い") {
new_status = '正解1';
question['正解1'].value.push(user);
body.record['正解1'] = {};
body.record['正解1'].value = question['正解1'].value;
} else if (status.status === '正解1') {
new_status = '正解2';
question['正解2'].value.push(user);
body.record['正解2'] = {};
body.record['正解2'].value = question['正解2'].value;
} else if (status.status === '正解2' || status.status === '正解3') {
new_status = '正解3';
question['正解3'].value.push(user);
body.record['正解3'] = {};
body.record['正解3'].value = question['正解3'].value;
}
}
3.個人の成績表(別アプリ)と問題集のアプリを更新します
各レコードに状態を持たせていると、個人で何問取り組んで、正解率がどれぐらいなのかという情報が取れません。
そこで、別アプリ(成績表アプリ)を用意し、各ステータスの問題数を保存しておくことで、以下の数値を計算できるようにしました。
- 実施問題数
- 正解率
- 定着率(3回連続正解した問題数)
// 問題集アプリの更新
return kintone.api(kintone.api.url('/k/v1/record', true), 'PUT', body)
.then(function(){
// 成績表アプリの更新
return client.record.getAllRecords({
app: dojo_config.dojo_result_appid,
condition: 'user in (LOGINUSER())'
})
.then(function(user_data) {
var result_body = {
app: dojo_config.dojo_result_appid,
id: user_data[0].$id.value,
record: {}
};
result_body.record[status.status] = {};
result_body.record[status.status].value = Number(user_data[0][status.status].value) - 1;
result_body.record[new_status] = {};
result_body.record[new_status].value = Number(user_data[0][new_status].value) + 1;
return kintone.api(kintone.api.url('/k/v1/record', true), 'PUT', result_body)
.then(function() {
location.reload();
return;
})
})
})
.catch(function(error) {
console.log(error);
return;
})
試験の結果は
完成した後、問題集の問題をすべてレコード化するのに一番時間がかかってしまいましたが、
かなり1問にかかる時間が短縮できました!勉強効率としては明らかに改善。
全問題を3回連続で正解できるまで繰り返し(3日間)、一旦リセットしてもう一周して試験に臨みました。
試験の結果は・・・
・・・
・・・
・・・
1回目で合格できました!!
合格できないとこれを作った意味が・・・と勝手にプレッシャーを感じながら臨みました(笑)
結局、本番は問題集以外の問題が結構でてかなり焦りましたがこのアプリを作る過程で他の標準機能についても学べたので結果オーライ。
今後の課題
レコードの更新に対する排他処理ができていないので、
二人以上が同時に同じ問題に回答すると回答が反映されません。
(複数ユーザ利用想定でそれはどうなんだという話ですが・・・)
問題の出題をランダムにしてできる限り同じ問題を回答しないようにしてますが、
ユーザ登録時は全レコードを編集するので、どうしてもエラーになる可能性が高いです。
対応策としては、revisionパラメータを使った管理があるようなので、今後の課題です。
安全に在庫管理を行うテクニック
補足
コード全体
(function($swal, kintoneUIComponent) {
"use strict";
window.kintone_dojo = window.kintone_dojo || {};
var Dojo_config = window.kintone_dojo.config;
var Dojo_functions = window.kintone_dojo.functions;
var SPINNER = new kintoneUIComponent.Spinner();
var client = new KintoneRestAPIClient();
var loginuser = kintone.getLoginUser();
kintone.events.on('app.record.index.show', function(event) {
if (document.getElementById('buttons_table') !== null) {
return event;
}
var el = kintone.app.getHeaderSpaceElement();
var el_Menu = kintone.app.getHeaderMenuSpaceElement();
var user_info_table = document.createElement("table");
user_info_table.id = 'buttons_table';
var user_newRow1 = user_info_table.insertRow(-1);
var user_newCell1_1 = user_newRow1.insertCell();
var user_newCell1_2 = user_newRow1.insertCell();
var register_button = new kintoneUIComponent.Button({
text: 'ユーザ登録',
type: 'normal'
});
el_Menu.appendChild(register_button.render());
register_button.on('click', function() {
return Dojo_functions.registered_check(client)
.then(function(flag) {
if (flag) {
SPINNER.show();
return Dojo_functions.user_register(client, loginuser, SPINNER);
} else {
return $swal.fire({
title: 'エラー!',
icon: 'error',
html: '登録済みです'
})
.then(function(){
return;
})
}
})
.catch(function(err){
console.log(err)
return;
})
});
var result_button = new kintoneUIComponent.Button({
text: 'ユーザ成績',
type: 'submit'
});
el_Menu.appendChild(result_button.render());
result_button.on('click', function() {
return client.record.getAllRecords({
app: Dojo_config.dojo_result_appid,
condition: 'user in (LOGINUSER())'
})
.then(function(user_data) {
if (user_data.length > 0) {
var url = location.protocol + '//' + location.hostname + '/k/' + Dojo_config.dojo_result_appid + '/?view=' + Dojo_config.dojo_result_viewid;
window.open(url, '_blank')
} else {
return $swal.fire({
title: 'エラー!',
icon: 'error',
html: 'ユーザが登録されていません<br>「ユーザ登録」ボタンから登録してください',
allowOutsideClick: false
})
.then(function(){
return;
})
}
})
.catch(function(err){
console.log(err)
return;
})
});
user_newCell1_1.appendChild(register_button.render());
user_newCell1_2.appendChild(result_button.render());
el_Menu.appendChild(user_info_table);
el.appendChild(SPINNER.render());
return client.record.getAllRecords({
app: kintone.app.getId(),
condition: now_query
})
.then(function(records) {
var question_number = Math.floor(Math.random() * records.length);
var question = records[question_number];
if (question['チェック'].value.map(function(e) {return e.code}).indexOf(loginuser.code) >= 0) {
check_flag = true;
}
Dojo_functions.after_getrecord(question, check_flag, el);
return event;
})
.catch(function(err) {
console.log(err)
return event;
})
});
})(Swal, kintoneUIComponent);
関数部分
(function($swal, kintoneUIComponent) {
"use strict";
window.kintone_dojo = window.kintone_dojo || {};
var dojo_config = window.kintone_dojo.config;
window.kintone_dojo.functions = {
registered_check : function(client) {
return client.record.getAllRecords({
app: kintone.app.getId(),
condition : '参加者 in (LOGINUSER())'
})
.then(function(result) {
if (result.length > 0) {
return false;
} else {
return true;
}
})
.catch(function(err) {
return err;
})
},
user_register : function(client, loginuser, SPINNER) {
return client.record.getAllRecords({
app: kintone.app.getId(),
})
.then(function(records) {
var result_body = {
app: dojo_config.dojo_result_appid,
record: {
user: {
value : [{
code: loginuser.code,
name: loginuser.name
}]
},
未実施: {
value: records.length
}
}
}
var registered_users = records[0]['参加者'].value;
registered_users.push({code: loginuser.code, name: loginuser.name})
var body = {
app: kintone.app.getId(),
records: []
};
Object.keys(records).forEach(function(k) {
var notanswerusers = records[k]['未実施'].value;
notanswerusers.push({code: loginuser.code, name: loginuser.name});
body.records.push({
id: records[k].$id.value,
record: {
未実施: {
value: notanswerusers
},
参加者: {
value: registered_users
}
}
});
})
return client.record.updateAllRecords(body)
.then(function(resp) {
return kintone.api(kintone.api.url('/k/v1/record', true), 'POST', result_body)
.then(function() {
console.log(resp);
SPINNER.hide();
$swal.fire({
title: '登録完了',
icon: 'success',
text: 'ユーザ登録が完了しました',
allowOutsideClick: false
})
.then(function() {
location.reload();
return;
})
})
})
})
.catch(function(err) {
console.log(err);
SPINNER.hide();
$swal.fire({
title: 'エラー!',
icon: 'error',
text: 'ユーザ登録に失敗しました',
allowOutsideClick: false
})
.then(function() {
location.reload();
return;
})
})
},
after_getrecord : function(question, check_flag, el) {
var select = '';
var quiz_table = document.createElement("table");
var newRow1 = quiz_table.insertRow(-1);
var newCell1 = newRow1.insertCell();
var newRow2 = quiz_table.insertRow(-1);
var newCell2_1 = newRow2.insertCell();
var newCell2_2 = newRow2.insertCell();
quiz_table.className = 'table';
newCell1.colSpan = 2;
newCell1.className = 'text-cell';
newCell2_1.className = 'button-cell';
newCell2_2.className = 'button-cell';
var text_content = document.createElement('div');
var question_record_number = question["問題番号"].value;
var question_text = question["問題"].value;
if (check_flag) {
var checklabel = new kintoneUIComponent.Label ({
'text': '<i class="fas fa-exclamation-triangle fa-3x"></i>'
});
newCell1.appendChild(checklabel.render());
}
text_content.textContent += '問題' + question_record_number + ' ' + question_text;
var true_button = document.createElement('button');
true_button.type = 'button';
true_button.className = 'button true';
true_button.innerText = '〇';
true_button.onclick = function() {
select = '〇';
put_record(select, question, check_flag);
};
var false_button = document.createElement('button');
false_button.type = 'button';
false_button.className = 'button false';
false_button.innerText = '×';
false_button.onclick = function() {
select = '×';
put_record(select, question, check_flag);
};
newCell1.appendChild(text_content);
newCell2_1.appendChild(true_button);
newCell2_2.appendChild(false_button);
el.appendChild(quiz_table);
return;
}
}
// 該当問題のユーザの回答状況をチェックする
var check_status = function(user, question) {
var status = {};
var new_question_idx = question['未実施'].value.map(function(e) {return e.code}).indexOf(user.code);
var miss_question_idx = question['間違い'].value.map(function(e) {return e.code}).indexOf(user.code);
var once_question_idx = question['正解1'].value.map(function(e) {return e.code}).indexOf(user.code);
var twice_question_idx = question['正解2'].value.map(function(e) {return e.code}).indexOf(user.code);
var comp_question_idx = question['正解3'].value.map(function(e) {return e.code}).indexOf(user.code);
if (new_question_idx >= 0) {
status = {status: '未実施', id: new_question_idx};
} else if (miss_question_idx >= 0) {
status = {status: '間違い', id: miss_question_idx};
} else if (once_question_idx >= 0) {
status = {status: '正解1', id: once_question_idx};
} else if (twice_question_idx >= 0) {
status = {status: '正解2', id: twice_question_idx};
} else if (comp_question_idx >= 0){
status = {status: '正解3', id: comp_question_idx};
} else {
status = {status: 'error', id: 0};
}
return status;
}
var put_record = function(select, question, check_flag) {
var user = {code: kintone.getLoginUser().code, name: kintone.getLoginUser().name};
var client = new KintoneRestAPIClient();
var question_answer = question["解答"].value;
var question_comment = question["解説"].value;
var status = check_status(user, question);
var answer_rate_data = {
all_users: question["参加者"].value.length,
miss_users: question["間違い"].value.length- (status.status === '間違い' ? 1 : 0),
undo_users: question["未実施"].value.length - (status.status === '未実施'? 1 : 0)
};
var swal_par = {
title : (select === question_answer ? '正解!' : 'ミス!'),
icon : (select === question_answer ? 'info' : 'warning'),
text: question_comment,
allowOutsideClick: false,
showConfirmButton: !check_flag,
showCancelButton: true,
showDenyButton: check_flag,
confirmButtonText: 'チェックを付ける',
denyButtonText: 'チェックを外す',
cancelButtonText: '次へ',
position: 'bottom'
};
var body = {
'app' : kintone.app.getId(),
'id': question.$id.value,
'record': {
'正解率': {
'value': ''
}
}
};
// ユーザ登録されてなければエラー
if (status.status === 'error') {
return $swal.fire({
title: 'Error!',
icon: 'error',
html: 'ユーザが登録されていません<br>「ユーザ登録」ボタンから登録してください',
allowOutsideClick: false
})
.then(function(){
return;
})
}
return $swal.fire(swal_par).then(function(result) {
var new_status = '';
// 新しい回答状況を判別
if (select !== question_answer) {
var miss_users = question['間違い'].value;
new_status = '間違い';
miss_users.push(user);
body.record['間違い'] = {};
body.record['間違い'].value = miss_users;
answer_rate_data.miss_users += 1;
} else {
if (status.status === "未実施" || status.status === "間違い") {
new_status = '正解1';
question['正解1'].value.push(user);
body.record['正解1'] = {};
body.record['正解1'].value = question['正解1'].value;
} else if (status.status === '正解1') {
new_status = '正解2';
question['正解2'].value.push(user);
body.record['正解2'] = {};
body.record['正解2'].value = question['正解2'].value;
} else if (status.status === '正解2' || status.status === '正解3') {
new_status = '正解3';
question['正解3'].value.push(user);
body.record['正解3'] = {};
body.record['正解3'].value = question['正解3'].value;
}
}
//正解率計算
var now_user = question[status.status].value;
now_user.splice(status.id, 1);
var reload_user = now_user;
body.record[status.status] = {};
body.record[status.status].value = reload_user;
body.record['正解率'].value = (1 - Math.round(answer_rate_data.miss_users / (answer_rate_data.all_users - answer_rate_data.undo_users) * 10) / 10) * 100;
//チェックを付けるかどうか判別
if (result.isConfirmed) {
var checked_users = question['チェック'].value;
checked_users.push(user);
body.record['チェック'] = {};
body.record['チェック'].value = checked_users;
} else if (result.isDenied) {
var checked_users = question['チェック'].value;
checked_users.splice(status.id, 1)
body.record['チェック'] = {};
body.record['チェック'].value = checked_users;
}
return kintone.api(kintone.api.url('/k/v1/record', true), 'PUT', body)
.then(function(){
// 成績表アプリの更新
return client.record.getAllRecords({
app: dojo_config.dojo_result_appid,
condition: 'user in (LOGINUSER())'
})
.then(function(user_data) {
var result_body = {
app: dojo_config.dojo_result_appid,
id: user_data[0].$id.value,
record: {}
};
result_body.record[status.status] = {};
result_body.record[status.status].value = Number(user_data[0][status.status].value) - 1;
result_body.record[new_status] = {};
result_body.record[new_status].value = Number(user_data[0][new_status].value) + 1;
return kintone.api(kintone.api.url('/k/v1/record', true), 'PUT', result_body)
.then(function() {
location.reload();
return;
})
})
})
.catch(function(error) {
console.log(error);
return;
})
})
};
})(Swal, kintoneUIComponent);
感想
何事も実際に触ってみることが一番理解できる方法であることを改めて実感しました。
kintoneの他のシステムやアプリとの連携のしやすさが面白いと感じているので、
今後も皆さんの記事を参考にしながら、楽しんで取り組んでいけたらと思います。
次はカスタマイズスペシャリストを取ることが目標です。
それでは2020年もあと11日に迫りました。皆様よいお年をお過ごしください。
次は@holynight_201さんです。よろしくお願いします!