InputManJSのコントロールを利用して、リッチテキスト形式で課題を入力したり、課題に対するコメントを投稿する機能を持つ課題管理ツールの実用例を紹介します。
このサンプルでは、次のシナリオを確認することができます。
課題の編集(GcRichTextEditor):「課題を編集」ボタンを押下すると、リッチテキストエディタで既存の内容が表示され、課題の内容を直接編集することができます。編集後は保存ボタンを押すことで、変更内容が反映されます。
新規課題を作成(GcRichTextEditor):「新規課題を作成」ボタンを押下すると、リッチテキストエディタが空の状態で表示され、内容を入力することができます。編集後は保存ボタンを押すことで、変更内容が反映されます。
レポート種類(GcComboBox):「新規課題を作成」ボタンを押下し、ドロップダウンリストより項目を選択すると、設定済みのテンプレートが表示されます。このテンプレートを編集し、課題を投稿します。
課題の保存(GcValidator):未入力の状態で保存ボタンを押下すると、検証コントロールで必須入力チェックが行われ、検証失敗をトーストで通知します。
課題に対するコメント(GcComment):コメントコンポーネントを使用して、コメントの追加、削除、返信、リアクションを利用できます。新規課題を作成する際に、既存のコメントがクリアされます。
import "@mescius/inputman.richtexteditor/CSS/gc.inputman.richtexteditor.css";
import "@mescius/inputman.comment/CSS/gc.inputman.comment.css";
import '@mescius/inputman/CSS/gc.inputman-js.css';
import "./styles.css";
import { InputMan } from "@mescius/inputman.richtexteditor";
import * as GC from "@mescius/inputman.comment";
import { users, postedComments } from './data';
import * as GcCommon from '@mescius/inputman';
import "@mescius/inputman.richtexteditor/JS/plugins/all";
const articleContentElement = document.getElementById("article_content");
const editArticleButton = document.getElementById("edit_article");
const newArticleButton = document.getElementById("new_article");
const articleContainer = document.getElementById("article_container");
const commentContainer = document.getElementById("comment_display");
const gcCommentElement = document.getElementById("comment");
const syncCheckedState = (container) => {
const radios = container.querySelectorAll('input[type="radio"]');
radios.forEach((r) => {
if (r.checked) {
r.setAttribute('checked', 'checked');
} else {
r.removeAttribute('checked');
}
});
const selects = container.querySelectorAll('select');
selects.forEach((select) => {
const options = select.querySelectorAll('option');
options.forEach((option) => {
if (option.selected) {
option.setAttribute('selected', 'selected');
} else {
option.removeAttribute('selected');
}
});
});
};
const createArticleEditor = (actionType) => {
const contentElement = articleContentElement.cloneNode(true);
syncCheckedState(contentElement);
contentElement.id = "";
const breakline1 = document.createElement("br");
const breakline2 = document.createElement("br");
const okButton = document.createElement("div");
okButton.textContent = "保存";
okButton.classList.add("btn", "ok-btn");
const cancelButton = document.createElement("div");
cancelButton.textContent = "キャンセル";
cancelButton.classList.add("btn", "cancel-btn");
const dropdownLabel = document.createElement("label");
dropdownLabel.textContent = "レポート種類 ";
dropdownLabel.htmlFor = "report-category";
const selectDropdown = document.createElement("select");
selectDropdown.id = "report-category";
let reportCategory;
if (actionType == "edit") {
articleContainer.appendChild(contentElement);
articleContainer.appendChild(okButton);
articleContainer.appendChild(cancelButton);
} else if (actionType == "new") {
contentElement.innerHTML = "";
commentContainer.style.display = "none";
articleContainer.appendChild(dropdownLabel);
articleContainer.appendChild(selectDropdown);
articleContainer.appendChild(breakline1);
articleContainer.appendChild(breakline2);
articleContainer.appendChild(contentElement);
articleContainer.appendChild(okButton);
articleContainer.appendChild(cancelButton);
reportCategory = new GcCommon.InputMan.GcComboBox(selectDropdown, {
items: ['バグ報告', '機能要望'],
isEditable: false,
});
reportCategory.addEventListener(GcCommon.InputMan.GcComboBoxEvent.SelectedChanged, (e) => {
if (e.selectedIndex === 0) {
rich.execCommand(InputMan.GcRichTextEditorCommand.Template)
} else {
rich.execCommand(
InputMan.GcRichTextEditorCommand.InsertTemplate,
"<section><h4>概要</h4><p>ここに要望の概要を記入してください。</p></section><section><h4>対象コントロール</h4><p>該当するコントロール名を記入してください。</p></section><section><h4>背景・課題</h4><p>この機能が必要性、現状の課題や制限などを記入してください。</p></section><section><h4>期待動作</h4><ul><li>XXXを実行される。</li><li>XXXが正しく動作する。</li></ul></section>"
);
}
});
}
const restoreElements = () => {
articleContainer.removeAttribute("mode");
contentElement.remove();
okButton.remove();
cancelButton.remove();
if (actionType == "new") {
breakline1.remove();
breakline2.remove();
dropdownLabel.remove();
}
};
cancelButton.addEventListener("click", () => {
restoreElements();
rich.destroy();
if (actionType == "new") {
reportCategory.destroy();
selectDropdown.remove();
commentContainer.style.display = "block";
}
});
okButton.addEventListener("click", () => {
syncCheckedState(rich.getContentBody());
if (gcRichTextEditorValidator.validate()) {
const newContent = rich.getContent();
articleContentElement.innerHTML = newContent;
restoreElements();
rich.destroy();
if (actionType == "new") {
reportCategory.destroy();
selectDropdown.remove();
gcComment.clear();
commentContainer.style.display = "block";
}
} else {
return;
}
});
const rich = new InputMan.GcRichTextEditor(contentElement, {
plugins: [InputMan.GcRichTextEditorPluginItem.All],
contextmenu: [InputMan.GcRichTextEditorMenuItem.Template],
templates: 'src/template.json',
watermarkText: 'テンプレート挿入より報告のフォーマットを設定してから編集を行ってください...',
});
const gcRichTextEditorValidator = new GcCommon.InputMan.GcValidator({
items: [
{
control: rich,
ruleSet: [
{
rule: GcCommon.InputMan.ValidateType.Required,
failMessage: (control) => `詳細を入力するか、テンプレートより挿入してください。`,
}
],
validateWhen: GcCommon.InputMan.ValidateWhen.Manual,
}
],
defaultNotify: {
fail: {
toast: {
title: '課題内容が必須です。',
position: GcCommon.InputMan.ToastPosition.TopCenter,
duration: 3,
showProgress: false,
showClose: false,
pauseOnHover: false,
},
},
}
});
};
editArticleButton.addEventListener("click", () => {
articleContainer.setAttribute("mode", "edit");
createArticleEditor("edit");
});
newArticleButton.addEventListener("click", () => {
articleContainer.setAttribute("mode", "edit");
createArticleEditor("new");
});
const gcComment = new GC.InputMan.GcComment(gcCommentElement, {
openLinkMode: GC.InputMan.OpenLinkMode.NewTab,
height: 200,
userInfo: {
id: "1",
name: "森上 偉久馬",
avatar:
"$IMDEMOROOT$/ja/samples/examples/comment/projectManagement/img/avatar1.png",
},
loadComments: (args) => {
return {
comments: postedComments,
};
},
openLinkMode: GC.InputMan.OpenLinkMode.GoToComment,
editorConfig: {
editorType: GC.InputMan.GcCommentEditorType.GcRichTextEditor,
plugins: [InputMan.GcRichTextEditorPluginItem.All],
height: 150,
menubar: [],
toolbar: [
InputMan.GcRichTextEditorToolbarItem.Copy,
InputMan.GcRichTextEditorToolbarItem.Cut,
InputMan.GcRichTextEditorToolbarItem.Paste,
InputMan.GcRichTextEditorToolbarItem.SeparateLine,
InputMan.GcRichTextEditorToolbarItem.Styles,
InputMan.GcRichTextEditorToolbarItem.AlignCenter,
InputMan.GcRichTextEditorToolbarItem.H3,
InputMan.GcRichTextEditorToolbarItem.H4,
]
},
addCommentEditorPosition: GC.InputMan.GcCommentEditorPosition.Top,
addNewCommentTo: GC.InputMan.AddNewCommentPosition.Top,
sortInfo: {
isDesc: true,
sortBy: GC.InputMan.GcCommentSortBy.PostTime
},
commentActionButtonType: GC.InputMan.GcCommentActionButtonType.IconText,
loadUsersInfoHandler: (context) => {
if (context.loadType === GC.InputMan.LoadUserType.FilterText) {
return users.filter((u) => u.name.includes(context.value));
} else if (context.loadType === GC.InputMan.LoadUserType.ById) {
return users.filter((u) => u.id === context.value);
}
},
updateTimeFormatter: (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const formattedDate = `${year}/${month}/${day} ${hours}:${minutes}`;
return `更新日時: ${formattedDate}`;
},
});
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>実用例 - 課題管理ツール</title>
<!-- SystemJS -->
<script src="node_modules/systemjs/dist/system.src.js"></script>
<script src="systemjs.config.js"></script>
<script>
window.onload = function () {
System.import("./src/app");
};
</script>
</head>
<body>
<div id="container">
<div id="article_container">
<div class="button-container">
<div class="edit-article" id="edit_article">
<img src="$IMDEMOROOT$/ja/samples/examples/comment/projectManagement/img/EditIcon.svg" />
<div>課題を編集</div>
</div>
<div class="new-article" id="new_article">
<img src="$IMDEMOROOT$/ja/samples/examples/comment/projectManagement/img/EditIcon.svg" />
<div>新規課題を作成</div>
</div>
</div>
<div class="content" id="article_content">
<h3>バグ報告: 任意入力フィールドにもバリデーションエラーが表示される</h3>
<hr style="border: 1px solid #ccc; margin: 16px 0;">
<section class="bug-description">
<h4>現象</h4>
<p>
お問い合わせフォームで、任意項目である「会社名」フィールドを空欄にした状態で「送信」ボタンを押下すると、エラーが表示され、送信できません。
</p>
</section>
<section class="repro-steps">
<h4>再現手順</h4>
<ol>
<li>ナビゲーションメニューから「お問い合わせ」を選択し、フォーム画面を開く</li>
<li>「会社名」フィールドを空欄のままにする(任意項目)</li>
<li>その他の必須項目(氏名、メールアドレス等)を入力する</li>
<li>ページ下部の「送信」ボタンをクリックする</li>
</ol>
</section>
<section class="repro-expected">
<h4>期待動作</h4>
<ul>
<li>任意項目が未入力であっても、エラーが発生することなくフォーム送信できる</li>
</ul>
</section>
<section class="attachments">
<h4>添付ファイル</h4>
<ul>
<li>
<p>スクリーンショット</p>
<img loading="lazy" width="500" height="236" src="$IMDEMOROOT$/ja/samples/examples/comment/projectManagement/img/validationerror.png" alt="スクリーンショット" class="noborder" />
</li>
</ul>
</section>
<section class="additional-info">
<h4>補足</h4>
<ul>
<li>
<p>コンソールログ(任意項目で送信失敗)</p>
<pre class="validation-error">[ValidationError] フィールド '会社名' は必須です。<br>at validateForm (form.js:45)<br>at HTMLFormElement.onsubmit (contact.html:88)</pre>
</li>
</ul>
</section>
<div></div>
</div>
</div>
<br>
<div class="comment_display" id="comment_display">
<div id="comment"></div>
</div>
</div>
</body>
</html>
#container {
height: 600px;
overflow-y: scroll;
}
.button-container {
display: flex;
gap: 16px;
}
.edit-article, .new-article {
margin-bottom: 10px;
justify-content: center;
width: fit-content;
background-color: #ececec;
padding: 5px 8px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid #ddd;
}
.edit-article:hover, .new-article:hover {
background-color: #dddddd;
}
#article_content {
transition: background-color 0.2s;
overflow-y: auto;
border: 1px solid #ccc;
padding: 16px;
}
#article_content:hover {
background-color: #fafafa;
}
#article_container[mode="edit"] .edit-article,
#article_container[mode="edit"] .new-article,
#article_container[mode="edit"] #article_content {
display: none;
}
#article_container[mode="edit"] .new-article ~ .comment_display {
display: none;
}
.comment_display {
transition: background-color 0.2s;
margin: 10px;
}
.validation-error {
background-color: #fafafa;
padding: 12px 16px;
border: 1px solid #ccc;
font-family: monospace;
white-space: pre-wrap;
text-align: left;
margin-top: 0;
line-height: 1.2;
}
.btn {
border: none;
border-radius: 4px;
color: #fff;
line-height: 40px;
outline: none;
text-align: center;
-webkit-user-select: none;
user-select: none;
display: inline;
padding: 5px 16px;
}
.ok-btn {
background-color: #1977ff;
}
.ok-btn:hover {
background-color: #479eff;
}
.cancel-btn {
background-color: #fff;
color: #1977ff;
border: 1px solid #1977ff;
margin-left: 1em;
}
.cancel-btn:hover {
background-color: #e6f0ff;
color: #479eff;
border: 1px solid #479eff;
}
#article_content input[type='radio'], #article_content select {
pointer-events: none;
}
export const postedComments = [
{
id: '1',
userInfo: {
id: '1',
name: '森上 偉久馬',
avatar: '$IMDEMOROOT$/ja/samples/examples/comment/projectManagement/img/avatar1.png',
},
content: '不具合と確認しました。',
postTime: new Date(2025, 4, 20, 7, 15, 5),
updateTime: new Date(2025, 4, 20, 7, 15, 5),
},
{
id: '2',
userInfo: {
id: '2',
name: '葛城 孝史',
avatar: '$IMDEMOROOT$/ja/samples/examples/comment/projectManagement/img/avatar2.png',
},
content: 'この件は、次のバージョンで修正予定です。',
postTime: new Date(2025, 4, 20, 8, 15, 5),
updateTime: new Date(2025, 4, 20, 8, 15, 5),
},
{
id: '3',
userInfo: {
id: '5',
name: '松沢 誠一',
avatar: '$IMDEMOROOT$/ja/samples/examples/comment/projectManagement/img/avatar5.png',
},
content: '了解しました。お客様に返信いたします。',
parentCommentId: '2',
postTime: new Date(2025, 4, 22, 7, 17, 47),
updateTime: new Date(2025, 4, 22, 7, 17, 47),
reactions: [
{
reactionChar: '👍',
count: 1,
},
],
},
{
id: '4',
userInfo: {
id: '6',
name: '成宮 真紀',
avatar: '$IMDEMOROOT$/ja/samples/examples/comment/projectManagement/img/avatar6.png',
},
content: 'テストが完了しました。',
postTime: new Date(2025, 4, 23, 7, 25, 42),
updateTime: new Date(2025, 4, 23, 7, 25, 48),
reactions: [
{
reactionChar: '👍',
count: 1,
},
],
},
{
id: '5',
userInfo: {
id: '1',
name: '森上 偉久馬',
avatar: '$IMDEMOROOT$/ja/samples/examples/comment/projectManagement/img/avatar1.png',
},
content: '本件をクローズしました。',
postTime: new Date(2025, 4, 28, 17, 10, 5),
updateTime: new Date(2025, 4, 28, 17, 10, 5),
reactions: [
{
reactionChar: '👍',
count: 1,
},
],
},
];
export const users = [
{
id: '1',
name: '森上 偉久馬',
avatar: '$IMDEMOROOT$/ja/samples/examples/comment/projectManagement/img/avatar1.png',
},
{
id: '2',
name: '葛城 孝史',
avatar: '$IMDEMOROOT$/ja/samples/examples/comment/projectManagement/img/avatar2.png',
},
{
id: '3',
name: '加藤 泰江',
avatar: '$IMDEMOROOT$/ja/samples/examples/comment/projectManagement/img/avatar3.png',
},
{
id: '4',
name: '川村 匡',
avatar: '$IMDEMOROOT$/ja/samples/examples/comment/projectManagement/img/avatar4.png',
},
{
id: '5',
name: '松沢 誠一',
avatar: '$IMDEMOROOT$/ja/samples/examples/comment/projectManagement/img/avatar5.png',
},
{
id: '6',
name: '成宮 真紀',
avatar: '$IMDEMOROOT$/ja/samples/examples/comment/projectManagement/img/avatar6.png',
},
];
System.config({
transpiler: 'plugin-babel',
babelOptions: {
es2015: true
},
meta: {
'*.css': { loader: 'css' }
},
paths: {
// paths serve as alias
'npm:': 'node_modules/'
},
// map tells the System loader where to look for things
map: {
'@mescius/inputman': 'npm:@mescius/inputman/index.js',
'@mescius/inputman/CSS': 'npm:@mescius/inputman/CSS',
'@mescius/inputman.richtexteditor': 'npm:@mescius/inputman.richtexteditor/index.js',
"@mescius/inputman.richtexteditor/CSS": "npm:@mescius/inputman.richtexteditor/CSS",
'@mescius/inputman.richtexteditor/JS/plugins/advlist': 'npm:@mescius/inputman.richtexteditor/JS/plugins/advlist/plugin.js',
'@mescius/inputman.richtexteditor/JS/plugins/all': 'npm:@mescius/inputman.richtexteditor/JS/plugins/all/plugin.js',
'@mescius/inputman.richtexteditor/JS/plugins/autosave': 'npm:@mescius/inputman.richtexteditor/JS/plugins/autosave/plugin.js',
'@mescius/inputman.richtexteditor/JS/plugins/charmap': 'npm:@mescius/inputman.richtexteditor/JS/plugins/charmap/plugin.js',
'@mescius/inputman.richtexteditor/JS/plugins/directionality': 'npm:@mescius/inputman.richtexteditor/JS/plugins/directionality/plugin.js',
'@mescius/inputman.richtexteditor/JS/plugins/emoticons': 'npm:@mescius/inputman.richtexteditor/JS/plugins/emoticons/plugin.js',
'@mescius/inputman.richtexteditor/JS/plugins/fullscreen': 'npm:@mescius/inputman.richtexteditor/JS/plugins/fullscreen/plugin.js',
'@mescius/inputman.richtexteditor/JS/plugins/htmlcode': 'npm:@mescius/inputman.richtexteditor/JS/plugins/htmlcode/plugin.js',
'@mescius/inputman.richtexteditor/JS/plugins/image': 'npm:@mescius/inputman.richtexteditor/JS/plugins/image/plugin.js',
'@mescius/inputman.richtexteditor/JS/plugins/link': 'npm:@mescius/inputman.richtexteditor/JS/plugins/link/plugin.js',
'@mescius/inputman.richtexteditor/JS/plugins/lists': 'npm:@mescius/inputman.richtexteditor/JS/plugins/lists/plugin.js',
'@mescius/inputman.richtexteditor/JS/plugins/media': 'npm:@mescius/inputman.richtexteditor/JS/plugins/media/plugin.js',
'@mescius/inputman.richtexteditor/JS/plugins/pagebreak': 'npm:@mescius/inputman.richtexteditor/JS/plugins/pagebreak/plugin.js',
'@mescius/inputman.richtexteditor/JS/plugins/preview': 'npm:@mescius/inputman.richtexteditor/JS/plugins/preview/plugin.js',
'@mescius/inputman.richtexteditor/JS/plugins/save': 'npm:@mescius/inputman.richtexteditor/JS/plugins/save/plugin.js',
'@mescius/inputman.richtexteditor/JS/plugins/searchreplace': 'npm:@mescius/inputman.richtexteditor/JS/plugins/searchreplace/plugin.js',
'@mescius/inputman.richtexteditor/JS/plugins/table': 'npm:@mescius/inputman.richtexteditor/JS/plugins/table/plugin.js',
'@mescius/inputman.richtexteditor/JS/plugins/template': 'npm:@mescius/inputman.richtexteditor/JS/plugins/template/plugin.js',
'@mescius/inputman.richtexteditor/JS/plugins/wordcount': 'npm:@mescius/inputman.richtexteditor/JS/plugins/wordcount/plugin.js',
'@mescius/inputman.comment': 'npm:@mescius/inputman.comment/index.js',
'@mescius/inputman.comment/CSS': 'npm:@mescius/inputman.comment/CSS',
'@mescius/wijmo': 'npm:@mescius/wijmo/index.js',
'@mescius/wijmo.styles': 'npm:@mescius/wijmo.styles',
'@mescius/wijmo.cultures': 'npm:@mescius/wijmo.cultures',
'@mescius/wijmo.input': 'npm:@mescius/wijmo.input/index.js',
'@mescius/wijmo.grid': 'npm:@mescius/wijmo.grid/index.js',
'@mescius/wijmo.nav': 'npm:@mescius/wijmo.nav/index.js',
'@mescius/spread-sheets': 'npm:@mescius/spread-sheets/index.js',
'@mescius/spread-sheets-resources-ja': 'npm:@mescius/spread-sheets-resources-ja/index.js',
'@mescius/spread-sheets/styles': 'npm:@mescius/spread-sheets/styles',
'css': 'npm:systemjs-plugin-css/css.js',
'plugin-babel': 'npm:systemjs-plugin-babel/plugin-babel.js',
'systemjs-babel-build': 'npm:systemjs-plugin-babel/systemjs-babel-browser.js'
},
// packages tells the System loader how to load when no filename and/or no extension
packages: {
src: {
defaultExtension: 'js'
},
"node_modules": {
defaultExtension: 'js'
},
}
});