Appearance
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:expandedItemskeeps 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
notEqualoperator.
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>
);
}