Angular/Advanced form
Loading live demo…from dhtmlx.github.io
Angular
Advanced form
Combine a custom lightbox with auto scheduling for a richer task-editing workflow.
- advanced-form.ts
- advanced-form.html
- advanced-form.css
- shared/advanced-form.ts
- shared/advanced-form.html
- shared/advanced-form.css
src/app/samples/advanced-form/advanced-form.ts View on GitHub
import { Component, ViewEncapsulation } from '@angular/core';
import { createBasicDemoData } from '../../shared/demo-data';
import { CustomLightboxConfig, DhxGanttComponent } from '@dhtmlx/trial-angular-gantt';
import { AdvancedFormComponent } from '../../shared/advanced-form/advanced-form';
@Component({
selector: 'advanced-form',
standalone: true,
imports: [DhxGanttComponent],
templateUrl: './advanced-form.html',
styleUrl: './advanced-form.css',
encapsulation: ViewEncapsulation.None
})
export class AdvancedFormSampleComponent {
private readonly initial = createBasicDemoData();
tasks = this.initial.tasks;
links = this.initial.links;
ganttLightboxConfig: CustomLightboxConfig = {
component: AdvancedFormComponent,
onSave: (data) => console.log('Saved:', data),
onCancel: () => console.log('Cancelled'),
onDelete: (id) => console.log('Deleted:', id)
};
plugins = { auto_scheduling: true };
config = {
auto_scheduling: {
enabled: true,
apply_constraints: false,
gap_behavior: "compress" as const
},
columns: [
{ name: "text", label: "Task name", tree: true, width: "*" },
{ name: "start_date", label: "Start", align: "center" },
{ name: "duration", label: "Duration", align: "center" },
{ name: "add", width: 44 },
],
date_format: "%Y-%m-%d %H:%i",
};
}
src/app/samples/advanced-form/advanced-form.html View on GitHub
<div class="card">
<div class="gantt-host">
<dhx-gantt style="height:600px" [tasks]="tasks" [links]="links" [config]="config" [plugins]="plugins"
[customLightbox]="ganttLightboxConfig">
</dhx-gantt>
</div>
</div>
src/app/samples/advanced-form/advanced-form.css View on GitHub
@import "@dhtmlx/trial-angular-gantt/dist/angular-gantt.css";
.dhx-gantt-root {
height: 100%;
width: 100%;
}
src/app/shared/advanced-form/advanced-form.ts View on GitHub
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import type { GanttStatic, Task, Link, SerializedTask, SerializedLink } from '@dhtmlx/trial-angular-gantt';
interface PredecessorRow {
linkId: string | number | null;
sourceId: string | number | null;
sourceName: string;
type: string;
lag: number;
}
const LINK_TYPES = [
{ value: '0', label: 'Finish-to-Start (FS)' },
{ value: '1', label: 'Start-to-Start (SS)' },
{ value: '2', label: 'Finish-to-Finish (FF)' },
{ value: '3', label: 'Start-to-Finish (SF)' },
];
@Component({
selector: 'advanced-lightbox',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './advanced-form.html',
styleUrl: './advanced-form.css',
})
export class AdvancedFormComponent {
@Input() data!: { id: string | number; task: Task | SerializedTask };
@Input() gantt!: GanttStatic;
@Input() onSave!: (task: Task | SerializedTask) => void;
@Input() onCancel!: () => void;
@Input() onDelete!: () => void;
localTask: any = {};
activeTab: 'general' | 'predecessors' = 'general';
predecessors: PredecessorRow[] = [];
availableTasks: { id: string | number; text: string }[] = [];
linkTypes = LINK_TYPES;
ngOnInit() {
if (this.data?.task) {
this.localTask = { ...this.data.task };
}
if (this.gantt && !this.isNewTask) {
this.initPredecessors();
this.initAvailableTasks();
}
}
get isNewTask(): boolean {
return Boolean(this.localTask?.$new);
}
get startDateStr(): string {
const d = this.localTask.start_date;
if (d instanceof Date) {
return this.dateToString(d);
}
return typeof d === 'string' ? d.substring(0, 10) : '';
}
onStartDateChange(event: Event): void {
const val = (event.target as HTMLInputElement).value;
if (val) {
this.localTask.start_date = this.stringToDate(val);
}
}
onSaveClick() {
this.onSave(this.localTask);
if (this.gantt && !this.isNewTask) {
this.applyLinkChanges();
}
}
onCancelClick() {
this.onCancel();
}
onDeleteClick() {
this.onDelete();
}
addPredecessor(): void {
this.predecessors.push({
linkId: null,
sourceId: null,
sourceName: '',
type: '0',
lag: 0,
});
}
removePredecessor(index: number): void {
this.predecessors.splice(index, 1);
}
onPredecessorTaskChange(index: number, taskId: string | number): void {
const row = this.predecessors[index];
if (taskId) {
const task = this.availableTasks.find(t => t.id == taskId);
row.sourceId = taskId;
row.sourceName = task?.text || `Task ${taskId}`;
} else {
row.sourceId = null;
row.sourceName = '';
}
}
private initPredecessors(): void {
const allLinks: (Link | SerializedLink)[] = this.gantt
.getDatastore('link')
.getItems()
.filter((l) => String((l as Link).target) === String(this.data.id)) as Link[];
this.predecessors = allLinks.map((link: Link | SerializedLink) => {
const sourceTask: Task = this.gantt.getTask(link.source);
return {
linkId: link.id,
sourceId: link.source,
sourceName: sourceTask.text || `Task ${link.source}`,
type: String(link.type),
lag: link.lag || 0,
};
});
}
private initAvailableTasks(): void {
this.availableTasks = [];
const currentId = this.data.id;
const excludeIds = new Set<string | number>();
excludeIds.add(currentId);
if (this.gantt.hasChild(currentId)) {
this.gantt.eachTask((child: Task) => {
excludeIds.add(child.id);
}, currentId);
}
this.gantt.eachTask((task: Task) => {
if (!excludeIds.has(task.id)) {
this.availableTasks.push({ id: task.id, text: task.text || `Task ${task.id}` });
}
});
}
private applyLinkChanges(): void {
const taskId = this.data.id;
// Collect IDs of links still present in the edited list
const editedLinkIds = new Set<string | number>();
for (const row of this.predecessors) {
if (row.linkId != null) {
editedLinkIds.add(row.linkId);
}
}
// Find original links targeting this task
const originalLinks: Link[] = this.gantt
.getDatastore('link')
.getItems()
.filter((l) => String((l as Link).target) === String(taskId)) as Link[];
// Links removed from the editor
const toDelete: (string | number)[] = [];
for (const link of originalLinks) {
if (!editedLinkIds.has(link.id)) {
toDelete.push(link.id);
}
}
// Links to update (existing) or add (new)
const toUpdate: { id: string | number; source: string | number; type: string; lag: number }[] = [];
const toAdd: { source: string | number; target: string | number; type: string; lag: number }[] = [];
for (const row of this.predecessors) {
if (row.sourceId == null) {
continue;
}
if (row.linkId != null) {
// Existing link — update with current values
toUpdate.push({
id: row.linkId,
source: row.sourceId,
type: row.type,
lag: row.lag || 0,
});
} else {
// New row — add a new link (gantt assigns the id internally)
toAdd.push({
source: row.sourceId,
target: taskId,
type: row.type,
lag: row.lag || 0,
});
}
}
const hasChanges = toDelete.length || toAdd.length || toUpdate.length;
if (hasChanges) {
this.gantt.batchUpdate(() => {
for (const linkId of toDelete) {
this.gantt.deleteLink(linkId);
}
for (const upd of toUpdate) {
const link = this.gantt.getLink(upd.id);
link.source = upd.source;
link.type = upd.type;
link.lag = upd.lag;
this.gantt.updateLink(upd.id);
}
for (const link of toAdd) {
this.gantt.addLink(link);
}
this.gantt.autoSchedule();
});
}
}
private dateToString(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
private stringToDate(s: string): Date {
const [y, m, d] = s.split('-').map(Number);
return new Date(y, m - 1, d);
}
}
src/app/shared/advanced-form/advanced-form.html View on GitHub
<div class="lightbox-backdrop" (click)="onCancelClick()"></div>
<div class="lightbox-modal">
<div class="lightbox-header">
<h3>{{ isNewTask ? 'New Task' : 'Edit Task' }}</h3>
<button class="close-btn" (click)="onCancelClick()" title="Close">
×
</button>
</div>
<div class="tab-bar">
<button class="tab-btn" [class.active]="activeTab === 'general'"
(click)="activeTab = 'general'">General</button>
<button class="tab-btn" [class.active]="activeTab === 'predecessors'"
(click)="activeTab = 'predecessors'">Predecessors</button>
</div>
<div class="lightbox-body" *ngIf="activeTab === 'general'">
<div class="form-group">
<label>Text</label>
<input
[(ngModel)]="localTask.text"
class="form-input"
placeholder="Task name"
/>
</div>
<div class="form-group">
<label>Description</label>
<textarea
[(ngModel)]="localTask.description"
class="form-input form-textarea"
placeholder="Task description"
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Start Date</label>
<input
type="date"
class="form-input"
[value]="startDateStr"
(change)="onStartDateChange($event)"
/>
</div>
<div class="form-group">
<label>Duration (days)</label>
<input
type="number"
class="form-input"
[(ngModel)]="localTask.duration"
min="0"
/>
</div>
</div>
</div>
<div class="lightbox-body" *ngIf="activeTab === 'predecessors'">
<div *ngIf="isNewTask" class="placeholder-text">
Save the task first to add predecessors.
</div>
<div *ngIf="!isNewTask" class="predecessors-editor">
<table class="pred-table">
<thead>
<tr>
<th class="col-id">ID</th>
<th class="col-name">Task Name</th>
<th class="col-type">Type</th>
<th class="col-lag">Lag</th>
<th class="col-action"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of predecessors; let i = index">
<td class="col-id">{{ row.sourceId }}</td>
<td class="col-name">
<select
*ngIf="!row.linkId"
class="form-input pred-select"
[ngModel]="row.sourceId"
(ngModelChange)="onPredecessorTaskChange(i, $event)"
>
<option [ngValue]="null">-- Select task --</option>
<option *ngFor="let t of availableTasks" [ngValue]="t.id">
{{ t.text }}
</option>
</select>
<span *ngIf="row.linkId">{{ row.sourceName }}</span>
</td>
<td class="col-type">
<select class="form-input pred-select" [(ngModel)]="row.type">
<option *ngFor="let lt of linkTypes" [value]="lt.value">
{{ lt.label }}
</option>
</select>
</td>
<td class="col-lag">
<input
type="number"
class="form-input pred-input"
[(ngModel)]="row.lag"
min="0"
/>
</td>
<td class="col-action">
<button class="remove-btn" (click)="removePredecessor(i)" title="Remove">
×
</button>
</td>
</tr>
<tr *ngIf="predecessors.length === 0">
<td colspan="5" class="empty-row">
No predecessors. Click "Add" to create one.
</td>
</tr>
</tbody>
</table>
<button class="btn btn-add" (click)="addPredecessor()">
+ Add Predecessor
</button>
</div>
</div>
<div class="lightbox-footer">
<button class="btn btn-danger" (click)="onDeleteClick()" [disabled]="isNewTask">
Delete
</button>
<div class="footer-spacer"></div>
<button class="btn btn-secondary" (click)="onCancelClick()">
Cancel
</button>
<button class="btn btn-primary" (click)="onSaveClick()">
OK
</button>
</div>
</div>
src/app/shared/advanced-form/advanced-form.css View on GitHub
.lightbox-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 999;
}
.lightbox-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
width: 620px;
max-width: 95vw;
background: #fff;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.lightbox-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px 12px;
background: #f8f9fa;
}
.lightbox-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 22px;
cursor: pointer;
color: #666;
padding: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.close-btn:hover {
background: #e0e0e0;
color: #333;
}
.tab-bar {
display: flex;
gap: 0;
padding: 0 24px;
background: #f8f9fa;
border-bottom: 1px solid #ddd;
}
.tab-btn {
padding: 8px 16px;
border: 1px solid transparent;
border-bottom: none;
background: none;
font-size: 13px;
font-weight: 500;
color: #666;
cursor: pointer;
position: relative;
top: 1px;
border-radius: 4px 4px 0 0;
}
.tab-btn:hover {
color: #333;
background: #eee;
}
.tab-btn.active {
background: #fff;
color: #333;
border-color: #ddd;
border-bottom: 1px solid #fff;
}
.lightbox-body {
padding: 24px;
flex: 1;
overflow-y: auto;
min-height: 200px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 4px;
font-weight: 500;
color: #555;
font-size: 13px;
}
.form-input {
width: 100%;
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.12);
}
.form-textarea {
min-height: 70px;
resize: vertical;
font-family: inherit;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.placeholder-text {
color: #999;
text-align: center;
padding: 40px 0;
font-size: 14px;
font-style: italic;
}
.lightbox-footer {
padding: 12px 24px 16px;
border-top: 1px solid #e0e0e0;
background: #f8f9fa;
display: flex;
gap: 8px;
align-items: center;
}
.footer-spacer {
flex: 1;
}
.btn {
padding: 8px 20px;
border: none;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #007bff;
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: #0056b3;
}
.btn-secondary {
background: #6c757d;
color: #fff;
}
.btn-secondary:hover:not(:disabled) {
background: #545b62;
}
.btn-danger {
background: #dc3545;
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: #bd2130;
}
/* Predecessors table */
.predecessors-editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.pred-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.pred-table th {
text-align: left;
padding: 8px 6px;
border-bottom: 2px solid #ddd;
font-weight: 600;
color: #555;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.pred-table td {
padding: 6px;
border-bottom: 1px solid #eee;
vertical-align: middle;
}
.pred-table tr:hover td {
background: #f8f9fa;
}
.col-id {
width: 50px;
text-align: center;
}
.col-name {
width: auto;
}
.col-type {
width: 180px;
}
.col-lag {
width: 80px;
}
.col-action {
width: 36px;
text-align: center;
}
.pred-select {
padding: 4px 6px !important;
font-size: 13px !important;
}
.pred-input {
padding: 4px 6px !important;
font-size: 13px !important;
width: 100% !important;
}
.remove-btn {
background: none;
border: none;
color: #dc3545;
font-size: 18px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
line-height: 1;
}
.remove-btn:hover {
background: #fee;
color: #bd2130;
}
.btn-add {
align-self: flex-start;
background: none;
border: 1px dashed #aaa;
color: #555;
padding: 6px 14px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
}
.btn-add:hover {
border-color: #007bff;
color: #007bff;
background: #f0f7ff;
}
.empty-row {
text-align: center;
color: #999;
padding: 20px 6px !important;
font-style: italic;
}