You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

355 lines
15KB

  1. /* kanban-settings.js — settings panel: add/rename/delete/reorder columns and lanes */
  2. (function () {
  3. 'use strict';
  4. var boardId = KANBAN.boardId;
  5. var panel = document.getElementById('settings-panel');
  6. var overlay = document.getElementById('settings-overlay');
  7. /* ── Panel open/close ────────────────────────────────────── */
  8. document.getElementById('btn-settings').addEventListener('click', openPanel);
  9. document.getElementById('btn-close-settings').addEventListener('click', closePanel);
  10. overlay.addEventListener('click', closePanel);
  11. function openPanel() {
  12. panel.classList.add('open');
  13. overlay.classList.remove('d-none');
  14. }
  15. function closePanel() {
  16. panel.classList.remove('open');
  17. overlay.classList.add('d-none');
  18. }
  19. /* ── Helpers ─────────────────────────────────────────────── */
  20. function post(url, data, cb) {
  21. var params = new URLSearchParams();
  22. Object.keys(data).forEach(function (k) { params.append(k, data[k]); });
  23. fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() })
  24. .then(function (r) { return r.json(); })
  25. .then(cb)
  26. .catch(function (e) { console.error(url, e); });
  27. }
  28. function postJson(url, payload, cb) {
  29. fetch(url, {
  30. method: 'POST',
  31. headers: { 'Content-Type': 'application/json' },
  32. body: JSON.stringify(payload)
  33. }).then(function (r) { return r.json(); }).then(cb)
  34. .catch(function (e) { console.error(url, e); });
  35. }
  36. function collectOrder(listId) {
  37. return Array.from(document.querySelectorAll('#' + listId + ' li')).map(function (li, idx) {
  38. return { id: parseInt(li.dataset.id), position: idx };
  39. });
  40. }
  41. function buildListItem(id, name, editClass, deleteClass, labelClass, countToggle, agePrefix) {
  42. var li = document.createElement('li');
  43. li.className = 'list-group-item py-2';
  44. li.dataset.id = id;
  45. li.innerHTML =
  46. '<div class="d-flex align-items-center gap-2">' +
  47. '<i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i>' +
  48. '<span class="flex-grow-1 ' + labelClass + '">' + esc(name) + '</span>' +
  49. (countToggle ?
  50. '<div class="form-check form-switch m-0" title="Show card count in header">' +
  51. '<input class="form-check-input ' + agePrefix + '-count-toggle" type="checkbox" role="switch">' +
  52. '</div>' : '') +
  53. '<div class="form-check form-switch m-0" title="Show export button on board">' +
  54. '<input class="form-check-input ' + agePrefix + '-export-toggle" type="checkbox" role="switch">' +
  55. '</div>' +
  56. '<button class="btn btn-sm btn-link p-0 text-secondary btn-toggle-' + agePrefix + '-age" title="Card age settings"><i class="bi bi-clock-history"></i></button>' +
  57. '<button class="btn btn-sm btn-link p-0 text-secondary ' + editClass + '" title="Rename"><i class="bi bi-pencil"></i></button>' +
  58. '<button class="btn btn-sm btn-link p-0 text-danger ' + deleteClass + '" title="Delete"><i class="bi bi-trash"></i></button>' +
  59. '</div>' +
  60. '<div class="' + agePrefix + '-age-settings d-none mt-2 ps-4">' +
  61. '<div class="form-check form-switch mb-1">' +
  62. '<input class="form-check-input ' + agePrefix + '-age-toggle" type="checkbox" role="switch">' +
  63. '<label class="form-check-label small">Show "time in cell" tooltip &amp; mark overdue</label>' +
  64. '</div>' +
  65. '<div class="input-group input-group-sm" style="max-width: 140px;">' +
  66. '<input type="number" min="0" step="1" class="form-control ' + agePrefix + '-age-days" value="0" placeholder="0">' +
  67. '<span class="input-group-text">days</span>' +
  68. '</div>' +
  69. '<div class="form-text">Overdue after this many days. 0 = no overdue marking.</div>' +
  70. '</div>';
  71. return li;
  72. }
  73. /* ── Card age settings (per column / per swim lane) ────────── */
  74. function bindAgeSettings(li, prefix, urlBase, setter) {
  75. var toggleBtn = li.querySelector('.btn-toggle-' + prefix + '-age');
  76. var settingsDiv = li.querySelector('.' + prefix + '-age-settings');
  77. if (toggleBtn && settingsDiv) {
  78. toggleBtn.addEventListener('click', function () {
  79. settingsDiv.classList.toggle('d-none');
  80. });
  81. }
  82. var ageToggle = li.querySelector('.' + prefix + '-age-toggle');
  83. var ageDays = li.querySelector('.' + prefix + '-age-days');
  84. if (!ageToggle || !ageDays) return;
  85. function save() {
  86. var show = ageToggle.checked;
  87. var days = parseInt(ageDays.value, 10);
  88. if (isNaN(days) || days < 0) days = 0;
  89. ageDays.value = days;
  90. post(urlBase + li.dataset.id + '/card-age-settings', {
  91. show_card_age: show ? '1' : '0',
  92. card_age_warning_days: days
  93. }, function (res) {
  94. if (!res.ok) {
  95. alert(res.error || 'Update failed');
  96. return;
  97. }
  98. setter(li.dataset.id, res.show_card_age, res.card_age_warning_days);
  99. });
  100. }
  101. ageToggle.addEventListener('change', save);
  102. ageDays.addEventListener('change', save);
  103. }
  104. function esc(s) {
  105. return String(s)
  106. .replace(/&/g, '&amp;').replace(/</g, '&lt;')
  107. .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  108. }
  109. /* ── Sortable reorder ─────────────────────────────────────── */
  110. function initSortable(listId, reorderUrl) {
  111. var el = document.getElementById(listId);
  112. Sortable.create(el, {
  113. handle: '.drag-handle',
  114. animation: 150,
  115. onEnd: function () {
  116. postJson(reorderUrl, collectOrder(listId), function (res) {
  117. if (!res.ok) console.error('Reorder failed', res);
  118. });
  119. }
  120. });
  121. }
  122. initSortable('col-list', '/columns/reorder');
  123. initSortable('lane-list', '/swimlanes/reorder');
  124. /* ═══════════════════════════════════════════════════════════
  125. COLUMNS
  126. ═══════════════════════════════════════════════════════════ */
  127. /* ── Add column ───────────────────────────────────────────── */
  128. document.getElementById('btn-add-column').addEventListener('click', function () {
  129. document.getElementById('col-add-form').classList.remove('d-none');
  130. document.getElementById('col-add-input').focus();
  131. });
  132. document.getElementById('btn-col-add-cancel').addEventListener('click', function () {
  133. document.getElementById('col-add-form').classList.add('d-none');
  134. document.getElementById('col-add-input').value = '';
  135. });
  136. document.getElementById('btn-col-add-save').addEventListener('click', function () {
  137. var name = document.getElementById('col-add-input').value.trim();
  138. if (!name) return;
  139. post('/columns', { board_id: boardId, name: name }, function (res) {
  140. if (!res.ok) { alert(res.error || 'Failed'); return; }
  141. document.getElementById('col-add-form').classList.add('d-none');
  142. document.getElementById('col-add-input').value = '';
  143. var li = buildListItem(res.id, res.name, 'btn-edit-col', 'btn-delete-col', 'col-label-text', true, 'col');
  144. document.getElementById('col-list').appendChild(li);
  145. bindColItem(li);
  146. window.KanbanBoard.addColumn(res);
  147. });
  148. });
  149. /* ── Bind edit/delete on existing column items ─────────────── */
  150. function bindColItem(li) {
  151. li.querySelector('.btn-edit-col').addEventListener('click', function () {
  152. startRename(li, '.col-label-text', function (newName, done) {
  153. post('/columns/' + li.dataset.id, { name: newName }, function (res) {
  154. if (res.ok) {
  155. done(true);
  156. window.KanbanBoard.renameColumn(li.dataset.id, newName);
  157. } else {
  158. done(false);
  159. alert(res.error || 'Rename failed');
  160. }
  161. });
  162. });
  163. });
  164. li.querySelector('.btn-delete-col').addEventListener('click', function () {
  165. if (!confirm('Delete this column and all its cards?')) return;
  166. post('/columns/' + li.dataset.id + '/delete', {}, function (res) {
  167. if (res.ok) {
  168. window.KanbanBoard.removeColumn(li.dataset.id);
  169. li.remove();
  170. } else {
  171. alert(res.error || 'Delete failed');
  172. }
  173. });
  174. });
  175. var countToggle = li.querySelector('.col-count-toggle');
  176. if (countToggle) {
  177. countToggle.addEventListener('change', function () {
  178. var show = countToggle.checked;
  179. post('/columns/' + li.dataset.id + '/toggle-count', { show_card_count: show ? '1' : '0' }, function (res) {
  180. if (res.ok) {
  181. window.KanbanBoard.setColumnShowCount(li.dataset.id, show);
  182. } else {
  183. countToggle.checked = !show;
  184. alert(res.error || 'Update failed');
  185. }
  186. });
  187. });
  188. }
  189. var colExportToggle = li.querySelector('.col-export-toggle');
  190. if (colExportToggle) {
  191. colExportToggle.addEventListener('change', function () {
  192. var show = colExportToggle.checked;
  193. post('/columns/' + li.dataset.id + '/toggle-export', { show_export_button: show ? '1' : '0' }, function (res) {
  194. if (res.ok) {
  195. window.KanbanBoard.setColumnShowExport(li.dataset.id, show);
  196. } else {
  197. colExportToggle.checked = !show;
  198. alert(res.error || 'Update failed');
  199. }
  200. });
  201. });
  202. }
  203. bindAgeSettings(li, 'col', '/columns/', function (id, show, days) {
  204. window.KanbanBoard.setColumnCardAge(id, show, days);
  205. });
  206. }
  207. document.querySelectorAll('#col-list li').forEach(bindColItem);
  208. /* ═══════════════════════════════════════════════════════════
  209. SWIM LANES
  210. ═══════════════════════════════════════════════════════════ */
  211. /* ── Add lane ─────────────────────────────────────────────── */
  212. document.getElementById('btn-add-lane').addEventListener('click', function () {
  213. document.getElementById('lane-add-form').classList.remove('d-none');
  214. document.getElementById('lane-add-input').focus();
  215. });
  216. document.getElementById('btn-lane-add-cancel').addEventListener('click', function () {
  217. document.getElementById('lane-add-form').classList.add('d-none');
  218. document.getElementById('lane-add-input').value = '';
  219. });
  220. document.getElementById('btn-lane-add-save').addEventListener('click', function () {
  221. var name = document.getElementById('lane-add-input').value.trim();
  222. if (!name) return;
  223. post('/swimlanes', { board_id: boardId, name: name }, function (res) {
  224. if (!res.ok) { alert(res.error || 'Failed'); return; }
  225. document.getElementById('lane-add-form').classList.add('d-none');
  226. document.getElementById('lane-add-input').value = '';
  227. var li = buildListItem(res.id, res.name, 'btn-edit-lane', 'btn-delete-lane', 'lane-label-text', true, 'lane');
  228. document.getElementById('lane-list').appendChild(li);
  229. bindLaneItem(li);
  230. window.KanbanBoard.addLane(res);
  231. });
  232. });
  233. /* ── Bind edit/delete on existing lane items ──────────────── */
  234. function bindLaneItem(li) {
  235. li.querySelector('.btn-edit-lane').addEventListener('click', function () {
  236. startRename(li, '.lane-label-text', function (newName, done) {
  237. post('/swimlanes/' + li.dataset.id, { name: newName }, function (res) {
  238. if (res.ok) {
  239. done(true);
  240. window.KanbanBoard.renameLane(li.dataset.id, newName);
  241. } else {
  242. done(false);
  243. alert(res.error || 'Rename failed');
  244. }
  245. });
  246. });
  247. });
  248. li.querySelector('.btn-delete-lane').addEventListener('click', function () {
  249. if (!confirm('Delete this swim lane and all its cards?')) return;
  250. post('/swimlanes/' + li.dataset.id + '/delete', {}, function (res) {
  251. if (res.ok) {
  252. window.KanbanBoard.removeLane(li.dataset.id);
  253. li.remove();
  254. } else {
  255. alert(res.error || 'Delete failed');
  256. }
  257. });
  258. });
  259. var laneCountToggle = li.querySelector('.lane-count-toggle');
  260. if (laneCountToggle) {
  261. laneCountToggle.addEventListener('change', function () {
  262. var show = laneCountToggle.checked;
  263. post('/swimlanes/' + li.dataset.id + '/toggle-count', { show_card_count: show ? '1' : '0' }, function (res) {
  264. if (res.ok) {
  265. window.KanbanBoard.setLaneShowCount(li.dataset.id, show);
  266. } else {
  267. laneCountToggle.checked = !show;
  268. alert(res.error || 'Update failed');
  269. }
  270. });
  271. });
  272. }
  273. var laneExportToggle = li.querySelector('.lane-export-toggle');
  274. if (laneExportToggle) {
  275. laneExportToggle.addEventListener('change', function () {
  276. var show = laneExportToggle.checked;
  277. post('/swimlanes/' + li.dataset.id + '/toggle-export', { show_export_button: show ? '1' : '0' }, function (res) {
  278. if (res.ok) {
  279. window.KanbanBoard.setLaneShowExport(li.dataset.id, show);
  280. } else {
  281. laneExportToggle.checked = !show;
  282. alert(res.error || 'Update failed');
  283. }
  284. });
  285. });
  286. }
  287. bindAgeSettings(li, 'lane', '/swimlanes/', function (id, show, days) {
  288. window.KanbanBoard.setLaneCardAge(id, show, days);
  289. });
  290. }
  291. document.querySelectorAll('#lane-list li').forEach(bindLaneItem);
  292. /* ── Inline rename helper ─────────────────────────────────── */
  293. function startRename(li, labelSel, saveCb) {
  294. var span = li.querySelector(labelSel);
  295. var oldName = span.textContent.trim();
  296. var input = document.createElement('input');
  297. input.type = 'text';
  298. input.className = 'form-control form-control-sm inline-rename flex-grow-1';
  299. input.value = oldName;
  300. span.replaceWith(input);
  301. input.focus();
  302. input.select();
  303. function commit() {
  304. var newName = input.value.trim();
  305. if (!newName || newName === oldName) {
  306. abort();
  307. return;
  308. }
  309. saveCb(newName, function (ok) {
  310. var replacement = document.createElement('span');
  311. replacement.className = labelSel.replace('.', '') + ' flex-grow-1';
  312. replacement.textContent = ok ? newName : oldName;
  313. input.replaceWith(replacement);
  314. });
  315. }
  316. function abort() {
  317. var replacement = document.createElement('span');
  318. replacement.className = labelSel.replace('.', '') + ' flex-grow-1';
  319. replacement.textContent = oldName;
  320. input.replaceWith(replacement);
  321. }
  322. input.addEventListener('blur', commit);
  323. input.addEventListener('keydown', function (e) {
  324. if (e.key === 'Enter') { e.preventDefault(); commit(); }
  325. if (e.key === 'Escape') { e.preventDefault(); abort(); }
  326. });
  327. }
  328. })();

Powered by TurnKey Linux.