Skip to content

Project tracker ​

Projects with nested tasks (tree data), editable status and progress.

How it works ​

  • Tree data: tasks contain subtasks (childrenField: "subtasks"), unlimited levels. Filters keep parents of matching tasks, sorting sorts within each level. v-model:expandedItems keeps the expanded projects.
  • Slots: assignees with generated avatars, status badges and progress bars.
  • Select editors: status and priority are changed with a dropdown (editor: "select"). Double click or start typing.
  • Validation: the progress parser only accepts numbers between 0 and 100.
  • Custom sort order: priorities are sorted Low → Medium → High with sortFunc.
  • Highlighting: overdue tasks (due date before today and not done) are red.
  • Programmatic filter: "Hide done tasks" sets a column filter with the notEqual operator.
vue
<template>
  <div class="demo">
    <div class="demo-toolbar">
      <button @click="grid?.expandAll()">Expand all</button>
      <button @click="grid?.collapseAll()">Collapse all</button>
      <label><input v-model="hideDone" type="checkbox" /> Hide done tasks</label>
    </div>
    <Datagrid
      ref="grid"
      :columns="columns"
      v-model:rows="tasks"
      :options="options"
      v-model:columnFilter="filters"
      v-model:expandedItems="expanded"
    >
      <template #cell-assignee="{ value }">
        <span v-if="value" class="assignee">
          <span class="assignee__avatar" :style="{ background: color(value) }">{{ initials(value) }}</span>
          {{ value }}
        </span>
      </template>

      <template #cell-status="{ value }">
        <span class="status" :class="`status--${value.toLowerCase().replace(' ', '-')}`">{{ value }}</span>
      </template>

      <template #cell-progress="{ value }">
        <span class="progress"><span class="progress__bar" :style="{ width: `${value}%` }"></span></span>
        <span class="progress__text">{{ value }} %</span>
      </template>
    </Datagrid>
  </div>
</template>

<script setup lang="ts">
import { computed, ref } from "vue";
import { Datagrid, formatters, type ColumnConfig, type GridOptions, type ItemUid } from "@datagrid/vue-ui";

type Task = {
  id: string;
  name: string;
  assignee?: string;
  status: "Open" | "In progress" | "Review" | "Done";
  priority: "Low" | "Medium" | "High";
  start: string;
  due: string;
  progress: number;
  subtasks?: Array<Task>;
};

const grid = ref<InstanceType<typeof Datagrid>>();
const expanded = ref<Array<ItemUid>>(["p1", "p2"]);
const hideDone = ref(false);

const task = (id: string, name: string, assignee: string, status: Task["status"], priority: Task["priority"], start: string, due: string, progress: number): Task => ({
  id, name, assignee, status, priority, start, due, progress,
});

const tasks = ref<Array<Task>>([
  {
    ...task("p1", "Website relaunch", "Anna Weber", "In progress", "High", "2026-09-01", "2026-12-15", 45),
    subtasks: [
      task("p1-1", "Design system", "Ben Fischer", "Done", "High", "2026-09-01", "2026-09-30", 100),
      task("p1-2", "Content migration", "Clara Meyer", "In progress", "Medium", "2026-10-01", "2026-11-15", 60),
      {
        ...task("p1-3", "Frontend", "David Koch", "In progress", "High", "2026-10-01", "2026-12-01", 35),
        subtasks: [
          task("p1-3-1", "Landing page", "David Koch", "Review", "High", "2026-10-01", "2026-10-20", 90),
          task("p1-3-2", "Product pages", "Emma Wagner", "In progress", "Medium", "2026-10-15", "2026-11-20", 40),
          task("p1-3-3", "Checkout", "David Koch", "Open", "High", "2026-11-01", "2026-12-01", 0),
        ],
      },
      task("p1-4", "Go live", "Anna Weber", "Open", "High", "2026-12-10", "2026-12-15", 0),
    ],
  },
  {
    ...task("p2", "Mobile app 2.0", "Felix Becker", "Review", "Medium", "2026-06-01", "2026-10-31", 85),
    subtasks: [
      task("p2-1", "Offline mode", "Greta Schulz", "Done", "High", "2026-06-01", "2026-08-15", 100),
      task("p2-2", "Push notifications", "Felix Becker", "Review", "Medium", "2026-08-01", "2026-09-15", 95),
      task("p2-3", "App store release", "Greta Schulz", "Open", "Medium", "2026-10-15", "2026-10-31", 0),
    ],
  },
  {
    ...task("p3", "Data warehouse", "Hannes Richter", "Open", "Low", "2026-11-01", "2027-03-31", 5),
    subtasks: [
      task("p3-1", "Requirements", "Hannes Richter", "In progress", "Low", "2026-11-01", "2026-11-30", 20),
      task("p3-2", "ETL pipelines", "Ida Hoffmann", "Open", "Low", "2026-12-01", "2027-02-28", 0),
    ],
  },
]);

// "hide done" is implemented with a column filter on the status column
const filters = computed({
  get: () => (hideDone.value ? [{ column: "status", value: { operator: "notEqual", value: "Done" } }] : []),
  set: () => {},
});

const today = "2026-10-15";

const initials = (name: string) => name.split(" ").map((part) => part[0]).join("");
const color = (name: string) => `hsl(${[...name].reduce((a, c) => a + c.charCodeAt(0), 0) % 360} 55% 45%)`;

const columns: Array<ColumnConfig<Task>> = [
  { field: "name", text: "Task", width: 260 },
  { field: "assignee", text: "Assignee", width: 170, filterType: "set" },
  {
    field: "status",
    text: "Status",
    width: 130,
    editable: true,
    editor: "select",
    editorParams: { options: ["Open", "In progress", "Review", "Done"] },
    filterType: "set",
  },
  {
    field: "priority",
    text: "Priority",
    width: 100,
    editable: true,
    editor: "select",
    editorParams: { options: ["Low", "Medium", "High"] },
    filterType: "set",
    // custom sort order
    sortFunc: (a, b) => ["Low", "Medium", "High"].indexOf(a.value) - ["Low", "Medium", "High"].indexOf(b.value),
  },
  { field: "start", text: "Start", type: "date", width: 120, editable: true, valueFormatter: formatters.date() },
  {
    field: "due",
    text: "Due",
    type: "date",
    width: 120,
    editable: true,
    valueFormatter: formatters.date(),
    // overdue tasks are highlighted
    cellClass: ({ value, data }) => (value < today && data.status !== "Done" ? "overdue" : undefined),
  },
  {
    field: "progress",
    text: "Progress",
    type: "number",
    width: 160,
    editable: true,
    valueParser: ({ value, oldValue }) => {
      const number = Number(value);
      return isNaN(number) ? oldValue : Math.min(100, Math.max(0, Math.round(number)));
    },
  },
];

const options: GridOptions<Task> = {
  childrenField: "subtasks",
  rowId: (data) => data.id,
  columnMenu: true,
  selection: "Row",
  defaultColumn: { sortable: true, filterable: true, resizeable: true },
};
</script>

<style>
.assignee {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.assignee__avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  color: white;
  font-size: 10px;
  font-weight: 700;
}
.status {
  padding: 1px 8px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 600;
}
.status--open {
  background: color-mix(in srgb, #6e7781 18%, transparent);
}
.status--in-progress {
  background: color-mix(in srgb, #0969da 20%, transparent);
  color: #0969da;
}
.status--review {
  background: color-mix(in srgb, #9a6700 20%, transparent);
  color: #9a6700;
}
.status--done {
  background: color-mix(in srgb, #1a7f37 20%, transparent);
  color: #1a7f37;
}
.progress {
  flex: 1;
  height: 6px;
  border-radius: 3px;
  background: var(--ut-row-border-color);
  overflow: hidden;
}
.progress__bar {
  display: block;
  height: 100%;
  background: var(--ut-accent-color);
}
.progress__text {
  width: 42px;
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.overdue {
  color: #cf222e;
  font-weight: 600;
}
</style>
tsx
import { useRef, useState } from "react";
import {
  Datagrid,
  formatters,
  type ColumnConfig,
  type DatagridHandle,
  type DatagridProps,
  type FilterModel,
  type GridOptions,
  type ItemUid,
} from "@datagrid/react-ui";

type Task = {
  id: string;
  name: string;
  assignee?: string;
  status: "Open" | "In progress" | "Review" | "Done";
  priority: "Low" | "Medium" | "High";
  start: string;
  due: string;
  progress: number;
  subtasks?: Array<Task>;
};

const task = (id: string, name: string, assignee: string, status: Task["status"], priority: Task["priority"], start: string, due: string, progress: number): Task => ({
  id, name, assignee, status, priority, start, due, progress,
});

const initialTasks: Array<Task> = [
  {
    ...task("p1", "Website relaunch", "Anna Weber", "In progress", "High", "2026-09-01", "2026-12-15", 45),
    subtasks: [
      task("p1-1", "Design system", "Ben Fischer", "Done", "High", "2026-09-01", "2026-09-30", 100),
      task("p1-2", "Content migration", "Clara Meyer", "In progress", "Medium", "2026-10-01", "2026-11-15", 60),
      {
        ...task("p1-3", "Frontend", "David Koch", "In progress", "High", "2026-10-01", "2026-12-01", 35),
        subtasks: [
          task("p1-3-1", "Landing page", "David Koch", "Review", "High", "2026-10-01", "2026-10-20", 90),
          task("p1-3-2", "Product pages", "Emma Wagner", "In progress", "Medium", "2026-10-15", "2026-11-20", 40),
          task("p1-3-3", "Checkout", "David Koch", "Open", "High", "2026-11-01", "2026-12-01", 0),
        ],
      },
      task("p1-4", "Go live", "Anna Weber", "Open", "High", "2026-12-10", "2026-12-15", 0),
    ],
  },
  {
    ...task("p2", "Mobile app 2.0", "Felix Becker", "Review", "Medium", "2026-06-01", "2026-10-31", 85),
    subtasks: [
      task("p2-1", "Offline mode", "Greta Schulz", "Done", "High", "2026-06-01", "2026-08-15", 100),
      task("p2-2", "Push notifications", "Felix Becker", "Review", "Medium", "2026-08-01", "2026-09-15", 95),
      task("p2-3", "App store release", "Greta Schulz", "Open", "Medium", "2026-10-15", "2026-10-31", 0),
    ],
  },
  {
    ...task("p3", "Data warehouse", "Hannes Richter", "Open", "Low", "2026-11-01", "2027-03-31", 5),
    subtasks: [
      task("p3-1", "Requirements", "Hannes Richter", "In progress", "Low", "2026-11-01", "2026-11-30", 20),
      task("p3-2", "ETL pipelines", "Ida Hoffmann", "Open", "Low", "2026-12-01", "2027-02-28", 0),
    ],
  },
];

const today = "2026-10-15";

const initials = (name: string) => name.split(" ").map((part) => part[0]).join("");
const color = (name: string) => `hsl(${[...name].reduce((a, c) => a + c.charCodeAt(0), 0) % 360} 55% 45%)`;

const columns: Array<ColumnConfig<Task>> = [
  { field: "name", text: "Task", width: 260 },
  { field: "assignee", text: "Assignee", width: 170, filterType: "set" },
  {
    field: "status",
    text: "Status",
    width: 130,
    editable: true,
    editor: "select",
    editorParams: { options: ["Open", "In progress", "Review", "Done"] },
    filterType: "set",
  },
  {
    field: "priority",
    text: "Priority",
    width: 100,
    editable: true,
    editor: "select",
    editorParams: { options: ["Low", "Medium", "High"] },
    filterType: "set",
    // custom sort order
    sortFunc: (a, b) => ["Low", "Medium", "High"].indexOf(a.value) - ["Low", "Medium", "High"].indexOf(b.value),
  },
  { field: "start", text: "Start", type: "date", width: 120, editable: true, valueFormatter: formatters.date() },
  {
    field: "due",
    text: "Due",
    type: "date",
    width: 120,
    editable: true,
    valueFormatter: formatters.date(),
    // overdue tasks are highlighted
    cellClass: ({ value, data }) => (value < today && data.status !== "Done" ? "overdue" : undefined),
  },
  {
    field: "progress",
    text: "Progress",
    type: "number",
    width: 160,
    editable: true,
    valueParser: ({ value, oldValue }) => {
      const number = Number(value);
      return isNaN(number) ? oldValue : Math.min(100, Math.max(0, Math.round(number)));
    },
  },
];

const options: GridOptions<Task> = {
  childrenField: "subtasks",
  rowId: (data) => data.id,
  columnMenu: true,
  selection: "Row",
  defaultColumn: { sortable: true, filterable: true, resizeable: true },
};

// custom cells by column id
const cellRenderers: DatagridProps<Task>["cellRenderers"] = {
  assignee: ({ value }) =>
    value && (
      <span className="assignee">
        <span className="assignee__avatar" style={{ background: color(value) }}>
          {initials(value)}
        </span>
        {value}
      </span>
    ),
  status: ({ value }) => (
    <span className={`status status--${value.toLowerCase().replace(" ", "-")}`}>{value}</span>
  ),
  progress: ({ value }) => (
    <>
      <span className="progress">
        <span className="progress__bar" style={{ width: `${value}%` }}></span>
      </span>
      <span className="progress__text">{value} %</span>
    </>
  ),
};

// "hide done" is implemented with a column filter on the status column
const hideDoneFilter: FilterModel = [{ column: "status", value: { operator: "notEqual", value: "Done" } }];

export default function ShowcaseProjects() {
  const grid = useRef<DatagridHandle>(null);
  // edited values are written back with onRowsChange
  const [tasks, setTasks] = useState(initialTasks);
  const [expanded, setExpanded] = useState<Array<ItemUid>>(["p1", "p2"]);
  const [filters, setFilters] = useState<FilterModel>([]);
  const [hideDone, setHideDone] = useState(false);

  function toggleHideDone(checked: boolean) {
    setHideDone(checked);
    setFilters(checked ? hideDoneFilter : []);
  }

  return (
    <div className="demo">
      <div className="demo-toolbar">
        <button onClick={() => grid.current?.expandAll()}>Expand all</button>
        <button onClick={() => grid.current?.collapseAll()}>Collapse all</button>
        <label>
          <input type="checkbox" checked={hideDone} onChange={(e) => toggleHideDone(e.target.checked)} /> Hide done tasks
        </label>
      </div>
      <Datagrid
        ref={grid}
        columns={columns}
        rows={tasks}
        onRowsChange={setTasks}
        options={options}
        columnFilter={filters}
        onColumnFilterChange={setFilters}
        expandedItems={expanded}
        onExpandedItemsChange={setExpanded}
        cellRenderers={cellRenderers}
      />
      <style>{`
        .assignee {
          display: inline-flex;
          align-items: center;
          gap: 6px;
        }
        .assignee__avatar {
          display: inline-flex;
          align-items: center;
          justify-content: center;
          width: 22px;
          height: 22px;
          border-radius: 50%;
          color: white;
          font-size: 10px;
          font-weight: 700;
        }
        .status {
          padding: 1px 8px;
          border-radius: 999px;
          font-size: 12px;
          font-weight: 600;
        }
        .status--open {
          background: color-mix(in srgb, #6e7781 18%, transparent);
        }
        .status--in-progress {
          background: color-mix(in srgb, #0969da 20%, transparent);
          color: #0969da;
        }
        .status--review {
          background: color-mix(in srgb, #9a6700 20%, transparent);
          color: #9a6700;
        }
        .status--done {
          background: color-mix(in srgb, #1a7f37 20%, transparent);
          color: #1a7f37;
        }
        .progress {
          flex: 1;
          height: 6px;
          border-radius: 3px;
          background: var(--ut-row-border-color);
          overflow: hidden;
        }
        .progress__bar {
          display: block;
          height: 100%;
          background: var(--ut-accent-color);
        }
        .progress__text {
          width: 42px;
          text-align: right;
          font-variant-numeric: tabular-nums;
        }
        .overdue {
          color: #cf222e;
          font-weight: 600;
        }
      `}</style>
    </div>
  );
}

Released under the ISC License.