Skip to content

Gantt Examples

The example browser reads the shared demo catalog directly. Every registered Gantt demo and framework variant appears here automatically; there is no separate manual inventory to maintain.

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

import { GanttPlugin } from '@revolist/gantt';
import { ExportExcelPlugin, RowStatusPlugin } from '@revolist/revogrid-pro';
import {
  STANDARD_CALENDAR,
  SHOWCASE_ASSIGNMENTS,
  SHOWCASE_BASELINES,
  SHOWCASE_COLUMNS_WITH_COMPLETION,
  SHOWCASE_DEFAULT_HIDDEN,
  SHOWCASE_DEPENDENCIES,
  SHOWCASE_GANTT_CONFIG,
  SHOWCASE_RESOURCES,
  SHOWCASE_TASKS,
  renderShowcaseTaskBarColor,
  renderShowcaseTaskBarContent,
} from '../shared/gantt-project-data';
import { currentTheme } from '../../composables/useRandomData';

// ─── 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 controls = document.createElement('div');
  controls.className = 'gantt-showcase-controls';
  container.appendChild(controls);

  grid.theme          = darkTheme ? 'darkCompact' : 'compact';
  grid.readonly       = false;
  grid.range          = true;
  grid.resize         = true;
  grid.rowSize        = 42;
  grid.rowHeaders     = false;
  grid.hideAttribution = true;
  grid.autoSizeColumn = false;
  grid.classList.add('gantt-showcase-grid');
  grid.plugins        = [GanttPlugin, ExportExcelPlugin, RowStatusPlugin];
  grid.hideColumns    = [...SHOWCASE_DEFAULT_HIDDEN];
  grid.columns        = [...SHOWCASE_COLUMNS_WITH_COMPLETION];
  grid.source         = [...SHOWCASE_TASKS];
  grid.ganttDependencies = [...SHOWCASE_DEPENDENCIES];
  grid.ganttCalendars    = [{ ...STANDARD_CALENDAR }];
  grid.ganttResources    = [...SHOWCASE_RESOURCES];
  grid.ganttAssignments  = [...SHOWCASE_ASSIGNMENTS];
  grid.ganttBaselines    = [...SHOWCASE_BASELINES];
  let showCriticalPath = Boolean(SHOWCASE_GANTT_CONFIG.visuals.showCriticalPath);
  let showBaseline = false;

  function applyGanttConfig() {
    grid.gantt = {
      ...SHOWCASE_GANTT_CONFIG,
      visuals: {
        ...SHOWCASE_GANTT_CONFIG.visuals,
        showCriticalPath,
        showBaseline,
        taskBarColorHook: renderShowcaseTaskBarColor,
        taskBarContentHook: renderShowcaseTaskBarContent,
      },
    } as typeof grid.gantt;
  }

  function createToggle(label: string, checked: () => boolean, onChange: (value: boolean) => void) {
    const control = document.createElement('label');
    control.className = 'gantt-showcase-control';
    const input = document.createElement('input');
    input.type = 'checkbox';
    input.className = 'gantt-showcase-control__input';
    const text = document.createElement('span');
    text.className = 'gantt-showcase-control__label';
    text.textContent = label;
    const sync = () => {
      input.checked = checked();
    };
    input.addEventListener('change', () => {
      onChange(input.checked);
      applyGanttConfig();
    });
    control.append(input, text);
    sync();
    return control;
  }

  controls.append(
    createToggle('Critical path', () => showCriticalPath, (value) => {
      showCriticalPath = value;
    }),
    createToggle('Baselines', () => showBaseline, (value) => {
      showBaseline = value;
    }),
  );

  applyGanttConfig();
  container.appendChild(grid);
}
Reacttsx
// src/components/gantt/gantt-showcase/GanttShowcase.tsx
import './gantt-showcase.scss';
import React, { useMemo, useRef, useState } from 'react';
import { RevoGrid } from '@revolist/react-datagrid';
import { GanttPlugin } from '@revolist/gantt';
import { ExportExcelPlugin, RowStatusPlugin } from '@revolist/revogrid-pro';
import {
  STANDARD_CALENDAR,
  SHOWCASE_ASSIGNMENTS,
  SHOWCASE_BASELINES,
  SHOWCASE_COLUMNS_WITH_COMPLETION,
  SHOWCASE_DEFAULT_HIDDEN,
  SHOWCASE_DEPENDENCIES,
  SHOWCASE_GANTT_CONFIG,
  SHOWCASE_RESOURCES,
  SHOWCASE_TASKS,
  renderShowcaseTaskBarColor,
  renderShowcaseTaskBarContent,
} from '../shared/gantt-project-data';
import type { GanttPluginConfig } from '@revolist/gantt';
import { currentTheme } from '../../composables/useRandomData';

const plugins = [GanttPlugin, ExportExcelPlugin, RowStatusPlugin];
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_WITH_COMPLETION];
const hiddenColumns = [...SHOWCASE_DEFAULT_HIDDEN];

function GanttShowcase() {
  const { isDark } = currentTheme();
  const darkTheme = isDark();
  const gridRef = useRef<HTMLRevoGridElement>(null);
  const [showCriticalPath, setShowCriticalPath] = useState(Boolean(SHOWCASE_GANTT_CONFIG.visuals.showCriticalPath));
  const [showBaseline, setShowBaseline] = useState(false);
  const ganttConfig: GanttPluginConfig = useMemo(() => ({
    ...SHOWCASE_GANTT_CONFIG,
    visuals: {
      ...SHOWCASE_GANTT_CONFIG.visuals,
      showCriticalPath,
      showBaseline,
      taskBarColorHook: renderShowcaseTaskBarColor,
      taskBarContentHook: renderShowcaseTaskBarContent,
    },
  } as GanttPluginConfig), [showCriticalPath, showBaseline]);

  return (
    <div className={`gantt-showcase-shell grow h-full ${darkTheme ? 'gantt-showcase-shell--dark' : 'gantt-showcase-shell--light'}`}>
      <div className="gantt-showcase-controls">
        <label className="gantt-showcase-control">
          <input
            className="gantt-showcase-control__input"
            type="checkbox"
            checked={showCriticalPath}
            onChange={(event) => setShowCriticalPath(event.currentTarget.checked)}
          />
          <span className="gantt-showcase-control__label">Critical path</span>
        </label>
        <label className="gantt-showcase-control">
          <input
            className="gantt-showcase-control__input"
            type="checkbox"
            checked={showBaseline}
            onChange={(event) => setShowBaseline(event.currentTarget.checked)}
          />
          <span className="gantt-showcase-control__label">Baselines</span>
        </label>
      </div>
      <RevoGrid
        ref={gridRef}
        className="gantt-showcase-grid"
        theme={darkTheme ? 'darkCompact' : 'compact'}
        hideAttribution
        readonly={false}
        range
        resize
        rowSize={42}
        rowHeaders={false}
        autoSizeColumn={false}
        plugins={plugins}
        hideColumns={hiddenColumns}
        source={source}
        columns={columns}
        gantt={ganttConfig}
        ganttDependencies={dependencies}
        ganttCalendars={calendars}
        ganttResources={resources}
        ganttAssignments={assignments}
        ganttBaselines={baselines}
      />
    </div>
  );
}

export default GanttShowcase;
Vuevue
<template>
  <div :class="shellClass">
    <div class="gantt-showcase-controls">
      <label class="gantt-showcase-control">
        <input v-model="showCriticalPath" class="gantt-showcase-control__input" type="checkbox" />
        <span class="gantt-showcase-control__label">Critical path</span>
      </label>
      <label class="gantt-showcase-control">
        <input v-model="showBaseline" class="gantt-showcase-control__input" type="checkbox" />
        <span class="gantt-showcase-control__label">Baselines</span>
      </label>
    </div>
    <RevoGrid
      ref="gridRef"
      class="gantt-showcase-grid skip-style cell-border"
      hide-attribution
      :readonly="false"
      :range="true"
      :resize="true"
      :row-size="42"
      :row-headers="false"
      :auto-size-column="false"
      :theme="gridTheme"
      :plugins="plugins"
      :hide-columns.prop="hiddenColumns"
      :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, onMounted, ref } from 'vue';
import RevoGrid from '@revolist/vue3-datagrid';
import { ExportExcelPlugin, RowStatusPlugin } from '@revolist/revogrid-pro';
import {
  STANDARD_CALENDAR,
  SHOWCASE_ASSIGNMENTS,
  SHOWCASE_BASELINES,
  SHOWCASE_COLUMNS_WITH_COMPLETION,
  SHOWCASE_DEFAULT_HIDDEN,
  SHOWCASE_DEPENDENCIES,
  SHOWCASE_GANTT_CONFIG,
  SHOWCASE_RESOURCES,
  SHOWCASE_TASKS,
  renderShowcaseTaskBarColor,
  renderShowcaseTaskBarContent,
} from '../shared/gantt-project-data';
import { currentThemeVue } from '../../composables/useRandomData';

// ── 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_WITH_COMPLETION]);
const hiddenColumns = [...SHOWCASE_DEFAULT_HIDDEN];
const showCriticalPath = ref(Boolean(SHOWCASE_GANTT_CONFIG.visuals.showCriticalPath));
const showBaseline = ref(false);
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 = computed(() => ({
  ...SHOWCASE_GANTT_CONFIG,
  visuals: {
    ...SHOWCASE_GANTT_CONFIG.visuals,
    showCriticalPath: showCriticalPath.value,
    showBaseline: showBaseline.value,
    taskBarColorHook: renderShowcaseTaskBarColor,
    taskBarContentHook: renderShowcaseTaskBarContent,
  },
}));

// ── Refs ──────────────────────────────────────────────────────────────────────
const gridRef    = ref<InstanceType<typeof RevoGrid> | HTMLRevoGridElement | null>(null);

onMounted(async () => {
  const { GanttPlugin } = await import('@revolist/gantt');

  plugins.value = [GanttPlugin, ExportExcelPlugin, RowStatusPlugin];
});
</script>

<style src="./gantt-showcase.scss" lang="scss"></style>

<style scoped>
.gantt-showcase :deep(revo-grid) {
  flex: 1;
  min-height: 0;
}
</style>
Angularts
// src/components/gantt/gantt-showcase/GanttShowcaseAngular.ts
import {
  Component,
  NO_ERRORS_SCHEMA,
  ViewEncapsulation,
} from '@angular/core';
import { RevoGrid } from '@revolist/angular-datagrid';
import { GanttPlugin } from '@revolist/gantt';
import { ExportExcelPlugin, RowStatusPlugin } from '@revolist/revogrid-pro';
import type { GanttPluginConfig } from '@revolist/gantt';
import {
  STANDARD_CALENDAR,
  SHOWCASE_ASSIGNMENTS,
  SHOWCASE_BASELINES,
  SHOWCASE_COLUMNS_WITH_COMPLETION,
  SHOWCASE_DEFAULT_HIDDEN,
  SHOWCASE_DEPENDENCIES,
  SHOWCASE_GANTT_CONFIG,
  SHOWCASE_RESOURCES,
  SHOWCASE_TASKS,
  renderShowcaseTaskBarColor,
  renderShowcaseTaskBarContent,
} from '../shared/gantt-project-data';
import { currentTheme } from '../../composables/useRandomData';

function createGanttConfig(showCriticalPath: boolean, showBaseline: boolean): GanttPluginConfig {
  return {
    ...SHOWCASE_GANTT_CONFIG,
    visuals: {
      ...SHOWCASE_GANTT_CONFIG.visuals,
      showCriticalPath,
      showBaseline,
      taskBarColorHook: renderShowcaseTaskBarColor,
      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.scss'],
  template: `
    <div [class]="shellClass">
      <div class="gantt-showcase-controls">
        <label class="gantt-showcase-control">
          <input
            class="gantt-showcase-control__input"
            type="checkbox"
            [checked]="showCriticalPath"
            (change)="setCriticalPath($any($event.target).checked)"
          />
          <span class="gantt-showcase-control__label">Critical path</span>
        </label>
        <label class="gantt-showcase-control">
          <input
            class="gantt-showcase-control__input"
            type="checkbox"
            [checked]="showBaseline"
            (change)="setBaseline($any($event.target).checked)"
          />
          <span class="gantt-showcase-control__label">Baselines</span>
        </label>
      </div>
      <revo-grid
        class="gantt-showcase-grid skip-style cell-border"
        [theme]="theme"
        [hideAttribution]="true"
        [readonly]="false"
        [range]="true"
        [resize]="true"
        [rowSize]="42"
        [rowHeaders]="false"
        [autoSizeColumn]="false"
        [plugins]="plugins"
        [hideColumns]="hiddenColumns"
        [source]="source"
        [columns]="columns"
        [gantt]="ganttConfig"
        [ganttDependencies]="dependencies"
        [ganttCalendars]="calendars"
        [ganttResources]="resources"
        [ganttAssignments]="assignments"
        [ganttBaselines]="baselines"
      ></revo-grid>
    </div>
  `,
})
export class GanttShowcaseGridComponent {
  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, RowStatusPlugin];
  showCriticalPath      = Boolean(SHOWCASE_GANTT_CONFIG.visuals.showCriticalPath);
  showBaseline          = false;
  ganttConfig           = createGanttConfig(this.showCriticalPath, this.showBaseline);
  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_WITH_COMPLETION];
  readonly hiddenColumns = [...SHOWCASE_DEFAULT_HIDDEN];

  setCriticalPath(value: boolean): void {
    this.showCriticalPath = value;
    this.ganttConfig = createGanttConfig(this.showCriticalPath, this.showBaseline);
  }

  setBaseline(value: boolean): void {
    this.showBaseline = value;
    this.ganttConfig = createGanttConfig(this.showCriticalPath, this.showBaseline);
  }
}

19 of 19 Gantt examples

Planning

Gantt Microsoft Project preview
Microsoft ProjectImportExport

Microsoft Project

Import Microsoft Project XML or MPP14, edit the schedule, inspect conversion warnings, and export the current project as XML.

Source files
  • src/index.ts
  • src/gantt-microsoft-project-data.ts
  • src/gantt-microsoft-project.css
  • src/recipes/current-schedule-snapshot.ts
Gantt Feature Recipes preview
ExamplesValidationContext Menu

Feature Recipes

Runnable example of the application-owned Gantt example recipes for column presets, context menus, validation, editor forms, and task status indicators.

Source files
  • src/index.ts
  • src/GanttExampleRecipesShared.ts
  • src/gantt-example-recipes.css
  • src/shared/gantt-project-data.ts
  • src/shared/gantt-project-base-data.ts
  • src/shared/gantt-showcase-data.ts
  • src/shared/gantt-showcase-columns.ts
  • src/shared/gantt-showcase-columns.css
  • src/shared/gantt-showcase-icons.ts
  • src/recipes/current-schedule-snapshot.ts
  • src/recipes/index.ts
  • src/recipes/print-pdf-recipe.css
  • gantt/features/context-menu/context-menu-extensions.ts
  • src/recipes/feature-helper-examples.ts
  • src/recipes/graphql-adapter.ts
  • src/recipes/grid-projection-examples.ts
  • src/recipes/persistence-examples.ts
  • src/recipes/postgres-persistence.ts
  • src/recipes/print-pdf-recipe.ts
  • src/recipes/rest-adapter.ts
  • src/recipes/validation-recipes.ts
Gantt Task Editor Form preview
EditorFormsTaskUpdate

Task Editor Form

Standalone task editor UI built from the exported Gantt field schema and submit normalizer.

Source files
  • src/index.ts
Gantt (Production Patterns) preview
HistoryRead OnlyLocalizationCustomization

Production Patterns

Try undo/redo, read-only and locked tasks, localized dates, custom visuals, JSON/Excel export, and a 750-task virtualized project.

Source files
  • src/index.ts
  • src/GanttProductionPatternsShared.ts
  • src/gantt-production-patterns.css
  • src/shared/gantt-project-data.ts
  • src/shared/gantt-project-base-data.ts
  • src/shared/gantt-showcase-data.ts
  • src/shared/gantt-showcase-columns.ts
  • src/shared/gantt-showcase-columns.css
  • src/shared/gantt-showcase-icons.ts
  • src/recipes/current-schedule-snapshot.ts
  • src/recipes/index.ts
  • src/recipes/print-pdf-recipe.css
  • gantt/features/context-menu/context-menu-extensions.ts
  • src/recipes/feature-helper-examples.ts
  • src/recipes/graphql-adapter.ts
  • src/recipes/grid-projection-examples.ts
  • src/recipes/persistence-examples.ts
  • src/recipes/postgres-persistence.ts
  • src/recipes/print-pdf-recipe.ts
  • src/recipes/rest-adapter.ts
  • src/recipes/validation-recipes.ts
Gantt (Full Showcase) preview
ShowcaseFull Feature

Full Showcase

Comprehensive Gantt implementation featuring toolbars, resources, and assignments.

Source files
  • src/index.ts
  • shared/gantt-project-data.ts
  • shared/gantt-project-base-data.ts
  • shared/gantt-showcase-data.ts
  • shared/gantt-showcase-columns.ts
  • shared/gantt-showcase-columns.css
  • shared/gantt-showcase-icons.ts
  • src/gantt-showcase.scss

Scheduling

Gantt Chart (Basic) preview
Project ManagementScheduling

Basic

A powerful Gantt chart plugin for project scheduling and task management.

Source files
  • src/index.ts
Gantt Chart (10,000 Tasks) preview
Big DataPerformanceDependencies

10,000 Tasks

Test virtual scrolling and task editing with 10,000 tasks and 19,796 dependencies across three months.

Source files
  • src/index.ts
Gantt Chart (Twenty-Year Timeline) preview
Big DataPerformanceDependenciesZoom

Twenty-Year Timeline

Test responsive horizontal scrolling, zooming, and task editing with 100 tasks and 194 dependencies across twenty years.

Source files
  • src/index.ts
Gantt (Scheduling) preview
SchedulingAutomation

Scheduling

Advanced scheduling features including auto-scheduling and constraint handling.

Source files
  • src/index.ts
Gantt (Advanced Dependencies) preview
DependenciesLead Lag

Advanced Dependencies

Finish-start, start-start, finish-finish, and start-finish links with lag, lead, and editable dependency columns.

Source files
  • src/index.ts
  • src/gantt-advanced-data.ts
  • src/GanttAdvancedVanillaDemo.ts
Gantt (Constraints And Deadlines) preview
ConstraintsDeadlines

Constraints And Deadlines

Constraint windows, hard constraints, deadline indicators, and scheduling warnings.

Source files
  • src/index.ts
  • src/gantt-advanced-data.ts
  • src/GanttAdvancedVanillaDemo.ts
Gantt (Project Finish ALAP) preview
ALAPScheduling

Project Finish ALAP

Backward scheduling from a project finish anchor with successor-driven task placement.

Source files
  • src/index.ts
  • src/gantt-advanced-data.ts
  • src/GanttAdvancedVanillaDemo.ts

Resources & Work

Gantt (Resource Planning) preview
ResourcesScheduling

Resource Planning

Resource assignments, allocation units, cost, over-allocation warnings, and deterministic auto-leveling.

Source files
  • src/index.ts
  • src/gantt-advanced-data.ts
  • src/GanttAdvancedVanillaDemo.ts
  • src/gantt-resource-planning-toolbar.ts
Gantt (Scheduler Features) preview
SchedulingLead LagWork

Scheduler Features

A single scheduler-focused example showing dependency lag, dependency lead, and fixed-duration, fixed-work, and fixed-units effort modes.

Source files
  • src/index.ts
  • src/gantt-advanced-data.ts
  • src/GanttAdvancedVanillaDemo.ts
Gantt (Progress And Work) preview
ProgressWork

Progress And Work

Fixed-duration, fixed-work, fixed-units, actuals, remaining duration, and split-range scheduling.

Source files
  • src/index.ts
  • src/gantt-advanced-data.ts
  • src/GanttAdvancedVanillaDemo.ts
Gantt (Split Tasks) preview
Split TasksScheduling

Split Tasks

Pause and resume work within task spans using split ranges while preserving planned working duration.

Source files
  • src/index.ts
  • src/gantt-advanced-data.ts
  • src/GanttAdvancedVanillaDemo.ts

Analysis & Optimization

Gantt Chart (Baselines) preview
BaselinesProject Management

Baselines

Compare current project progress against approved baselines in real-time.

Source files
  • src/index.ts
Gantt (Critical Path) preview
Critical PathOptimization

Critical Path

Highlight tasks on the critical path to identify project bottlenecks.

Source files
  • src/index.ts
Gantt (Critical Path Analysis) preview
Critical PathSlack

Critical Path Analysis

Show early/late dates, total slack, and noncritical parallel work with positive float.

Source files
  • src/index.ts
  • src/gantt-advanced-data.ts
  • src/GanttAdvancedVanillaDemo.ts

These helpers live in the examples workspace. Copy the relevant recipes files beside your application example and use relative imports; they are not exports of @revolist/gantt.

Application recipe sources

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

Context menuGrid actions

Context menu extensions

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

packages/gantt/src/gantt/features/context-menu/context-menu-extensions.ts
Open preview
Source code
gantt/features/context-menu/context-menu-extensions.tsts
/**
 * @packageDocumentation
 * Helpers 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 exportIcon from '@fortawesome/fontawesome-free/svgs/solid/file-export.svg?raw';
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',
    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',
    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, icon: options.icon ?? exportIcon }, {
    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' });
    },
  }), title: typeof options.name === 'string' ? options.name : 'Export 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.

examples/components/src/components/gantt/recipes/feature-helper-examples.ts
Open preview
Source code
src/gantt/recipes/feature-helper-examples.tsts
/**
 * @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 '@revolist/gantt';
import type { AssignmentEntity, ResourceEntity, TaskEntity, TaskId, TaskUpdate } from '@revolist/gantt';
import type { SchedulerConflict, SchedulerIssue } from '@revolist/gantt';
import type { ResourcePlanningRowData, GanttGridRow } from '@revolist/gantt';
import {
  createGanttRoleEditabilityHelpers,
  type GanttBeforeChangeDetailByEventName,
  type GanttBeforeChangeEventName,
  type GanttRoleEditabilityContext,
  type GanttRoleEditabilityPolicy,
} from '@revolist/gantt';
import {
  TASK_EDITOR_FIELD_SCHEMA,
  createTaskEditorFormValues,
  normalizeTaskEditorSubmit,
  type NormalizeTaskEditorSubmitOptions,
  type TaskEditorFieldId,
  type TaskEditorFieldSchema,
  type TaskEditorFormValues,
  type TaskEditorSubmitResult,
} from '@revolist/gantt';
import {
  createTaskDetailPopoverModel,
  type TaskDetailPopoverModel,
} from '@revolist/gantt';
import {
  buildDependencyValidationSummary,
  type DependencyValidationSummary,
  type DependencyValidationSummaryInput,
} from '@revolist/gantt';
import {
  buildResourceOverallocationSummary,
  type ResourceOverallocationSummary,
  type ResourceOverallocationSummaryInput,
  type ResourceOverallocationLoadSummary,
} from '@revolist/gantt';
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: GanttGridRow;
  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',
  'duration',
  '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.

examples/components/src/components/gantt/recipes/graphql-adapter.ts
Open preview
Source code
src/gantt/recipes/graphql-adapter.tsts
/**
 * @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 '@revolist/gantt';
import {
  exportProjectSnapshot,
  parseProjectSnapshotJson,
} from '@revolist/gantt';

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 === false) {
      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.

examples/components/src/components/gantt/recipes/grid-projection-examples.ts
Open preview
Source code
src/gantt/recipes/grid-projection-examples.tsts
/**
 * @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 '@revolist/gantt';
import {
  mapGanttRowsToExcelExport,
  type GanttExcelExportRow,
  type GanttExcelMappingOptions,
} from '@revolist/gantt';
import {
  printGanttContainer,
  type GanttPrintDateRange,
  type GanttPrintOptions,
  type GanttPrintResult,
} from './print-pdf-recipe';
import {
  createGanttColumnPreset,
  getGanttColumnPresetProps,
  type GanttColumnPresetName,
  type GanttColumnPresetProp,
} from '@revolist/gantt';
import {
  exportGanttExcel,
  type GanttExcelExportOptions,
} from '@revolist/gantt';
import {
  createTaskStatusIndicators,
  type TaskStatusIndicatorProjection,
} from '@revolist/gantt';
import type { GanttGridRow } from '@revolist/gantt';
import {
  createGanttContextMenuExtensionItems,
  type AssignResourceMenuItemOptions,
  type DeleteTaskMenuItemOptions,
  type DuplicateTaskMenuItemOptions,
  type ExportMenuItemOptions,
  type GanttContextMenuExtensionContext,
} from '@revolist/gantt';

export interface GanttExcelExportRecipeOptions extends GanttExcelMappingOptions {
  readonly rows: readonly GanttGridRow[];
  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<GanttGridRow, '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.

examples/components/src/components/gantt/recipes/persistence-examples.ts
Open preview
Source code
src/gantt/recipes/persistence-examples.tsts
/**
 * @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 '@revolist/gantt';
import {
  cloneProjectSnapshot,
  exportProjectSnapshot,
  parseProjectSnapshotJson,
  type ProjectSnapshotParseResult,
} from '@revolist/gantt';
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: [
      'examples/components/src/components/gantt/recipes/rest-adapter.ts',
      'packages/gantt/src/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: [
      'examples/components/src/components/gantt/recipes/graphql-adapter.ts',
      'packages/gantt/src/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: [
      'examples/components/src/components/gantt/recipes/postgres-persistence.ts',
      'packages/gantt/src/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: [
      'examples/components/src/components/gantt/recipes/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: [
      'examples/components/src/components/gantt/recipes/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/gantt/src/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: [
      'apps/portal/src/components/gantt/GanttSvelteExample.svelte',
      'apps/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: [
      'apps/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 === false) {
        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 === false) {
        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 === false) {
        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.

examples/components/src/components/gantt/recipes/postgres-persistence.ts
Open preview
Source code
src/gantt/recipes/postgres-persistence.tsts
/**
 * @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 '@revolist/gantt';
import {
  exportProjectSnapshot,
  parseProjectSnapshotJson,
} from '@revolist/gantt';

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 === false) {
    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.

examples/components/src/components/gantt/recipes/print-pdf-recipe.ts
Open preview
Source code
src/gantt/recipes/print-pdf-recipe.tsts
/**
 * @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.
 */
type ISODateString = import('@revolist/gantt').TaskEntity['startDate'];
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;
  removeEventListener?(type: 'afterprint', listener: () => void): 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}) > *:not(.${normalized.printRootClass}):not(.revo-gantt-print-ancestor), body.gantt-print-active .revo-gantt-print-ancestor > *:not(.${normalized.printRootClass}):not(.revo-gantt-print-ancestor) { display: none !important; }`,
    '  body.gantt-print-active .revo-gantt-print-ancestor { display: block !important; height: auto !important; overflow: visible !important; }',
    `  .${normalized.printRootClass} { display: block !important; width: 100% !important; overflow: visible !important; }`,
    `  .${normalized.printRootClass} revo-grid { height: var(--gantt-print-height, 500px) !important; overflow: visible !important; }`,
    `  .${normalized.printRootClass} .rgRoot { height: 100% !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);

  let cleanupTimer: number | undefined;
  const cleanupOnce = createOnce(() => {
    printWindow.removeEventListener?.('afterprint', cleanupOnce);
    if (cleanupTimer !== undefined) ownerDocument.defaultView?.clearTimeout(cleanupTimer);
    cleanup();
  });

  try {
    printWindow.addEventListener?.('afterprint', cleanupOnce, { once: true } as AddEventListenerOptions);
    if (options.cleanupDelayMs !== undefined) {
      cleanupTimer = ownerDocument.defaultView?.setTimeout(cleanupOnce, options.cleanupDelayMs);
    }
    printWindow.print();
    if (options.cleanupDelayMs === undefined) cleanupOnce();
    return { ok: true };
  } catch (error) {
    cleanupOnce();
    return { ok: false, reason: 'print-failed', error };
  }
}

function prepareGanttPrintDom(
  target: Element,
  ownerDocument: Document,
  metadataOptions: GanttPrintableMetadataOptions | undefined,
  options: NormalizedGanttPrintOptions,
): () => void {
  // Virtualized rows need a definite viewport height when print ancestors become
  // auto-sized. Capture the current viewport, then restore application styles.
  const grids = [
    ...(target.matches('revo-grid') ? [target as HTMLElement] : []),
    ...Array.from(target.querySelectorAll<HTMLElement>('revo-grid')),
  ];
  const gridHeights = grids.map((grid) => ({
    grid,
    previous: grid.style.getPropertyValue('--gantt-print-height'),
    priority: grid.style.getPropertyPriority('--gantt-print-height'),
  }));
  for (const { grid } of gridHeights) {
    const height = grid.getBoundingClientRect().height;
    if (height > 0) grid.style.setProperty('--gantt-print-height', `${height}px`);
  }
  const printStyle = ownerDocument.createElement('style');
  printStyle.dataset.ganttPrintStyle = '';
  printStyle.textContent = createGanttPrintStyles(options);
  ownerDocument.head.appendChild(printStyle);
  const body = ownerDocument.body;
  const hadActiveClass = body.classList.contains('gantt-print-active');
  const addedAncestors: Element[] = [];
  for (let ancestor = target.parentElement; ancestor && ancestor !== body; ancestor = ancestor.parentElement) {
    if (!ancestor.classList.contains('revo-gantt-print-ancestor')) {
      ancestor.classList.add('revo-gantt-print-ancestor');
      addedAncestors.push(ancestor);
    }
  }
  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();
    printStyle.remove();
    for (const { grid, previous, priority } of gridHeights) {
      if (previous) grid.style.setProperty('--gantt-print-height', previous, priority);
      else grid.style.removeProperty('--gantt-print-height');
    }

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

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

    for (const ancestor of addedAncestors) {
      ancestor.classList.remove('revo-gantt-print-ancestor');
    }
    if (!hadActiveClass) body.classList.remove('gantt-print-active');
  };
}

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.

examples/components/src/components/gantt/recipes/rest-adapter.ts
Open preview
Source code
src/gantt/recipes/rest-adapter.tsts
/**
 * @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 '@revolist/gantt';
import {
  exportProjectSnapshot,
  parseProjectSnapshotJson,
} from '@revolist/gantt';

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 === false) {
      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.

examples/components/src/components/gantt/recipes/validation-recipes.ts
Open preview
Source code
src/gantt/recipes/validation-recipes.tsts
/**
 * @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 { TaskEntity, TaskId } from '@revolist/gantt';
type ISODateString = import('@revolist/gantt').TaskEntity['startDate'];
import type {
  GanttBeforeTaskChangeAction,
  GanttBeforeTaskChangeDetail,
} from '@revolist/gantt';
import {
  createGanttRoleEditabilityHelpers,
  type GanttRoleEditabilityContext,
  type GanttRoleEditabilityPolicy,
} from '@revolist/gantt';

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,
  };
}