|
|
@@ -357,25 +357,70 @@
|
|
|
tr.setAttribute('data-prio', String(task.priority));
|
|
|
tr.setAttribute('data-owner', task.owner_worker_id || '');
|
|
|
tr.setAttribute('data-sort-order', String(task.sort_order));
|
|
|
-
|
|
|
- // handle
|
|
|
- const tdHandle = document.createElement('td');
|
|
|
- tdHandle.className = 'px-2 py-1';
|
|
|
- const handle = document.createElement('span');
|
|
|
- handle.className = 'handle cursor-grab text-slate-400 select-none dark:text-slate-500';
|
|
|
- handle.innerHTML = '≡';
|
|
|
- tdHandle.appendChild(handle);
|
|
|
- tr.appendChild(tdHandle);
|
|
|
-
|
|
|
- // title
|
|
|
+ tr.setAttribute('data-description', task.description || '');
|
|
|
+ tr.setAttribute('data-url', task.url || '');
|
|
|
+
|
|
|
+ // hamburger trigger
|
|
|
+ const tdMenu = document.createElement('td');
|
|
|
+ tdMenu.className = 'px-2 py-1';
|
|
|
+ const trig = document.createElement('button');
|
|
|
+ trig.type = 'button';
|
|
|
+ trig.setAttribute('data-task-menu-trigger', '');
|
|
|
+ trig.className = 'task-menu-trigger inline-flex items-center justify-center w-6 h-6 rounded text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-400';
|
|
|
+ trig.setAttribute('aria-haspopup', 'true');
|
|
|
+ trig.setAttribute('aria-expanded', 'false');
|
|
|
+ trig.setAttribute('aria-label', 'Task actions');
|
|
|
+ trig.innerHTML = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">'
|
|
|
+ + '<line x1="2" y1="4" x2="14" y2="4" stroke="currentColor" stroke-width="2"/>'
|
|
|
+ + '<line x1="2" y1="8" x2="14" y2="8" stroke="currentColor" stroke-width="2"/>'
|
|
|
+ + '<line x1="2" y1="12" x2="14" y2="12" stroke="currentColor" stroke-width="2"/>'
|
|
|
+ + '</svg>';
|
|
|
+ tdMenu.appendChild(trig);
|
|
|
+ tr.appendChild(tdMenu);
|
|
|
+
|
|
|
+ // title cell with URL + description affordances
|
|
|
const tdTitle = document.createElement('td');
|
|
|
tdTitle.className = 'px-2 py-1 min-w-[14rem]';
|
|
|
+ const titleWrap = document.createElement('div');
|
|
|
+ titleWrap.className = 'flex items-center gap-1.5';
|
|
|
const title = document.createElement('input');
|
|
|
title.type = 'text';
|
|
|
title.setAttribute('data-title', '');
|
|
|
title.value = task.title || '';
|
|
|
- title.className = 'w-full rounded border border-slate-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500';
|
|
|
- tdTitle.appendChild(title);
|
|
|
+ title.className = 'flex-1 rounded border border-slate-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500';
|
|
|
+ titleWrap.appendChild(title);
|
|
|
+
|
|
|
+ const urlLink = document.createElement('a');
|
|
|
+ urlLink.setAttribute('data-task-url-link', '');
|
|
|
+ urlLink.target = '_blank';
|
|
|
+ urlLink.rel = 'noopener';
|
|
|
+ urlLink.href = task.url || '';
|
|
|
+ urlLink.title = 'Open task link';
|
|
|
+ urlLink.setAttribute('aria-label', 'Open task link');
|
|
|
+ urlLink.className = 'task-url-link inline-flex items-center justify-center w-5 h-5 rounded text-blue-600 hover:bg-slate-100 dark:text-blue-400 dark:hover:bg-slate-700';
|
|
|
+ if (!task.url) { urlLink.classList.add('hidden'); }
|
|
|
+ urlLink.innerHTML = '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">'
|
|
|
+ + '<path d="M9 2h5v5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
|
|
|
+ + '<path d="M14 2L7 9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
|
|
|
+ + '<path d="M12 9v4a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
|
|
|
+ + '</svg>';
|
|
|
+ titleWrap.appendChild(urlLink);
|
|
|
+
|
|
|
+ const descBtn = document.createElement('button');
|
|
|
+ descBtn.type = 'button';
|
|
|
+ descBtn.setAttribute('data-task-desc-trigger', '');
|
|
|
+ descBtn.className = 'task-desc-trigger inline-flex items-center justify-center w-5 h-5 rounded text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700';
|
|
|
+ descBtn.title = 'View description';
|
|
|
+ descBtn.setAttribute('aria-label', 'View description');
|
|
|
+ if (!task.description) { descBtn.classList.add('hidden'); }
|
|
|
+ descBtn.innerHTML = '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">'
|
|
|
+ + '<rect x="2" y="3" width="12" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/>'
|
|
|
+ + '<line x1="4" y1="6" x2="12" y2="6" stroke="currentColor" stroke-width="1.5"/>'
|
|
|
+ + '<line x1="4" y1="9" x2="10" y2="9" stroke="currentColor" stroke-width="1.5"/>'
|
|
|
+ + '</svg>';
|
|
|
+ titleWrap.appendChild(descBtn);
|
|
|
+
|
|
|
+ tdTitle.appendChild(titleWrap);
|
|
|
tr.appendChild(tdTitle);
|
|
|
|
|
|
// owner
|
|
|
@@ -453,17 +498,6 @@
|
|
|
tr.appendChild(td);
|
|
|
});
|
|
|
|
|
|
- // delete
|
|
|
- const tdDel = document.createElement('td');
|
|
|
- tdDel.className = 'px-1 py-1 text-right';
|
|
|
- const delBtn = document.createElement('button');
|
|
|
- delBtn.type = 'button';
|
|
|
- delBtn.setAttribute('data-delete-task', '');
|
|
|
- delBtn.className = 'text-sm text-red-600 hover:underline dark:text-red-400';
|
|
|
- delBtn.textContent = '×';
|
|
|
- tdDel.appendChild(delBtn);
|
|
|
- tr.appendChild(tdDel);
|
|
|
-
|
|
|
return tr;
|
|
|
}
|
|
|
|
|
|
@@ -529,15 +563,15 @@
|
|
|
.catch((e) => flash(e.message, true));
|
|
|
});
|
|
|
|
|
|
- // --- Delete task ------------------------------------------------------
|
|
|
+ // --- Delete task (invoked from the task hamburger menu) --------------
|
|
|
|
|
|
- on(root, 'click', '[data-delete-task]', function () {
|
|
|
- const tr = this.closest('tr');
|
|
|
- const id = parseInt(tr.getAttribute('data-task-id'), 10);
|
|
|
+ function deleteTask(taskId) {
|
|
|
+ const tr = qs('tr[data-task-id="' + taskId + '"]', taskTbody);
|
|
|
+ if (!tr) { return; }
|
|
|
const titleInp = qs('[data-title]', tr);
|
|
|
const title = titleInp ? titleInp.value : '(untitled)';
|
|
|
if (!window.confirm('Delete task "' + title + '"?')) { return; }
|
|
|
- request('DELETE', '/tasks/' + id)
|
|
|
+ request('DELETE', '/tasks/' + taskId)
|
|
|
.then(function (data) {
|
|
|
tr.remove();
|
|
|
applyServerCapacity(data && data.per_worker);
|
|
|
@@ -548,7 +582,7 @@
|
|
|
}
|
|
|
})
|
|
|
.catch((e) => flash(e.message, true));
|
|
|
- });
|
|
|
+ }
|
|
|
|
|
|
// --- Per-cell assignment days pipeline -------------------------------
|
|
|
|
|
|
@@ -850,31 +884,568 @@
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- // --- Task reorder (SortableJS) ---------------------------------------
|
|
|
+ // --- Task hamburger menu + details modal + click-pickup reorder -----
|
|
|
+ //
|
|
|
+ // Replaces SortableJS for tasks. The menu, modal, description popover,
|
|
|
+ // and pickup overlay are all single body-attached nodes built lazily.
|
|
|
|
|
|
- if (sortableAvailable && hasTaskUi && qs('.handle', taskTbody)) {
|
|
|
- window.Sortable.create(taskTbody, {
|
|
|
- handle: '.handle',
|
|
|
- draggable: 'tr[data-task-row]',
|
|
|
- animation: 150,
|
|
|
- onStart: function () {
|
|
|
- if (currentSort.col !== null) { clearSort(); }
|
|
|
- },
|
|
|
- onEnd: function () {
|
|
|
- const ordering = qsa('tr[data-task-row]', taskTbody).map(function (el, i) {
|
|
|
- return { task_id: parseInt(el.getAttribute('data-task-id'), 10), sort_order: i + 1 };
|
|
|
- });
|
|
|
- request('POST', '/sprints/' + sprintId + '/tasks/reorder', ordering)
|
|
|
- .then(function (data) {
|
|
|
- ordering.forEach(function (o) {
|
|
|
- const r = qs('tr[data-task-id="' + o.task_id + '"]', taskTbody);
|
|
|
- if (r) { r.setAttribute('data-sort-order', String(o.sort_order)); }
|
|
|
- });
|
|
|
- flash(data.moved ? 'Order saved' : 'No changes');
|
|
|
+ const sprintChoices = (function () {
|
|
|
+ if (!taskSection) { return []; }
|
|
|
+ try {
|
|
|
+ const raw = taskSection.getAttribute('data-sprint-choices') || '[]';
|
|
|
+ const arr = JSON.parse(raw);
|
|
|
+ if (!Array.isArray(arr)) { return []; }
|
|
|
+ return arr.filter(function (s) {
|
|
|
+ return s && typeof s.id === 'number' && typeof s.name === 'string';
|
|
|
+ });
|
|
|
+ } catch (_) { return []; }
|
|
|
+ })();
|
|
|
+
|
|
|
+ let taskMenu = null; // root container
|
|
|
+ let taskMenuTaskId = null;
|
|
|
+ let taskMenuTrigger = null; // the button it was opened from
|
|
|
+ let taskMenuOpenAt = 0;
|
|
|
+
|
|
|
+ function buildTaskMenu() {
|
|
|
+ if (taskMenu) { return taskMenu; }
|
|
|
+ const r = document.createElement('div');
|
|
|
+ r.className = 'task-menu hidden';
|
|
|
+ r.setAttribute('role', 'menu');
|
|
|
+
|
|
|
+ function item(label, action, opts) {
|
|
|
+ const b = document.createElement('button');
|
|
|
+ b.type = 'button';
|
|
|
+ b.setAttribute('role', 'menuitem');
|
|
|
+ b.setAttribute('data-task-menu-item', action);
|
|
|
+ b.className = 'task-menu-item';
|
|
|
+ if (opts && opts.danger) { b.classList.add('task-menu-item-danger'); }
|
|
|
+ b.textContent = label;
|
|
|
+ return b;
|
|
|
+ }
|
|
|
+
|
|
|
+ function submenu(label, action) {
|
|
|
+ const wrap = document.createElement('div');
|
|
|
+ wrap.className = 'task-menu-subwrap';
|
|
|
+ wrap.setAttribute('data-task-submenu', action);
|
|
|
+ const b = document.createElement('button');
|
|
|
+ b.type = 'button';
|
|
|
+ b.setAttribute('role', 'menuitem');
|
|
|
+ b.setAttribute('data-task-submenu-trigger', action);
|
|
|
+ b.className = 'task-menu-item task-menu-item-with-sub';
|
|
|
+ b.innerHTML = '<span>' + label + '</span><span class="opacity-60">▸</span>';
|
|
|
+ wrap.appendChild(b);
|
|
|
+
|
|
|
+ const list = document.createElement('div');
|
|
|
+ list.className = 'task-menu-sub hidden';
|
|
|
+ list.setAttribute('data-task-submenu-list', action);
|
|
|
+ wrap.appendChild(list);
|
|
|
+ return wrap;
|
|
|
+ }
|
|
|
+
|
|
|
+ r.appendChild(item('Edit details…', 'edit'));
|
|
|
+ r.appendChild(submenu('Move to sprint', 'move'));
|
|
|
+ r.appendChild(submenu('Copy to sprint', 'copy'));
|
|
|
+ r.appendChild(item('Move (pick up)', 'pickup'));
|
|
|
+ r.appendChild(item('Delete', 'delete', { danger: true }));
|
|
|
+
|
|
|
+ document.body.appendChild(r);
|
|
|
+ taskMenu = r;
|
|
|
+ return r;
|
|
|
+ }
|
|
|
+
|
|
|
+ function refreshTaskMenuChoices(currentSprintId) {
|
|
|
+ const moveList = taskMenu.querySelector('[data-task-submenu-list="move"]');
|
|
|
+ const copyList = taskMenu.querySelector('[data-task-submenu-list="copy"]');
|
|
|
+ [moveList, copyList].forEach((el) => { if (el) { el.innerHTML = ''; } });
|
|
|
+ if (sprintChoices.length === 0) {
|
|
|
+ [moveList, copyList].forEach(function (el) {
|
|
|
+ if (!el) { return; }
|
|
|
+ const empty = document.createElement('div');
|
|
|
+ empty.className = 'task-menu-sub-empty';
|
|
|
+ empty.textContent = 'No other sprints';
|
|
|
+ el.appendChild(empty);
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ sprintChoices.forEach(function (s) {
|
|
|
+ if (Number(s.id) === Number(currentSprintId)) { return; }
|
|
|
+ ['move', 'copy'].forEach(function (action) {
|
|
|
+ const list = action === 'move' ? moveList : copyList;
|
|
|
+ if (!list) { return; }
|
|
|
+ const b = document.createElement('button');
|
|
|
+ b.type = 'button';
|
|
|
+ b.setAttribute('role', 'menuitem');
|
|
|
+ b.setAttribute('data-task-' + action + '-target', String(s.id));
|
|
|
+ b.className = 'task-menu-sub-item';
|
|
|
+ b.textContent = s.name;
|
|
|
+ list.appendChild(b);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function positionTaskMenu() {
|
|
|
+ if (!taskMenu || !taskMenuTrigger) { return; }
|
|
|
+ const rect = taskMenuTrigger.getBoundingClientRect();
|
|
|
+ const mh = taskMenu.offsetHeight;
|
|
|
+ const mw = taskMenu.offsetWidth;
|
|
|
+ const vw = document.documentElement.clientWidth;
|
|
|
+ const vh = document.documentElement.clientHeight;
|
|
|
+
|
|
|
+ let top = window.scrollY + rect.bottom + 4;
|
|
|
+ let left = window.scrollX + rect.left;
|
|
|
+ if (left + mw > window.scrollX + vw - 8) {
|
|
|
+ left = window.scrollX + vw - mw - 8;
|
|
|
+ }
|
|
|
+ if (top + mh > window.scrollY + vh - 8) {
|
|
|
+ top = window.scrollY + rect.top - mh - 4;
|
|
|
+ if (top < window.scrollY + 8) { top = window.scrollY + 8; }
|
|
|
+ }
|
|
|
+ taskMenu.style.top = top + 'px';
|
|
|
+ taskMenu.style.left = left + 'px';
|
|
|
+ }
|
|
|
+
|
|
|
+ function openTaskMenu(trigger) {
|
|
|
+ if (!hasTaskUi) { return; }
|
|
|
+ const tr = trigger.closest('tr[data-task-row]');
|
|
|
+ if (!tr) { return; }
|
|
|
+ const taskId = parseInt(tr.getAttribute('data-task-id'), 10);
|
|
|
+ if (!Number.isFinite(taskId)) { return; }
|
|
|
+
|
|
|
+ buildTaskMenu();
|
|
|
+ refreshTaskMenuChoices(sprintId);
|
|
|
+ closeAllTaskSubmenus();
|
|
|
+ taskMenuTaskId = taskId;
|
|
|
+ taskMenuTrigger = trigger;
|
|
|
+ trigger.setAttribute('aria-expanded', 'true');
|
|
|
+ taskMenu.classList.remove('hidden');
|
|
|
+ positionTaskMenu();
|
|
|
+ taskMenuOpenAt = Date.now();
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeAllTaskSubmenus() {
|
|
|
+ if (!taskMenu) { return; }
|
|
|
+ qsa('[data-task-submenu-list]', taskMenu).forEach(function (el) {
|
|
|
+ el.classList.add('hidden');
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeTaskMenu() {
|
|
|
+ if (taskMenu) { taskMenu.classList.add('hidden'); }
|
|
|
+ if (taskMenuTrigger) { taskMenuTrigger.setAttribute('aria-expanded', 'false'); }
|
|
|
+ closeAllTaskSubmenus();
|
|
|
+ taskMenuTaskId = null;
|
|
|
+ taskMenuTrigger = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ on(root, 'click', '[data-task-menu-trigger]', function (ev) {
|
|
|
+ ev.preventDefault();
|
|
|
+ ev.stopPropagation();
|
|
|
+ if (taskMenuTrigger === this && taskMenu && !taskMenu.classList.contains('hidden')) {
|
|
|
+ closeTaskMenu();
|
|
|
+ } else {
|
|
|
+ openTaskMenu(this);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ document.addEventListener('pointerdown', function (ev) {
|
|
|
+ if (!taskMenu || taskMenu.classList.contains('hidden')) { return; }
|
|
|
+ if (Date.now() - taskMenuOpenAt < 50) { return; }
|
|
|
+ if (taskMenu.contains(ev.target)) { return; }
|
|
|
+ if (taskMenuTrigger && taskMenuTrigger.contains(ev.target)) { return; }
|
|
|
+ closeTaskMenu();
|
|
|
+ }, true);
|
|
|
+
|
|
|
+ document.addEventListener('keydown', function (ev) {
|
|
|
+ if (ev.key === 'Escape' && taskMenu && !taskMenu.classList.contains('hidden')) {
|
|
|
+ closeTaskMenu();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ window.addEventListener('scroll', function () {
|
|
|
+ if (taskMenu && !taskMenu.classList.contains('hidden')) { closeTaskMenu(); }
|
|
|
+ }, true);
|
|
|
+ window.addEventListener('resize', function () {
|
|
|
+ if (taskMenu && !taskMenu.classList.contains('hidden')) { closeTaskMenu(); }
|
|
|
+ });
|
|
|
+
|
|
|
+ document.addEventListener('click', function (ev) {
|
|
|
+ if (!taskMenu || taskMenu.classList.contains('hidden')) { return; }
|
|
|
+
|
|
|
+ const subTrig = ev.target.closest('[data-task-submenu-trigger]');
|
|
|
+ if (subTrig && taskMenu.contains(subTrig)) {
|
|
|
+ ev.preventDefault();
|
|
|
+ const action = subTrig.getAttribute('data-task-submenu-trigger');
|
|
|
+ const list = taskMenu.querySelector('[data-task-submenu-list="' + action + '"]');
|
|
|
+ const wasHidden = list && list.classList.contains('hidden');
|
|
|
+ closeAllTaskSubmenus();
|
|
|
+ if (list && wasHidden) { list.classList.remove('hidden'); }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const moveTarget = ev.target.closest('[data-task-move-target]');
|
|
|
+ if (moveTarget && taskMenu.contains(moveTarget)) {
|
|
|
+ ev.preventDefault();
|
|
|
+ const destId = parseInt(moveTarget.getAttribute('data-task-move-target'), 10);
|
|
|
+ const taskId = taskMenuTaskId;
|
|
|
+ closeTaskMenu();
|
|
|
+ if (Number.isFinite(destId) && Number.isFinite(taskId)) {
|
|
|
+ request('POST', '/tasks/' + taskId + '/move', { sprint_id: destId })
|
|
|
+ .then(function () {
|
|
|
+ flash('Moved to sprint');
|
|
|
+ const tr = qs('tr[data-task-id="' + taskId + '"]', taskTbody);
|
|
|
+ if (tr) { tr.remove(); }
|
|
|
+ recomputeAllCapacity();
|
|
|
+ if (qsa('tr[data-task-row]', taskTbody).length === 0) {
|
|
|
+ window.location.reload();
|
|
|
+ }
|
|
|
})
|
|
|
.catch((e) => flash(e.message, true));
|
|
|
- },
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const copyTarget = ev.target.closest('[data-task-copy-target]');
|
|
|
+ if (copyTarget && taskMenu.contains(copyTarget)) {
|
|
|
+ ev.preventDefault();
|
|
|
+ const destId = parseInt(copyTarget.getAttribute('data-task-copy-target'), 10);
|
|
|
+ const taskId = taskMenuTaskId;
|
|
|
+ closeTaskMenu();
|
|
|
+ if (Number.isFinite(destId) && Number.isFinite(taskId)) {
|
|
|
+ request('POST', '/tasks/' + taskId + '/copy', { sprint_id: destId })
|
|
|
+ .then(function () { flash('Copied to sprint'); window.location.reload(); })
|
|
|
+ .catch((e) => flash(e.message, true));
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const it = ev.target.closest('[data-task-menu-item]');
|
|
|
+ if (it && taskMenu.contains(it)) {
|
|
|
+ ev.preventDefault();
|
|
|
+ const action = it.getAttribute('data-task-menu-item');
|
|
|
+ const taskId = taskMenuTaskId;
|
|
|
+ closeTaskMenu();
|
|
|
+ if (!Number.isFinite(taskId)) { return; }
|
|
|
+ if (action === 'edit') { openDetailsModal(taskId); }
|
|
|
+ if (action === 'pickup') { startPickup(taskId); }
|
|
|
+ if (action === 'delete') { deleteTask(taskId); }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // --- Details modal (description + URL) ------------------------------
|
|
|
+
|
|
|
+ let detailsModal = null;
|
|
|
+ let detailsTaskId = null;
|
|
|
+
|
|
|
+ function buildDetailsModal() {
|
|
|
+ if (detailsModal) { return detailsModal; }
|
|
|
+ const overlay = document.createElement('div');
|
|
|
+ overlay.className = 'task-modal-overlay hidden';
|
|
|
+ overlay.setAttribute('role', 'dialog');
|
|
|
+ overlay.setAttribute('aria-modal', 'true');
|
|
|
+ overlay.setAttribute('aria-label', 'Edit task details');
|
|
|
+
|
|
|
+ const panel = document.createElement('div');
|
|
|
+ panel.className = 'task-modal-panel';
|
|
|
+
|
|
|
+ panel.innerHTML =
|
|
|
+ '<header class="task-modal-header"><h2 class="text-base font-semibold">Edit details</h2></header>' +
|
|
|
+ '<div class="task-modal-body space-y-3">' +
|
|
|
+ '<label class="block text-sm">' +
|
|
|
+ '<span class="block mb-1 text-slate-700 dark:text-slate-200">Description</span>' +
|
|
|
+ '<textarea data-modal-description rows="6" maxlength="8000" ' +
|
|
|
+ 'class="w-full rounded border border-slate-300 px-2 py-1 font-sans text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500"></textarea>' +
|
|
|
+ '</label>' +
|
|
|
+ '<label class="block text-sm">' +
|
|
|
+ '<span class="block mb-1 text-slate-700 dark:text-slate-200">URL</span>' +
|
|
|
+ '<input type="url" data-modal-url maxlength="2048" placeholder="https://…" ' +
|
|
|
+ 'class="w-full rounded border border-slate-300 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">' +
|
|
|
+ '<span class="block mt-1 text-xs text-slate-500 dark:text-slate-400">Must start with http:// or https://. Leave empty to remove.</span>' +
|
|
|
+ '</label>' +
|
|
|
+ '</div>' +
|
|
|
+ '<footer class="task-modal-footer">' +
|
|
|
+ '<button type="button" data-modal-cancel class="rounded border border-slate-300 bg-white text-slate-700 px-3 py-1 text-sm hover:bg-slate-100 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">Cancel</button>' +
|
|
|
+ '<button type="button" data-modal-save class="rounded bg-slate-900 text-white px-3 py-1 text-sm font-medium hover:bg-slate-800 dark:bg-slate-700 dark:hover:bg-slate-600">Save</button>' +
|
|
|
+ '</footer>';
|
|
|
+
|
|
|
+ overlay.appendChild(panel);
|
|
|
+ document.body.appendChild(overlay);
|
|
|
+
|
|
|
+ overlay.addEventListener('click', function (ev) {
|
|
|
+ if (ev.target === overlay) { closeDetailsModal(); }
|
|
|
+ });
|
|
|
+
|
|
|
+ panel.querySelector('[data-modal-cancel]').addEventListener('click', closeDetailsModal);
|
|
|
+ panel.querySelector('[data-modal-save]').addEventListener('click', saveDetailsModal);
|
|
|
+
|
|
|
+ detailsModal = overlay;
|
|
|
+ return overlay;
|
|
|
+ }
|
|
|
+
|
|
|
+ function openDetailsModal(taskId) {
|
|
|
+ const tr = qs('tr[data-task-id="' + taskId + '"]', taskTbody);
|
|
|
+ if (!tr) { return; }
|
|
|
+ buildDetailsModal();
|
|
|
+ detailsTaskId = taskId;
|
|
|
+ detailsModal.querySelector('[data-modal-description]').value = tr.getAttribute('data-description') || '';
|
|
|
+ detailsModal.querySelector('[data-modal-url]').value = tr.getAttribute('data-url') || '';
|
|
|
+ detailsModal.classList.remove('hidden');
|
|
|
+ detailsModal.querySelector('[data-modal-description]').focus();
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeDetailsModal() {
|
|
|
+ if (detailsModal) { detailsModal.classList.add('hidden'); }
|
|
|
+ detailsTaskId = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ function saveDetailsModal() {
|
|
|
+ if (!detailsModal || !Number.isFinite(detailsTaskId)) { return; }
|
|
|
+ const desc = String(detailsModal.querySelector('[data-modal-description]').value || '');
|
|
|
+ const url = String(detailsModal.querySelector('[data-modal-url]').value || '').trim();
|
|
|
+ if (url !== '' && !/^https?:\/\//i.test(url)) {
|
|
|
+ flash('URL must start with http:// or https://', true);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const taskId = detailsTaskId;
|
|
|
+ request('PATCH', '/tasks/' + taskId, { description: desc, url })
|
|
|
+ .then(function () {
|
|
|
+ const tr = qs('tr[data-task-id="' + taskId + '"]', taskTbody);
|
|
|
+ if (tr) {
|
|
|
+ tr.setAttribute('data-description', desc);
|
|
|
+ tr.setAttribute('data-url', url);
|
|
|
+ const link = qs('[data-task-url-link]', tr);
|
|
|
+ const descBtn = qs('[data-task-desc-trigger]', tr);
|
|
|
+ if (link) {
|
|
|
+ link.href = url;
|
|
|
+ link.classList.toggle('hidden', url === '');
|
|
|
+ }
|
|
|
+ if (descBtn) {
|
|
|
+ descBtn.classList.toggle('hidden', desc === '');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ closeDetailsModal();
|
|
|
+ flash('Saved');
|
|
|
+ })
|
|
|
+ .catch((e) => flash(e.message, true));
|
|
|
+ }
|
|
|
+
|
|
|
+ document.addEventListener('keydown', function (ev) {
|
|
|
+ if (ev.key === 'Escape' && detailsModal && !detailsModal.classList.contains('hidden')) {
|
|
|
+ closeDetailsModal();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // --- Description popover (click on the description marker) ----------
|
|
|
+ //
|
|
|
+ // Read-only popover so non-admins can see the description, and admins
|
|
|
+ // can peek without opening the full edit modal.
|
|
|
+
|
|
|
+ let descPopover = null;
|
|
|
+ let descPopoverAnchor = null;
|
|
|
+ let descPopoverOpenAt = 0;
|
|
|
+
|
|
|
+ function buildDescPopover() {
|
|
|
+ if (descPopover) { return descPopover; }
|
|
|
+ const r = document.createElement('div');
|
|
|
+ r.className = 'task-desc-popover hidden';
|
|
|
+ r.setAttribute('role', 'tooltip');
|
|
|
+ document.body.appendChild(r);
|
|
|
+ descPopover = r;
|
|
|
+ return r;
|
|
|
+ }
|
|
|
+
|
|
|
+ function openDescPopover(anchor, text) {
|
|
|
+ if (!text) { return; }
|
|
|
+ buildDescPopover();
|
|
|
+ descPopoverAnchor = anchor;
|
|
|
+ descPopover.textContent = text;
|
|
|
+ descPopover.classList.remove('hidden');
|
|
|
+ const rect = anchor.getBoundingClientRect();
|
|
|
+ const ph = descPopover.offsetHeight;
|
|
|
+ const pw = descPopover.offsetWidth;
|
|
|
+ const vw = document.documentElement.clientWidth;
|
|
|
+ let top = window.scrollY + rect.bottom + 4;
|
|
|
+ let left = window.scrollX + rect.left;
|
|
|
+ if (left + pw > window.scrollX + vw - 8) {
|
|
|
+ left = window.scrollX + vw - pw - 8;
|
|
|
+ }
|
|
|
+ if (top + ph > window.scrollY + document.documentElement.clientHeight - 8) {
|
|
|
+ top = window.scrollY + rect.top - ph - 4;
|
|
|
+ }
|
|
|
+ descPopover.style.top = top + 'px';
|
|
|
+ descPopover.style.left = left + 'px';
|
|
|
+ descPopoverOpenAt = Date.now();
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeDescPopover() {
|
|
|
+ if (descPopover) { descPopover.classList.add('hidden'); }
|
|
|
+ descPopoverAnchor = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ on(root, 'click', '[data-task-desc-trigger]', function (ev) {
|
|
|
+ ev.preventDefault();
|
|
|
+ ev.stopPropagation();
|
|
|
+ const tr = this.closest('tr[data-task-row]');
|
|
|
+ if (!tr) { return; }
|
|
|
+ const text = tr.getAttribute('data-description') || '';
|
|
|
+ openDescPopover(this, text);
|
|
|
+ });
|
|
|
+
|
|
|
+ document.addEventListener('pointerdown', function (ev) {
|
|
|
+ if (!descPopover || descPopover.classList.contains('hidden')) { return; }
|
|
|
+ if (Date.now() - descPopoverOpenAt < 50) { return; }
|
|
|
+ if (descPopover.contains(ev.target)) { return; }
|
|
|
+ if (descPopoverAnchor && descPopoverAnchor.contains(ev.target)) { return; }
|
|
|
+ closeDescPopover();
|
|
|
+ }, true);
|
|
|
+
|
|
|
+ document.addEventListener('keydown', function (ev) {
|
|
|
+ if (ev.key === 'Escape' && descPopover && !descPopover.classList.contains('hidden')) {
|
|
|
+ closeDescPopover();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // --- Click-pickup reorder -------------------------------------------
|
|
|
+ //
|
|
|
+ // The user picks "Move (pick up)" from the menu, the row tracks the
|
|
|
+ // cursor, and a horizontal indicator marks the target slot. Click
|
|
|
+ // anywhere to drop. Escape cancels and reverts to original position.
|
|
|
+
|
|
|
+ let pickupTaskId = null;
|
|
|
+ let pickupRow = null;
|
|
|
+ let pickupOriginNext = null; // sibling that was below pickupRow at start
|
|
|
+ let pickupIndicator = null;
|
|
|
+ let pickupOriginalIndex = -1;
|
|
|
+
|
|
|
+ function buildPickupIndicator() {
|
|
|
+ if (pickupIndicator) { return pickupIndicator; }
|
|
|
+ const ind = document.createElement('div');
|
|
|
+ ind.className = 'task-pickup-indicator hidden';
|
|
|
+ document.body.appendChild(ind);
|
|
|
+ pickupIndicator = ind;
|
|
|
+ return ind;
|
|
|
+ }
|
|
|
+
|
|
|
+ function rowsExceptPickup() {
|
|
|
+ return qsa('tr[data-task-row]', taskTbody).filter((r) => r !== pickupRow);
|
|
|
+ }
|
|
|
+
|
|
|
+ function startPickup(taskId) {
|
|
|
+ if (!hasTaskUi) { return; }
|
|
|
+ const tr = qs('tr[data-task-id="' + taskId + '"]', taskTbody);
|
|
|
+ if (!tr) { return; }
|
|
|
+ if (currentSort.col !== null) { clearSort(); }
|
|
|
+
|
|
|
+ pickupTaskId = taskId;
|
|
|
+ pickupRow = tr;
|
|
|
+ pickupOriginNext = tr.nextElementSibling;
|
|
|
+ pickupOriginalIndex = qsa('tr[data-task-row]', taskTbody).indexOf(tr);
|
|
|
+
|
|
|
+ tr.classList.add('task-pickup-active');
|
|
|
+ buildPickupIndicator();
|
|
|
+ pickupIndicator.classList.remove('hidden');
|
|
|
+
|
|
|
+ document.body.style.cursor = 'grabbing';
|
|
|
+ flash('Pick up active — move the cursor and click to drop, Escape to cancel');
|
|
|
+
|
|
|
+ document.addEventListener('mousemove', onPickupMove);
|
|
|
+ document.addEventListener('click', onPickupClick, true);
|
|
|
+ document.addEventListener('keydown', onPickupKey, true);
|
|
|
+ }
|
|
|
+
|
|
|
+ function onPickupMove(ev) {
|
|
|
+ if (!pickupRow) { return; }
|
|
|
+ const peers = rowsExceptPickup();
|
|
|
+ let inserted = false;
|
|
|
+ for (let i = 0; i < peers.length; i++) {
|
|
|
+ const r = peers[i];
|
|
|
+ const rect = r.getBoundingClientRect();
|
|
|
+ const mid = rect.top + rect.height / 2;
|
|
|
+ if (ev.clientY < mid) {
|
|
|
+ if (pickupRow.nextElementSibling !== r) {
|
|
|
+ taskTbody.insertBefore(pickupRow, r);
|
|
|
+ }
|
|
|
+ placePickupIndicator(r, 'before');
|
|
|
+ inserted = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!inserted) {
|
|
|
+ if (peers.length > 0) {
|
|
|
+ const last = peers[peers.length - 1];
|
|
|
+ if (pickupRow.previousElementSibling !== last) {
|
|
|
+ taskTbody.appendChild(pickupRow);
|
|
|
+ }
|
|
|
+ placePickupIndicator(last, 'after');
|
|
|
+ } else {
|
|
|
+ pickupIndicator.classList.add('hidden');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function placePickupIndicator(refRow, side) {
|
|
|
+ if (!pickupIndicator) { return; }
|
|
|
+ const rect = refRow.getBoundingClientRect();
|
|
|
+ const y = side === 'before' ? rect.top : rect.bottom;
|
|
|
+ pickupIndicator.style.top = (window.scrollY + y - 1) + 'px';
|
|
|
+ pickupIndicator.style.left = (window.scrollX + rect.left) + 'px';
|
|
|
+ pickupIndicator.style.width = rect.width + 'px';
|
|
|
+ pickupIndicator.classList.remove('hidden');
|
|
|
+ }
|
|
|
+
|
|
|
+ function onPickupClick(ev) {
|
|
|
+ if (!pickupRow) { return; }
|
|
|
+ ev.preventDefault();
|
|
|
+ ev.stopPropagation();
|
|
|
+ finishPickup(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ function onPickupKey(ev) {
|
|
|
+ if (ev.key === 'Escape' && pickupRow) {
|
|
|
+ ev.preventDefault();
|
|
|
+ finishPickup(false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function finishPickup(commit) {
|
|
|
+ document.removeEventListener('mousemove', onPickupMove);
|
|
|
+ document.removeEventListener('click', onPickupClick, true);
|
|
|
+ document.removeEventListener('keydown', onPickupKey, true);
|
|
|
+ document.body.style.cursor = '';
|
|
|
+ if (pickupIndicator) { pickupIndicator.classList.add('hidden'); }
|
|
|
+ if (pickupRow) { pickupRow.classList.remove('task-pickup-active'); }
|
|
|
+
|
|
|
+ if (!commit) {
|
|
|
+ // Restore original DOM position.
|
|
|
+ if (pickupRow) {
|
|
|
+ if (pickupOriginNext && pickupOriginNext.parentElement === taskTbody) {
|
|
|
+ taskTbody.insertBefore(pickupRow, pickupOriginNext);
|
|
|
+ } else if (pickupRow.parentElement === taskTbody) {
|
|
|
+ taskTbody.appendChild(pickupRow);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ pickupTaskId = null; pickupRow = null; pickupOriginNext = null;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const ordering = qsa('tr[data-task-row]', taskTbody).map(function (el, i) {
|
|
|
+ return { task_id: parseInt(el.getAttribute('data-task-id'), 10), sort_order: i + 1 };
|
|
|
});
|
|
|
+ const newIndex = ordering.findIndex((o) => o.task_id === pickupTaskId);
|
|
|
+ const moved = newIndex !== pickupOriginalIndex;
|
|
|
+ const taskId = pickupTaskId;
|
|
|
+ pickupTaskId = null; pickupRow = null; pickupOriginNext = null;
|
|
|
+
|
|
|
+ if (!moved) { flash('No changes'); return; }
|
|
|
+
|
|
|
+ request('POST', '/sprints/' + sprintId + '/tasks/reorder', ordering)
|
|
|
+ .then(function (data) {
|
|
|
+ ordering.forEach(function (o) {
|
|
|
+ const r = qs('tr[data-task-id="' + o.task_id + '"]', taskTbody);
|
|
|
+ if (r) { r.setAttribute('data-sort-order', String(o.sort_order)); }
|
|
|
+ });
|
|
|
+ flash(data.moved ? 'Order saved' : 'No changes');
|
|
|
+ })
|
|
|
+ .catch((e) => flash(e.message, true));
|
|
|
}
|
|
|
|
|
|
// --- Multi-select owner filter ---------------------------------------
|