Skip to content

Gantt Examples

Source code
TypeScript ts
// src/components/gantt/GanttShowcase.ts
import { defineCustomElements } from '@revolist/revogrid/loader';
defineCustomElements();

import {
  GanttPlugin,
  defineGanttToolbar,
} from '@revolist/revogrid-enterprise';
import { ExportExcelPlugin } from '@revolist/revogrid-pro';
import {
  STANDARD_CALENDAR,
  SHOWCASE_ASSIGNMENTS,
  SHOWCASE_BASELINES,
  SHOWCASE_COLUMNS_POLISHED,
  SHOWCASE_DEPENDENCIES,
  SHOWCASE_GANTT_CONFIG,
  SHOWCASE_RESOURCES,
  SHOWCASE_TASKS,
  SHOWCASE_TOOLBAR_COLUMNS,
  getShowcaseTaskBarColor,
  renderShowcaseTaskBarContent,
} from './gantt-project-data';
import { currentTheme } from '../composables/useRandomData';
import './gantt-showcase.css';

// ─── Entry point ──────────────────────────────────────────────────────────────

export function load(parentSelector: string): void {
  const parent = document.querySelector(parentSelector);
  if (!parent) return;
  const darkTheme = currentTheme().isDark();

  const container = document.createElement('div');
  container.className = `gantt-showcase-shell grow h-full ${darkTheme ? 'gantt-showcase-shell--dark' : 'gantt-showcase-shell--light'}`;
  parent.appendChild(container);

  const grid = document.createElement('revo-grid') as HTMLRevoGridElement;
  const toolbar = document.createElement('div');
  container.appendChild(toolbar);

  grid.theme          = darkTheme ? 'darkCompact' : 'compact';
  grid.readonly       = false;
  grid.range          = true;
  grid.resize         = true;
  grid.rowHeaders     = true;
  grid.hideAttribution = true;
  grid.autoSizeColumn = true;
  grid.classList.add('gantt-showcase-grid');
  grid.plugins        = [GanttPlugin, ExportExcelPlugin];
  grid.columns        = [...SHOWCASE_COLUMNS_POLISHED];
  grid.source         = [...SHOWCASE_TASKS];
  grid.ganttDependencies = [...SHOWCASE_DEPENDENCIES];
  grid.ganttCalendars    = [{ ...STANDARD_CALENDAR }];
  grid.ganttResources    = [...SHOWCASE_RESOURCES];
  grid.ganttAssignments  = [...SHOWCASE_ASSIGNMENTS];
  grid.ganttBaselines    = [...SHOWCASE_BASELINES];
  grid.gantt = {
    ...SHOWCASE_GANTT_CONFIG,
    visuals: {
      ...SHOWCASE_GANTT_CONFIG.visuals,
      taskBarColorHook:    getShowcaseTaskBarColor,
      taskBarContentHook:  renderShowcaseTaskBarContent,
    },
  } as typeof grid.gantt;

  container.appendChild(grid);
  defineGanttToolbar(toolbar, {
    grid,
    columns: SHOWCASE_TOOLBAR_COLUMNS,
  });
}
React tsx
// src/components/gantt/GanttShowcase.tsx
import React, { useEffect, useRef } from 'react';
import { RevoGrid } from '@revolist/react-datagrid';
import {
  GanttPlugin,
  defineGanttToolbar,
} from '@revolist/revogrid-enterprise';
import { ExportExcelPlugin } from '@revolist/revogrid-pro';
import {
  STANDARD_CALENDAR,
  SHOWCASE_ASSIGNMENTS,
  SHOWCASE_BASELINES,
  SHOWCASE_COLUMNS_POLISHED,
  SHOWCASE_DEPENDENCIES,
  SHOWCASE_GANTT_CONFIG,
  SHOWCASE_RESOURCES,
  SHOWCASE_TASKS,
  SHOWCASE_TOOLBAR_COLUMNS,
  getShowcaseTaskBarColor,
  renderShowcaseTaskBarContent,
} from './gantt-project-data';
import type { GanttPluginConfig } from '@revolist/revogrid-enterprise';
import { currentTheme } from '../composables/useRandomData';
import './gantt-showcase.css';

const plugins = [GanttPlugin, ExportExcelPlugin];
const source      = [...SHOWCASE_TASKS];
const dependencies = [...SHOWCASE_DEPENDENCIES];
const calendars    = [{ ...STANDARD_CALENDAR }];
const resources    = [...SHOWCASE_RESOURCES];
const assignments  = [...SHOWCASE_ASSIGNMENTS];
const baselines    = [...SHOWCASE_BASELINES];
const columns      = [...SHOWCASE_COLUMNS_POLISHED];
const ganttConfig: GanttPluginConfig = {
  ...SHOWCASE_GANTT_CONFIG,
  visuals: {
    ...SHOWCASE_GANTT_CONFIG.visuals,
    taskBarColorHook: getShowcaseTaskBarColor,
    taskBarContentHook: renderShowcaseTaskBarContent,
  },
} as GanttPluginConfig;

function GanttShowcase() {
  const { isDark } = currentTheme();
  const darkTheme = isDark();
  const gridRef = useRef<HTMLRevoGridElement>(null);
  const toolbarRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const toolbar = toolbarRef.current;
    const grid = gridRef.current;
    if (!toolbar || !grid) {
      return undefined;
    }

    defineGanttToolbar(toolbar, {
      grid,
      columns: SHOWCASE_TOOLBAR_COLUMNS,
    });

    return () => {
      toolbar.textContent = '';
    };
  }, []);

  return (
    <div className={`gantt-showcase-shell grow h-full ${darkTheme ? 'gantt-showcase-shell--dark' : 'gantt-showcase-shell--light'}`}>
      <div ref={toolbarRef} />
      <RevoGrid
        ref={gridRef}
        className="gantt-showcase-grid"
        theme={darkTheme ? 'darkCompact' : 'compact'}
        hideAttribution
        readonly={false}
        range
        resize
        rowHeaders
        plugins={plugins}
        source={source}
        columns={columns}
        gantt={ganttConfig}
        ganttDependencies={dependencies}
        ganttCalendars={calendars}
        ganttResources={resources}
        ganttAssignments={assignments}
        ganttBaselines={baselines}
      />
    </div>
  );
}

export default GanttShowcase;
Vue vue
<template>
  <div :class="shellClass">
    <div ref="toolbarRef"></div>
    <RevoGrid
      ref="gridRef"
      class="gantt-showcase-grid skip-style cell-border"
      hide-attribution
      :readonly="false"
      :range="true"
      :resize="true"
      :row-headers="true"
      :theme="gridTheme"
      :plugins="plugins"
      :source="source"
      :columns="columns"
      :gantt.prop="ganttConfig"
      :gantt-dependencies.prop="dependencies"
      :gantt-calendars.prop="calendars"
      :gantt-resources.prop="resources"
      :gantt-assignments.prop="assignments"
      :gantt-baselines.prop="baselines"
    />
  </div>
</template>

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import RevoGrid from '@revolist/vue3-datagrid';
import { ExportExcelPlugin } from '@revolist/revogrid-pro';
import {
  STANDARD_CALENDAR,
  SHOWCASE_ASSIGNMENTS,
  SHOWCASE_BASELINES,
  SHOWCASE_COLUMNS_POLISHED,
  SHOWCASE_DEPENDENCIES,
  SHOWCASE_GANTT_CONFIG,
  SHOWCASE_RESOURCES,
  SHOWCASE_TASKS,
  SHOWCASE_TOOLBAR_COLUMNS,
  getShowcaseTaskBarColor,
  renderShowcaseTaskBarContent,
} from './gantt-project-data';
import { currentThemeVue } from '../composables/useRandomData';
import './gantt-showcase.css';

// ── Static grid data ──────────────────────────────────────────────────────────
const plugins = ref<unknown[]>([]);
const source      = ref([...SHOWCASE_TASKS]);
const dependencies = ref([...SHOWCASE_DEPENDENCIES]);
const calendars    = ref([{ ...STANDARD_CALENDAR }]);
const resources    = ref([...SHOWCASE_RESOURCES]);
const assignments  = ref([...SHOWCASE_ASSIGNMENTS]);
const baselines    = ref([...SHOWCASE_BASELINES]);
const columns      = ref([...SHOWCASE_COLUMNS_POLISHED]);
const { isDark } = currentThemeVue();
const gridTheme = computed(() => (isDark.value ? 'darkCompact' : 'compact'));
const shellClass = computed(() => [
  'gantt-showcase',
  'gantt-showcase-shell',
  'grow',
  'h-full',
  isDark.value ? 'gantt-showcase-shell--dark' : 'gantt-showcase-shell--light',
]);

const ganttConfig = ref({
  ...SHOWCASE_GANTT_CONFIG,
  visuals: {
    ...SHOWCASE_GANTT_CONFIG.visuals,
    taskBarColorHook:    getShowcaseTaskBarColor,
    taskBarContentHook:  renderShowcaseTaskBarContent,
  },
});

// ── Refs ──────────────────────────────────────────────────────────────────────
const gridRef    = ref<InstanceType<typeof RevoGrid> | HTMLRevoGridElement | null>(null);
const toolbarRef = ref<HTMLElement | null>(null);
let toolbarFrame = 0;

function getGridEl(): HTMLRevoGridElement | null {
  const refValue = gridRef.value as (InstanceType<typeof RevoGrid> & { $el?: HTMLRevoGridElement }) | HTMLRevoGridElement | null;
  const grid = (refValue && '$el' in refValue ? refValue.$el : refValue) as HTMLRevoGridElement | null;
  return grid ?? toolbarRef.value?.parentElement?.querySelector<HTMLRevoGridElement>('revo-grid') ?? null;
}

onMounted(async () => {
  const {
    GanttPlugin,
    defineGanttToolbar,
  } = await import('@revolist/revogrid-enterprise');

  plugins.value = [GanttPlugin, ExportExcelPlugin];
  await nextTick();

  toolbarFrame = requestAnimationFrame(() => {
    const grid = getGridEl();
    if (!toolbarRef.value || !grid) {
      return;
    }

    defineGanttToolbar(toolbarRef.value, {
      grid,
      columns: SHOWCASE_TOOLBAR_COLUMNS,
    });
  });
});

onBeforeUnmount(() => {
  cancelAnimationFrame(toolbarFrame);
  if (toolbarRef.value) {
    toolbarRef.value.textContent = '';
  }
});
</script>

<style scoped>
.gantt-showcase :deep(revo-grid) {
  flex: 1;
  min-height: 0;
}
</style>
Angular ts
// src/components/gantt/GanttShowcaseAngular.ts
import {
  Component,
  NO_ERRORS_SCHEMA,
  ViewChild,
  ElementRef,
  ViewEncapsulation,
  AfterViewInit,
} from '@angular/core';
import { RevoGrid } from '@revolist/angular-datagrid';
import {
  GanttPlugin,
  defineGanttToolbar,
} from '@revolist/revogrid-enterprise';
import { ExportExcelPlugin } from '@revolist/revogrid-pro';
import type { GanttPluginConfig } from '@revolist/revogrid-enterprise';
import {
  STANDARD_CALENDAR,
  SHOWCASE_ASSIGNMENTS,
  SHOWCASE_BASELINES,
  SHOWCASE_COLUMNS_POLISHED,
  SHOWCASE_DEPENDENCIES,
  SHOWCASE_GANTT_CONFIG,
  SHOWCASE_RESOURCES,
  SHOWCASE_TASKS,
  SHOWCASE_TOOLBAR_COLUMNS,
  getShowcaseTaskBarColor,
  renderShowcaseTaskBarContent,
} from './gantt-project-data';
import { currentTheme } from '../composables/useRandomData';

const ganttConfig: GanttPluginConfig = {
  ...SHOWCASE_GANTT_CONFIG,
  visuals: {
    ...SHOWCASE_GANTT_CONFIG.visuals,
    taskBarColorHook:    getShowcaseTaskBarColor,
    taskBarContentHook:  renderShowcaseTaskBarContent,
  },
} as GanttPluginConfig;

@Component({
  selector: 'gantt-showcase-grid',
  standalone: true,
  host: {
    class: 'gantt-showcase-angular-host',
  },
  // Allows Angular demos to bind RevoGrid plugin props that are not wrapper inputs.
  schemas: [NO_ERRORS_SCHEMA],
  imports: [RevoGrid],
  encapsulation: ViewEncapsulation.None,
  styleUrls: ['./gantt-showcase.css'],
  template: `
    <div [class]="shellClass">
      <div #toolbar></div>
      <revo-grid
        #grid
        class="gantt-showcase-grid skip-style cell-border"
        [theme]="theme"
        [hideAttribution]="true"
        [readonly]="false"
        [range]="true"
        [resize]="true"
        [rowHeaders]="true"
        [plugins]="plugins"
        [source]="source"
        [columns]="columns"
        [gantt]="ganttConfig"
        [ganttDependencies]="dependencies"
        [ganttCalendars]="calendars"
        [ganttResources]="resources"
        [ganttAssignments]="assignments"
        [ganttBaselines]="baselines"
      ></revo-grid>
    </div>
  `,
})
export class GanttShowcaseGridComponent implements AfterViewInit {
  @ViewChild('grid',    { read: ElementRef }) gridRef!:    ElementRef<HTMLRevoGridElement>;
  @ViewChild('toolbar', { read: ElementRef }) toolbarRef!: ElementRef<HTMLElement>;

  readonly isDark       = currentTheme().isDark();
  readonly theme        = this.isDark ? 'darkCompact' : 'compact';
  readonly shellClass   = `gantt-showcase-shell grow h-full ${this.isDark ? 'gantt-showcase-shell--dark' : 'gantt-showcase-shell--light'}`;
  readonly plugins      = [GanttPlugin, ExportExcelPlugin];
  readonly ganttConfig  = ganttConfig;
  readonly source       = [...SHOWCASE_TASKS];
  readonly dependencies = [...SHOWCASE_DEPENDENCIES];
  readonly calendars    = [{ ...STANDARD_CALENDAR }];
  readonly resources    = [...SHOWCASE_RESOURCES];
  readonly assignments  = [...SHOWCASE_ASSIGNMENTS];
  readonly baselines    = [...SHOWCASE_BASELINES];
  readonly columns      = [...SHOWCASE_COLUMNS_POLISHED];

  ngAfterViewInit(): void {
    defineGanttToolbar(this.toolbarRef.nativeElement, {
      grid: this.gridRef.nativeElement,
      columns: SHOWCASE_TOOLBAR_COLUMNS,
    });
  }
}

Use demos for end-to-end behavior checks:

Example helper previews

Open the runnable feature-recipes demo for the rendered behavior, or expand any helper to inspect the packaged source inline.

Context menuGrid actions

Context menu extensions

Duplicate, delete, assign-resource, and export actions for Gantt row menus.

packages/enterprise/plugins/gantt/examples/context-menu-extensions.ts
Open preview
Source code
gantt/examples/context-menu-extensions.ts ts
/**
 * @packageDocumentation
 * Example API for extending the Gantt context menu through RevoGrid Pro.
 *
 * The helpers create task-aware context-menu items for duplicate, delete,
 * assign-resource, and export actions. They keep persistence and permission
 * decisions in the host application while providing a consistent Gantt context
 * object with the focused task, grid element, providers, selected range, and
 * close callback.
 */
import type { Cell, PluginProviders, RangeArea } from '@revolist/revogrid';
import {
  EXCEL_EXPORT_EVENT,
  type ContextMenuItem,
  type ExportExcelEvent,
} from '@revolist/revogrid-pro';

import type { TaskEntity, TaskId } from '../core';
import { GANTT_TASK_CREATE_EVENT } from '../grid/gantt-events';
import type { GanttCreateTaskOptions } from '../grid/gantt-plugin.types';

type ContextMenuActionContext = NonNullable<Parameters<NonNullable<ContextMenuItem['action']>>[4]>;

export interface GanttContextMenuExtensionContext {
  readonly event: MouseEvent;
  readonly focused?: Cell | null;
  readonly range?: RangeArea | null;
  readonly close?: () => void;
  readonly revogrid: HTMLRevoGridElement;
  readonly providers: PluginProviders;
  readonly taskId: TaskId | null;
  readonly task: Partial<TaskEntity> | null;
}

export interface GanttContextMenuBaseItemOptions {
  readonly name?: ContextMenuItem['name'];
  readonly class?: string;
  readonly rowClass?: ContextMenuItem['rowClass'];
  readonly icon?: string;
  readonly hidden?: ContextMenuItem['hidden'];
  readonly keepOpen?: boolean;
}

export interface DuplicateTaskMenuItemOptions extends GanttContextMenuBaseItemOptions {
  readonly copyName?: (task: Partial<TaskEntity>, context: GanttContextMenuExtensionContext) => string;
  readonly createTaskOptions?: (
    context: GanttContextMenuExtensionContext,
  ) => GanttCreateTaskOptions | null | undefined;
}

export interface DeleteTaskMenuItemOptions extends GanttContextMenuBaseItemOptions {
  readonly onDeleteTask: (context: GanttContextMenuExtensionContext) => void;
  readonly confirm?: (context: GanttContextMenuExtensionContext) => boolean;
}

export interface AssignResourceMenuItemOptions extends GanttContextMenuBaseItemOptions {
  readonly onAssignResource: (context: GanttContextMenuExtensionContext) => void;
}

export interface ExportMenuItemOptions extends GanttContextMenuBaseItemOptions {
  readonly exportOptions?: ExportExcelEvent | ((context: GanttContextMenuExtensionContext) => ExportExcelEvent | undefined);
}

export function createDuplicateTaskMenuItem(
  options: DuplicateTaskMenuItemOptions = {},
): ContextMenuItem {
  return createMenuItem(options, {
    name: options.name ?? 'Duplicate task',
    action: (context) => {
      const createOptions = options.createTaskOptions?.(context)
        ?? createDuplicateTaskOptions(context, options.copyName);

      if (!createOptions) {
        return;
      }

      dispatchGridEvent(context.revogrid, GANTT_TASK_CREATE_EVENT, createOptions);
    },
  });
}

export function createDeleteTaskMenuItem(
  options: DeleteTaskMenuItemOptions,
): ContextMenuItem {
  return createMenuItem(options, {
    name: options.name ?? 'Delete task',
    action: (context) => {
      if (!context.taskId) {
        return;
      }

      if (options.confirm && !options.confirm(context)) {
        return;
      }

      options.onDeleteTask(context);
    },
  });
}

export function createAssignResourceMenuItem(
  options: AssignResourceMenuItemOptions,
): ContextMenuItem {
  return createMenuItem(options, {
    name: options.name ?? 'Assign resource',
    action: (context) => {
      if (!context.taskId) {
        return;
      }

      options.onAssignResource(context);
    },
  });
}

export function createExportMenuItem(
  options: ExportMenuItemOptions = {},
): ContextMenuItem {
  return createMenuItem(options, {
    name: options.name ?? 'Export Gantt',
    action: (context) => {
      const detail = typeof options.exportOptions === 'function'
        ? options.exportOptions(context)
        : options.exportOptions;

      dispatchGridEvent(context.revogrid, EXCEL_EXPORT_EVENT, detail ?? { sheetName: 'Gantt' });
    },
  });
}

export function createGanttContextMenuExtensionItems(options: {
  readonly duplicate?: DuplicateTaskMenuItemOptions | false;
  readonly delete?: DeleteTaskMenuItemOptions | false;
  readonly assignResource?: AssignResourceMenuItemOptions | false;
  readonly export?: ExportMenuItemOptions | false;
}): ContextMenuItem[] {
  return [
    options.duplicate === false ? null : createDuplicateTaskMenuItem(options.duplicate),
    options.delete === false || !options.delete ? null : createDeleteTaskMenuItem(options.delete),
    options.assignResource === false || !options.assignResource
      ? null
      : createAssignResourceMenuItem(options.assignResource),
    options.export === false ? null : createExportMenuItem(options.export),
  ].filter((item): item is ContextMenuItem => item !== null);
}

function createMenuItem(
  options: GanttContextMenuBaseItemOptions,
  defaults: {
    readonly name: ContextMenuItem['name'];
    readonly action: (context: GanttContextMenuExtensionContext) => void;
  },
): ContextMenuItem {
  return {
    name: defaults.name,
    class: options.class,
    rowClass: options.rowClass,
    icon: options.icon,
    hidden: options.hidden,
    keepOpen: options.keepOpen,
    action: (event, focused, range, close, actionContext) => {
      const context = createExtensionContext(event, focused, range, close, actionContext);

      if (!context) {
        return;
      }

      defaults.action(context);
    },
  };
}

function createExtensionContext(
  event: MouseEvent,
  focused: Cell | null | undefined,
  range: RangeArea | null | undefined,
  close: (() => void) | undefined,
  actionContext: ContextMenuActionContext | undefined,
): GanttContextMenuExtensionContext | null {
  if (!actionContext) {
    return null;
  }

  const task = getFocusedTask(focused, actionContext.providers);

  return {
    event,
    focused,
    range,
    close,
    revogrid: actionContext.revogrid,
    providers: actionContext.providers,
    taskId: typeof task?.id === 'string' ? task.id : null,
    task,
  };
}

function getFocusedTask(
  focused: Cell | null | undefined,
  providers: PluginProviders,
): Partial<TaskEntity> | null {
  if (!focused) {
    return null;
  }

  const model = providers.data.getModel?.(focused.y, 'rgRow') as Partial<TaskEntity> | undefined;

  return typeof model?.id === 'string' ? model : null;
}

function createDuplicateTaskOptions(
  context: GanttContextMenuExtensionContext,
  copyName?: (task: Partial<TaskEntity>, context: GanttContextMenuExtensionContext) => string,
): GanttCreateTaskOptions | null {
  if (!context.taskId || !context.task) {
    return null;
  }

  return {
    parentId: context.task.parentId,
    insertAfterTaskId: context.taskId,
    startDate: context.task.startDate,
    endDate: context.task.endDate,
    name: copyName?.(context.task, context)
      ?? `${context.task.name ?? 'Task'} copy`,
  };
}

function dispatchGridEvent<T>(
  revogrid: HTMLRevoGridElement,
  eventName: string,
  detail: T,
): void {
  revogrid.dispatchEvent(new CustomEvent(eventName, {
    detail,
    bubbles: true,
  }));
}
EditingDiagnostics

Feature helper examples

Role editability, task editor fields, popover models, and diagnostics summaries.

packages/enterprise/plugins/gantt/examples/feature-helper-examples.ts
Open preview
Source code
gantt/examples/feature-helper-examples.ts ts
/**
 * @packageDocumentation
 * Grouped example API for Gantt editing, validation, diagnostics, and popovers.
 *
 * These recipes show how to combine the small quick-win helpers into
 * application-facing wiring: role-based before-change guards, custom task
 * editor forms, task detail popover models, dependency validation summaries,
 * resource over-allocation summaries, and composed task validation handlers.
 */
import {
  GANTT_BEFORE_ASSIGNMENT_CHANGE_EVENT,
  GANTT_BEFORE_DEPENDENCY_CHANGE_EVENT,
  GANTT_BEFORE_TASK_CHANGE_EVENT,
  type GanttBeforeAssignmentChangeDetail,
  type GanttBeforeDependencyChangeDetail,
  type GanttBeforeTaskChangeDetail,
} from '../grid/gantt-events';
import type { AssignmentEntity, ResourceEntity, TaskEntity, TaskId, TaskUpdate } from '../core';
import type { SchedulerConflict, SchedulerIssue } from '../engine';
import type { ResourcePlanningRowData, TaskGridRow } from '../projection';
import {
  createGanttRoleEditabilityHelpers,
  type GanttBeforeChangeDetailByEventName,
  type GanttBeforeChangeEventName,
  type GanttRoleEditabilityContext,
  type GanttRoleEditabilityPolicy,
} from '../features/permissions/role-editability';
import {
  TASK_EDITOR_FIELD_SCHEMA,
  createTaskEditorFormValues,
  normalizeTaskEditorSubmit,
  type NormalizeTaskEditorSubmitOptions,
  type TaskEditorFieldId,
  type TaskEditorFieldSchema,
  type TaskEditorFormValues,
  type TaskEditorSubmitResult,
} from '../features/editors/task-editor-form';
import {
  createTaskDetailPopoverModel,
  type TaskDetailPopoverModel,
} from '../features/popover/task-detail-popover';
import {
  buildDependencyValidationSummary,
  type DependencyValidationSummary,
  type DependencyValidationSummaryInput,
} from '../features/diagnostics/dependency-validation-summary';
import {
  buildResourceOverallocationSummary,
  type ResourceOverallocationSummary,
  type ResourceOverallocationSummaryInput,
  type ResourceOverallocationLoadSummary,
} from '../features/diagnostics/resource-overallocation-summary';
import {
  createApprovalGateValidator,
  createForbiddenDateRangeValidator,
  createGanttBeforeTaskChangeValidationHandler,
  createLockedPhaseValidator,
  createRoleGuardValidator,
  validateGanttBeforeTaskChange,
  type GanttBeforeTaskChangeValidator,
  type GanttDateRange,
  type GanttValidationContext,
  type GanttValidationDecision,
  type GanttValidationReject,
} from './validation-recipes';

export interface GanttExampleCancelableEvent<TDetail> {
  readonly detail: TDetail;
  preventDefault(): void;
}

export interface GanttRoleBasedEditabilityRecipeOptions {
  readonly policy?: GanttRoleEditabilityPolicy;
  readonly getRoleContext: () => GanttRoleEditabilityContext;
  readonly onDeny?: (
    eventName: GanttBeforeChangeEventName,
    detail: GanttBeforeChangeDetailByEventName[GanttBeforeChangeEventName],
  ) => void;
}

export interface GanttRoleBasedEditabilityRecipe {
  readonly policy: GanttRoleEditabilityPolicy;
  readonly handlers: {
    readonly [GANTT_BEFORE_TASK_CHANGE_EVENT]: (event: GanttExampleCancelableEvent<GanttBeforeTaskChangeDetail>) => void;
    readonly [GANTT_BEFORE_DEPENDENCY_CHANGE_EVENT]: (
      event: GanttExampleCancelableEvent<GanttBeforeDependencyChangeDetail>,
    ) => void;
    readonly [GANTT_BEFORE_ASSIGNMENT_CHANGE_EVENT]: (
      event: GanttExampleCancelableEvent<GanttBeforeAssignmentChangeDetail>,
    ) => void;
  };
  readonly canEdit: <TEventName extends GanttBeforeChangeEventName>(
    eventName: TEventName,
    detail: Pick<GanttBeforeChangeDetailByEventName[TEventName], 'action'>,
  ) => boolean;
}

export interface CustomTaskEditorRecipeOptions {
  readonly task: TaskEntity;
  readonly resources?: readonly ResourceEntity[];
  readonly assignments?: readonly AssignmentEntity[];
  readonly fieldIds?: readonly TaskEditorFieldId[];
  readonly includeUnchanged?: boolean;
  readonly onSubmitPatch?: (patch: TaskUpdate) => void;
}

export interface CustomTaskEditorRecipe {
  readonly fields: readonly TaskEditorFieldSchema[];
  readonly initialValues: TaskEditorFormValues;
  readonly submit: (values: TaskEditorFormValues, options?: NormalizeTaskEditorSubmitOptions) => TaskEditorSubmitResult;
}

export interface TaskDetailPopoverRecipeOptions {
  readonly row: TaskGridRow;
  readonly task?: TaskEntity;
  readonly resources?: readonly ResourceEntity[];
  readonly assignments?: readonly AssignmentEntity[];
}

export interface TaskDetailPopoverRecipe {
  readonly model: TaskDetailPopoverModel;
  readonly sectionTitles: readonly string[];
  readonly warningMessages: readonly string[];
}

export interface DependencyValidationSummaryRecipe {
  readonly summary: DependencyValidationSummary;
  readonly hasBlockingConflicts: boolean;
  readonly badgeLabel: string;
  readonly focusTaskIds: readonly TaskId[];
}

export interface ResourceOverallocationSummaryRecipe {
  readonly summary: ResourceOverallocationSummary;
  readonly hasOverallocations: boolean;
  readonly badgeLabel: string;
  readonly worstResourceId: string | null;
}

export interface CustomValidationRecipeOptions {
  readonly context: GanttValidationContext | (() => GanttValidationContext);
  readonly rolePolicy?: GanttRoleEditabilityPolicy;
  readonly freezeWindows?: readonly GanttDateRange[];
  readonly lockedPhaseIds?: readonly string[];
  readonly phaseByTaskId?: Readonly<Record<TaskId, string>>;
  readonly validators?: readonly GanttBeforeTaskChangeValidator<GanttValidationContext>[];
  readonly onReject?: (rejection: GanttValidationReject) => void;
}

export interface CustomValidationRecipe {
  readonly validators: readonly GanttBeforeTaskChangeValidator<GanttValidationContext>[];
  readonly handler: (event: GanttExampleCancelableEvent<GanttBeforeTaskChangeDetail>) => void;
  readonly validate: (detail: GanttBeforeTaskChangeDetail) => GanttValidationDecision;
}

const DEFAULT_ROLE_POLICY: GanttRoleEditabilityPolicy = {
  roles: {
    viewer: [],
    planner: ['task:*', 'dependency:create', 'dependency:delete'],
    resourceManager: ['assignment:edit'],
    admin: ['*'],
  },
};

const DEFAULT_EDITOR_FIELDS: readonly TaskEditorFieldId[] = [
  'name',
  'startDate',
  'endDate',
  'durationDays',
  'progressPercent',
  'constraintType',
  'constraintDate',
  'deadlineDate',
  'resourceLabels',
  'notes',
];

/**
 * Recipe: attach these handlers to the three cancelable Gantt before-change events.
 * The app owns role lookup; the helper only turns roles into deterministic decisions.
 */
export function createRoleBasedEditabilityRecipe(
  options: GanttRoleBasedEditabilityRecipeOptions,
): GanttRoleBasedEditabilityRecipe {
  const policy = options.policy ?? DEFAULT_ROLE_POLICY;
  const editability = createGanttRoleEditabilityHelpers(policy);

  function handle<TEventName extends GanttBeforeChangeEventName>(
    eventName: TEventName,
    event: GanttExampleCancelableEvent<GanttBeforeChangeDetailByEventName[TEventName]>,
  ): void {
    if (editability.decide(eventName, event.detail, options.getRoleContext())) {
      return;
    }

    event.preventDefault();
    options.onDeny?.(eventName, event.detail);
  }

  return {
    policy,
    handlers: {
      [GANTT_BEFORE_TASK_CHANGE_EVENT]: (event) => handle(GANTT_BEFORE_TASK_CHANGE_EVENT, event),
      [GANTT_BEFORE_DEPENDENCY_CHANGE_EVENT]: (event) => handle(GANTT_BEFORE_DEPENDENCY_CHANGE_EVENT, event),
      [GANTT_BEFORE_ASSIGNMENT_CHANGE_EVENT]: (event) => handle(GANTT_BEFORE_ASSIGNMENT_CHANGE_EVENT, event),
    },
    canEdit: (eventName, detail) => editability.decide(eventName, detail, options.getRoleContext()),
  };
}

/**
 * Recipe: use the schema to render any UI form, then normalize submitted values
 * into the task mutation patch expected by Gantt task services.
 */
export function createCustomTaskEditorFormRecipe(
  options: CustomTaskEditorRecipeOptions,
): CustomTaskEditorRecipe {
  const fieldIds = new Set(options.fieldIds ?? DEFAULT_EDITOR_FIELDS);
  const fields = TASK_EDITOR_FIELD_SCHEMA.filter((field) => fieldIds.has(field.id));
  const initialValues = createTaskEditorFormValues(options.task, options.resources, options.assignments);

  return {
    fields,
    initialValues,
    submit: (values, submitOptions) => {
      const result = normalizeTaskEditorSubmit(options.task, values, {
        includeUnchanged: submitOptions?.includeUnchanged ?? options.includeUnchanged,
      });

      if (result.ok) {
        options.onSubmitPatch?.(result.patch);
      }

      return result;
    },
  };
}

/**
 * Recipe: resolve the presentation-free popover model at hover/focus time and
 * let the framework render sections, fields, warnings, and resource assignments.
 */
export function createTaskDetailPopoverRecipe(
  options: TaskDetailPopoverRecipeOptions,
): TaskDetailPopoverRecipe {
  const model = createTaskDetailPopoverModel(options);
  const warnings = model.sections
    .find((section) => section.id === 'warnings')
    ?.fields.flatMap((field) => Array.isArray(field.value) ? field.value : [field.displayValue])
    .filter(Boolean) ?? [];

  return {
    model,
    sectionTitles: model.sections.map((section) => section.title),
    warningMessages: warnings,
  };
}

/**
 * Recipe: combine scheduler diagnostics and projected row warnings into one
 * compact dependency-health object for side panels, badges, or command palettes.
 */
export function createDependencyValidationSummaryRecipe(
  input: DependencyValidationSummaryInput,
): DependencyValidationSummaryRecipe {
  const summary = buildDependencyValidationSummary(input);
  const focusTaskIds = [
    ...new Set(summary.groups.flatMap((group) => group.items.flatMap((item) => item.relatedTaskIds))),
  ];

  return {
    summary,
    hasBlockingConflicts: summary.conflictCount > 0,
    badgeLabel: summary.totalCount === 0 ? 'No schedule issues' : `${summary.totalCount} schedule issues`,
    focusTaskIds,
  };
}

/**
 * Recipe: feed either projected resource rows, raw load summaries, or scheduler
 * conflicts into a summary object that a resource dashboard can render.
 */
export function createResourceOverallocationSummaryRecipe(
  input: ResourceOverallocationSummaryInput,
): ResourceOverallocationSummaryRecipe {
  const summary = buildResourceOverallocationSummary(input);

  return {
    summary,
    hasOverallocations: summary.totalCount > 0,
    badgeLabel: summary.totalCount === 0 ? 'No over-allocation' : `${summary.totalCount} over-allocation windows`,
    worstResourceId: summary.groups[0]?.resourceId ?? null,
  };
}

/**
 * Recipe: compose custom before-task-change validation from small validators.
 * Add this handler to `gantt-before-task-change` and render `onReject` however
 * the app reports validation failures.
 */
export function createCustomValidationRecipe(
  options: CustomValidationRecipeOptions,
): CustomValidationRecipe {
  const configuredValidators: GanttBeforeTaskChangeValidator<GanttValidationContext>[] = [];

  if (options.rolePolicy) {
    configuredValidators.push(createRoleGuardValidator({ policy: options.rolePolicy }));
  }

  if (options.freezeWindows?.length) {
    configuredValidators.push(createForbiddenDateRangeValidator({ ranges: options.freezeWindows }));
  }

  if (options.lockedPhaseIds?.length) {
    configuredValidators.push(createLockedPhaseValidator({
      lockedPhaseIds: options.lockedPhaseIds,
      getPhaseId: (detail) => detail.taskId ? options.phaseByTaskId?.[detail.taskId] : null,
    }));
  }

  configuredValidators.push(createApprovalGateValidator());
  configuredValidators.push(...(options.validators ?? []));

  const resolveContext = (): GanttValidationContext =>
    typeof options.context === 'function'
      ? (options.context as () => GanttValidationContext)()
      : options.context;

  return {
    validators: configuredValidators,
    handler: (event) => {
      createGanttBeforeTaskChangeValidationHandler({
        context: resolveContext,
        validators: configuredValidators,
        onReject: options.onReject,
      })(event as CustomEvent<GanttBeforeTaskChangeDetail>);
    },
    validate: (detail) => validateGanttBeforeTaskChange(detail, resolveContext(), configuredValidators),
  };
}

export function createResourceLoadSummaryExample(
  resourceId: string,
  resourceName: string,
  planning: ResourcePlanningRowData,
): ResourceOverallocationLoadSummary {
  return {
    resourceId,
    resourceName,
    planning,
  };
}

export function createSchedulerDiagnosticsExample(
  conflicts: readonly SchedulerConflict[] = [],
  issues: readonly SchedulerIssue[] = [],
): DependencyValidationSummaryInput {
  return {
    conflicts,
    issues,
  };
}
GraphQLPersistence

GraphQL adapter

Configurable project snapshot load/save operations over a GraphQL endpoint.

packages/enterprise/plugins/gantt/examples/graphql-adapter.ts
Open preview
Source code
gantt/examples/graphql-adapter.ts ts
/**
 * @packageDocumentation
 * GraphQL persistence adapter example for Gantt project snapshots.
 *
 * The adapter wraps configurable GraphQL load and save operations around the
 * JSON project snapshot helpers. It accepts an injected `fetch` implementation
 * for tests, SSR, and framework adapters, and converts HTTP, GraphQL, response,
 * network, and snapshot-validation failures into typed adapter errors.
 */
import type { ProjectSnapshot } from '../core/domain';
import {
  exportProjectSnapshot,
  parseProjectSnapshotJson,
} from '../core/project-snapshot-json';

export type ProjectSnapshotGraphqlErrorCode =
  | 'GRAPHQL_ERROR'
  | 'HTTP_ERROR'
  | 'INVALID_RESPONSE'
  | 'INVALID_SNAPSHOT'
  | 'NETWORK_ERROR';

export interface ProjectSnapshotGraphqlErrorDetails {
  readonly status?: number;
  readonly payload?: unknown;
  readonly cause?: unknown;
}

export class ProjectSnapshotGraphqlError extends Error {
  constructor(
    public readonly code: ProjectSnapshotGraphqlErrorCode,
    message: string,
    public readonly details: ProjectSnapshotGraphqlErrorDetails = {},
  ) {
    super(message);
    this.name = 'ProjectSnapshotGraphqlError';
  }
}

export interface ProjectSnapshotGraphqlAdapterOptions {
  /** GraphQL HTTP endpoint. */
  readonly url: string;
  /** Optional headers for authentication, tenancy, or content negotiation. */
  readonly headers?: Readonly<Record<string, string>>;
  /** Custom fetch implementation for tests, SSR, or framework adapters. */
  readonly fetchImpl?: typeof fetch;
  /** JSON indentation used for readable save payloads. */
  readonly space?: number | string;
  /** Query used by load(). Must return a serialized project snapshot string. */
  readonly loadQuery?: string;
  /** Mutation used by save(). Receives the serialized snapshot in `$snapshot`. */
  readonly saveMutation?: string;
  /** Response data field read by load(). */
  readonly loadField?: string;
  /** Response data field checked by save(). */
  readonly saveField?: string;
}

export interface ProjectSnapshotGraphqlRequestOptions {
  readonly signal?: AbortSignal;
  readonly variables?: Readonly<Record<string, unknown>>;
}

interface GraphqlResponsePayload {
  readonly data?: Record<string, unknown> | null;
  readonly errors?: unknown;
}

const DEFAULT_LOAD_QUERY = `
  query LoadProjectSnapshot {
    projectSnapshot
  }
`;

const DEFAULT_SAVE_MUTATION = `
  mutation SaveProjectSnapshot($snapshot: String!) {
    saveProjectSnapshot(snapshot: $snapshot)
  }
`;

/**
 * Framework-neutral example for persisting project snapshots through GraphQL.
 */
export class ProjectSnapshotGraphqlAdapter {
  private readonly fetchImpl: typeof fetch;

  constructor(private readonly options: ProjectSnapshotGraphqlAdapterOptions) {
    this.fetchImpl = options.fetchImpl ?? fetch;
  }

  async load(request: ProjectSnapshotGraphqlRequestOptions = {}): Promise<ProjectSnapshot> {
    const payload = await this.execute({
      query: this.options.loadQuery ?? DEFAULT_LOAD_QUERY,
      variables: request.variables,
      signal: request.signal,
    });
    const field = this.options.loadField ?? 'projectSnapshot';
    const snapshotJson = payload.data?.[field];

    if (typeof snapshotJson !== 'string') {
      throw new ProjectSnapshotGraphqlError(
        'INVALID_RESPONSE',
        'Project snapshot GraphQL response did not include a serialized snapshot.',
        { payload },
      );
    }

    const parsed = parseProjectSnapshotJson(snapshotJson);

    if (!parsed.ok) {
      throw new ProjectSnapshotGraphqlError('INVALID_SNAPSHOT', parsed.error, {
        payload: snapshotJson,
      });
    }

    return parsed.project;
  }

  async save(
    project: ProjectSnapshot,
    request: ProjectSnapshotGraphqlRequestOptions = {},
  ): Promise<void> {
    const snapshot = exportProjectSnapshot(project, { space: this.options.space });
    const payload = await this.execute({
      query: this.options.saveMutation ?? DEFAULT_SAVE_MUTATION,
      variables: {
        ...request.variables,
        snapshot,
      },
      signal: request.signal,
    });
    const field = this.options.saveField ?? 'saveProjectSnapshot';

    if (payload.data && field in payload.data && payload.data[field] === false) {
      throw new ProjectSnapshotGraphqlError(
        'INVALID_RESPONSE',
        'Project snapshot GraphQL save operation was rejected.',
        { payload },
      );
    }
  }

  private async execute(request: {
    readonly query: string;
    readonly variables?: Readonly<Record<string, unknown>>;
    readonly signal?: AbortSignal;
  }): Promise<GraphqlResponsePayload> {
    const response = await this.request(() =>
      this.fetchImpl(this.options.url, {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          ...this.options.headers,
        },
        body: JSON.stringify({
          query: request.query,
          variables: request.variables ?? {},
        }),
        signal: request.signal,
      }),
    );

    const payload = await this.readPayload(response);

    if (!response.ok) {
      throw new ProjectSnapshotGraphqlError(
        'HTTP_ERROR',
        'Project snapshot GraphQL request returned an error.',
        {
          status: response.status,
          payload,
        },
      );
    }

    if (payload.errors) {
      throw new ProjectSnapshotGraphqlError(
        'GRAPHQL_ERROR',
        'Project snapshot GraphQL operation returned errors.',
        { payload },
      );
    }

    return payload;
  }

  private async request(run: () => Promise<Response>): Promise<Response> {
    try {
      return await run();
    } catch (error) {
      throw new ProjectSnapshotGraphqlError(
        'NETWORK_ERROR',
        'Project snapshot GraphQL request failed.',
        {
          cause: error,
        },
      );
    }
  }

  private async readPayload(response: Response): Promise<GraphqlResponsePayload> {
    const text = await response.text();

    if (!text) {
      return {};
    }

    try {
      const payload = JSON.parse(text) as GraphqlResponsePayload;

      if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
        throw new Error('GraphQL response must be a JSON object.');
      }

      return payload;
    } catch (error) {
      throw new ProjectSnapshotGraphqlError(
        'INVALID_RESPONSE',
        'Project snapshot GraphQL response could not be parsed.',
        {
          status: response.status,
          payload: text,
          cause: error,
        },
      );
    }
  }
}
GridExport

Grid projection examples

Column presets, Excel row mapping, status indicators, and menu composition.

packages/enterprise/plugins/gantt/examples/grid-projection-examples.ts
Open preview
Source code
gantt/examples/grid-projection-examples.ts ts
/**
 * @packageDocumentation
 * Grouped example API for grid, export, rendering, and context-menu quick wins.
 *
 * The recipes demonstrate how an application can map projected Gantt rows to
 * spreadsheet-safe exports, choose packaged Gantt column presets, derive
 * read-only/locked task indicator metadata, and compose context-menu actions
 * without depending on internal projection details.
 */
import type { ColumnRegular } from '@revolist/revogrid';
import type { ContextMenuItem } from '@revolist/revogrid-pro';

import type { ProjectSnapshot, TaskId } from '../core';
import {
  mapGanttRowsToExcelExport,
  type GanttExcelExportRow,
  type GanttExcelMappingOptions,
} from '../features/export/gantt-excel-mapping';
import {
  printGanttContainer,
  type GanttPrintDateRange,
  type GanttPrintOptions,
  type GanttPrintResult,
} from './print-pdf-recipe';
import {
  createGanttColumnPreset,
  getGanttColumnPresetProps,
  type GanttColumnPresetName,
  type GanttColumnPresetProp,
} from '../grid/gantt-column-presets';
import {
  exportGanttExcel,
  type GanttExcelExportOptions,
} from '../../gantt-toolbar/actions/export-actions';
import {
  createTaskStatusIndicators,
  type TaskStatusIndicatorProjection,
} from '../projection/task-status-indicators';
import type { TaskGridRow } from '../projection/task-row-types';
import {
  createGanttContextMenuExtensionItems,
  type AssignResourceMenuItemOptions,
  type DeleteTaskMenuItemOptions,
  type DuplicateTaskMenuItemOptions,
  type ExportMenuItemOptions,
  type GanttContextMenuExtensionContext,
} from './context-menu-extensions';

export interface GanttExcelExportRecipeOptions extends GanttExcelMappingOptions {
  readonly rows: readonly TaskGridRow[];
  readonly project: ProjectSnapshot;
}

export interface GanttExcelExportRecipe {
  readonly rows: readonly GanttExcelExportRow[];
  readonly fieldNames: readonly (keyof GanttExcelExportRow)[];
}

export interface GanttPdfExcelReportingRecipeOptions {
  readonly grid: HTMLRevoGridElement;
  readonly target: Element | null | undefined;
  readonly document?: Document;
  readonly window?: GanttPrintOptions['window'];
  readonly now?: () => Date | string;
  readonly pdf?: Omit<GanttPrintOptions, 'document' | 'window' | 'metadata'>;
  readonly excel?: GanttExcelExportOptions;
}

export interface GanttPdfExcelReportingExportOptions {
  readonly range?: GanttPrintDateRange;
  readonly excel?: GanttExcelExportOptions;
}

export interface GanttPdfExcelReportingRecipe {
  exportPdf(project: ProjectSnapshot, range?: GanttPrintDateRange): GanttPrintResult;
  exportExcel(options?: GanttExcelExportOptions): CustomEvent<GanttExcelExportOptions>;
  exportReport(project: ProjectSnapshot, options?: GanttPdfExcelReportingExportOptions): {
    readonly pdf: GanttPrintResult;
    readonly excel: CustomEvent<GanttExcelExportOptions>;
  };
}

export interface GanttColumnPresetRecipeOptions {
  readonly preset: GanttColumnPresetName;
  readonly extraColumns?: readonly ColumnRegular[];
}

export interface GanttColumnPresetRecipe {
  readonly preset: GanttColumnPresetName;
  readonly presetProps: readonly GanttColumnPresetProp[];
  readonly columns: readonly ColumnRegular[];
}

export interface GanttTaskStatusIndicatorRecipeOptions {
  readonly rows: readonly Pick<TaskGridRow, 'id'>[];
  readonly projectReadOnly?: boolean;
  readonly lockedTaskIds?: ReadonlySet<TaskId> | readonly TaskId[];
  readonly labels?: Parameters<typeof createTaskStatusIndicators>[0]['labels'];
  readonly classNames?: Parameters<typeof createTaskStatusIndicators>[0]['classNames'];
}

export interface GanttTaskStatusIndicatorRecipeRow {
  readonly taskId: TaskId;
  readonly status: TaskStatusIndicatorProjection;
  readonly className: string;
  readonly title: string;
  readonly ariaLabel: string | undefined;
}

export interface GanttContextMenuQuickWinRecipeOptions {
  readonly duplicate?: DuplicateTaskMenuItemOptions | false;
  readonly delete?: DeleteTaskMenuItemOptions | false;
  readonly assignResource?: AssignResourceMenuItemOptions | false;
  readonly export?: ExportMenuItemOptions | false;
}

const EMPTY_EXCEL_FIELD_NAMES = Object.freeze([]) as readonly (keyof GanttExcelExportRow)[];

/**
 * Framework-neutral Excel recipe for customers that export the projected Gantt
 * task table instead of serializing renderer-only timeline fields.
 */
export function createGanttExcelExportRecipe(
  options: GanttExcelExportRecipeOptions,
): GanttExcelExportRecipe {
  const rows = mapGanttRowsToExcelExport(
    options.rows,
    options.project,
    { includeResourceRows: options.includeResourceRows },
  );

  return {
    rows,
    fieldNames: rows[0]
      ? Object.keys(rows[0]) as (keyof GanttExcelExportRow)[]
      : EMPTY_EXCEL_FIELD_NAMES,
  };
}

/**
 * Framework-neutral reporting recipe for toolbars that offer both stakeholder
 * PDF reports and spreadsheet handoff from the same Gantt grid.
 */
export function createGanttPdfExcelReportingRecipe(
  options: GanttPdfExcelReportingRecipeOptions,
): GanttPdfExcelReportingRecipe {
  const exportPdf = (project: ProjectSnapshot, range?: GanttPrintDateRange): GanttPrintResult =>
    printGanttContainer(options.target, {
      ...options.pdf,
      document: options.document,
      window: options.window,
      metadata: {
        title: project.name,
        subtitle: `${project.tasks.length} tasks | ${project.resources.length} resources`,
        range,
        printedAt: options.now?.() ?? new Date(),
      },
    });
  const exportExcel = (excelOptions?: GanttExcelExportOptions): CustomEvent<GanttExcelExportOptions> =>
    exportGanttExcel(options.grid, {
      ...options.excel,
      ...excelOptions,
    });

  return {
    exportPdf,
    exportExcel,
    exportReport(project, reportOptions = {}) {
      return {
        pdf: exportPdf(project, reportOptions.range),
        excel: exportExcel(reportOptions.excel),
      };
    },
  };
}

/**
 * Framework-neutral column recipe for switching between Gantt-aware presets and
 * customer-defined columns before assigning the result to `grid.columns`.
 */
export function createGanttColumnPresetRecipe(
  options: GanttColumnPresetRecipeOptions,
): GanttColumnPresetRecipe {
  const presetColumns = createGanttColumnPreset(options.preset);

  return {
    preset: options.preset,
    presetProps: getGanttColumnPresetProps(options.preset),
    columns: [
      ...presetColumns,
      ...(options.extraColumns ?? []),
    ],
  };
}

/**
 * Framework-neutral visual-indicator recipe for read-only projects and locked
 * tasks. The returned class/title values can be bound to a custom cell renderer
 * or status column without changing the projected task row.
 */
export function createGanttTaskStatusIndicatorRecipe(
  options: GanttTaskStatusIndicatorRecipeOptions,
): readonly GanttTaskStatusIndicatorRecipeRow[] {
  const lockedTaskIds = toTaskIdSet(options.lockedTaskIds);

  return options.rows.map((row) => {
    const status = createTaskStatusIndicators({
      taskId: row.id,
      projectReadOnly: options.projectReadOnly,
      taskLocked: lockedTaskIds.has(row.id),
      labels: options.labels,
      classNames: options.classNames,
    });

    return {
      taskId: row.id,
      status,
      className: status.classNames.join(' '),
      title: status.label,
      ariaLabel: status.label || undefined,
    };
  });
}

/**
 * Framework-neutral context-menu recipe for composing the Gantt quick actions
 * with RevoGrid Pro's context-menu plugin.
 */
export function createGanttContextMenuQuickWinRecipe(
  options: GanttContextMenuQuickWinRecipeOptions,
): readonly ContextMenuItem[] {
  return createGanttContextMenuExtensionItems(options);
}

export function createTaskNameExcelExportMenuItem(
  workbookName: string,
): ContextMenuItem {
  return createGanttContextMenuQuickWinRecipe({
    duplicate: false,
    delete: false,
    assignResource: false,
    export: {
      exportOptions: (context: GanttContextMenuExtensionContext) => ({
        sheetName: context.task?.name ?? 'Gantt',
        workbookName,
      }),
    },
  })[0];
}

function toTaskIdSet(taskIds: ReadonlySet<TaskId> | readonly TaskId[] | undefined): ReadonlySet<TaskId> {
  if (!taskIds) {
    return new Set();
  }

  return taskIds instanceof Set ? taskIds : new Set(taskIds);
}
PersistenceRecipes

Persistence examples

Grouped recipes for REST, GraphQL, PostgreSQL, JSON snapshots, print/PDF, and framework metadata.

packages/enterprise/plugins/gantt/examples/persistence-examples.ts
Open preview
Source code
gantt/examples/persistence-examples.ts ts
/**
 * @packageDocumentation
 * Grouped example API for Gantt persistence, reporting, and framework metadata.
 *
 * The recipes wrap REST, GraphQL, Supabase/PostgreSQL, JSON snapshot, and print
 * helpers with injected state and transport dependencies. They are designed as
 * copyable integration patterns for applications that own the backend, file
 * picker, print shell, or framework-specific demo catalog.
 */
import type { ProjectSnapshot } from '../core/domain';
import {
  cloneProjectSnapshot,
  exportProjectSnapshot,
  parseProjectSnapshotJson,
  type ProjectSnapshotParseResult,
} from '../core/project-snapshot-json';
import {
  ProjectSnapshotGraphqlAdapter,
  type ProjectSnapshotGraphqlAdapterOptions,
  type ProjectSnapshotGraphqlRequestOptions,
} from './graphql-adapter';
import {
  buildCreateProjectSnapshotTableSql,
  buildLoadProjectSnapshotSql,
  buildSaveProjectSnapshotSql,
  ProjectSnapshotSupabaseAdapter,
  type PostgresStatement,
  type ProjectSnapshotPostgresOptions,
  type ProjectSnapshotPostgresRow,
  type SupabaseLikeClient,
} from './postgres-persistence';
import {
  printGanttContainer,
  type GanttPrintDateRange,
  type GanttPrintOptions,
  type GanttPrintResult,
} from './print-pdf-recipe';
import {
  ProjectSnapshotRestAdapter,
  type ProjectSnapshotRestAdapterOptions,
  type ProjectSnapshotRestRequestOptions,
} from './rest-adapter';

export type GanttPersistenceRecipeId =
  | 'rest-project-snapshot'
  | 'graphql-project-snapshot'
  | 'supabase-postgres-project-snapshot'
  | 'postgres-sql-project-snapshot'
  | 'print-pdf-export'
  | 'json-snapshot-helpers'
  | 'portal-svelte-gantt'
  | 'portal-resource-filter';

export type GanttPersistenceRecipeChannel =
  | 'persistence'
  | 'export'
  | 'reporting'
  | 'framework';

export type GanttPersistenceRecipeFramework =
  | 'framework-neutral'
  | 'svelte'
  | 'vanilla';

export interface GanttPersistenceRecipeMetadata {
  readonly id: GanttPersistenceRecipeId;
  readonly title: string;
  readonly channel: GanttPersistenceRecipeChannel;
  readonly frameworks: readonly GanttPersistenceRecipeFramework[];
  readonly sourceFiles: readonly string[];
  readonly dependencies: readonly string[];
  readonly summary: string;
}

export interface ProjectSnapshotState {
  getProject(): ProjectSnapshot;
  setProject(project: ProjectSnapshot): void;
}

export interface ProjectSnapshotPersistenceRecipe {
  readonly metadata: GanttPersistenceRecipeMetadata;
  load(request?: ProjectSnapshotRestRequestOptions | ProjectSnapshotGraphqlRequestOptions): Promise<ProjectSnapshot>;
  save(request?: ProjectSnapshotRestRequestOptions | ProjectSnapshotGraphqlRequestOptions): Promise<void>;
}

export interface ProjectSnapshotStorageRecipe {
  readonly metadata: GanttPersistenceRecipeMetadata;
  load(projectId: string): Promise<ProjectSnapshot | null>;
  save(): Promise<ProjectSnapshot>;
}

export interface ProjectSnapshotSqlExecutor {
  query<T>(statement: PostgresStatement): Promise<{ readonly rows: readonly T[] }>;
}

export interface ProjectSnapshotPostgresRecipe {
  readonly metadata: GanttPersistenceRecipeMetadata;
  createTable(): Promise<void>;
  load(projectId: string): Promise<ProjectSnapshot | null>;
  save(): Promise<ProjectSnapshot>;
  buildReportingStatement(projectId: string): PostgresStatement;
}

export interface GanttPrintPdfRecipeOptions {
  readonly target: Element | null | undefined;
  readonly document?: Document;
  readonly window?: GanttPrintOptions['window'];
  readonly now?: () => Date | string;
}

export interface GanttPrintPdfRecipe {
  readonly metadata: GanttPersistenceRecipeMetadata;
  exportProject(project: ProjectSnapshot, range?: GanttPrintDateRange): GanttPrintResult;
}

export interface ProjectSnapshotJsonRecipeOptions {
  readonly readText?: () => string | Promise<string>;
  readonly writeText?: (fileName: string, contents: string) => void | Promise<void>;
  readonly fileName?: (project: ProjectSnapshot) => string;
}

export interface ProjectSnapshotJsonRecipe {
  readonly metadata: GanttPersistenceRecipeMetadata;
  clone(project: ProjectSnapshot): ProjectSnapshot;
  export(project: ProjectSnapshot, space?: number | string): string;
  parse(json: string): ProjectSnapshotParseResult;
  load(): Promise<ProjectSnapshot>;
  save(project: ProjectSnapshot, space?: number | string): Promise<string>;
}

export const GANTT_PERSISTENCE_QUICK_WIN_EXAMPLES: readonly GanttPersistenceRecipeMetadata[] = [
  {
    id: 'rest-project-snapshot',
    title: 'REST project snapshot persistence',
    channel: 'persistence',
    frameworks: ['framework-neutral'],
    sourceFiles: [
      'packages/enterprise/plugins/gantt/examples/rest-adapter.ts',
      'packages/enterprise/plugins/gantt/core/project-snapshot-json.ts',
    ],
    dependencies: ['fetch'],
    summary: 'Loads and saves a serialized ProjectSnapshot through an injected fetch implementation.',
  },
  {
    id: 'graphql-project-snapshot',
    title: 'GraphQL project snapshot persistence',
    channel: 'persistence',
    frameworks: ['framework-neutral'],
    sourceFiles: [
      'packages/enterprise/plugins/gantt/examples/graphql-adapter.ts',
      'packages/enterprise/plugins/gantt/core/project-snapshot-json.ts',
    ],
    dependencies: ['fetch'],
    summary: 'Uses configurable GraphQL operations and variables to load and save ProjectSnapshot JSON.',
  },
  {
    id: 'supabase-postgres-project-snapshot',
    title: 'Supabase/PostgreSQL project snapshot persistence',
    channel: 'persistence',
    frameworks: ['framework-neutral'],
    sourceFiles: [
      'packages/enterprise/plugins/gantt/examples/postgres-persistence.ts',
      'packages/enterprise/plugins/gantt/core/project-snapshot-json.ts',
    ],
    dependencies: ['Supabase-like client'],
    summary: 'Persists JSONB snapshots with a minimal Supabase-style client shape.',
  },
  {
    id: 'postgres-sql-project-snapshot',
    title: 'PostgreSQL SQL project snapshot persistence',
    channel: 'reporting',
    frameworks: ['framework-neutral'],
    sourceFiles: [
      'packages/enterprise/plugins/gantt/examples/postgres-persistence.ts',
    ],
    dependencies: ['PostgreSQL query executor'],
    summary: 'Builds parameterized DDL, load, save, and reporting SQL without owning the database client.',
  },
  {
    id: 'print-pdf-export',
    title: 'Print/PDF export',
    channel: 'export',
    frameworks: ['framework-neutral', 'vanilla'],
    sourceFiles: [
      'packages/enterprise/plugins/gantt/examples/print-pdf-recipe.ts',
    ],
    dependencies: ['DOM print API'],
    summary: 'Prepares a printable Gantt container that users can route to browser print or Save as PDF.',
  },
  {
    id: 'json-snapshot-helpers',
    title: 'JSON snapshot helpers',
    channel: 'persistence',
    frameworks: ['framework-neutral'],
    sourceFiles: [
      'packages/enterprise/plugins/gantt/core/project-snapshot-json.ts',
    ],
    dependencies: ['JSON'],
    summary: 'Clones, serializes, parses, imports, and exports ProjectSnapshot payloads.',
  },
  {
    id: 'portal-svelte-gantt',
    title: 'Portal Svelte Gantt demo',
    channel: 'framework',
    frameworks: ['svelte'],
    sourceFiles: [
      'packages/portal/src/components/gantt/GanttSvelteExample.svelte',
      'packages/portal/src/components/gantt/GanttSvelteExample.md',
    ],
    dependencies: ['@revolist/svelte-datagrid'],
    summary: 'Existing portal reference showing Gantt usage through the Svelte wrapper.',
  },
  {
    id: 'portal-resource-filter',
    title: 'Portal resource filter demo',
    channel: 'framework',
    frameworks: ['framework-neutral', 'vanilla'],
    sourceFiles: [
      'packages/portal/src/components/gantt/GanttResourceFilterExample.ts',
    ],
    dependencies: ['resourceFilterIds'],
    summary: 'Existing portal reference for filtering projected Gantt rows by assigned resource IDs.',
  },
];

export function createRestProjectPersistenceRecipe(
  options: ProjectSnapshotRestAdapterOptions,
  state: ProjectSnapshotState,
): ProjectSnapshotPersistenceRecipe {
  const adapter = new ProjectSnapshotRestAdapter(options);

  return {
    metadata: getPersistenceRecipeMetadata('rest-project-snapshot'),
    async load(request) {
      const project = await adapter.load(request as ProjectSnapshotRestRequestOptions | undefined);
      state.setProject(project);
      return project;
    },
    async save(request) {
      await adapter.save(state.getProject(), request as ProjectSnapshotRestRequestOptions | undefined);
    },
  };
}

export function createGraphqlProjectPersistenceRecipe(
  options: ProjectSnapshotGraphqlAdapterOptions,
  state: ProjectSnapshotState,
): ProjectSnapshotPersistenceRecipe {
  const adapter = new ProjectSnapshotGraphqlAdapter(options);

  return {
    metadata: getPersistenceRecipeMetadata('graphql-project-snapshot'),
    async load(request) {
      const project = await adapter.load(request as ProjectSnapshotGraphqlRequestOptions | undefined);
      state.setProject(project);
      return project;
    },
    async save(request) {
      await adapter.save(state.getProject(), request as ProjectSnapshotGraphqlRequestOptions | undefined);
    },
  };
}

export function createSupabasePostgresPersistenceRecipe(
  client: SupabaseLikeClient<ProjectSnapshotPostgresRow>,
  state: ProjectSnapshotState,
  options: ProjectSnapshotPostgresOptions = {},
): ProjectSnapshotStorageRecipe {
  const adapter = new ProjectSnapshotSupabaseAdapter(client, options);

  return {
    metadata: getPersistenceRecipeMetadata('supabase-postgres-project-snapshot'),
    async load(projectId) {
      const project = await adapter.load(projectId);

      if (project) {
        state.setProject(project);
      }

      return project;
    },
    async save() {
      const project = await adapter.save(state.getProject());
      state.setProject(project);
      return project;
    },
  };
}

export function createPostgresSqlPersistenceRecipe(
  executor: ProjectSnapshotSqlExecutor,
  state: ProjectSnapshotState,
  options: ProjectSnapshotPostgresOptions = {},
): ProjectSnapshotPostgresRecipe {
  return {
    metadata: getPersistenceRecipeMetadata('postgres-sql-project-snapshot'),
    async createTable() {
      await executor.query(buildCreateProjectSnapshotTableSql(options));
    },
    async load(projectId) {
      const result = await executor.query<{ readonly snapshot: unknown }>(
        buildLoadProjectSnapshotSql(projectId, options),
      );
      const snapshot = result.rows[0]?.snapshot;

      if (snapshot === undefined) {
        return null;
      }

      const parsed = parseProjectSnapshotJson(
        typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot),
      );

      if (!parsed.ok) {
        throw new Error(parsed.error);
      }

      state.setProject(parsed.project);
      return parsed.project;
    },
    async save() {
      const project = state.getProject();
      const result = await executor.query<ProjectSnapshotPostgresRow>(
        buildSaveProjectSnapshotSql(project, options),
      );
      const persisted = result.rows[0]?.snapshot ?? project;
      const parsed = parseProjectSnapshotJson(
        typeof persisted === 'string' ? persisted : JSON.stringify(persisted),
      );

      if (!parsed.ok) {
        throw new Error(parsed.error);
      }

      state.setProject(parsed.project);
      return parsed.project;
    },
    buildReportingStatement(projectId) {
      return {
        text: [
          'select',
          "  snapshot->>'id' as project_id,",
          "  snapshot->>'name' as project_name,",
          "  jsonb_array_length(snapshot->'tasks') as task_count,",
          "  jsonb_array_length(snapshot->'resources') as resource_count,",
          "  jsonb_array_length(snapshot->'dependencies') as dependency_count",
          `from ${quoteQualifiedIdentifier(options.tableName ?? 'gantt_project_snapshots')}`,
          'where project_id = $1;',
        ].join('\n'),
        values: [projectId],
      };
    },
  };
}

export function createPrintPdfExportRecipe(
  options: GanttPrintPdfRecipeOptions,
): GanttPrintPdfRecipe {
  return {
    metadata: getPersistenceRecipeMetadata('print-pdf-export'),
    exportProject(project, range) {
      return printGanttContainer(options.target, {
        document: options.document,
        window: options.window,
        metadata: {
          title: project.name,
          subtitle: `${project.tasks.length} tasks | ${project.resources.length} resources`,
          range,
          printedAt: options.now?.() ?? new Date(),
        },
      });
    },
  };
}

export function createJsonSnapshotRecipe(
  options: ProjectSnapshotJsonRecipeOptions = {},
): ProjectSnapshotJsonRecipe {
  return {
    metadata: getPersistenceRecipeMetadata('json-snapshot-helpers'),
    clone: cloneProjectSnapshot,
    export(project, space) {
      return exportProjectSnapshot(project, { space });
    },
    parse: parseProjectSnapshotJson,
    async load() {
      if (!options.readText) {
        throw new Error('Project snapshot JSON load requires an injected readText function.');
      }

      const parsed = parseProjectSnapshotJson(await options.readText());

      if (!parsed.ok) {
        throw new Error(parsed.error);
      }

      return parsed.project;
    },
    async save(project, space) {
      if (!options.writeText) {
        throw new Error('Project snapshot JSON save requires an injected writeText function.');
      }

      const contents = exportProjectSnapshot(project, { space });
      const fileName = options.fileName?.(project) ?? `${project.id}-gantt-project.json`;

      await options.writeText(fileName, contents);

      return contents;
    },
  };
}

export function getPersistenceRecipeMetadata(
  id: GanttPersistenceRecipeId,
): GanttPersistenceRecipeMetadata {
  const metadata = GANTT_PERSISTENCE_QUICK_WIN_EXAMPLES.find(example => example.id === id);

  if (!metadata) {
    throw new Error(`Unknown Gantt persistence recipe "${id}".`);
  }

  return metadata;
}

function quoteQualifiedIdentifier(identifier: string): string {
  const parts = identifier.split('.');

  if (parts.some(part => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part))) {
    throw new Error(`PostgreSQL identifier "${identifier}" is not supported by this example.`);
  }

  return parts.map(part => `"${part}"`).join('.');
}
PostgreSQLSupabase

PostgreSQL persistence

SQL builders and a Supabase-style adapter for project snapshot storage.

packages/enterprise/plugins/gantt/examples/postgres-persistence.ts
Open preview
Source code
gantt/examples/postgres-persistence.ts ts
/**
 * @packageDocumentation
 * PostgreSQL and Supabase-style persistence example for Gantt project snapshots.
 *
 * The module provides parameterized SQL builders for a JSONB snapshot table and
 * a small Supabase-like adapter shape. It intentionally avoids importing a
 * concrete database client so applications can use the same snapshot contract
 * with Supabase, node-postgres, serverless SQL clients, or tests.
 */
import type { ProjectSnapshot } from '../core/domain';
import {
  exportProjectSnapshot,
  parseProjectSnapshotJson,
} from '../core/project-snapshot-json';

export const PROJECT_SNAPSHOT_TABLE = 'gantt_project_snapshots';

export interface PostgresStatement {
  readonly text: string;
  readonly values: readonly unknown[];
}

export interface ProjectSnapshotPostgresOptions {
  readonly tableName?: string;
}

export interface ProjectSnapshotPostgresRow {
  readonly project_id: string;
  readonly version: string;
  readonly snapshot: unknown;
  readonly updated_at: string;
}

export type ProjectSnapshotPostgresErrorCode =
  | 'INVALID_IDENTIFIER'
  | 'INVALID_SNAPSHOT'
  | 'QUERY_ERROR';

export interface ProjectSnapshotPostgresErrorDetails {
  readonly cause?: unknown;
  readonly payload?: unknown;
}

export class ProjectSnapshotPostgresError extends Error {
  constructor(
    public readonly code: ProjectSnapshotPostgresErrorCode,
    message: string,
    public readonly details: ProjectSnapshotPostgresErrorDetails = {},
  ) {
    super(message);
    this.name = 'ProjectSnapshotPostgresError';
  }
}

export interface SupabaseLikeError {
  readonly message: string;
  readonly code?: string;
  readonly details?: string;
  readonly hint?: string;
}

export interface SupabaseLikeResult<T> {
  readonly data: T;
  readonly error: SupabaseLikeError | null;
}

export interface SupabaseLikeMaybeSingleQuery<T> {
  maybeSingle(): Promise<SupabaseLikeResult<T | null>>;
}

export interface SupabaseLikeSingleQuery<T> {
  single(): Promise<SupabaseLikeResult<T>>;
}

export interface SupabaseLikeFilterBuilder<T> {
  eq(column: string, value: unknown): SupabaseLikeMaybeSingleQuery<T>;
}

export interface SupabaseLikeUpsertBuilder<T> {
  select(columns?: string): SupabaseLikeSingleQuery<T>;
}

export interface SupabaseLikeTable<T> {
  select(columns?: string): SupabaseLikeFilterBuilder<T>;
  upsert(row: T, options?: { readonly onConflict?: string }): SupabaseLikeUpsertBuilder<T>;
}

export interface SupabaseLikeClient<T> {
  from(table: string): SupabaseLikeTable<T>;
}

/**
 * SQL shape for storing one JSONB project snapshot per project id.
 */
export function buildCreateProjectSnapshotTableSql(
  options: ProjectSnapshotPostgresOptions = {},
): PostgresStatement {
  const table = quoteQualifiedIdentifier(options.tableName ?? PROJECT_SNAPSHOT_TABLE);

  return {
    text: [
      `create table if not exists ${table} (`,
      '  project_id text primary key,',
      '  version text not null,',
      '  snapshot jsonb not null,',
      '  updated_at timestamptz not null,',
      "  constraint gantt_project_snapshots_snapshot_object check (jsonb_typeof(snapshot) = 'object')",
      ');',
    ].join('\n'),
    values: [],
  };
}

export function buildLoadProjectSnapshotSql(
  projectId: string,
  options: ProjectSnapshotPostgresOptions = {},
): PostgresStatement {
  const table = quoteQualifiedIdentifier(options.tableName ?? PROJECT_SNAPSHOT_TABLE);

  return {
    text: `select snapshot from ${table} where project_id = $1 limit 1;`,
    values: [projectId],
  };
}

export function buildSaveProjectSnapshotSql(
  project: ProjectSnapshot,
  options: ProjectSnapshotPostgresOptions = {},
): PostgresStatement {
  const table = quoteQualifiedIdentifier(options.tableName ?? PROJECT_SNAPSHOT_TABLE);

  return {
    text: [
      `insert into ${table} (project_id, version, snapshot, updated_at)`,
      'values ($1, $2, $3::jsonb, $4::timestamptz)',
      'on conflict (project_id) do update set',
      '  version = excluded.version,',
      '  snapshot = excluded.snapshot,',
      '  updated_at = excluded.updated_at',
      'returning project_id, version, snapshot, updated_at;',
    ].join('\n'),
    values: [
      project.id,
      project.version,
      exportProjectSnapshot(project),
      project.updatedAt,
    ],
  };
}

/**
 * Framework-neutral Supabase-style example for storing ProjectSnapshot JSON.
 * The client shape is intentionally local and minimal, so no Supabase package is
 * required by the Gantt package or by consumers using another data client.
 */
export class ProjectSnapshotSupabaseAdapter {
  constructor(
    private readonly client: SupabaseLikeClient<ProjectSnapshotPostgresRow>,
    private readonly options: ProjectSnapshotPostgresOptions = {},
  ) {}

  async load(projectId: string): Promise<ProjectSnapshot | null> {
    const result = await this.client
      .from(this.tableName)
      .select('snapshot')
      .eq('project_id', projectId)
      .maybeSingle();

    if (result.error) {
      throw createQueryError(result.error);
    }

    if (!result.data) {
      return null;
    }

    return parseSnapshotPayload(result.data.snapshot);
  }

  async save(project: ProjectSnapshot): Promise<ProjectSnapshot> {
    const result = await this.client
      .from(this.tableName)
      .upsert(toProjectSnapshotRow(project), { onConflict: 'project_id' })
      .select('project_id, version, snapshot, updated_at')
      .single();

    if (result.error) {
      throw createQueryError(result.error);
    }

    return parseSnapshotPayload(result.data.snapshot);
  }

  private get tableName(): string {
    return this.options.tableName ?? PROJECT_SNAPSHOT_TABLE;
  }
}

export function toProjectSnapshotRow(project: ProjectSnapshot): ProjectSnapshotPostgresRow {
  return {
    project_id: project.id,
    version: project.version,
    snapshot: JSON.parse(exportProjectSnapshot(project)) as ProjectSnapshot,
    updated_at: project.updatedAt,
  };
}

function parseSnapshotPayload(payload: unknown): ProjectSnapshot {
  const json = typeof payload === 'string' ? payload : JSON.stringify(payload);
  const parsed = parseProjectSnapshotJson(json);

  if (!parsed.ok) {
    throw new ProjectSnapshotPostgresError('INVALID_SNAPSHOT', parsed.error, {
      payload,
    });
  }

  return parsed.project;
}

function createQueryError(error: SupabaseLikeError): ProjectSnapshotPostgresError {
  return new ProjectSnapshotPostgresError('QUERY_ERROR', error.message, {
    cause: error,
  });
}

function quoteQualifiedIdentifier(identifier: string): string {
  const parts = identifier.split('.');

  if (parts.some(part => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part))) {
    throw new ProjectSnapshotPostgresError(
      'INVALID_IDENTIFIER',
      `PostgreSQL identifier "${identifier}" is not supported by this example.`,
      { payload: identifier },
    );
  }

  return parts.map(part => `"${part}"`).join('.');
}
PrintPDF

Print/PDF recipe

DOM print preparation for browser print and Save as PDF workflows.

packages/enterprise/plugins/gantt/examples/print-pdf-recipe.ts
Open preview
Source code
gantt/examples/print-pdf-recipe.ts ts
/**
 * @packageDocumentation
 * Print and Save-as-PDF recipe API for Gantt reporting.
 *
 * The helpers prepare a target Gantt container for browser printing, attach
 * optional report metadata, call an injected `window.print()` implementation,
 * and clean up after printing. The browser print dialog remains the PDF engine,
 * which keeps this recipe dependency-free and suitable for stakeholder reports.
 */
import type { ISODateString } from '../core';
import './print-pdf-recipe.css';

export type GanttPrintOrientation = 'portrait' | 'landscape';

export interface GanttPrintDateRange {
  readonly startDate?: ISODateString;
  readonly endDate?: ISODateString;
  readonly label?: string;
}

export interface GanttPrintableMetadataOptions {
  readonly title?: string;
  readonly subtitle?: string;
  readonly range?: GanttPrintDateRange;
  readonly printedAt?: Date | string;
}

export interface GanttPrintableMetadata {
  readonly title: string;
  readonly subtitle?: string;
  readonly range?: GanttPrintDateRange;
  readonly rangeLabel?: string;
  readonly printedAt?: string;
}

export interface GanttPrintStyleOptions {
  readonly pageSize?: string;
  readonly orientation?: GanttPrintOrientation;
  readonly margin?: string;
  readonly printRootClass?: string;
  readonly printHeaderClass?: string;
  readonly hideSelectors?: readonly string[];
  readonly preserveColor?: boolean;
}

export interface GanttPrintOptions extends GanttPrintStyleOptions {
  readonly metadata?: GanttPrintableMetadataOptions;
  readonly document?: Document;
  readonly window?: GanttPrintWindow;
  readonly cleanupDelayMs?: number;
}

export interface GanttPrintWindow {
  print(): void;
  addEventListener?(type: 'afterprint', listener: () => void, options?: AddEventListenerOptions): void;
}

export interface GanttPrintResult {
  readonly ok: boolean;
  readonly reason?: 'missing-target' | 'missing-document' | 'missing-window-print' | 'print-failed';
  readonly error?: unknown;
}

interface NormalizedGanttPrintOptions {
  readonly pageSize: string;
  readonly orientation: GanttPrintOrientation;
  readonly margin: string;
  readonly printRootClass: string;
  readonly printHeaderClass: string;
  readonly hideSelectors: readonly string[];
  readonly preserveColor: boolean;
}

const DEFAULT_PRINT_OPTIONS: NormalizedGanttPrintOptions = {
  pageSize: 'A4',
  orientation: 'landscape',
  margin: '12mm',
  printRootClass: 'revo-gantt-print-root',
  printHeaderClass: 'revo-gantt-print-header',
  hideSelectors: [
    '[data-gantt-print-hidden]',
    '.gantt-toolbar',
    '.rgContextMenu',
  ],
  preserveColor: true,
};

export function createGanttPrintOptions(
  options: GanttPrintStyleOptions = {},
): NormalizedGanttPrintOptions {
  return {
    pageSize: options.pageSize ?? DEFAULT_PRINT_OPTIONS.pageSize,
    orientation: options.orientation ?? DEFAULT_PRINT_OPTIONS.orientation,
    margin: options.margin ?? DEFAULT_PRINT_OPTIONS.margin,
    printRootClass: options.printRootClass ?? DEFAULT_PRINT_OPTIONS.printRootClass,
    printHeaderClass: options.printHeaderClass ?? DEFAULT_PRINT_OPTIONS.printHeaderClass,
    hideSelectors: options.hideSelectors ?? DEFAULT_PRINT_OPTIONS.hideSelectors,
    preserveColor: options.preserveColor ?? DEFAULT_PRINT_OPTIONS.preserveColor,
  };
}

export function createGanttPrintStyles(options: GanttPrintStyleOptions = {}): string {
  const normalized = createGanttPrintOptions(options);
  const hideRule = normalized.hideSelectors.length > 0
    ? `${normalized.hideSelectors.join(', ')} { display: none !important; }`
    : '';
  const colorRule = normalized.preserveColor
    ? 'print-color-adjust: exact; -webkit-print-color-adjust: exact;'
    : '';

  return [
    '@media print {',
    `  @page { size: ${normalized.pageSize} ${normalized.orientation}; margin: ${normalized.margin}; }`,
    `  body.gantt-print-active { ${colorRule} }`,
    `  body.gantt-print-active > *:not(.${normalized.printRootClass}) { display: none !important; }`,
    `  .${normalized.printRootClass} { display: block !important; width: 100% !important; overflow: visible !important; }`,
    `  .${normalized.printRootClass} revo-grid, .${normalized.printRootClass} .rgRoot { height: auto !important; overflow: visible !important; }`,
    `  .${normalized.printHeaderClass} { margin: 0 0 12px; break-after: avoid; }`,
    `  .${normalized.printHeaderClass} h1 { margin: 0 0 4px; font-size: 18pt; line-height: 1.2; }`,
    `  .${normalized.printHeaderClass} p { margin: 0; font-size: 9pt; color: #4b5563; }`,
    hideRule ? `  ${hideRule}` : '',
    '}',
  ].filter(Boolean).join('\n');
}

export function createPrintableGanttMetadata(
  options: GanttPrintableMetadataOptions = {},
): GanttPrintableMetadata {
  const rangeLabel = formatGanttPrintRange(options.range);
  const printedAt = options.printedAt instanceof Date
    ? options.printedAt.toISOString()
    : options.printedAt;

  return {
    title: normalizeTitle(options.title),
    subtitle: normalizeOptionalText(options.subtitle),
    range: options.range,
    rangeLabel,
    printedAt,
  };
}

export function formatGanttPrintRange(range: GanttPrintDateRange | undefined): string | undefined {
  if (!range) {
    return undefined;
  }

  if (range.label) {
    return range.label;
  }

  if (range.startDate && range.endDate) {
    return `${range.startDate} - ${range.endDate}`;
  }

  return range.startDate ?? range.endDate;
}

export function printGanttContainer(
  target: Element | null | undefined,
  options: GanttPrintOptions = {},
): GanttPrintResult {
  if (!target) {
    return { ok: false, reason: 'missing-target' };
  }

  const ownerDocument = options.document ?? target.ownerDocument;
  if (!ownerDocument) {
    return { ok: false, reason: 'missing-document' };
  }

  const printWindow = options.window ?? ownerDocument.defaultView;
  if (!printWindow || typeof printWindow.print !== 'function') {
    return { ok: false, reason: 'missing-window-print' };
  }

  const normalized = createGanttPrintOptions(options);
  const cleanup = prepareGanttPrintDom(target, ownerDocument, options.metadata, normalized);

  try {
    const cleanupOnce = createOnce(cleanup);
    printWindow.addEventListener?.('afterprint', cleanupOnce, { once: true } as AddEventListenerOptions);
    printWindow.print();

    if (options.cleanupDelayMs !== undefined) {
      ownerDocument.defaultView?.setTimeout(cleanupOnce, options.cleanupDelayMs);
    } else {
      cleanupOnce();
    }

    return { ok: true };
  } catch (error) {
    cleanup();
    return { ok: false, reason: 'print-failed', error };
  }
}

function prepareGanttPrintDom(
  target: Element,
  ownerDocument: Document,
  metadataOptions: GanttPrintableMetadataOptions | undefined,
  options: NormalizedGanttPrintOptions,
): () => void {
  const body = ownerDocument.body;
  const previousBodyClass = body.className;
  const hadRootClass = target.classList.contains(options.printRootClass);
  const hadDefaultRootClass = target.classList.contains(DEFAULT_PRINT_OPTIONS.printRootClass);
  const headerClasses = [
    DEFAULT_PRINT_OPTIONS.printHeaderClass,
    options.printHeaderClass,
  ].filter((className, index, classNames) => classNames.indexOf(className) === index);
  const header = createPrintHeader(
    ownerDocument,
    createPrintableGanttMetadata(metadataOptions),
    headerClasses.join(' '),
  );

  body.classList.add('gantt-print-active');
  target.classList.add(DEFAULT_PRINT_OPTIONS.printRootClass);
  target.classList.add(options.printRootClass);
  target.insertBefore(header, target.firstChild);

  return () => {
    header.remove();

    if (!hadRootClass) {
      target.classList.remove(options.printRootClass);
    }

    if (!hadDefaultRootClass) {
      target.classList.remove(DEFAULT_PRINT_OPTIONS.printRootClass);
    }

    body.className = previousBodyClass;
  };
}

function createPrintHeader(
  ownerDocument: Document,
  metadata: GanttPrintableMetadata,
  headerClass: string,
): HTMLElement {
  const header = ownerDocument.createElement('header');
  const title = ownerDocument.createElement('h1');
  const details = [
    metadata.subtitle,
    metadata.rangeLabel,
    metadata.printedAt ? `Printed ${metadata.printedAt}` : undefined,
  ].filter((value): value is string => Boolean(value));

  header.className = headerClass;
  title.textContent = metadata.title;
  header.appendChild(title);

  if (details.length > 0) {
    const paragraph = ownerDocument.createElement('p');
    paragraph.textContent = details.join(' | ');
    header.appendChild(paragraph);
  }

  return header;
}

function normalizeTitle(title: string | undefined): string {
  const normalized = title?.trim();
  return normalized || 'Gantt schedule';
}

function normalizeOptionalText(value: string | undefined): string | undefined {
  const normalized = value?.trim();
  return normalized || undefined;
}

function createOnce(callback: () => void): () => void {
  let called = false;

  return () => {
    if (called) {
      return;
    }

    called = true;
    callback();
  };
}
RESTPersistence

REST adapter

Fetch-based project snapshot persistence with injected request options.

packages/enterprise/plugins/gantt/examples/rest-adapter.ts
Open preview
Source code
gantt/examples/rest-adapter.ts ts
/**
 * @packageDocumentation
 * REST persistence adapter example for Gantt project snapshots.
 *
 * The adapter loads and saves serialized `ProjectSnapshot` JSON through a
 * configurable endpoint and injected `fetch` implementation. It is intended as
 * a minimal backend integration pattern that keeps authentication headers,
 * abort signals, transport, and storage ownership in the host application.
 */
import type { ProjectSnapshot } from '../core/domain';
import {
  exportProjectSnapshot,
  parseProjectSnapshotJson,
} from '../core/project-snapshot-json';

export type ProjectSnapshotRestErrorCode =
  | 'HTTP_ERROR'
  | 'INVALID_SNAPSHOT'
  | 'NETWORK_ERROR';

export interface ProjectSnapshotRestErrorDetails {
  readonly status?: number;
  readonly payload?: unknown;
  readonly cause?: unknown;
}

export class ProjectSnapshotRestError extends Error {
  constructor(
    public readonly code: ProjectSnapshotRestErrorCode,
    message: string,
    public readonly details: ProjectSnapshotRestErrorDetails = {},
  ) {
    super(message);
    this.name = 'ProjectSnapshotRestError';
  }
}

export interface ProjectSnapshotRestAdapterOptions {
  /** Endpoint that returns and accepts a serialized project snapshot. */
  readonly url: string;
  /** Optional headers for authentication, tenancy, or content negotiation. */
  readonly headers?: Readonly<Record<string, string>>;
  /** Custom fetch implementation for tests, SSR, or framework adapters. */
  readonly fetchImpl?: typeof fetch;
  /** JSON indentation used for readable save payloads. */
  readonly space?: number | string;
}

export interface ProjectSnapshotRestRequestOptions {
  readonly signal?: AbortSignal;
}

/**
 * Framework-neutral example for persisting project snapshots through REST.
 */
export class ProjectSnapshotRestAdapter {
  private readonly fetchImpl: typeof fetch;

  constructor(private readonly options: ProjectSnapshotRestAdapterOptions) {
    this.fetchImpl = options.fetchImpl ?? fetch;
  }

  async load(request: ProjectSnapshotRestRequestOptions = {}): Promise<ProjectSnapshot> {
    const response = await this.request(() =>
      this.fetchImpl(this.options.url, {
        method: 'GET',
        headers: this.options.headers,
        signal: request.signal,
      }),
    );

    await this.assertOk(response);
    const json = await response.text();
    const parsed = parseProjectSnapshotJson(json);

    if (!parsed.ok) {
      throw new ProjectSnapshotRestError('INVALID_SNAPSHOT', parsed.error, {
        status: response.status,
        payload: json,
      });
    }

    return parsed.project;
  }

  async save(
    project: ProjectSnapshot,
    request: ProjectSnapshotRestRequestOptions = {},
  ): Promise<void> {
    const response = await this.request(() =>
      this.fetchImpl(this.options.url, {
        method: 'PUT',
        headers: {
          'content-type': 'application/json',
          ...this.options.headers,
        },
        body: exportProjectSnapshot(project, { space: this.options.space }),
        signal: request.signal,
      }),
    );

    await this.assertOk(response);
  }

  private async request(run: () => Promise<Response>): Promise<Response> {
    try {
      return await run();
    } catch (error) {
      throw new ProjectSnapshotRestError('NETWORK_ERROR', 'Project snapshot request failed.', {
        cause: error,
      });
    }
  }

  private async assertOk(response: Response): Promise<void> {
    if (response.ok) {
      return;
    }

    throw new ProjectSnapshotRestError('HTTP_ERROR', 'Project snapshot request returned an error.', {
      status: response.status,
      payload: await readErrorPayload(response),
    });
  }
}

async function readErrorPayload(response: Response): Promise<unknown> {
  const text = await response.text();
  if (!text) {
    return undefined;
  }

  try {
    return JSON.parse(text);
  } catch {
    return text;
  }
}
ValidationPermissions

Validation recipes

Forbidden dates, locked phases, approval gates, and role-guard checks.

packages/enterprise/plugins/gantt/examples/validation-recipes.ts
Open preview
Source code
gantt/examples/validation-recipes.ts ts
/**
 * @packageDocumentation
 * Validation recipe API for cancelable Gantt task changes.
 *
 * The module provides small composable validators for role guards, forbidden
 * date ranges, locked phases, and approval gates. Validators can be attached to
 * `gantt-before-task-change` through a generated event handler or invoked
 * directly before applying task mutations in non-DOM flows.
 */
import type { ISODateString, TaskEntity, TaskId } from '../core';
import type {
  GanttBeforeTaskChangeAction,
  GanttBeforeTaskChangeDetail,
} from '../grid/gantt-events';
import {
  createGanttRoleEditabilityHelpers,
  type GanttRoleEditabilityContext,
  type GanttRoleEditabilityPolicy,
} from '../features/permissions/role-editability';

export interface GanttValidationDecision {
  readonly ok: boolean;
  readonly code?: string;
  readonly message?: string;
}

export type GanttBeforeTaskChangeValidator<TContext = GanttValidationContext> = (
  detail: GanttBeforeTaskChangeDetail,
  context: TContext,
) => GanttValidationDecision | boolean;

export interface GanttValidationContext {
  readonly getTaskById?: (taskId: TaskId) => TaskEntity | null | undefined;
  readonly roleContext?: GanttRoleEditabilityContext;
  readonly approvals?: GanttApprovalRegistry;
}

export interface GanttValidationReject {
  readonly detail: GanttBeforeTaskChangeDetail;
  readonly decision: GanttValidationDecision;
}

export interface GanttValidationHandlerOptions<TContext> {
  readonly context: TContext | (() => TContext);
  readonly validators: readonly GanttBeforeTaskChangeValidator<TContext>[];
  readonly onReject?: (rejection: GanttValidationReject) => void;
}

export interface GanttDateRange {
  readonly startDate: ISODateString;
  readonly endDate: ISODateString;
  readonly label?: string;
}

export interface ForbiddenDateRangeValidatorOptions {
  readonly ranges: readonly GanttDateRange[];
  readonly actions?: readonly GanttBeforeTaskChangeAction[];
  readonly message?: (range: GanttDateRange) => string;
}

export interface LockedPhaseValidatorOptions<TContext> {
  readonly lockedPhaseIds: readonly string[];
  readonly getPhaseId: (detail: GanttBeforeTaskChangeDetail, context: TContext) => string | null | undefined;
  readonly actions?: readonly GanttBeforeTaskChangeAction[];
  readonly message?: (phaseId: string) => string;
}

export interface ApprovalRecord {
  readonly approved: boolean;
  readonly approvedBy?: string;
  readonly expiresAt?: string;
}

export type GanttApprovalRegistry =
  | Readonly<Record<TaskId, ApprovalRecord | undefined>>
  | ((detail: GanttBeforeTaskChangeDetail) => ApprovalRecord | null | undefined);

export interface ApprovalGateValidatorOptions<TContext extends GanttValidationContext> {
  readonly actions?: readonly GanttBeforeTaskChangeAction[];
  readonly requiresApproval?: (
    detail: GanttBeforeTaskChangeDetail,
    context: TContext,
  ) => boolean;
  readonly isApprovalValid?: (
    approval: ApprovalRecord,
    detail: GanttBeforeTaskChangeDetail,
    context: TContext,
  ) => boolean;
  readonly message?: string;
}

export interface RoleGuardValidatorOptions<TContext extends GanttValidationContext> {
  readonly policy: GanttRoleEditabilityPolicy;
  readonly getRoleContext?: (
    detail: GanttBeforeTaskChangeDetail,
    context: TContext,
  ) => GanttRoleEditabilityContext;
  readonly message?: string;
}

export function createGanttBeforeTaskChangeValidationHandler<TContext>(
  options: GanttValidationHandlerOptions<TContext>,
): (event: CustomEvent<GanttBeforeTaskChangeDetail>) => void {
  return (event) => {
    const context = typeof options.context === 'function'
      ? (options.context as () => TContext)()
      : options.context;
    const decision = validateGanttBeforeTaskChange(event.detail, context, options.validators);

    if (decision.ok) {
      return;
    }

    event.preventDefault();
    options.onReject?.({
      detail: event.detail,
      decision,
    });
  };
}

export function validateGanttBeforeTaskChange<TContext>(
  detail: GanttBeforeTaskChangeDetail,
  context: TContext,
  validators: readonly GanttBeforeTaskChangeValidator<TContext>[],
): GanttValidationDecision {
  for (const validator of validators) {
    const decision = normalizeDecision(validator(detail, context));

    if (!decision.ok) {
      return decision;
    }
  }

  return allow();
}

export function createForbiddenDateRangeValidator<TContext extends GanttValidationContext>(
  options: ForbiddenDateRangeValidatorOptions,
): GanttBeforeTaskChangeValidator<TContext> {
  const actions = new Set(options.actions);

  return (detail, context) => {
    if (!matchesAction(actions, detail.action)) {
      return allow();
    }

    const range = resolveProposedTaskRange(detail, context);
    if (!range) {
      return allow();
    }

    const forbiddenRange = options.ranges.find((candidate) => rangesOverlap(range, candidate));
    if (!forbiddenRange) {
      return allow();
    }

    return deny(
      'forbidden-date-range',
      options.message?.(forbiddenRange)
        ?? `Task changes cannot overlap ${forbiddenRange.label ?? 'the forbidden range'}.`,
    );
  };
}

export function createLockedPhaseValidator<TContext>(
  options: LockedPhaseValidatorOptions<TContext>,
): GanttBeforeTaskChangeValidator<TContext> {
  const actions = new Set(options.actions);
  const lockedPhaseIds = new Set(options.lockedPhaseIds);

  return (detail, context) => {
    if (!matchesAction(actions, detail.action)) {
      return allow();
    }

    const phaseId = options.getPhaseId(detail, context);
    if (!phaseId || !lockedPhaseIds.has(phaseId)) {
      return allow();
    }

    return deny(
      'locked-phase',
      options.message?.(phaseId) ?? `Phase "${phaseId}" is locked.`,
    );
  };
}

export function createApprovalGateValidator<TContext extends GanttValidationContext>(
  options: ApprovalGateValidatorOptions<TContext> = {},
): GanttBeforeTaskChangeValidator<TContext> {
  const defaultActions: readonly GanttBeforeTaskChangeAction[] = ['move', 'resize', 'progress'];
  const actions = new Set(options.actions ?? defaultActions);

  return (detail, context) => {
    if (!matchesAction(actions, detail.action)) {
      return allow();
    }

    if (options.requiresApproval && !options.requiresApproval(detail, context)) {
      return allow();
    }

    const approval = getApproval(detail, context.approvals);
    const isApprovalValid = options.isApprovalValid ?? defaultApprovalCheck;

    if (approval && isApprovalValid(approval, detail, context)) {
      return allow();
    }

    return deny(
      'approval-required',
      options.message ?? 'Task change requires approval.',
    );
  };
}

export function createRoleGuardValidator<TContext extends GanttValidationContext>(
  options: RoleGuardValidatorOptions<TContext>,
): GanttBeforeTaskChangeValidator<TContext> {
  const editability = createGanttRoleEditabilityHelpers(options.policy);

  return (detail, context) => {
    const roleContext = options.getRoleContext?.(detail, context)
      ?? context.roleContext
      ?? { roles: undefined };

    return editability.canEditTask(detail, roleContext)
      ? allow()
      : deny('role-denied', options.message ?? 'Current role cannot edit this task.');
  };
}

function resolveProposedTaskRange<TContext extends GanttValidationContext>(
  detail: GanttBeforeTaskChangeDetail,
  context: TContext,
): GanttDateRange | null {
  const task = detail.taskId ? context.getTaskById?.(detail.taskId) : null;
  const startDate = detail.changes.startDate
    ?? detail.previousValues.startDate
    ?? task?.startDate;
  const endDate = detail.changes.endDate
    ?? detail.previousValues.endDate
    ?? task?.endDate;

  return startDate && endDate
    ? { startDate, endDate }
    : null;
}

function rangesOverlap(left: GanttDateRange, right: GanttDateRange): boolean {
  return left.startDate <= right.endDate && right.startDate <= left.endDate;
}

function matchesAction(
  actions: ReadonlySet<GanttBeforeTaskChangeAction>,
  action: GanttBeforeTaskChangeAction,
): boolean {
  return actions.size === 0 || actions.has(action);
}

function getApproval(
  detail: GanttBeforeTaskChangeDetail,
  approvals: GanttApprovalRegistry | undefined,
): ApprovalRecord | null | undefined {
  if (!approvals || !detail.taskId) {
    return null;
  }

  return typeof approvals === 'function'
    ? approvals(detail)
    : approvals[detail.taskId];
}

function defaultApprovalCheck(approval: ApprovalRecord): boolean {
  if (!approval.approved) {
    return false;
  }

  return !approval.expiresAt || Date.parse(approval.expiresAt) > Date.now();
}

function normalizeDecision(decision: GanttValidationDecision | boolean): GanttValidationDecision {
  return typeof decision === 'boolean'
    ? { ok: decision }
    : decision;
}

function allow(): GanttValidationDecision {
  return { ok: true };
}

function deny(code: string, message: string): GanttValidationDecision {
  return {
    ok: false,
    code,
    message,
  };
}