Popular features/Custom task editor
Loading live demo…from dhtmlxcode.com
Popular features
Custom task editor
Replace the default lightbox with a custom editor that opens on double click and follows the host product's own look and layout.
// custom_task_editor/31_custom_task_editor.js
/* -- the team: fuels both the owner combo-box and the grid avatar chip ------- */
let OWNERS = [
{ id: 1, name: "Priya Shah", role: "Product Lead", color: "#2563eb" },
{ id: 2, name: "Marcus Webb", role: "SEO & Analytics", color: "#7c3aed" },
{ id: 3, name: "Elena Torres", role: "Content Strategist", color: "#059669" },
{ id: 4, name: "Noah Kim", role: "Front-end Engineer", color: "#d97706" },
{ id: 5, name: "Grace Liu", role: "Design Lead", color: "#db2777" }
];
let OWNERS_BY_ID = {};
OWNERS.forEach(function (owner) { OWNERS_BY_ID[owner.id] = owner; });
function normalizeOwnerIds(value) {
if (Array.isArray(value)) {
return value.map(function (id) { return Number(id); }).filter(function (id) {
return !!OWNERS_BY_ID[id];
});
}
if (value == null || value === "") return [];
let id = Number(value);
return OWNERS_BY_ID[id] ? [id] : [];
}
let PRIORITY_LABELS = { high: "High", medium: "Medium", low: "Low" };
gantt.plugins({
auto_scheduling: true
});
gantt.config.auto_scheduling = {
enabled: true,
apply_constraints: false,
gap_behavior: "compress",
};
gantt.config.row_height = 36;
gantt.config.bar_height = 24;
gantt.config.scale_height = 52;
gantt.config.work_time = true;
gantt.config.fit_tasks = true;
gantt.config.min_column_width = 34;
function mondayOnOrAfter(date) {
date = gantt.date.day_start(new Date(date));
while (date.getDay() !== 1) date = gantt.date.add(date, 1, "day");
return date;
}
let ANCHOR = mondayOnOrAfter(new Date());
function addDays(n) { return gantt.date.add(ANCHOR, n, "day"); }
gantt.config.scales = [
{ unit: "month", step: 1, format: "%F %Y" },
{ unit: "day", step: 1, format: "%j %D" }
];
gantt.templates.scale_cell_class = function (date) {
return gantt.isWorkTime(date) ? "" : "weekend";
};
gantt.templates.timeline_cell_class = function (task, date) {
return gantt.isWorkTime({ date, task }) ? "" : "weekend";
};
/* Leaf-task bars pick up a color from their priority. */
gantt.templates.task_class = function (start, end, task) {
let isLeaf = !gantt.isSummaryTask(task) && task.type !== gantt.config.types.milestone;
if (isLeaf) return "task_priority_" + (task.priority || "medium");
return "";
};
/* -- grid ------------------------------------------------------------------ */
gantt.config.columns = [
{ name: "text", label: "Task", tree: true, width: 220, resize: true },
{ name: "start_date", label: "Start", align: "center", width: 100, resize: true,
template: function (task) {
return task.type === gantt.config.types.project ? "" : gantt.templates.date_grid(task.start_date, task);
}
},
{ name: "end_date", label: "End", align: "center", width: 100, resize: true,
template: function (task) {
return task.type === gantt.config.types.project ? "" : gantt.templates.date_grid(task.end_date, task);
}
},
{ name: "owner", label: "Owners", width: 170, resize: true,
template: function (task) {
let ownerIds = normalizeOwnerIds(task.owner);
if (!ownerIds.length) return "";
const avatars = ownerIds.map(function (ownerId) {
let owner = OWNERS_BY_ID[ownerId];
return "<span class='dhx_chip' style='background:" + owner.color +
"' title='" + DHX.ui.escape(owner.name) + "'>" +
DHX.ui.initials(owner.name) + "</span>";
}).join("")
const length = `<span class='owner_name'>${ownerIds.length} assigned</span>`;
return `<span class='owner_cell'>${avatars} ${length}</span>`
} },
{ name: "priority", label: "Priority", align: "center", width: 96, resize: true,
template: function (task) {
if (task.type === gantt.config.types.project || !task.priority) return "";
return `<span class='priority_cell'><span class='dhx_dot dhx_dot--${task.priority}'></span>${PRIORITY_LABELS[task.priority]}</span>`;
} },
{ name: "add", width: 40, resize: true }
];
/* -- fully custom task editor ----------------------------------------------- */
let editorState = {
taskId: null,
ownerIds: [],
priority: "medium",
wasNew: false
};
let editorBackdrop = document.getElementById("task_editor_backdrop");
let editor = document.getElementById("task_editor");
let editorTitle = document.getElementById("task_editor_title");
let editorType = document.getElementById("task_editor_type");
let editorName = document.getElementById("task_editor_name");
let editorProgress = document.getElementById("task_editor_progress");
let editorProgressValue = document.getElementById("task_editor_progress_value");
let editorStartField = document.getElementById("task_editor_start_field");
let editorEndField = document.getElementById("task_editor_end_field");
let editorStart = document.getElementById("task_editor_start");
let editorEnd = document.getElementById("task_editor_end");
let ownerCombo = document.getElementById("task_editor_owner_combo");
let ownerChips = document.getElementById("task_editor_owner_chips");
let ownerInput = document.getElementById("task_editor_owner_input");
let ownerMenu = document.getElementById("task_editor_owner_menu");
let ownerToggle = document.getElementById("task_editor_owner_toggle");
let predecessorList = document.getElementById("task_editor_pred_list");
let autoSchedulingToggle = document.getElementById("auto_scheduling_toggle");
let runAutoSchedulingButton = document.getElementById("run_auto_scheduling");
let editorDateFormat = gantt.date.date_to_str("%Y-%m-%d");
let noticeDateFormat = gantt.date.date_to_str("%d %M %Y");
let editorAutoScheduleContext = null;
const linkLabels = {
"0": "Finish-to-start",
"1": "Start-to-start",
"2": "Finish-to-finish",
"3": "Start-to-finish"
};
function formatNoticeDateRange(startDate, endDate) {
const startText = noticeDateFormat(startDate);
return endDate ? startText + " – " + noticeDateFormat(endDate) : startText;
}
function autoScheduleLinkTypeLabel(linkType) {
return linkLabels[String(linkType)] || "dependency";
}
function autoScheduleLagLabel(lag) {
const numericLag = Number(lag) || 0;
if (!numericLag) return "no lag";
const absoluteLag = Math.abs(numericLag);
return absoluteLag + " " + (absoluteLag === 1 ? "day" : "days") + (numericLag > 0 ? " lag" : " lead");
}
function showEditorAutoScheduleNotice(task, context) {
const requestedDates = formatNoticeDateRange(context.requestedStartDate, context.requestedEndDate);
const scheduledEndDate = context.requestedEndDate ? task.end_date : null;
const scheduledDates = formatNoticeDateRange(task.start_date, scheduledEndDate);
let reason = "to satisfy its predecessor dependencies.";
if (context.link && context.predecessor) {
const linkType = autoScheduleLinkTypeLabel(context.link.type);
const predecessorName = DHX.ui.escape(context.predecessor.text);
const lag = autoScheduleLagLabel(context.link.lag);
reason = "because of the " + linkType + " dependency on <b>" + predecessorName + "</b> (" + lag + ").";
}
gantt.message({
text: "<b>" + DHX.ui.escape(task.text) + "</b> was moved by auto scheduling from " +
requestedDates + " to " + scheduledDates + " " + reason
});
}
gantt.attachEvent("onAfterTaskAutoSchedule", function (task, startDate, link, predecessor) {
if (!editorAutoScheduleContext || !editorAutoScheduleContext.waitingForFinalSchedule) return;
if (String(task.id) !== editorAutoScheduleContext.taskId) return;
editorAutoScheduleContext.link = link;
editorAutoScheduleContext.predecessor = predecessor;
});
gantt.attachEvent("onAfterAutoSchedule", function (rootTaskId, updatedTaskIds) {
const context = editorAutoScheduleContext;
if (!context || !context.waitingForFinalSchedule) return;
context.waitingForFinalSchedule = false;
const editedTaskWasUpdated = Array.isArray(updatedTaskIds) && updatedTaskIds.some(function (taskId) {
return String(taskId) === context.taskId;
});
if (!editedTaskWasUpdated || !gantt.isTaskExists(context.taskId)) {
editorAutoScheduleContext = null;
return;
}
const task = gantt.getTask(context.taskId);
const startDateChanged = context.requestedStartDate &&
task.start_date.valueOf() !== context.requestedStartDate.valueOf();
const endDateChanged = context.requestedEndDate &&
task.end_date.valueOf() !== context.requestedEndDate.valueOf();
if (startDateChanged || endDateChanged) {
showEditorAutoScheduleNotice(task, context);
}
editorAutoScheduleContext = null;
});
function setAutoScheduling(enabled) {
gantt.config.auto_scheduling.enabled = enabled;
DHX.toggle.setWrap(autoSchedulingToggle, enabled);
if (enabled){
gantt.autoSchedule();
}
}
function runAutoScheduling() {
editorAutoScheduleContext = null;
gantt.autoSchedule();
}
function parseEditorDate(value) {
if (!value) return null;
let parts = value.split("-");
return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
}
function taskTypeLabel(task) {
if (task.type === gantt.config.types.project) return "Project editor";
if (task.type === gantt.config.types.milestone) return "Milestone editor";
return "Task editor";
}
function setEditorTab(name) {
editor.querySelectorAll("[data-editor-tab]").forEach(function (tab) {
tab.classList.toggle("is_active", tab.getAttribute("data-editor-tab") === name);
});
editor.querySelectorAll("[data-editor-page]").forEach(function (page) {
page.classList.toggle("is_active", page.getAttribute("data-editor-page") === name);
});
}
function getOwnerById(id) {
return OWNERS_BY_ID[Number(id)] || null;
}
function hasSelectedOwner(id) {
return editorState.ownerIds.some(function (ownerId) {
return String(ownerId) === String(id);
});
}
function renderSelectedOwners() {
ownerChips.innerHTML = editorState.ownerIds.map(function (ownerId) {
let owner = getOwnerById(ownerId);
if (!owner) return "";
return "<span class='task_editor_combo__chip'>" +
"<span class='task_editor_combo__chip_name'>" + DHX.ui.escape(owner.name) + "</span>" +
"<button type='button' class='task_editor_combo__chip_remove' data-owner-id='" +
owner.id + "' aria-label='Remove " + DHX.ui.escape(owner.name) + "'>×</button>" +
"</span>";
}).join("");
ownerChips.querySelectorAll(".task_editor_combo__chip_remove").forEach(function (button) {
button.addEventListener("mousedown", function (event) {
event.preventDefault();
event.stopPropagation();
toggleOwner(button.getAttribute("data-owner-id"), false);
ownerInput.focus();
});
});
}
function renderOwnerMenu(query) {
let normalized = (query || "").trim().toLowerCase();
let matches = OWNERS.filter(function (owner) {
return !normalized ||
owner.name.toLowerCase().indexOf(normalized) !== -1 ||
owner.role.toLowerCase().indexOf(normalized) !== -1;
});
if (!matches.length) {
ownerMenu.innerHTML = "<span class='task_editor_combo__empty'>No matching owners</span>";
return;
}
ownerMenu.innerHTML = matches.map(function (owner, index) {
let selected = hasSelectedOwner(owner.id);
return "<div class='task_editor_combo__option" +
(index === 0 ? " is_active" : "") +
(selected ? " is_selected" : "") +
"' data-owner-id='" + owner.id + "'>" +
"<span class='task_editor_combo__avatar' style='background:" + owner.color + "'>" +
DHX.ui.initials(owner.name) +
"</span>" +
"<span><span class='task_editor_combo__name'>" + DHX.ui.escape(owner.name) + "</span>" +
"<span class='task_editor_combo__role'>" + DHX.ui.escape(owner.role) + "</span></span>" +
"<span class='task_editor_combo__check'>" + (selected ? "✓" : "") + "</span>" +
"</div>";
}).join("");
ownerMenu.querySelectorAll(".task_editor_combo__option").forEach(function (option) {
option.addEventListener("mousedown", function (event) {
event.preventDefault();
toggleOwner(option.getAttribute("data-owner-id"));
});
});
}
function openOwnerMenu() {
renderOwnerMenu(ownerInput.value);
ownerCombo.classList.add("is_open");
ownerInput.setAttribute("aria-expanded", "true");
}
function closeOwnerMenu() {
ownerCombo.classList.remove("is_open");
ownerInput.setAttribute("aria-expanded", "false");
}
function toggleOwner(id, keepOpen) {
let ownerId = Number(id);
let selected = hasSelectedOwner(ownerId);
if (selected) {
editorState.ownerIds = editorState.ownerIds.filter(function (currentId) {
return String(currentId) !== String(ownerId);
});
} else {
editorState.ownerIds.push(ownerId);
}
ownerInput.value = "";
renderSelectedOwners();
renderOwnerMenu("");
if (keepOpen !== false) {
ownerCombo.classList.add("is_open");
ownerInput.setAttribute("aria-expanded", "true");
ownerInput.focus();
} else {
closeOwnerMenu();
}
}
function setPriority(value) {
editorState.priority = value || "medium";
document.querySelectorAll("#task_editor_priority [data-priority]").forEach(function (button) {
button.classList.toggle("is_active", button.getAttribute("data-priority") === editorState.priority);
});
}
function incomingLinks(taskId) {
return gantt.getLinks().filter(function (link) {
return String(link.target) === String(taskId);
});
}
function isDescendantOf(candidateId, ancestorId) {
let currentId = candidateId;
while (gantt.isTaskExists(currentId)) {
let current = gantt.getTask(currentId);
if (String(current.parent) === String(ancestorId)) return true;
if (
current.parent == null ||
current.parent === gantt.config.root_id ||
current.parent === 0 ||
String(current.parent) === String(currentId)
) {
break;
}
currentId = current.parent;
}
return false;
}
function predecessorOptions(selectedId) {
let options = ["<option value=''>Choose a task...</option>"];
gantt.eachTask(function (task) {
if (String(task.id) === String(editorState.taskId)) return;
if (isDescendantOf(task.id, editorState.taskId)) return;
options.push(
"<option value='" + DHX.ui.escape(String(task.id)) + "'" +
(String(task.id) === String(selectedId) ? " selected" : "") + ">" +
DHX.ui.escape(task.text) +
"</option>"
);
});
return options.join("");
}
function linkTypeOptions(selectedType) {
const options = [];
for (const prop in linkLabels){
options.push(
`<option value='${prop}'${(prop === String(selectedType) ? " selected" : "")}>${linkLabels[prop]}</option>`
)
}
return options.join("");
}
function addPredecessorRow(link) {
let row = document.createElement("div");
row.className = "task_editor__pred_row";
row.setAttribute("data-link-id", link && link.id != null ? link.id : "");
row.innerHTML =
"<select class='task_editor__select task_editor__pred_source'>" +
predecessorOptions(link ? link.source : "") +
"</select>" +
"<select class='task_editor__select task_editor__pred_type'>" +
linkTypeOptions(link ? link.type : "0") +
"</select>" +
"<input class='task_editor__number task_editor__pred_lag' type='number' step='1' value='" +
DHX.ui.escape(String(link && link.lag != null ? link.lag : 0)) + "'>" +
"<button type='button' class='task_editor__pred_remove' title='Remove predecessor'>" +
"<svg viewBox='0 0 24 24'><polyline points='4 7 20 7'/><path d='M9 7V4h6v3'/><path d='M6 7l1 13h10l1-13'/></svg>" +
"</button>";
row.querySelector(".task_editor__pred_remove").addEventListener("click", function () {
row.parentNode.removeChild(row);
updatePredecessorEmptyState();
});
predecessorList.appendChild(row);
updatePredecessorEmptyState();
}
function updatePredecessorEmptyState() {
let empty = predecessorList.querySelector(".task_editor__pred_empty");
let rows = predecessorList.querySelectorAll(".task_editor__pred_row");
if (rows.length && empty) {
empty.parentNode.removeChild(empty);
} else if (!rows.length && !empty) {
predecessorList.innerHTML = "<div class='task_editor__pred_empty'>No predecessors yet. Add one to define how this task depends on another task.</div>";
}
}
function renderPredecessors(taskId) {
predecessorList.innerHTML = "";
let links = incomingLinks(taskId);
if (!links.length) {
updatePredecessorEmptyState();
return;
}
links.forEach(addPredecessorRow);
}
function positionEditorInitially() {
let width = Math.min(540, window.innerWidth - 36);
let height = Math.min(570, window.innerHeight - 36);
editor.style.width = width + "px";
editor.style.height = height + "px";
editor.style.left = Math.max(6, (window.innerWidth - width) / 2) + "px";
editor.style.top = Math.max(6, (window.innerHeight - height) / 2) + "px";
}
function openTaskEditor(taskId) {
if (!gantt.isTaskExists(taskId)) return;
let task = gantt.getTask(taskId);
editorState.taskId = task.id;
editorState.ownerIds = normalizeOwnerIds(task.owner);
editorState.wasNew = !!task.$new;
editorTitle.textContent = task.text || "Untitled task";
editorType.textContent = taskTypeLabel(task);
editorName.value = task.text || "";
ownerInput.value = "";
renderSelectedOwners();
renderOwnerMenu("");
setPriority(task.priority || "medium");
let isProject = task.type === gantt.config.types.project;
let isMilestone = task.type === gantt.config.types.milestone;
if (isMilestone) {
editorProgress.parentNode.parentNode.style.display = "none";
}
else {
let progress = Math.round((task.progress || 0) * 100);
editorProgress.value = progress;
editorProgressValue.textContent = progress + "%";
editorProgress.parentNode.parentNode.style.display = "";
}
editorStartField.style.display = isProject ? "none" : "";
editorEndField.style.display = (isProject || isMilestone) ? "none" : "";
if (!isProject) {
editorStart.value = editorDateFormat(task.start_date);
if (!isMilestone) {
editorEnd.value = editorDateFormat(task.end_date);
editorEnd.min = editorStart.value;
}
}
renderPredecessors(task.id);
setEditorTab("details");
positionEditorInitially();
editorBackdrop.classList.add("is_open");
setTimeout(function () {
editorName.focus();
editorName.select();
}, 0);
}
function closeTaskEditor(options) {
options = options || {};
if (editorState.wasNew && !options.keepNewTask && editorState.taskId != null && gantt.isTaskExists(editorState.taskId)) {
gantt.deleteTask(editorState.taskId);
}
closeOwnerMenu();
editorBackdrop.classList.remove("is_open");
editorState.taskId = null;
editorState.wasNew = false;
}
function collectPredecessors() {
let result = [];
predecessorList.querySelectorAll(".task_editor__pred_row").forEach(function (row) {
let source = row.querySelector(".task_editor__pred_source").value;
if (!source) return;
result.push({
id: row.getAttribute("data-link-id") || null,
source: source,
target: editorState.taskId,
type: row.querySelector(".task_editor__pred_type").value,
lag: Number(row.querySelector(".task_editor__pred_lag").value) || 0
});
});
return result;
}
function validateEditor(task, predecessors) {
let name = editorName.value.trim();
if (!name) {
setEditorTab("details");
editorName.focus();
gantt.message({ type: "error", text: "Give the task a name before saving." });
return false;
}
if (task.type !== gantt.config.types.project) {
let start = parseEditorDate(editorStart.value);
if (!start) {
setEditorTab("details");
editorStart.focus();
gantt.message({ type: "error", text: "Choose a start date." });
return false;
}
if (task.type !== gantt.config.types.milestone) {
let end = parseEditorDate(editorEnd.value);
if (!end || end.valueOf() <= start.valueOf()) {
setEditorTab("details");
editorEnd.focus();
gantt.message({ type: "error", text: "End date must be after the start date." });
return false;
}
}
}
let seenSources = {};
for (let i = 0; i < predecessors.length; i++) {
let key = String(predecessors[i].source);
if (seenSources[key]) {
setEditorTab("predecessors");
gantt.message({ type: "error", text: "The same predecessor cannot be added twice." });
return false;
}
seenSources[key] = true;
}
return true;
}
function savePredecessors(taskId, rows) {
let existing = incomingLinks(taskId);
let retained = {};
rows.forEach(function (row) {
if (row.id && gantt.isLinkExists(row.id)) {
let link = gantt.getLink(row.id);
link.source = row.source;
link.target = taskId;
link.type = row.type;
link.lag = row.lag;
gantt.updateLink(link.id);
retained[String(link.id)] = true;
} else {
let newId = gantt.addLink({
source: row.source,
target: taskId,
type: row.type,
lag: row.lag,
});
retained[String(newId)] = true;
}
});
existing.forEach(function (link) {
if (!retained[String(link.id)] && gantt.isLinkExists(link.id)) {
gantt.deleteLink(link.id);
}
});
}
function saveTaskEditor() {
if (editorState.taskId == null || !gantt.isTaskExists(editorState.taskId)) return;
let task = gantt.getTask(editorState.taskId);
let predecessorRows = collectPredecessors();
if (!validateEditor(task, predecessorRows)) return;
const taskValues = {
text: editorName.value.trim(),
owner: editorState.ownerIds.slice(),
priority: editorState.priority,
progress: Number(editorProgress.value) / 100,
startDate: task.type !== gantt.config.types.project ? parseEditorDate(editorStart.value) : null,
endDate: task.type !== gantt.config.types.project && task.type !== gantt.config.types.milestone
? parseEditorDate(editorEnd.value)
: null
};
gantt.batchUpdate(function () {
/* Apply the final predecessor set before updating the task itself. */
savePredecessors(task.id, predecessorRows);
/* Auto scheduling may have changed the task while links were updated. */
task = gantt.getTask(task.id);
task.text = taskValues.text;
task.owner = taskValues.owner;
task.priority = taskValues.priority;
if (task.type !== gantt.config.types.milestone) {
task.progress = taskValues.progress;
}
if (task.type !== gantt.config.types.project) {
task.start_date = taskValues.startDate;
if (task.type !== gantt.config.types.milestone) {
task.end_date = taskValues.endDate;
task.duration = gantt.calculateDuration({
start_date: taskValues.startDate,
end_date: taskValues.endDate,
task: task
});
}
}
task.$new = false;
/*
* Create the context only for the final task update. Auto-scheduling
* cycles caused by predecessor changes above must not show the notice.
*/
if (gantt.config.auto_scheduling.enabled && task.type !== gantt.config.types.project) {
editorAutoScheduleContext = {
taskId: String(task.id),
requestedStartDate: taskValues.startDate ? new Date(taskValues.startDate) : null,
requestedEndDate: taskValues.endDate ? new Date(taskValues.endDate) : null,
waitingForFinalSchedule: true,
link: null,
predecessor: null
};
} else {
editorAutoScheduleContext = null;
}
gantt.updateTask(task.id);
});
editorState.wasNew = false;
closeTaskEditor({ keepNewTask: true });
}
function deleteEditedTask() {
if (editorState.taskId == null || !gantt.isTaskExists(editorState.taskId)) return;
let task = gantt.getTask(editorState.taskId);
if (!window.confirm('Delete "' + task.text + '"?')) return;
let taskId = task.id;
editorState.wasNew = false;
closeTaskEditor({ keepNewTask: true });
gantt.deleteTask(taskId);
}
/* Redirect every built-in lightbox request to the custom editor. */
gantt.attachEvent("onBeforeLightbox", function (taskId) {
openTaskEditor(taskId);
return false;
});
/* Tabs and footer actions. */
editor.querySelectorAll("[data-editor-tab]").forEach(function (tab) {
tab.addEventListener("click", function () {
setEditorTab(tab.getAttribute("data-editor-tab"));
});
});
document.getElementById("task_editor_close").addEventListener("click", function () {
closeTaskEditor();
});
document.getElementById("task_editor_cancel").addEventListener("click", function () {
closeTaskEditor();
});
document.getElementById("task_editor_save").addEventListener("click", saveTaskEditor);
document.getElementById("task_editor_delete").addEventListener("click", deleteEditedTask);
document.getElementById("task_editor_add_pred").addEventListener("click", function () {
let empty = predecessorList.querySelector(".task_editor__pred_empty");
if (empty) empty.parentNode.removeChild(empty);
addPredecessorRow(null);
});
autoSchedulingToggle.addEventListener("click", function () {
setAutoScheduling(!gantt.config.auto_scheduling.enabled);
});
runAutoSchedulingButton.addEventListener("click", runAutoScheduling);
setAutoScheduling(gantt.config.auto_scheduling.enabled);
/* Details controls. */
editorName.addEventListener("input", function () {
editorTitle.textContent = editorName.value.trim() || "Untitled task";
});
document.querySelectorAll("#task_editor_priority [data-priority]").forEach(function (button) {
button.addEventListener("click", function () {
setPriority(button.getAttribute("data-priority"));
});
});
editorProgress.addEventListener("input", function () {
editorProgressValue.textContent = editorProgress.value + "%";
});
editorStart.addEventListener("change", function () {
editorEnd.min = editorStart.value;
const currentTask = editorState.taskId != null && gantt.isTaskExists(editorState.taskId)
? gantt.getTask(editorState.taskId)
: null;
if (!currentTask || currentTask.type === gantt.config.types.milestone) return;
const startDate = parseEditorDate(editorStart.value);
if (!startDate) return;
const duration = Math.max(Number(currentTask.duration) || 1, 1);
editorEnd.value = editorDateFormat(gantt.calculateEndDate({
start_date: startDate,
duration: duration,
task: currentTask
}));
});
/* Owner combo-box interactions. */
ownerInput.addEventListener("focus", function () {
openOwnerMenu();
});
ownerInput.addEventListener("input", function () {
renderOwnerMenu(ownerInput.value);
ownerCombo.classList.add("is_open");
ownerInput.setAttribute("aria-expanded", "true");
});
ownerToggle.addEventListener("mousedown", function (event) {
event.preventDefault();
if (ownerCombo.classList.contains("is_open")) {
closeOwnerMenu();
} else {
ownerInput.value = "";
ownerInput.focus();
openOwnerMenu();
}
});
ownerInput.addEventListener("keydown", function (event) {
let options = ownerMenu.querySelectorAll(".task_editor_combo__option");
let active = ownerMenu.querySelector(".task_editor_combo__option.is_active");
let index = Array.prototype.indexOf.call(options, active);
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!ownerCombo.classList.contains("is_open")) {
openOwnerMenu();
}
options = ownerMenu.querySelectorAll(".task_editor_combo__option");
if (!options.length) return;
index = event.key === "ArrowDown"
? Math.min(index + 1, options.length - 1)
: Math.max(index <= 0 ? 0 : index - 1, 0);
options.forEach(function (option) {
option.classList.remove("is_active");
});
options[index].classList.add("is_active");
options[index].scrollIntoView({ block: "nearest" });
} else if (event.key === "Enter" && active) {
event.preventDefault();
toggleOwner(active.getAttribute("data-owner-id"));
} else if (event.key === "Backspace" && !ownerInput.value && editorState.ownerIds.length) {
editorState.ownerIds.pop();
renderSelectedOwners();
renderOwnerMenu("");
} else if (event.key === "Escape") {
event.stopPropagation();
closeOwnerMenu();
}
});
ownerInput.addEventListener("blur", function () {
setTimeout(function () {
if (!ownerCombo.contains(document.activeElement)) {
ownerInput.value = "";
closeOwnerMenu();
}
}, 0);
});
/* Close only when the backdrop itself is pressed. */
editorBackdrop.addEventListener("mousedown", function (event) {
if (event.target === editorBackdrop) {
closeTaskEditor();
}
});
document.addEventListener("keydown", function (event) {
if (!editorBackdrop.classList.contains("is_open")) return;
if (event.key === "Escape" && !ownerCombo.classList.contains("is_open")) {
closeTaskEditor();
}
});
/* Drag from any non-interactive part of the editor. */
let dragState = null;
editor.addEventListener("pointerdown", function (event) {
if (event.button !== 0) return;
if (event.target.closest(
"input, select, textarea, button, label, .task_editor_combo__option, " +
".task_editor__pred_row, .task_editor__resize"
)) {
return;
}
let rect = editor.getBoundingClientRect();
dragState = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
left: rect.left,
top: rect.top
};
editor.classList.add("is_dragging");
editor.setPointerCapture(event.pointerId);
event.preventDefault();
});
editor.addEventListener("pointermove", function (event) {
if (!dragState || dragState.pointerId !== event.pointerId) return;
let left = dragState.left + event.clientX - dragState.startX;
let top = dragState.top + event.clientY - dragState.startY;
let rect = editor.getBoundingClientRect();
editor.style.left = Math.max(0, Math.min(left, window.innerWidth - rect.width)) + "px";
editor.style.top = Math.max(0, Math.min(top, window.innerHeight - rect.height)) + "px";
});
function stopEditorDrag(event) {
if (!dragState || dragState.pointerId !== event.pointerId) return;
dragState = null;
editor.classList.remove("is_dragging");
if (editor.hasPointerCapture(event.pointerId)) {
editor.releasePointerCapture(event.pointerId);
}
}
editor.addEventListener("pointerup", stopEditorDrag);
editor.addEventListener("pointercancel", stopEditorDrag);
/* Resize from the lower-right corner. */
let resizeState = null;
let resizeHandle = document.getElementById("task_editor_resize");
resizeHandle.addEventListener("pointerdown", function (event) {
if (event.button !== 0) return;
let rect = editor.getBoundingClientRect();
resizeState = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
width: rect.width,
height: rect.height
};
editor.classList.add("is_resizing");
resizeHandle.setPointerCapture(event.pointerId);
event.preventDefault();
event.stopPropagation();
});
resizeHandle.addEventListener("pointermove", function (event) {
if (!resizeState || resizeState.pointerId !== event.pointerId) return;
let rect = editor.getBoundingClientRect();
let minWidth = Math.min(540, window.innerWidth - rect.left);
let minHeight = Math.min(470, window.innerHeight - rect.top);
let width = Math.max(minWidth, Math.min(
resizeState.width + event.clientX - resizeState.startX,
window.innerWidth - rect.left
));
let height = Math.max(minHeight, Math.min(
resizeState.height + event.clientY - resizeState.startY,
window.innerHeight - rect.top
));
editor.style.width = width + "px";
editor.style.height = height + "px";
});
function stopEditorResize(event) {
if (!resizeState || resizeState.pointerId !== event.pointerId) return;
resizeState = null;
editor.classList.remove("is_resizing");
if (resizeHandle.hasPointerCapture(event.pointerId)) {
resizeHandle.releasePointerCapture(event.pointerId);
}
}
resizeHandle.addEventListener("pointerup", stopEditorResize);
resizeHandle.addEventListener("pointercancel", stopEditorResize);
/* -- data: marketing-site redesign, two phases, five-person team ------------ */
let data = {
tasks: [
{ id: 1, text: "Discovery & Design", type: "project", open: true, progress: 0.55 },
{ id: 11, text: "Stakeholder interviews", parent: 1, start_date: addDays(0), duration: 3, owner: 1, priority: "high", progress: 1 },
{ id: 12, text: "Competitor & analytics audit", parent: 1, start_date: addDays(0), duration: 3, owner: 2, priority: "medium", progress: 1 },
{ id: 13, text: "Sitemap & content strategy", parent: 1, start_date: addDays(3), duration: 4, owner: 3, priority: "high", progress: 0.8 },
{ id: 14, text: "Wireframes", parent: 1, start_date: addDays(7), duration: 5, owner: 5, priority: "high", progress: 0.6 },
{ id: 15, text: "Visual design system", parent: 1, start_date: addDays(7), duration: 6, owner: 5, priority: "medium", progress: 0.4 },
{ id: 16, text: "Design review & sign-off", parent: 1, start_date: addDays(13), duration: 2, owner: 1, priority: "high", progress: 0.1 },
{ id: 17, text: "Design phase complete", parent: 1, start_date: addDays(15), type: "milestone" },
{ id: 2, text: "Build & Launch", type: "project", open: true, progress: 0.05 },
{ id: 21, text: "Component library build", parent: 2, start_date: addDays(15), duration: 6, owner: 4, priority: "high", progress: 0 },
{ id: 22, text: "Homepage build", parent: 2, start_date: addDays(21), duration: 4, owner: 4, priority: "high", progress: 0 },
{ id: 23, text: "Product pages build", parent: 2, start_date: addDays(21), duration: 5, owner: 2, priority: "medium", progress: 0 },
{ id: 24, text: "Blog & resources build", parent: 2, start_date: addDays(21), duration: 4, owner: 3, priority: "low", progress: 0 },
{ id: 25, text: "CMS content migration", parent: 2, start_date: addDays(21), duration: 5, owner: 3, priority: "medium", progress: 0 },
{ id: 26, text: "SEO & metadata setup", parent: 2, start_date: addDays(26), duration: 3, owner: 2, priority: "medium", progress: 0 },
{ id: 27, text: "Analytics & tracking setup", parent: 2, start_date: addDays(26), duration: 2, owner: 4, priority: "low", progress: 0 },
{ id: 28, text: "Cross-browser QA", parent: 2, start_date: addDays(29), duration: 4, owner: 1, priority: "high", progress: 0 },
{ id: 29, text: "Performance & accessibility audit", parent: 2, start_date: addDays(29), duration: 3, owner: 5, priority: "medium", progress: 0 },
{ id: 30, text: "Legal & compliance review", parent: 2, start_date: addDays(26), duration: 2, owner: 1, priority: "low", progress: 0 },
{ id: 31, text: "Stakeholder UAT", parent: 2, start_date: addDays(33), duration: 3, owner: 1, priority: "high", progress: 0 },
{ id: 32, text: "Launch prep & DNS cutover", parent: 2, start_date: addDays(36), duration: 2, owner: 4, priority: "high", progress: 0 },
{ id: 33, text: "Site launch", parent: 2, start_date: addDays(38), type: "milestone" },
{ id: 34, text: "Post-launch monitoring", parent: 2, start_date: addDays(38), duration: 3, owner: 2, priority: "low", progress: 0 }
],
links: [
{ id: 1, source: 11, target: 13, type: "0" },
{ id: 2, source: 13, target: 14, type: "0" },
{ id: 3, source: 13, target: 15, type: "0" },
{ id: 4, source: 14, target: 16, type: "0" },
{ id: 5, source: 15, target: 16, type: "0" },
{ id: 6, source: 16, target: 17, type: "0" },
{ id: 7, source: 16, target: 21, type: "0" },
{ id: 8, source: 21, target: 22, type: "0" },
{ id: 9, source: 21, target: 23, type: "0" },
{ id: 10, source: 21, target: 24, type: "0" },
{ id: 11, source: 13, target: 25, type: "0" },
{ id: 12, source: 22, target: 26, type: "0" },
{ id: 13, source: 23, target: 26, type: "0" },
{ id: 14, source: 22, target: 27, type: "0" },
{ id: 15, source: 22, target: 28, type: "0" },
{ id: 16, source: 23, target: 28, type: "0" },
{ id: 17, source: 24, target: 28, type: "0" },
{ id: 18, source: 22, target: 29, type: "0" },
{ id: 19, source: 23, target: 29, type: "0" },
{ id: 20, source: 25, target: 30, type: "0" },
{ id: 21, source: 28, target: 31, type: "0" },
{ id: 22, source: 29, target: 31, type: "0" },
{ id: 23, source: 30, target: 31, type: "0" },
{ id: 24, source: 31, target: 32, type: "0" },
{ id: 25, source: 32, target: 33, type: "0" },
{ id: 26, source: 33, target: 34, type: "0" }
]
};
/* The button opens the lightbox for "Homepage build": it carries every
field the demo showcases (owner, priority, partial progress, dates). */
function openSampleEditor() {
openTaskEditor(22);
}
document.getElementById("open_sample_editor").addEventListener("click", openSampleEditor);
gantt.init("gantt_here");
gantt.parse(data);<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8">
<title>A task editor that matches your product: dhtmlxGantt</title>
<script src="../../codebase/dhtmlxgantt.js?v=10.0.0"></script>
<link rel="stylesheet" href="../../codebase/dhtmlxgantt.css?v=10.0.0">
<link rel="stylesheet" href="../common/demo-controls/dhx_controls.css">
<script src="../common/demo-controls/dhx_controls.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="custom_task_editor/31_custom_task_editor.css">
</head>
<body>
<div class="dhx_header">
<span class="hint" style="flex:1 1 0">Double-click any bar to open the editor</span>
<button class="dhx_btn dhx_btn--primary" id="open_sample_editor">Open editor for a sample task</button>
<div class="dhx_sep"></div>
<label class="dhx_toggle" id="auto_scheduling_toggle">
<span>Auto scheduling</span><span class="dhx_toggle__track"></span>
</label>
<button type="button" class="dhx_btn dhx_btn--surface" id="run_auto_scheduling">Recalculate all tasks</button>
<div class="dhx_spacer"></div>
</div>
<div id="gantt_here"></div>
<div class="task_editor_backdrop" id="task_editor_backdrop">
<section class="task_editor" id="task_editor" role="dialog" aria-modal="true" aria-labelledby="task_editor_title">
<header class="task_editor__header">
<div class="task_editor__title_wrap">
<span class="task_editor__eyebrow" id="task_editor_type">Task editor</span>
<span class="task_editor__title" id="task_editor_title">Task</span>
</div>
<button type="button" class="task_editor__close" id="task_editor_close" aria-label="Close">
<svg viewBox="0 0 24 24"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
</button>
</header>
<nav class="task_editor__tabs" aria-label="Task editor pages">
<button type="button" class="task_editor__tab is_active" data-editor-tab="details">Details</button>
<button type="button" class="task_editor__tab" data-editor-tab="predecessors">Predecessors</button>
</nav>
<div class="task_editor__body">
<div class="task_editor__page is_active" data-editor-page="details">
<div class="task_editor__grid">
<label class="task_editor__field task_editor__field--wide">
<span class="task_editor__label">Task name</span>
<input type="text" class="task_editor__input" id="task_editor_name" autocomplete="off">
</label>
<div class="task_editor__field task_editor__field--wide">
<span class="task_editor__label">Owner</span>
<div class="task_editor_combo" id="task_editor_owner_combo">
<div class="task_editor_combo__control">
<div class="task_editor_combo__chips" id="task_editor_owner_chips"></div>
<input type="text" class="task_editor_combo__input" id="task_editor_owner_input"
placeholder="Search people..." autocomplete="off" role="combobox"
aria-autocomplete="list" aria-expanded="false">
<button type="button" class="task_editor_combo__toggle" id="task_editor_owner_toggle" aria-label="Show owners">
<svg viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</button>
</div>
<div class="task_editor_combo__menu" id="task_editor_owner_menu" role="listbox"></div>
</div>
</div>
<div class="task_editor__field task_editor__field--wide">
<span class="task_editor__label">Priority</span>
<div class="task_editor__priority" id="task_editor_priority">
<button type="button" class="task_editor__priority_btn" data-priority="high">
<span class="task_editor__dot task_editor__dot--high"></span>High
</button>
<button type="button" class="task_editor__priority_btn" data-priority="medium">
<span class="task_editor__dot task_editor__dot--medium"></span>Medium
</button>
<button type="button" class="task_editor__priority_btn" data-priority="low">
<span class="task_editor__dot task_editor__dot--low"></span>Low
</button>
</div>
</div>
<div class="task_editor__field task_editor__field--wide">
<span class="task_editor__label">Progress</span>
<div class="task_editor__progress">
<input type="range" class="task_editor__range" id="task_editor_progress" min="0" max="100" step="5">
<span class="task_editor__progress_value" id="task_editor_progress_value">0%</span>
</div>
</div>
<label class="task_editor__field" id="task_editor_start_field">
<span class="task_editor__label">Start date</span>
<input type="date" class="task_editor__input" id="task_editor_start">
</label>
<label class="task_editor__field" id="task_editor_end_field">
<span class="task_editor__label">End date</span>
<input type="date" class="task_editor__input" id="task_editor_end">
</label>
</div>
</div>
<div class="task_editor__page" data-editor-page="predecessors">
<div class="task_editor__pred_head">
<span>Predecessor</span>
<span>Link type</span>
<span>Lag</span>
<span></span>
</div>
<div class="task_editor__pred_list" id="task_editor_pred_list"></div>
<button type="button" class="task_editor__add_pred" id="task_editor_add_pred">+ Add predecessor</button>
</div>
</div>
<footer class="task_editor__footer">
<button type="button" class="task_editor__button task_editor__button--delete" id="task_editor_delete">Delete</button>
<span class="task_editor__footer_spacer"></span>
<button type="button" class="task_editor__button" id="task_editor_cancel">Cancel</button>
<button type="button" class="task_editor__button task_editor__button--save" id="task_editor_save">Save</button>
</footer>
<span class="task_editor__resize" id="task_editor_resize" aria-hidden="true"></span>
</section>
</div>
<script src="custom_task_editor/31_custom_task_editor.js"></script>
</body>
</html>