Plan editing

The same plan with editing on: drag a bar or its edges, drag the diamond to set percent, drag a dot onto another bar to draw a link. A phase moves its whole subtree in one callback, successors slide forward over weekends, a cycle is refused, and Ctrl+Z replays it backwards. A log panel lists every write-back the view asks for. (requires @svgrid/enterprise)

A live, editable Svelte 5 data grid example from the SvGrid gallery (Gantt). See the SvGrid documentation for the full API.

About this example

An editable project plan in the Svelte 5 data grid: drag a Gantt bar to move it, drag either edge to resize, drag the diamond on the fill to set percent complete, and drag a bar's end handle onto another bar to draw a dependency. Dragging a phase moves its whole subtree in one callback, moving a predecessor slides its successors forward over weekends and holidays, a link that would close a cycle is refused with a flash, and Ctrl+Z replays the callbacks backwards. A log panel lists every write-back the view asks for, because the view never mutates a row itself.

The same plan as demo 474, with editable on. Every gesture writes back through a callback and nothing else: the view never mutates a row, so the log panel on the right is the complete record of what it asked for.

  • Drag a bar to move it. Drag a PHASE and its whole subtree goes with it, in one onTaskMove carrying the batch.
  • Drag either edge to resize; drag the diamond on the fill to set percent complete in 5% steps.
  • Drag the dot at a bar's end onto another bar to draw a link. The edges you drag from and drop on pick FS / SS / FF / SF. A link that would close a cycle flashes red instead.
  • Moving a predecessor slides its successors forward, skipping weekends and the one holiday below (respectWorkingTime).
  • Click a bar for the drawer; right-click for the menu; Ctrl+Z and Ctrl+Shift+Z undo and redo, replaying the callbacks so the data follows.

Imports, features and API used

Imports: @svgrid/grid, @svgrid/enterprise

Columns: name (Task), owner (Owner), start (Start), end (Finish)

Frequently asked questions

Does dragging a bar change my data?

No. The view moves its own overlay and fires onTaskMove with the new dates; your handler decides whether to write them to the row. Every gesture in this demo is listed in the log panel for that reason.

What happens when I drag a phase?

Its whole subtree moves by the same amount and one onTaskMove fires carrying the batch in its subtree field, so the write-back is a single pass rather than one callback per child.

How is a dependency drawn?

Drag the dot at a bar's start or end onto another bar. The edge you drag from and the half you drop on choose finish-to-start, start-to-start, finish-to-finish or start-to-finish. A link that would close a cycle flashes red and is not added.

Related documentation

Related articles

  • Paste from Excel into a Svelte Data Grid - How to wire up clipboard paste so users can drop a copied Excel or Google Sheets block directly into SvGrid - TSV parsing, type coercion, validation, and row growth all covered.
  • A Fill Handle (Drag to Fill) in SvGrid - Build a working spreadsheet-style fill handle on top of SvGrid's cell selection and editing - pointer tracking, range highlighting, series fill, and undo/redo integration all covered.
  • An Editable Select / Dropdown Cell in SvGrid - Build a dropdown cell editor in SvGrid using a Svelte 5 snippet - constrain user input to a fixed option set while keeping sort, filter, and undo working on the underlying value.

Source code (475-gantt-editing.svelte)

<script lang="ts">
  /**
   * 475. Plan editing (Enterprise Gantt)
   * ------------------------------------
   * The same plan as demo 474, with `editable` on. Every gesture writes back
   * through a callback and nothing else: the view never mutates a row, so the
   * log panel on the right is the complete record of what it asked for.
   *
   *   - Drag a bar to move it. Drag a PHASE and its whole subtree goes with
   *     it, in one `onTaskMove` carrying the batch.
   *   - Drag either edge to resize; drag the diamond on the fill to set
   *     percent complete in 5% steps.
   *   - Drag the dot at a bar's end onto another bar to draw a link. The
   *     edges you drag from and drop on pick FS / SS / FF / SF. A link that
   *     would close a cycle flashes red instead.
   *   - Moving a predecessor slides its successors forward, skipping weekends
   *     and the one holiday below (`respectWorkingTime`).
   *   - Click a bar for the drawer; right-click for the menu; `Ctrl+Z` and
   *     `Ctrl+Shift+Z` undo and redo, replaying the callbacks so the data
   *     follows.
   */
  import { SvGrid, type ColumnDef, type GanttConfig, type GanttDependency } from '@svgrid/grid'
  import { enableGanttView } from '@svgrid/enterprise'

  enableGanttView()

  type Task = {
    id: string
    name: string
    owner: string
    start: string
    end?: string
    progress: number
    parentId: string | null
    milestone?: boolean
    color?: string
  }

  const COLOR = { plan: '#7c3aed', build: '#2563eb', ship: '#16a34a' } as const
  const MILESTONE = '#f59e0b'

  const pad = (n: number) => String(n).padStart(2, '0')
  const iso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
  const base = new Date()
  base.setHours(0, 0, 0, 0)
  base.setDate(base.getDate() - ((base.getDay() + 6) % 7) - 7)
  const at = (d: number) => {
    const x = new Date(base)
    x.setDate(x.getDate() + d)
    return iso(x)
  }
  /**
   * The inclusive finish of a task that starts `from` days from the anchor
   * (a Monday) and runs `days` WORKING days: every task starts on a weekday
   * and ends on one, the way a real plan is typed in.
   */
  const wd = (from: number, days: number) => {
    let d = from
    for (let left = days; left > 1; left--) {
      d += d % 7 === 4 ? 3 : 1 // Friday -> Monday
    }
    return at(d)
  }

  let seq = 100
  let rows = $state<Task[]>([
    { id: 'p1', name: 'Plan', owner: 'Priya', start: at(0), progress: 0, parentId: null, color: COLOR.plan },
    { id: 't1', name: 'Scope & estimates', owner: 'Priya', start: at(0), end: wd(0, 4), progress: 100, parentId: 'p1', color: COLOR.plan },
    { id: 't2', name: 'Tech design', owner: 'Marco', start: at(7), end: wd(7, 4), progress: 60, parentId: 'p1', color: COLOR.plan },

    { id: 'p2', name: 'Build', owner: 'Sven', start: at(14), progress: 0, parentId: null, color: COLOR.build },
    { id: 't3', name: 'Service layer', owner: 'Sven', start: at(14), end: wd(14, 5), progress: 30, parentId: 'p2', color: COLOR.build },
    { id: 't4', name: 'Client', owner: 'Mia', start: at(16), end: wd(16, 8), progress: 10, parentId: 'p2', color: COLOR.build },
    { id: 't5', name: 'QA pass', owner: 'Lena', start: at(28), end: wd(28, 5), progress: 0, parentId: 'p2', color: COLOR.build },

    { id: 'p3', name: 'Ship', owner: 'Omar', start: at(35), progress: 0, parentId: null, color: COLOR.ship },
    { id: 't6', name: 'Release notes', owner: 'Lena', start: at(35), end: wd(35, 4), progress: 0, parentId: 'p3', color: COLOR.ship },
    { id: 'm1', name: 'Go live', owner: '-', start: at(42), progress: 0, parentId: null, milestone: true, color: MILESTONE },
  ])

  let dependencies = $state<GanttDependency[]>([
    { id: 'd1', from: 't1', to: 't2' },
    { id: 'd2', from: 't2', to: 't3' },
    // The client starts two days after the service layer has: start-to-start.
    { id: 'd7', from: 't3', to: 't4', type: 'SS', lag: 2 },
    { id: 'd3', from: 't3', to: 't5' },
    { id: 'd4', from: 't4', to: 't5' },
    { id: 'd5', from: 't5', to: 't6' },
    { id: 'd6', from: 't6', to: 'm1' },
  ])

  // One working day off, to show cascades stepping over it.
  const holidays = [at(24)]

  const columns: ColumnDef<any, Task>[] = [
    { field: 'name', header: 'Task', width: 190, editorType: 'text' },
    { field: 'owner', header: 'Owner', width: 90, editorType: 'text' },
    { field: 'start', header: 'Start', width: 110 },
    { field: 'end', header: 'Finish', width: 110 },
  ]

  // ---- the log: every callback the view fires, newest first ----------------
  type Entry = { id: number; what: string; detail: string }
  let log = $state<Entry[]>([])
  let logSeq = 0
  const fmt = (d: Date) => d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
  function note(what: string, detail: string) {
    log = [{ id: ++logSeq, what, detail }, ...log].slice(0, 40)
  }

  const byId = (id: string) => rows.find((r) => r.id === id)
  /** Write a span back to a row. The view asked; this is the app agreeing. */
  function writeSpan(row: Task, start: Date, end: Date) {
    row.start = iso(start)
    // `end` is exclusive, and these rows store an inclusive date-only finish.
    const last = new Date(end.getTime() - 86400000)
    row.end = iso(last < start ? start : last)
  }

  const ganttCfg: GanttConfig<any, Task> = {
    startField: 'start',
    endField: 'end',
    titleField: 'name',
    progressField: 'progress',
    parentField: 'parentId',
    milestoneField: 'milestone',
    colorField: 'color',
    tableColumns: ['name', 'owner', '__duration', '__progress'],
    dependencies,
    holidays,
    respectWorkingTime: true,
    zoom: 'week',
    weekStartsOn: 1,
    editable: true,
    history: true,
    drawer: true,
    tooltip: true,

    onTaskMove: (e) => {
      writeSpan(e.row, e.start, e.end)
      for (const s of e.subtree ?? []) writeSpan(s.row, s.start, s.end)
      note(
        'onTaskMove',
        `${e.row.name} -> ${fmt(e.start)}${e.subtree?.length ? ` (+${e.subtree.length} in subtree)` : ''}`,
      )
    },
    onTaskResize: (e) => {
      writeSpan(e.row, e.start, e.end)
      note('onTaskResize', `${e.row.name} ${e.edge} edge -> ${fmt(e.start)} to ${fmt(e.end)}`)
    },
    onProgressChange: (e) => {
      e.row.progress = e.progress
      note('onProgressChange', `${e.row.name} -> ${e.progress}%`)
    },
    onDependenciesChange: (moves) => {
      for (const m of moves) {
        const r = byId(m.id)
        if (r) writeSpan(r, m.start, m.end)
      }
      note('onDependenciesChange', `${moves.length} task${moves.length === 1 ? '' : 's'} rescheduled`)
    },
    onDependencyAdd: (dep) => {
      dependencies = [...dependencies, dep]
      note('onDependencyAdd', `${dep.from} -> ${dep.to} (${dep.type ?? 'FS'})`)
    },
    onDependencyRemove: (id) => {
      dependencies = dependencies.filter((d) => d.id !== id)
      note('onDependencyRemove', id)
    },
    onTaskAdd: (start, end, parentId) => {
      const id = `n${++seq}`
      rows = [
        ...rows,
        {
          id,
          name: 'New task',
          owner: 'TBD',
          start: iso(start),
          end: iso(new Date(end.getTime() - 86400000)),
          progress: 0,
          parentId: parentId ?? null,
          color: COLOR.build,
        },
      ]
      note('onTaskAdd', `${id} on ${fmt(start)}${parentId ? ` under ${parentId}` : ''}`)
    },
    onTaskDelete: (row) => {
      rows = rows.filter((r) => r.id !== row.id)
      note('onTaskDelete', row.name)
    },
    onTaskCommit: (e) => {
      Object.assign(e.row, e.values)
      note('onTaskCommit', `${e.row.name}: ${Object.keys(e.values).join(', ')}`)
    },
  }
  // `dependencies` is reassigned when a link is drawn, so the config has to see
  // the new array rather than the one captured above.
  const cfg = $derived({ ...ganttCfg, dependencies })

  // The same rows as a plain table: the Gantt is one view of the grid.
  let view = $state<'gantt' | 'table'>('gantt')
</script>

<section class="ge">
  <header class="ge-head">
    <div class="ge-title">
      <strong>Editable plan</strong>
      <span class="ge-sub">
        Drag a bar or its edges, drag the diamond to set percent, drag a dot onto another bar to link.
        Ctrl+Z undoes. Nothing here mutates a row until a callback below says so.
      </span>
    </div>
    <div class="ge-seg" role="tablist" aria-label="View">
      <button class="ge-seg-btn" role="tab" aria-selected={view === 'gantt'} class:ge-on={view === 'gantt'} onclick={() => (view = 'gantt')}>Gantt</button>
      <button class="ge-seg-btn" role="tab" aria-selected={view === 'table'} class:ge-on={view === 'table'} onclick={() => (view = 'table')}>Table</button>
    </div>
  </header>

  <div class="ge-body">
    <div class="ge-chart">
      {#if view === 'gantt'}
        <SvGrid
          columnResize
          data={rows}
          columns={columns}
          getRowId={(r) => r.id}
          containerHeight="100%"
          gantt={cfg}
        />
      {:else}
        <SvGrid columnResize data={rows} columns={columns} getRowId={(r) => r.id} containerHeight="100%" sortable fitColumns />
      {/if}
    </div>

    <aside class="ge-log">
      <div class="ge-log-head">
        <span>Callbacks</span>
        {#if log.length}
          <button class="ge-clear" onclick={() => (log = [])}>Clear</button>
        {/if}
      </div>
      {#if !log.length}
        <p class="ge-empty">Drag something. Every write-back the view asks for shows up here.</p>
      {:else}
        <ol class="ge-entries">
          {#each log as e (e.id)}
            <li class="ge-entry">
              <code class="ge-what">{e.what}</code>
              <span class="ge-detail">{e.detail}</span>
            </li>
          {/each}
        </ol>
      {/if}
    </aside>
  </div>
</section>

<style>
  .ge {
    display: flex;
    flex: 1 1 auto;
    flex-direction: column;
    min-height: 0;
    border: 1px solid var(--sg-border, #e5e7eb);
    border-radius: 12px;
    overflow: hidden;
    background: var(--sg-bg, #fff);
  }
  .ge-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 10px 14px; border-bottom: 1px solid var(--sg-border, #e5e7eb); }
  .ge-seg { display: inline-flex; flex: none; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 8px; overflow: hidden; }
  .ge-seg-btn { padding: 5px 12px; border: 0; background: transparent; color: inherit; font: inherit; font-size: 0.8rem; cursor: pointer; }
  .ge-seg-btn:hover { background: color-mix(in srgb, var(--sg-fg, #1f2937) 6%, transparent); }
  .ge-on { background: var(--sg-accent, #4f46e5); color: #fff; }
  .ge-on:hover { background: var(--sg-accent, #4f46e5); }

  .ge-title { display: flex; flex-direction: column; gap: 2px; }
  .ge-sub { font-size: 0.78rem; color: var(--sg-muted, #6b7280); }

  .ge-body { flex: 1 1 auto; display: flex; min-height: 0; }
  .ge-chart { flex: 1 1 auto; min-width: 0; padding: 8px; }

  .ge-log {
    flex: 0 0 230px;
    display: flex;
    flex-direction: column;
    min-height: 0;
    border-left: 1px solid var(--sg-border, #e5e7eb);
    background: color-mix(in srgb, var(--sg-fg, #1f2937) 3%, transparent);
  }
  .ge-log-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 9px 12px;
    font-size: 0.7rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--sg-muted, #9ca3af);
    border-bottom: 1px solid var(--sg-border, #e5e7eb);
  }
  .ge-clear {
    border: 0;
    background: transparent;
    color: var(--sg-accent, #4f46e5);
    font: inherit;
    font-size: 0.7rem;
    cursor: pointer;
    text-transform: none;
    letter-spacing: 0;
  }
  .ge-empty { margin: 0; padding: 14px 12px; font-size: 0.78rem; color: var(--sg-muted, #6b7280); }
  .ge-entries { margin: 0; padding: 6px 0; list-style: none; overflow-y: auto; }
  .ge-entry {
    display: flex;
    flex-direction: column;
    gap: 1px;
    padding: 6px 12px;
    border-bottom: 1px solid color-mix(in srgb, var(--sg-border, #e5e7eb) 50%, transparent);
  }
  .ge-what { font-size: 0.72rem; font-weight: 600; color: var(--sg-accent, #4f46e5); }
  .ge-detail { font-size: 0.75rem; color: var(--sg-fg, #374151); }

  @media (max-width: 767px) {
    .ge { min-width: 860px; flex-shrink: 0; }
  }
</style>

View this example on GitHub

More Gantt examples

  • Project plan - A 14-week release as a Gantt: phases nest their tasks and draw a rolled-up summary bar, finish-to-start links draw as arrows, and a violated one turns red. Collapse a phase and its summary keeps the span. Toggle to the Table - same grid rows, just a view. The renderer ships in @svgrid/enterprise.
  • Critical path & baselines - A fit-out schedule with the planning layer on: the chain with no slack ringed in red with its arrows, a Slack column for everything else, baseline ghosts that redden where the plan has drifted, and two pinned tasks where a cascade stops at the constraint instead of running past it.
  • Resource load & folded axis - A field-service quarter with a load strip under the chart: one row per crew, one bar per column, red where a crew is booked past its capacity - the specialist is one person with two sign-offs in the same week. Capacity is per resource, so a two-van crew takes two jobs at once. Fold the weekends out and the quarter fits in the width a month used to take.
  • One plan, four views: Grid, Gantt, Scheduler, Kanban - One <SvGrid>, one array, four renderers behind a switch: the plain table with inline editing, the Gantt with phases and links, a resource timeline with a lane per owner, and a Kanban of status lanes. Every view writes back through a callback to the same rows - change a status in the grid and the card moves lane, drag a bar in the Gantt and the timeline follows - and a line under the switcher names the last write.
  • Product roadmap - a year in quarters - The Gantt as a roadmap: three teams, their initiatives across four quarters, and the releases that cut across them as milestones. A `task` snippet draws the owner and a status chip inside each bar, colour is by status, a custom tooltip carries the effort and a summary, the header chips filter by status, and a month / quarter / year zoom ladder drills in with Ctrl+wheel.