Skip to content

Editing, Validation, Diagnostics

Try moving Visual Design into the April 14–18 freeze window: the before-change handler rejects the move. Inspect the validation result and protected-row indicators, then open the Task Editing guide for the custom editor.

Source code
TypeScriptts
import { defineCustomElements } from '@revolist/revogrid/loader';
defineCustomElements();

import { GanttPlugin } from '@revolist/gantt';
import { currentTheme } from '../composables/useRandomData';
import './gantt-example-recipes.css';
import {
  RECIPE_ASSIGNMENTS,
  RECIPE_CALENDARS,
  RECIPE_COLUMNS,
  RECIPE_DEPENDENCIES,
  RECIPE_GANTT_CONFIG,
  RECIPE_PANEL_ROWS,
  RECIPE_PANEL_SOURCE_REFERENCES,
  RECIPE_RESOURCES,
  RECIPE_ROW_CONTEXT_MENU,
  RECIPE_TASKS,
  attachRecipeValidation,
} from './GanttExampleRecipesShared';

const { isDark } = currentTheme();

function createPanel(): HTMLElement {
  const panel = document.createElement('aside');
  panel.className = 'gantt-recipe-panel';
  panel.innerHTML = `
    <div class="gantt-recipe-panel__header">
      <strong>Feature recipes</strong>
      <span>Live outputs from exported example helpers</span>
    </div>
    <dl class="gantt-recipe-panel__list">
      ${RECIPE_PANEL_ROWS.map((row) => `
        <div>
          <dt>${row.label}</dt>
          <dd>${row.value}</dd>
        </div>
      `).join('')}
    </dl>
    <div class="gantt-recipe-panel__sources">
      ${RECIPE_PANEL_SOURCE_REFERENCES.map((source) => `<code>${source}</code>`).join('')}
    </div>
  `;
  return panel;
}

export function load(parentSelector: string) {
  const parent = document.querySelector(parentSelector);
  if (!parent) {
    return () => {};
  }

  const root = document.createElement('section');
  root.className = 'gantt-recipe-demo';
  const grid = document.createElement('revo-grid');
  grid.className = 'gantt-recipe-grid';
  grid.theme = isDark() ? 'darkCompact' : 'compact';
  grid.hideAttribution = true;
  grid.plugins = [GanttPlugin];
  grid.columns = RECIPE_COLUMNS;
  grid.gantt = RECIPE_GANTT_CONFIG;
  grid.ganttDependencies = RECIPE_DEPENDENCIES;
  grid.ganttCalendars = RECIPE_CALENDARS;
  grid.ganttResources = RECIPE_RESOURCES;
  grid.ganttAssignments = RECIPE_ASSIGNMENTS;
  grid.rowContextMenu = RECIPE_ROW_CONTEXT_MENU;

  root.appendChild(grid);
  root.appendChild(createPanel());
  parent.appendChild(root);
  const detachValidation = attachRecipeValidation(grid);
  grid.source = RECIPE_TASKS;

  return () => {
    detachValidation();
    root.remove();
  };
}
Vuevue
<template>
  <section class="gantt-recipe-demo">
    <RevoGrid
      ref="gridRef"
      hide-attribution
      class="gantt-recipe-grid"
      :theme="isDark ? 'darkCompact' : 'compact'"
      :plugins="plugins"
      :source="tasks"
      :columns="columns"
      :gantt.prop="ganttConfig"
      :gantt-dependencies.prop="dependencies"
      :gantt-calendars.prop="calendars"
      :gantt-resources.prop="resources"
      :gantt-assignments.prop="assignments"
      :row-context-menu.prop="rowContextMenu"
    />
    <aside class="gantt-recipe-panel">
      <div class="gantt-recipe-panel__header">
        <strong>Feature recipes</strong>
        <span>Live outputs from exported example helpers</span>
      </div>
      <dl class="gantt-recipe-panel__list">
        <div v-for="row in panelRows" :key="row.label">
          <dt>{{ row.label }}</dt>
          <dd>{{ row.value }}</dd>
        </div>
      </dl>
      <div class="gantt-recipe-panel__sources">
        <code v-for="source in sourceReferences" :key="source">{{ source }}</code>
      </div>
    </aside>
  </section>
</template>

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import RevoGrid from '@revolist/vue3-datagrid';
import { GanttPlugin } from '@revolist/gantt';
import { currentThemeVue } from '../composables/useRandomData';
import {
  RECIPE_ASSIGNMENTS,
  RECIPE_CALENDARS,
  RECIPE_COLUMNS,
  RECIPE_DEPENDENCIES,
  RECIPE_GANTT_CONFIG,
  RECIPE_PANEL_ROWS,
  RECIPE_PANEL_SOURCE_REFERENCES,
  RECIPE_RESOURCES,
  RECIPE_ROW_CONTEXT_MENU,
  RECIPE_TASKS,
  attachRecipeValidation,
} from './GanttExampleRecipesShared';

const { isDark } = currentThemeVue();
const gridRef = ref<{ $el?: HTMLRevoGridElement } | null>(null);
const plugins = [GanttPlugin];
const tasks = ref(RECIPE_TASKS);
const columns = ref(RECIPE_COLUMNS);
const dependencies = ref(RECIPE_DEPENDENCIES);
const calendars = ref(RECIPE_CALENDARS);
const resources = ref(RECIPE_RESOURCES);
const assignments = ref(RECIPE_ASSIGNMENTS);
const ganttConfig = computed(() => RECIPE_GANTT_CONFIG);
const rowContextMenu = computed(() => RECIPE_ROW_CONTEXT_MENU);
const panelRows = RECIPE_PANEL_ROWS;
const sourceReferences = RECIPE_PANEL_SOURCE_REFERENCES;
let detachValidation: (() => void) | undefined;

onMounted(() => {
  const grid = gridRef.value?.$el;
  if (grid) {
    detachValidation = attachRecipeValidation(grid);
  }
});

onBeforeUnmount(() => {
  detachValidation?.();
});
</script>

<style src="./gantt-example-recipes.css"></style>
Reacttsx
import React, { useEffect, useMemo, useRef } from 'react';
import { RevoGrid } from '@revolist/react-datagrid';
import { GanttPlugin } from '@revolist/gantt';
import { currentTheme } from '../composables/useRandomData';
import {
  RECIPE_ASSIGNMENTS,
  RECIPE_CALENDARS,
  RECIPE_COLUMNS,
  RECIPE_DEPENDENCIES,
  RECIPE_GANTT_CONFIG,
  RECIPE_PANEL_ROWS,
  RECIPE_PANEL_SOURCE_REFERENCES,
  RECIPE_RESOURCES,
  RECIPE_ROW_CONTEXT_MENU,
  RECIPE_TASKS,
  attachRecipeValidation,
} from './GanttExampleRecipesShared';

const { isDark } = currentTheme();

export default function GanttExampleRecipes() {
  const gridRef = useRef<HTMLRevoGridElement | null>(null);
  const plugins = useMemo(() => [GanttPlugin], []);
  const columns = useMemo(() => RECIPE_COLUMNS, []);
  const gantt = useMemo(() => RECIPE_GANTT_CONFIG, []);
  const rowContextMenu = useMemo(() => RECIPE_ROW_CONTEXT_MENU, []);

  useEffect(() => {
    const grid = gridRef.current;
    return grid ? attachRecipeValidation(grid) : undefined;
  }, []);

  return (
    <section style={{
      display: 'grid',
      gridTemplateColumns: 'minmax(0, 1fr) 300px',
      gap: 16,
      minHeight: 540,
    }}>
      <RevoGrid
        ref={gridRef}
        style={{ minHeight: 540 }}
        theme={isDark() ? 'darkCompact' : 'compact'}
        hideAttribution
        plugins={plugins}
        source={RECIPE_TASKS}
        columns={columns}
        gantt={gantt}
        ganttDependencies={RECIPE_DEPENDENCIES}
        ganttCalendars={RECIPE_CALENDARS}
        ganttResources={RECIPE_RESOURCES}
        ganttAssignments={RECIPE_ASSIGNMENTS}
        rowContextMenu={rowContextMenu}
      />
      <aside style={{
        borderLeft: '1px solid #d8dee8',
        padding: '12px 0 12px 16px',
        font: '13px/1.4 system-ui, sans-serif',
      }}>
        <div style={{ display: 'grid', gap: 3, marginBottom: 14 }}>
          <strong>Feature recipes</strong>
          <span style={{ color: '#64748b' }}>Live outputs from exported example helpers</span>
        </div>
        <dl style={{ display: 'grid', gap: 10, margin: 0 }}>
          {RECIPE_PANEL_ROWS.map((row) => (
            <div key={row.label} style={{ display: 'grid', gap: 2 }}>
              <dt style={{ color: '#64748b' }}>{row.label}</dt>
              <dd style={{ margin: 0, fontWeight: 600 }}>{row.value}</dd>
            </div>
          ))}
        </dl>
        <div style={{ display: 'grid', gap: 5, marginTop: 18 }}>
          {RECIPE_PANEL_SOURCE_REFERENCES.map((source) => (
            <code key={source} style={{ whiteSpace: 'normal', fontSize: 11, color: '#475569' }}>{source}</code>
          ))}
        </div>
      </aside>
    </section>
  );
}
Angularts
import { Component, NO_ERRORS_SCHEMA, ViewChild, ViewEncapsulation, ElementRef, AfterViewInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RevoGrid } from '@revolist/angular-datagrid';
import { GanttPlugin } from '@revolist/gantt';
import { currentTheme } from '../composables/useRandomData';
import {
  RECIPE_ASSIGNMENTS,
  RECIPE_CALENDARS,
  RECIPE_COLUMNS,
  RECIPE_DEPENDENCIES,
  RECIPE_GANTT_CONFIG,
  RECIPE_PANEL_ROWS,
  RECIPE_PANEL_SOURCE_REFERENCES,
  RECIPE_RESOURCES,
  RECIPE_ROW_CONTEXT_MENU,
  RECIPE_TASKS,
  attachRecipeValidation,
} from './GanttExampleRecipesShared';

@Component({
  selector: 'gantt-example-recipes-grid',
  standalone: true,
  schemas: [NO_ERRORS_SCHEMA],
  imports: [CommonModule, RevoGrid],
  template: `
    <section class="gantt-recipe-demo">
      <revo-grid
        #grid
        class="gantt-recipe-grid"
        [theme]="theme"
        [hideAttribution]="true"
        [plugins]="plugins"
        [source]="tasks"
        [columns]="columns"
        [gantt]="ganttConfig"
        [ganttDependencies]="dependencies"
        [ganttCalendars]="calendars"
        [ganttResources]="resources"
        [ganttAssignments]="assignments"
        [rowContextMenu]="rowContextMenu"
      ></revo-grid>
      <aside class="gantt-recipe-panel">
        <div class="gantt-recipe-panel__header">
          <strong>Feature recipes</strong>
          <span>Live outputs from exported example helpers</span>
        </div>
        <dl class="gantt-recipe-panel__list">
          <div *ngFor="let row of panelRows">
            <dt>{{ row.label }}</dt>
            <dd>{{ row.value }}</dd>
          </div>
        </dl>
        <div class="gantt-recipe-panel__sources">
          <code *ngFor="let source of sourceReferences">{{ source }}</code>
        </div>
      </aside>
    </section>
  `,
  styleUrls: ['./gantt-example-recipes.css'],
  encapsulation: ViewEncapsulation.None,
})
export class GanttExampleRecipesGridComponent implements AfterViewInit, OnDestroy {
  @ViewChild('grid', { read: ElementRef }) grid?: ElementRef<HTMLRevoGridElement>;

  theme = currentTheme().isDark() ? 'darkCompact' : 'compact';
  plugins = [GanttPlugin];
  tasks = RECIPE_TASKS;
  columns = RECIPE_COLUMNS;
  ganttConfig = RECIPE_GANTT_CONFIG;
  dependencies = RECIPE_DEPENDENCIES;
  calendars = RECIPE_CALENDARS;
  resources = RECIPE_RESOURCES;
  assignments = RECIPE_ASSIGNMENTS;
  rowContextMenu = RECIPE_ROW_CONTEXT_MENU;
  panelRows = RECIPE_PANEL_ROWS;
  sourceReferences = RECIPE_PANEL_SOURCE_REFERENCES;
  private detachValidation?: () => void;

  ngAfterViewInit(): void {
    const grid = this.grid?.nativeElement;
    if (grid) {
      this.detachValidation = attachRecipeValidation(grid);
    }
  }

  ngOnDestroy(): void {
    this.detachValidation?.();
  }
}
Sharedts
import type { ContextMenuConfig } from '@revolist/revogrid-pro';
import type {
  GanttBeforeTaskChangeDetail,
  GanttPluginConfig,
  GanttTaskSourceRow,
  TaskEntity,
} from '@revolist/gantt';
import {
  createCustomTaskEditorFormRecipe,
  createCustomValidationRecipe,
  createGanttColumnPresetRecipe,
  createGanttTaskStatusIndicatorRecipe,
} from './recipes';
import { createGanttContextMenuExtensionItems } from '@revolist/gantt';
import {
  ASSIGNMENTS,
  CALENDAR_ID,
  DEPENDENCIES,
  GANTT_BASE_CONFIG,
  RESOURCES,
  STANDARD_CALENDAR,
  TASKS,
} from './shared/gantt-project-data';

export const RECIPE_GANTT_CONFIG = {
  ...GANTT_BASE_CONFIG,
  id: 'project-gantt-recipes',
  name: 'Gantt Feature Recipes',
  primaryCalendarId: CALENDAR_ID,
  updatedAt: '2026-04-06T00:00:00Z',
  statusDate: '2026-04-06',
  zoomPreset: 'week-month' as const,
  allowTaskCreate: true,
  visuals: {
    showTaskLabels: true,
    showTodayLine: true,
    shadeNonWorkingTime: true,
  },
} satisfies GanttPluginConfig;

const lockedTaskIds = ['t7'];
const taskById = new Map(TASKS.map((task) => [task.id, task]));
function toRecipeTask(task: GanttTaskSourceRow | undefined): TaskEntity | undefined {
  if (!task) {
    return undefined;
  }

  return {
    ...task,
    projectId: RECIPE_GANTT_CONFIG.id,
    parentId: task.parentId ?? null,
    wbsCode: '',
    type: task.type ?? 'task',
    workflowStatus: task.workflowStatus ?? 'not-started',
    duration: task.duration ?? 0,
    progressPercent: task.percentDone ?? 0,
    calendarId: String(task.calendarId ?? CALENDAR_ID),
    isCritical: false,
    tags: Array.isArray(task.tags) ? task.tags : [],
  } as TaskEntity;
}
const columnPreset = createGanttColumnPresetRecipe({ preset: 'core' });
const editorRecipe = createCustomTaskEditorFormRecipe({
  task: toRecipeTask(taskById.get('t4'))!,
  resources: RESOURCES,
  assignments: ASSIGNMENTS,
  fieldIds: ['name', 'startDate', 'endDate', 'progressPercent', 'resourceLabels'],
});
const statusIndicators = createGanttTaskStatusIndicatorRecipe({
  rows: TASKS.map((task) => ({ id: task.id })),
  lockedTaskIds,
});
const validationRecipe = createCustomValidationRecipe({
  context: {
    getTaskById: (taskId) => toRecipeTask(taskById.get(taskId)),
    roleContext: { roles: ['planner'] },
  },
  rolePolicy: {
    roles: {
      planner: ['task:move', 'task:resize', 'task:progress'],
    },
  },
  freezeWindows: [
    { startDate: '2026-04-14', endDate: '2026-04-18', label: 'design freeze' },
  ],
});

const validationDetail: GanttBeforeTaskChangeDetail = {
  action: 'move',
  taskId: 't4',
  changes: {
    startDate: '2026-04-15',
    endDate: '2026-04-18',
  },
  previousValues: {
    startDate: '2026-04-13',
    endDate: '2026-04-24',
  },
};
const validationDecision = validationRecipe.validate(validationDetail);

export const RECIPE_COLUMNS = columnPreset.columns;
export const RECIPE_TASKS = TASKS.map((task) =>
  lockedTaskIds.includes(task.id) ? { ...task, locked: true } : task);
export const RECIPE_DEPENDENCIES = DEPENDENCIES;
export const RECIPE_CALENDARS = [STANDARD_CALENDAR];
export const RECIPE_RESOURCES = RESOURCES;
export const RECIPE_ASSIGNMENTS = ASSIGNMENTS;
export const RECIPE_ROW_CONTEXT_MENU: ContextMenuConfig = {
  items: [
    ...createGanttContextMenuExtensionItems({
      duplicate: {
        icon: 'fa-solid fa-copy',
        copyName: (task) => `${task.name ?? 'Task'} copy`,
      },
      delete: false,
      assignResource: {
        icon: 'fa-solid fa-user-plus',
        name: 'Open assignment panel',
        onAssignResource: (context) => {
          context.revogrid.dispatchEvent(new CustomEvent('gantt-recipe-action', {
            bubbles: true,
            detail: { action: 'assign-resource', taskId: context.taskId },
          }));
        },
      },
      export: {
        icon: 'fa-solid fa-file-excel',
        name: 'Export task workbook',
        exportOptions: (context) => ({
          sheetName: context.task?.name ?? 'Gantt',
          workbookName: 'gantt-recipes.xlsx',
        }),
      },
    }),
  ],
};

export const RECIPE_PANEL_ROWS = [
  {
    label: 'Column preset',
    value: `${columnPreset.presetProps.length} core fields`,
  },
  {
    label: 'Editor form',
    value: `${editorRecipe.fields.length} fields for ${editorRecipe.initialValues.name}`,
  },
  {
    label: 'Status indicators',
    value: `${statusIndicators.filter((row) => row.status.indicators.length > 0).length} protected rows`,
  },
  {
    label: 'Validation result',
    value: validationDecision.ok ? 'Allowed' : validationDecision.message ?? 'Blocked',
  },
  {
    label: 'Row menu extensions',
    value: `${RECIPE_ROW_CONTEXT_MENU.items.length} custom actions`,
  },
] as const;

export const RECIPE_PANEL_SOURCE_REFERENCES = [
  'packages/gantt/src/gantt/features/context-menu/context-menu-extensions.ts',
  'examples/components/src/components/gantt/recipes/feature-helper-examples.ts',
  'examples/components/src/components/gantt/recipes/grid-projection-examples.ts',
  'examples/components/src/components/gantt/recipes/validation-recipes.ts',
] as const;

export function attachRecipeValidation(grid: HTMLRevoGridElement): () => void {
  const handler = validationRecipe.handler as unknown as EventListener;
  grid.addEventListener('gantt-before-task-change', handler);
  return () => grid.removeEventListener('gantt-before-task-change', handler);
}

These APIs are presentation-free. They make decisions and shape data, while your application owns forms, popovers, panels, toasts, and persistence.

The application recipes below live in the examples source browser. Copy their recipes/ files beside your code before using the relative imports; they are maintained examples rather than published Gantt exports.

import {
GANTT_BEFORE_ASSIGNMENT_CHANGE_EVENT,
GANTT_BEFORE_DEPENDENCY_CHANGE_EVENT,
GANTT_BEFORE_TASK_CHANGE_EVENT,
TASK_EDITOR_FIELD_SCHEMA,
buildDependencyValidationSummary,
buildResourceOverallocationSummary,
createGanttRoleEditabilityHelpers,
createTaskDetailPopoverModel,
createTaskEditorFormValues,
normalizeTaskEditorSubmit,
} from '@revolist/gantt';
import {
createApprovalGateValidator,
createForbiddenDateRangeValidator,
createGanttBeforeTaskChangeValidationHandler,
createLockedPhaseValidator,
createRoleGuardValidator,
} from './recipes/validation-recipes';

Before-change events are cancelable. Use role helpers to decide whether a task, dependency, or assignment mutation should proceed. Call event.preventDefault() to stop the packaged mutation before the Gantt store changes; timeline previews are cleared and the next render keeps the previous task, dependency, or assignment data.

const policy = {
roles: {
viewer: [],
planner: ['task:*', 'dependency:create', 'dependency:delete'],
staffing: ['assignment:edit'],
admin: ['*'],
},
fallback: 'deny',
} as const;
const editability = createGanttRoleEditabilityHelpers(policy);
const getRoleContext = () => ({ roles: currentUser.roles });
grid.addEventListener(GANTT_BEFORE_TASK_CHANGE_EVENT, (event) => {
if (!editability.canEditTask(event.detail, getRoleContext())) {
event.preventDefault();
}
});
grid.addEventListener(GANTT_BEFORE_DEPENDENCY_CHANGE_EVENT, (event) => {
if (!editability.canEditDependency(event.detail, getRoleContext())) {
event.preventDefault();
}
});
grid.addEventListener(GANTT_BEFORE_ASSIGNMENT_CHANGE_EVENT, (event) => {
if (!editability.canEditAssignment(event.detail, getRoleContext())) {
event.preventDefault();
}
});

Task before-change actions cover move, resize, create, edit, progress, split, indent, outdent, and delete. Dependency before-change actions cover create, delete, edit, and replace; replace is emitted by predecessor/successor inline field edits and includes previousDependencies plus proposed dependencies because one cell edit can add, update, and remove multiple links. Assignment edits emit proposed assignments and previousAssignments.

For task edits, event.detail.sourcePatch contains the flat source-row fields that the packaged Gantt store will write. Use it when persisting custom row fields or when your backend uses public source names such as duration, percentDone, and taskMode.

grid.addEventListener(GANTT_BEFORE_TASK_CHANGE_EVENT, (event) => {
const { sourceIndex, sourcePatch, previousSourceValues } = event.detail;
if (sourceIndex === undefined || !sourcePatch) {
return;
}
queueTaskRowPersistence({
rowIndex: sourceIndex,
patch: sourcePatch,
previousValues: previousSourceValues ?? {},
});
});

Use the field schema and initial value builder to render a framework form, then normalize submitted values into a TaskUpdate patch.

For a complete standalone UI example, see Task Editing.

const fields = TASK_EDITOR_FIELD_SCHEMA.filter((field) =>
['name', 'startDate', 'endDate', 'progressPercent', 'resourceLabels'].includes(field.id),
);
const values = createTaskEditorFormValues(task, resources, assignments);
renderTaskEditor({
fields,
values,
onSubmit(nextValues) {
const result = normalizeTaskEditorSubmit(task, nextValues);
if (!result.ok) {
renderErrors(result.errors);
return;
}
updateTask(task.id, result.patch);
},
});

createTaskDetailPopoverModel() returns sections, fields, warnings, and resource labels. Your app handles hover/focus, positioning, and rendering.

const model = createTaskDetailPopoverModel({
row,
task,
resources,
assignments,
});
renderPopover({
title: model.title,
subtitle: model.subtitle,
sections: model.sections,
resources: model.resourceAssignments.map((assignment) => assignment.displayValue),
});

Use dependency summaries to power badges, side panels, or task focus commands.

const summary = buildDependencyValidationSummary({
conflicts: schedulerResult.conflicts,
issues: schedulerResult.issues,
rows: projectedRows,
});
renderDiagnosticsPanel({
count: summary.totalCount,
groups: summary.groups,
});

Use resource summaries when staffing dashboards need to show who is overloaded and when.

const overloads = buildResourceOverallocationSummary({
rows: resourcePlanningRows,
loadSummaries: resourceLoadSummaries,
conflicts: schedulerResult.conflicts,
});
renderResourceHealth({
count: overloads.totalCount,
peakOverloadUnits: overloads.peakOverloadUnits,
groups: overloads.groups,
});

Compose validators and attach the generated handler to gantt-before-task-change.

const validators = [
createRoleGuardValidator({ policy }),
createForbiddenDateRangeValidator({
ranges: [{ startDate: '2026-05-20', endDate: '2026-05-27', label: 'release freeze' }],
}),
createLockedPhaseValidator({
lockedPhaseIds: ['closed'],
getPhaseId: (detail) => detail.taskId ? phaseByTaskId[detail.taskId] : null,
}),
createApprovalGateValidator({
actions: ['move', 'resize', 'progress'],
requiresApproval: (detail) => detail.changes.progressPercent === 100,
}),
];
grid.addEventListener(
GANTT_BEFORE_TASK_CHANGE_EVENT,
createGanttBeforeTaskChangeValidationHandler({
context: () => validationContext,
validators,
onReject: ({ decision }) => showValidationToast(decision.message ?? 'Change rejected'),
}),
);