DHTMLX. Kanban, Gantt, Event Calendar, To do list with real-time backend sync example
This demo showcases a full project management application built with four DHTMLX widgets: Kanban, Gantt, Event Calendar, and To Do List, all connected to a shared backend with real-time multiuser synchronization. A tabbed layout lets users switch between views while all widgets stay in sync through WebSocket connections.
Live example
let activeView = "kanban";
// Check Go backend repository here - https://github.com/DHTMLX/project-management-go
// const serverURL = "http://localhost:3000";
const serverURL = "https://docs.dhtmlx.com/demos/project-management";
login(serverURL + "/login?id=1")
.then((token) => {
initKanban(token, serverURL);
initTodo(token, serverURL);
initGantt(token, serverURL);
initScheduler(token, serverURL);
})
.catch((err) => {
console.error(err);
});
// Layout initialization
const layout = new dhx.Layout("layout", {
rows: [
{
id: "toolbar",
height: "content"
},
{
id: "tabbar",
height: "content"
},
{
html: `
<main id="views">
<section id="kanban">
<div id="kanban_toolbar"></div>
<div id="kanban_root"></div>
</section>
<section id="todo">
<div id="todo_toolbar"></div>
<div id="todo_root"></div>
</section>
<section id="gantt">
<div id="gantt_root"></div>
</section>
<section id="calendar">
<div id="calendar_root">
<div class="pm_toolbar">
<div class="pm_toolbar_left">
<button class="pm_icon_btn mdi mdi-menu" id="pm_toggle_sidebar"></button>
<button class="pm_btn_primary" id="pm_create_event">
<i class="mdi mdi-plus"></i><span>Create event</span>
</button>
</div>
<div class="pm_toolbar_center">
<button class="pm_icon_btn mdi mdi-chevron-left" id="pm_nav_prev"></button>
<div class="pm_cal_title" id="pm_cal_title"></div>
<button class="pm_icon_btn mdi mdi-chevron-right" id="pm_nav_next"></button>
</div>
<div class="pm_toolbar_right">
<button class="pm_btn_secondary" id="pm_nav_today">Today</button>
<select class="pm_view_select" id="pm_view_select">
<option value="week">Week</option>
<option value="day">Day</option>
<option value="month">Month</option>
<option value="year">Year</option>
<option value="agenda">Agenda</option>
</select>
</div>
</div>
<div class="pm_body">
<aside class="pm_sidebar" id="pm_sidebar">
<div class="pm_minical" id="pm_minical"></div>
<div class="pm_calendars">
<div class="pm_calendars_header">
<span>Calendars</span>
<i class="mdi mdi-chevron-down" id="pm_calendars_toggle"></i>
</div>
<ul class="pm_calendar_list" id="pm_calendar_list"></ul>
</div>
<div class="pm_backlog">
<div class="pm_calendars_header">
<span>Backlog</span>
<i class="mdi mdi-chevron-down" id="pm_backlog_toggle"></i>
</div>
<ul class="pm_backlog_list" id="pm_backlog_list"></ul>
</div>
</aside>
<div class="pm_scheduler dhx_cal_container" id="pm_scheduler">
<div class="dhx_cal_navline" style="display: none;"></div>
<div class="dhx_cal_header"></div>
<div class="dhx_cal_data"></div>
</div>
</div>
</div>
</section>
</main>
`
}
]
});
// Toolbar initialization
const toolbar = new dhx.Toolbar(null);
// loading data into Toolbar
toolbar.data.parse(toolbarData);
// initializing Tabbar for navigation
const tabbar = new dhx.Tabbar(null, {
views: [
{ id: "kanban", tab: "Kanban" },
{ id: "todo", tab: "To Do List" },
{ id: "gantt", tab: "Gantt" },
{ id: "calendar", tab: "Scheduler" },
],
tabAlign: "center",
noContent: true,
});
// attaching widgets into Layout cells
layout.getCell("toolbar").attach(toolbar);
layout.getCell("tabbar").attach(tabbar);
// set theme for toolbar
dhx.setTheme("dark", "toolbar");
// set active view
setActiveView(activeView);
tabbar.events.on("change", id => setActiveView(id));
function login(url) {
const token = sessionStorage.getItem("login-token");
if (token) {
return Promise.resolve(token);
}
return fetch(url)
.then(async (res) => {
if (res.ok && res.status == 200) {
return res.text();
} else {
sessionStorage.clear();
return Promise.reject(await res.text());
}
})
.then((token) => {
sessionStorage.setItem("login-token", token);
return token;
})
.catch((err) => err);
}
function initKanban(token, serverURL) {
const url = serverURL + "/api/kanban";
const {
Kanban,
Toolbar,
RestDataProvider,
RemoteEvents,
kanbanUpdates,
defaultEditorShape,
defaultCardShape
} = kanban;
const restProvider = new RestDataProvider(url);
restProvider.setHeaders({
"Remote-Token": token
});
Promise.all([
restProvider.getCards(),
restProvider.getColumns(),
restProvider.getRows(),
restProvider.getUsers(),
restProvider.getLinks(),
]).then(([cards, columns, rows, users, links]) => {
users.forEach((v) => (v.label = v.name));
const cardShape = {
...defaultCardShape,
label: true,
description: true,
progress: true,
start_date: true,
end_date: true,
priority: true,
color: true,
cover: true,
comments: true,
attached: true,
votes: {
show: true
},
users: {
show: true,
values: users
}
};
const editorShape = [
...defaultEditorShape,
{
type: "links",
key: "links",
label: "Links"
},
{
type: "files",
key: "attached", // the "attached" key is used when configuring the "cardShape" property
label: "Attachment",
uploadURL: url + "/uploads", // specify url as string
config: {
accept: "image/*", // "video/*", "audio/*"
disabled: false,
multiple: true,
folder: false
}
},
{
type: "comments",
key: "comments",
label: "Comments",
config: {
placement: "editor",
},
},
];
const board = new Kanban("#kanban_root", {
cards,
columns,
rows,
rowKey: "row",
cardShape,
editorShape,
history: false,
currentUser: 1,
links,
});
new Toolbar("#kanban_toolbar", {
api: board.api,
items: [
"search",
"spacer",
"sort",
"addColumn",
"addRow"
],
});
board.api.setNext(restProvider);
const events = new RemoteEvents(url + "/v1", token);
const handlers = kanbanUpdates(
board.api,
restProvider.getIDResolver()
);
events.on(handlers);
});
}
function initTodo(token, serverURL) {
const url = serverURL + "/api/todo";
const {
ToDo,
Toolbar,
RestDataProvider,
todoUpdates,
RemoteEvents
} = todo;
const restProvider = new RestDataProvider(url);
restProvider.setHeaders({
"Remote-Token": token
});
const fetchProjectTasks = true;
let activeProject = 0;
Promise.all([
restProvider.getUsers(),
restProvider.getProjects(),
restProvider.getTags()
])
.then(([users, projects, tags]) => {
if (projects && projects.length > 0) {
activeProject = projects[0].id;
const fetchTasks = fetchProjectTasks
? () => restProvider.getProjectTasks(activeProject)
: () => restProvider.getTasks();
return fetchTasks().then((tasks) => {
return [tasks, users, projects, tags];
});
}
return Promise.resolve([[], users, projects, tags]);
})
.then(([tasks, users, projects, tags]) => {
const list = new ToDo("#todo_root", {
tasks,
users,
projects,
tags,
activeProject,
history: false,
taskShape: {
completed: {
behavior: "manual" // "auto" by default
}
}
});
new Toolbar("#todo_toolbar", {
api: list.api
});
// save data from client to server
list.api.setNext(restProvider);
restProvider.setAPI(list.api);
// get updates from server to client
const handlers = todoUpdates(
list.api,
restProvider.getIDResolver()
);
const events = new RemoteEvents(url + "/v1", token);
events.on(handlers);
// disable multiselect
list.api.intercept("select-task", ({ join }) => {
return !join;
});
});
}
function initGantt(token, serverURL) {
const apiURL = serverURL + "/api/gantt";
const wsURL = apiURL + "/v1";
Promise.all([
fetchData(apiURL + "/tasks"),
fetchData(apiURL + "/links")
]).then(([data, links]) => {
init(token, [data, links]);
});
function init(token, [tasks, links]) {
gantt.i18n.setLocale({
labels: {
time_enable_button: "Schedule",
time_disable_button: "Unschedule",
},
});
gantt.config.date_format = "%Y-%m-%d %H:%i:%s";
gantt.config.cascade_delete = false;
gantt.attachEvent("onBeforeRowDragEnd", (id, parent, tindex) => {
gantt.getTask(id).tindex = tindex;
return true;
});
gantt.init("gantt_root");
gantt.config.lightbox.sections = [
{
name: "description",
height: 38,
map_to: "text",
type: "textarea",
focus: true,
},
{ name: "type", type: "typeselect", map_to: "type" },
{
name: "time",
map_to: "auto",
button: true,
type: "duration_optional",
},
];
gantt.attachEvent("onLightboxSave", function (id, task, is_new) {
console.log("save", task);
task.unscheduled = !task.start_date;
return true;
});
const remoteData = {
data: prepareTasks(tasks),
links
};
gantt.parse(remoteData);
const dp = gantt.createDataProcessor({
url: apiURL
});
dp.setTransactionMode({
mode: "REST-JSON",
headers: {
"Remote-Token": token
}
});
const remoteEvents = new gantt.RemoteEvents(wsURL, token);
remoteEvents.on(ganttUpdates(gantt));
}
function ganttUpdates(gantt) {
function taskUpdates(obj) {
const task = prepareTask(obj.data);
switch (obj.type) {
case "add-task":
gantt.silent(function () {
checkUnscheduled(task);
gantt.addTask(task, task.parent);
});
break;
case "update-task":
gantt.silent(function () {
if (!checkUnscheduled(task)) {
task.unscheduled = false;
task.end_date = gantt.calculateEndDate(
task.start_date,
task.duration
);
}
gantt.updateTask(task.id, task);
});
break;
case "move-task":
gantt.silent(function () {
gantt.moveTask(task.id, task.index, task.parent);
});
break;
case "delete-task":
gantt.silent(function () {
if (obj.ids?.length) {
for (let i = 0; i < obj.ids.length; i++) {
gantt.deleteTask(obj.ids[i]);
}
} else {
if (gantt.isTaskExists(task.id))
gantt.deleteTask(task.id);
}
});
break;
}
if (activeView === "gantt") gantt.render();
}
function linkUpdates(obj) {
switch (obj.type) {
case "add-link":
gantt.silent(function () {
gantt.addLink(obj.data);
});
break;
case "delete-link":
gantt.silent(function () {
gantt.deleteLink(obj.data.id);
});
break;
}
if (activeView === "gantt") gantt.render();
}
return {
tasks: taskUpdates,
links: linkUpdates
};
}
function fetchData(url) {
return fetch(url)
.then((data) => {
return data.json();
})
.catch((err) => {
throw new Error(err);
});
}
function prepareTasks(tasks) {
if (!tasks || !tasks.length) return [];
for (let i = 0; i < tasks.length; i++) {
prepareTask(tasks[i]);
}
return tasks;
}
function prepareTask(task) {
if (!task) return null;
if (!checkUnscheduled(task)) {
task.start_date = new Date(task.start_date);
}
task.open = true;
return task;
}
function checkUnscheduled(task) {
if (!task.start_date) {
task.unscheduled = true;
task.start_date = null;
task.end_date = null;
task.duration = null;
return true;
}
return false;
}
}
function initScheduler(token, serverURL) {
const url = serverURL + "/api/scheduler";
function request(method, endpoint, body) {
return fetch(endpoint, {
method,
headers: {
"Remote-Token": token,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
}).then(async (res) => {
if (!res.ok) return Promise.reject(await res.text());
return res.json();
});
}
function isScheduled(event) {
return !!(event.start_date && event.end_date);
}
function isFullDay(event) {
if (!isScheduled(event)) return false;
return scheduler.date.time_part(new Date(event.start_date)) === 0 &&
scheduler.date.time_part(new Date(event.end_date)) === 0;
}
function parseEvent(event) {
event.start_date = event.start_date ? new Date(event.start_date) : null;
event.end_date = event.end_date ? new Date(event.end_date) : null;
return event;
}
function serializeEvent(event) {
return {
type: Number(event.type) || 0,
allDay: isFullDay(event),
text: event.text || "",
details: event.details || "",
start_date: event.start_date ? new Date(event.start_date).toISOString() : null,
end_date: event.end_date ? new Date(event.end_date).toISOString() : null,
};
}
let calendars = [];
let backlogEvents = [];
const activeCalendars = new Set();
const backlogDrops = new Set();
function getCalendar(id) {
return calendars.find((c) => Number(c.id) === Number(id));
}
scheduler.plugins({
minical: true,
year_view: true,
agenda_view: true,
quick_info: true,
});
scheduler.config.date_format = "%Y-%m-%dT%H:%i:%s";
scheduler.config.details_on_create = true;
scheduler.config.details_on_dblclick = true;
scheduler.config.mark_now = true;
scheduler.config.full_day = true;
scheduler.xy.nav_height = 0;
scheduler.locale.labels.section_description = "Event";
scheduler.locale.labels.section_details = "Description";
scheduler.locale.labels.section_calendar = "Calendar";
scheduler.locale.labels.full_day = "All day";
["day", "week", "month", "year", "agenda"].forEach((view) => {
scheduler["filter_" + view] = (id, event) => activeCalendars.has(Number(event.type));
});
scheduler.templates.event_class = (start, end, event) => "pm_cal_" + event.type;
function applyCalendarColors() {
let css = "";
calendars.forEach((calendar) => {
const { background, border } = calendar.color;
const cls = ".pm_cal_" + calendar.id;
css += `
.dhx_cal_event${cls}, .dhx_cal_event_line${cls} {
--dhx-scheduler-event-background: ${background};
--dhx-scheduler-event-border: 1px solid ${border};
--dhx-scheduler-event-color: #fff;
}
.dhx_cal_event_clear${cls} {
color: ${border};
}
`;
});
document.getElementById("pm_calendar_colors").textContent = css;
}
function renderCalendarList() {
const list = document.getElementById("pm_calendar_list");
list.innerHTML = "";
calendars.forEach((calendar) => {
const item = document.createElement("li");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = activeCalendars.has(Number(calendar.id));
checkbox.style.accentColor = calendar.color.background;
checkbox.addEventListener("change", () => {
if (checkbox.checked) {
activeCalendars.add(Number(calendar.id));
} else {
activeCalendars.delete(Number(calendar.id));
}
scheduler.updateView();
renderBacklog();
});
const label = document.createElement("span");
label.textContent = calendar.label;
item.append(checkbox, label);
item.addEventListener("click", (e) => {
if (e.target !== checkbox) checkbox.click();
});
list.appendChild(item);
});
}
function renderBacklog() {
const list = document.getElementById("pm_backlog_list");
list.innerHTML = "";
const visible = backlogEvents.filter((event) => activeCalendars.has(Number(event.type)));
if (!visible.length) {
const empty = document.createElement("div");
empty.className = "pm_backlog_empty";
empty.textContent = "No unscheduled events";
list.appendChild(empty);
return;
}
visible.forEach((event) => {
const item = document.createElement("li");
item.textContent = event.text || "Untitled";
item.title = event.text || "";
item.draggable = true;
item.dataset.id = event.id;
const calendar = getCalendar(event.type);
if (calendar) item.style.setProperty("--pm-backlog-color", calendar.color.background);
item.addEventListener("dragstart", (e) => {
item.classList.add("pm_dragging");
e.dataTransfer.setData("text/plain", String(event.id));
e.dataTransfer.effectAllowed = "move";
});
item.addEventListener("dragend", () => {
item.classList.remove("pm_dragging");
clearDropHighlight();
});
list.appendChild(item);
});
}
let dropMark = null;
function clearDropHighlight() {
if (dropMark) {
scheduler.unmarkTimespan(dropMark);
dropMark = null;
}
}
function dropTimes(e) {
const action = scheduler.getActionData(e);
if (!action || !action.date) return null;
const start_date = action.date;
const mode = scheduler.getState().mode;
const duration = (mode === "month" || mode === "year") ? 24 * 60 : 60;
const end_date = scheduler.date.add(start_date, duration, "minute");
return { start_date, end_date };
}
function initBacklogDnd() {
const dropZone = document.getElementById("pm_scheduler");
dropZone.addEventListener("dragover", (e) => {
const times = dropTimes(e);
if (!times) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
clearDropHighlight();
const mode = scheduler.getState().mode;
if (mode === "day" || mode === "week") {
dropMark = scheduler.markTimespan({
start_date: times.start_date,
end_date: times.end_date,
css: "pm_drop_highlight",
html: scheduler.templates.event_date(times.start_date),
});
}
});
dropZone.addEventListener("dragleave", (e) => {
if (!dropZone.contains(e.relatedTarget)) clearDropHighlight();
});
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
clearDropHighlight();
const id = e.dataTransfer.getData("text/plain");
const index = backlogEvents.findIndex((event) => String(event.id) === id);
const times = dropTimes(e);
if (index < 0 || !times) return;
const event = backlogEvents[index];
backlogDrops.add(String(event.id));
scheduler.addEvent({
...event,
start_date: times.start_date,
end_date: times.end_date,
});
backlogEvents.splice(index, 1);
renderBacklog();
});
}
function updateLightboxCalendars() {
const section = scheduler.config.lightbox.sections.find((s) => s.name === "calendar");
if (section) {
section.options = calendars.map((calendar) => ({
key: Number(calendar.id),
label: calendar.label,
}));
}
scheduler.resetLightbox();
}
function updateToolbarTitle() {
const state = scheduler.getState();
const template = scheduler.templates[state.mode + "_date"] || scheduler.templates.month_date;
document.getElementById("pm_cal_title").innerHTML =
template(state.min_date, state.max_date, state.date);
}
function initToolbar() {
const viewSelect = document.getElementById("pm_view_select");
document.getElementById("pm_create_event").addEventListener("click", () => {
// same as Event Calendar: the new event goes to the currently
// displayed date, at the current time of day
const now = new Date();
const start = new Date(scheduler.getState().date);
start.setHours(now.getHours(), now.getMinutes(), 0, 0);
scheduler.addEventNow(start, scheduler.date.add(start, 1, "hour"));
});
document.getElementById("pm_toggle_sidebar").addEventListener("click", () => {
document.getElementById("pm_sidebar").classList.toggle("pm_collapsed");
scheduler.updateView();
});
[
["pm_calendars_toggle", "pm_calendar_list"],
["pm_backlog_toggle", "pm_backlog_list"],
].forEach(([toggleId, listId]) => {
document.getElementById(toggleId).addEventListener("click", (e) => {
const list = document.getElementById(listId);
const collapsed = list.style.display === "none";
list.style.display = collapsed ? "" : "none";
e.target.classList.toggle("mdi-chevron-down", collapsed);
e.target.classList.toggle("mdi-chevron-right", !collapsed);
});
});
document.getElementById("pm_cal_title").addEventListener("click", () => {
if (scheduler.isCalendarVisible()) {
scheduler.destroyCalendar(null, true);
} else {
scheduler.renderCalendar({
position: document.getElementById("pm_cal_title"),
date: scheduler.getState().date,
navigation: true,
handler: (date) => {
scheduler.setCurrentView(date);
scheduler.destroyCalendar(null, true);
},
});
}
});
document.getElementById("pm_nav_today").addEventListener("click", () => {
scheduler.setCurrentView(new Date());
});
document.getElementById("pm_nav_prev").addEventListener("click", () => navigate(-1));
document.getElementById("pm_nav_next").addEventListener("click", () => navigate(1));
function navigate(direction) {
const state = scheduler.getState();
const unit = state.mode === "agenda" ? "month" : state.mode;
scheduler.setCurrentView(scheduler.date.add(state.date, direction, unit));
}
viewSelect.addEventListener("change", () => {
scheduler.setCurrentView(null, viewSelect.value);
});
scheduler.attachEvent("onViewChange", (mode) => {
viewSelect.value = mode;
updateToolbarTitle();
});
}
let minical = null;
let minicalSyncing = false;
function initMiniCalendar() {
minical = scheduler.renderCalendar({
container: "pm_minical",
navigation: true,
handler: (date) => {
scheduler.setCurrentView(date);
},
events: {
onMonthChange: (oldDate, newDate) => {
if (minicalSyncing || !newDate) return;
if (oldDate && oldDate.getMonth() === newDate.getMonth() &&
oldDate.getFullYear() === newDate.getFullYear()) return;
scheduler.setCurrentView(newDate);
},
},
});
scheduler.attachEvent("onViewChange", () => {
if (minical) {
minicalSyncing = true;
scheduler.updateCalendar(minical, scheduler.getState().date);
minicalSyncing = false;
}
});
}
Promise.all([
request("GET", url + "/events"),
request("GET", url + "/calendars"),
]).then(([events, calendarsData]) => {
calendars = calendarsData;
calendars.forEach((calendar) => {
if (calendar.active) activeCalendars.add(Number(calendar.id));
});
const colorStyles = document.createElement("style");
colorStyles.id = "pm_calendar_colors";
document.head.appendChild(colorStyles);
applyCalendarColors();
renderCalendarList();
scheduler.config.lightbox.sections = [
{ name: "description", map_to: "text", type: "textarea", height: 38, focus: true },
{ name: "details", map_to: "details", type: "textarea", height: 42 },
{
name: "calendar",
map_to: "type",
type: "select",
options: calendars.map((calendar) => ({
key: Number(calendar.id),
label: calendar.label,
})),
},
{ name: "time", map_to: "auto", type: "time", height: 72 },
];
scheduler.attachEvent("onEventCreated", (id) => {
const event = scheduler.getEvent(id);
if (!event.type) event.type = calendars.length ? Number(calendars[0].id) : 0;
});
initToolbar();
scheduler.init("pm_scheduler", new Date(), "week");
initMiniCalendar();
initBacklogDnd();
updateToolbarTitle();
events.map(parseEvent);
scheduler.parse(events.filter(isScheduled));
backlogEvents = events.filter((event) => !isScheduled(event));
renderBacklog();
// save data from client to server
scheduler.createDataProcessor((entity, action, data, id) => {
switch (action) {
case "create":
if (backlogDrops.has(String(id))) {
backlogDrops.delete(String(id));
return request("PUT", url + "/events/" + id, serializeEvent(data))
.then(() => ({ action: "updated" }));
}
return request("POST", url + "/events", serializeEvent(data))
.then((res) => ({ tid: res.id }));
case "update":
return request("PUT", url + "/events/" + id, serializeEvent(data))
.then(() => ({ action: "updated" }));
case "delete":
return request("DELETE", url + "/events/" + id)
.then(() => ({ action: "deleted" }));
}
});
// get updates from server to client
const { RemoteEvents, remoteUpdates } = scheduler.ext.liveUpdates;
const remoteEvents = new RemoteEvents(url + "/v1", token);
remoteEvents.on({
events: (obj) => {
if (!obj || !obj.event) return;
const event = parseEvent(obj.event);
let type = obj.type;
const backlogIndex = backlogEvents.findIndex((e) => e.id == event.id);
if (type === "delete-event") {
if (backlogIndex >= 0) {
backlogEvents.splice(backlogIndex, 1);
renderBacklog();
}
if (scheduler.getEvent(event.id)) remoteUpdates.events({ type, event });
return;
}
if (!isScheduled(event)) {
if (scheduler.getEvent(event.id)) {
remoteUpdates.events({ type: "delete-event", event });
}
if (backlogIndex >= 0) backlogEvents[backlogIndex] = event;
else backlogEvents.push(event);
renderBacklog();
return;
}
if (backlogIndex >= 0) {
backlogEvents.splice(backlogIndex, 1);
renderBacklog();
}
if (type === "update-event" && !scheduler.getEvent(event.id)) {
type = "add-event";
}
remoteUpdates.events({ type, event });
},
calendars: (obj) => {
if (!obj || !obj.calendar) return;
const calendar = obj.calendar;
const index = calendars.findIndex((c) => c.id == calendar.id);
switch (obj.type) {
case "add-calendar":
if (index < 0) {
calendars.push(calendar);
activeCalendars.add(Number(calendar.id));
}
break;
case "update-calendar":
if (index >= 0) calendars[index] = calendar;
break;
case "delete-calendar":
if (index >= 0) calendars.splice(index, 1);
activeCalendars.delete(Number(calendar.id));
break;
}
applyCalendarColors();
renderCalendarList();
renderBacklog();
updateLightboxCalendars();
if (activeView === "calendar") scheduler.updateView();
},
});
});
}
function setActiveView(id) {
const views = document.getElementById("views").children;
for (let i = 0; i < views.length; i++) {
views[i].style.display = views[i].id === id ? "block" : "none";
}
activeView = id;
if (activeView === "gantt" && gantt.$container) gantt.render();
if (activeView === "calendar" && scheduler.$container) scheduler.updateView();
}
<!-- icons -->
<link rel="stylesheet" href="//cdn.materialdesignicons.com/5.4.55/css/materialdesignicons.min.css" />
<!-- component styles -->
<style>
#views, #kanban, #todo, #calendar, #gantt, #gantt_root {
height: 100%;
width: 100%;
background: var(--dhx-background-secondary);
}
#kanban_root, #todo_root {
height: calc(100% - 56px);
overflow: auto;
}
#todo {
width: 700px;
margin: 0 auto;
}
#kanban .wx-material-theme {
--wx-field-width: 100%;
}
#calendar_root {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: #fff;
}
.pm_toolbar {
height: 56px;
min-height: 56px;
display: flex;
align-items: center;
padding: 0 12px;
background: #fff;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
box-sizing: border-box;
font-family: Roboto, Arial, Helvetica, sans-serif;
}
.pm_toolbar_left, .pm_toolbar_right {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.pm_toolbar_right {
justify-content: flex-end;
}
.pm_toolbar_center {
display: flex;
align-items: center;
gap: 4px;
}
.pm_icon_btn {
width: 36px;
height: 36px;
border: none;
background: transparent;
border-radius: 50%;
font-size: 20px;
color: rgba(0, 0, 0, 0.6);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.pm_icon_btn:hover {
background: rgba(0, 0, 0, 0.06);
}
.pm_btn_primary {
height: 36px;
padding: 0 16px 0 12px;
border: none;
border-radius: 3px;
background: #3E7FD8;
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
}
.pm_btn_primary:hover {
background: #3671C4;
}
.pm_btn_secondary {
height: 36px;
padding: 0 16px;
border: none;
border-radius: 3px;
background: #F0F1F3;
color: rgba(0, 0, 0, 0.8);
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.pm_btn_secondary:hover {
background: #E4E6E9;
}
#pm_scheduler .dhx_cal_event .dhx_event_resize.dhx_footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
margin: 0;
}
.pm_cal_title {
font-size: 16px;
font-weight: 500;
color: rgba(0, 0, 0, 0.8);
min-width: 180px;
text-align: center;
white-space: nowrap;
cursor: pointer;
}
.pm_cal_title:hover {
color: #3E7FD8;
}
.pm_view_select {
height: 36px;
padding: 0 8px;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 3px;
background: #fff;
font-size: 14px;
color: rgba(0, 0, 0, 0.8);
cursor: pointer;
outline: none;
}
.pm_body {
flex: 1;
display: flex;
min-height: 0;
}
.pm_sidebar {
width: 248px;
min-width: 248px;
border-right: 1px solid rgba(0, 0, 0, 0.12);
background: #fff;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.pm_sidebar.pm_collapsed {
display: none;
}
.pm_minical {
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
flex-shrink: 0;
}
.pm_minical .dhx_mini_calendar {
box-shadow: none;
border: none;
border-radius: 0;
margin: 0;
width: 100%;
}
.pm_calendars {
flex-shrink: 0;
}
.pm_minical .dhx_cal_qi_big_icon, .pm_minical .dhx_minical_popup {
position: static;
}
.pm_calendars_header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px 8px;
font-size: 14px;
font-weight: 500;
color: #23272A;
font-family: Inter, Helvetica, Arial, sans-serif;
}
.pm_calendars_header .mdi {
color: rgba(0, 0, 0, 0.5);
cursor: pointer;
font-size: 18px;
}
.pm_calendar_list {
list-style: none;
margin: 0;
padding: 0;
font-family: Roboto, Arial, Helvetica, sans-serif;
}
.pm_calendar_list li {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
font-size: 14px;
color: rgba(0, 0, 0, 0.8);
cursor: pointer;
}
.pm_calendar_list li + li {
border-top: 1px solid rgba(0, 0, 0, 0.08);
}
.pm_calendar_list li:hover {
background: rgba(0, 0, 0, 0.03);
}
.pm_calendar_list input[type="checkbox"] {
width: 16px;
height: 16px;
margin: 0;
cursor: pointer;
}
.pm_scheduler {
flex: 1;
position: relative;
min-width: 0;
}
.pm_backlog {
border-top: 1px solid rgba(0, 0, 0, 0.12);
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.pm_backlog .pm_calendars_header {
flex-shrink: 0;
}
.pm_backlog_list {
list-style: none;
margin: 0;
padding: 0;
font-family: Roboto, Arial, Helvetica, sans-serif;
flex: 1;
min-height: 48px;
overflow-y: auto;
}
.pm_backlog_list li {
position: relative;
padding: 9px 16px 9px 20px;
border-top: 1px solid rgba(0, 0, 0, 0.08);
font-size: 13px;
color: rgba(0, 0, 0, 0.8);
cursor: grab;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pm_backlog_list li::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--pm-backlog-color, #999);
}
.pm_backlog_list li:active {
cursor: grabbing;
}
.pm_backlog_list li.pm_dragging {
opacity: 0.5;
}
.pm_backlog_empty {
padding: 4px 16px 12px;
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
font-family: Roboto, Arial, Helvetica, sans-serif;
}
.pm_drop_highlight {
background: rgba(62, 127, 216, 0.25) !important;
border: 1px dashed #3E7FD8;
box-sizing: border-box;
color: rgba(0, 0, 0, 0.7);
font-size: 11px;
padding: 2px 4px;
}
.toolbar_logo-container {
height: 55px;
width: 300px;
}
.toolbar_logo-container span {
margin-left: 20px;
}
.toolbar_logo-container a {
height: 100%;
width: 100%;
padding-left: 20px;
display: flex;
justify-content: flex-start;
align-items: center;
text-decoration: none;
color: var(--dhx-color-secondary);
font-weight: var(--dhx-font-weight-medium);
}
</style>
<script>
const toolbarData = [
{
type: "customHTML",
html: `
<a href="https://dhtmlx.com/docs/products/javascript-project-management-library/" target="_blank">
<img src="https://dhtmlx.com/docs/products/demoApps/dhtmlxKPI/static/dhx.png" alt="logo" /><span>Project Management Demo</span>
</a>`,
css: "toolbar_logo-container",
},
{
type: "spacer"
},
{
id: "earth",
icon: "mdi mdi-earth",
type: "button",
view: "link",
color: "secondary",
circle: true,
},
{
id: "settings",
icon: "mdi mdi-cog",
type: "button",
view: "link",
color: "secondary",
circle: true,
},
{
id: "info",
icon: "mdi mdi-information-outline",
type: "button",
view: "link",
color: "secondary",
circle: true,
},
{
id: "avatar",
type: "imageButton",
src: "https://snippet.dhtmlx.com/codebase/data/common/img/02/avatar_61.jpg"
}
];
</script>The code uses DHTMLX Suite's Layout and Tabbar for the UI shell. Each widget initializes independently with its own RestDataProvider pointing to different API endpoints under the same server. All four widgets use RemoteEvents and their respective update handlers (kanbanUpdates, todoUpdates, ganttUpdates, custom calendar handlers) to receive real-time updates from other users. Authentication tokens are shared across all providers. The Go backend repository is available as a reference implementation.
Solution overview
- Authenticate and obtain a token
- Initialize Layout with Toolbar and Tabbar for navigation
- Initialize each widget (Kanban, Todo, Gantt, Calendar) with its own
RestDataProvider - Connect each widget to the backend with
setNextandRemoteEvents - Use a tabbed interface to switch between views
Key points
- Shared backend, separate APIs: Each widget has its own REST endpoint (
/api/kanban,/api/todo,/api/gantt,/api/scheduler). They share a database but have independent API surfaces - Real-time sync across widgets: Changes in one widget appear in others via WebSocket. No polling or manual refresh needed
- Gantt requires manual render on tab switch: Gantt does not redraw itself automatically when hidden. Call
gantt.render()when the Gantt tab becomes active - Testing multiuser sync: To see real-time updates across tabs, open the demo with different user IDs (
?id=1in one tab,?id=2in another). Two tabs with the same user ID won't show each other's changes because the server skips broadcasting back to the originating user - Reference backend in Go: The full backend implementation is available at
github.com/DHTMLX/project-management-go
Related examples
- Kanban + Todo (client-side)
- Real-time multiuser backend
- Kanban + ChatBot (AI assistant)
- Salesforce LWC: Kanban, Gantt, Scheduler