Skip to content

Master / detail ​

Rows can show additional content below their cells. The expand button is shown in the first column.

Detail content ​

The detail area can contain any content. Use it for information which doesn't fit into columns, e.g. contact data or a description. hasDetails defines which rows can be expanded – here only employees of the engineering department.

Source
vue
<template>
  <Datagrid :columns="columns" :rows="rows" :options="options">
    <template #detail="{ data }">
      <div class="employee-detail">
        <div>
          <strong>{{ data.firstName }} {{ data.lastName }}</strong><br />
          {{ data.role }} · {{ data.department }}<br />
          {{ data.city }}, {{ data.country }}
        </div>
        <dl>
          <dt>Email</dt>
          <dd><a :href="`mailto:${data.email}`">{{ data.email }}</a></dd>
          <dt>Since</dt>
          <dd>{{ data.startDate }}</dd>
          <dt>Remote</dt>
          <dd>{{ data.remote ? "yes" : "no" }}</dd>
        </dl>
      </div>
    </template>
  </Datagrid>
</template>

<script setup lang="ts">
import { Datagrid, type ColumnConfig, type GridOptions } from "@datagrid/vue-ui";
import { createEmployees, type Employee } from "./data";

const rows = createEmployees(100);

const columns: Array<ColumnConfig<Employee>> = [
  { field: "firstName", text: "First name", width: 160 },
  { field: "lastName", text: "Last name" },
  { field: "department", text: "Department" },
  { field: "salary", text: "Salary", type: "number" },
];

const options: GridOptions<Employee> = {
  // optional: only rows of the engineering department have details
  hasDetails: (row) => row.data.department === "Engineering",
  defaultColumn: { flex: 1 },
};
</script>

<style>
.employee-detail {
  display: flex;
  gap: 48px;
  padding: 4px 24px;
}

.employee-detail dl {
  display: grid;
  grid-template-columns: auto auto;
  gap: 2px 12px;
  margin: 0;
}

.employee-detail dt {
  color: var(--ut-muted-color);
}

.employee-detail dd {
  margin: 0;
}
</style>
tsx
import { Datagrid, type ColumnConfig, type GridOptions } from "@datagrid/react-ui";
import { createEmployees, type Employee } from "../data";

const rows = createEmployees(100);

const columns: Array<ColumnConfig<Employee>> = [
  { field: "firstName", text: "First name", width: 160 },
  { field: "lastName", text: "Last name" },
  { field: "department", text: "Department" },
  { field: "salary", text: "Salary", type: "number" },
];

const options: GridOptions<Employee> = {
  // optional: only rows of the engineering department have details
  hasDetails: (row) => row.data.department === "Engineering",
  defaultColumn: { flex: 1 },
};

function EmployeeDetail({ data }: { data: Employee }) {
  return (
    <div className="employee-detail">
      <div>
        <strong>
          {data.firstName} {data.lastName}
        </strong>
        <br />
        {data.role} · {data.department}
        <br />
        {data.city}, {data.country}
      </div>
      <dl>
        <dt>Email</dt>
        <dd>
          <a href={`mailto:${data.email}`}>{data.email}</a>
        </dd>
        <dt>Since</dt>
        <dd>{data.startDate}</dd>
        <dt>Remote</dt>
        <dd>{data.remote ? "yes" : "no"}</dd>
      </dl>
    </div>
  );
}

export default function MasterDetail() {
  return (
    <>
      <Datagrid
        columns={columns}
        rows={rows}
        options={options}
        renderDetail={({ data }) => <EmployeeDetail data={data} />}
      />
      <style>{`
        .employee-detail {
          display: flex;
          gap: 48px;
          padding: 4px 24px;
        }

        .employee-detail dl {
          display: grid;
          grid-template-columns: auto auto;
          gap: 2px 12px;
          margin: 0;
        }

        .employee-detail dt {
          color: var(--ut-muted-color);
        }

        .employee-detail dd {
          margin: 0;
        }
      `}</style>
    </>
  );
}

Configuration ​

OptionDescription
#detail slot (Vue) / renderDetail prop (React)content of the detail area, receives row, data and api
hasDetails (grid option)(row, data) => boolean – which rows have details (default: all rows if the slot / renderDetail is used)
renderDetails (grid option)alternative to the slot: (row, data) => component

The height of the detail area is measured automatically, so it can contain anything, even another grid.

ts
api.toggleDetails(row);   // row.showDetails is true while the details are shown

@cell-click etc. are not emitted for clicks inside of the detail area and the keyboard navigation of the grid is not active there.

Nested grid ​

A typical master / detail view: customers with their orders. The detail area contains a second grid with its own columns, sorting and selection. A grid fills its parent, so the nested grid needs a container with a fixed height.

Source
vue
<template>
  <Datagrid :columns="columns" :rows="rows" :options="options">
    <template #detail="{ data }">
      <!-- the nested grid needs a height -->
      <div class="customer-orders">
        <Datagrid :columns="orderColumns" :rows="data.orders" :options="orderOptions" />
      </div>
    </template>
  </Datagrid>
</template>

<script setup lang="ts">
import { Datagrid, formatters, type ColumnConfig, type GridOptions } from "@datagrid/vue-ui";
import { createCustomers, type Customer, type Order } from "./data";

const rows = createCustomers(40);
const currency = formatters.currency("EUR", { maximumFractionDigits: 0 });

const columns: Array<ColumnConfig<Customer>> = [
  { field: "company", text: "Company", width: 220 },
  { field: "contact", text: "Contact" },
  { field: "city", text: "City" },
  { id: "orders", text: "Orders", type: "number", valueGetter: ({ data }) => data.orders.length },
  {
    id: "revenue",
    text: "Revenue",
    type: "number",
    valueGetter: ({ data }) => data.orders.reduce((sum, order) => sum + order.total, 0),
    valueFormatter: currency,
  },
];

const options: GridOptions<Customer> = {
  rowId: (data) => data.id,
  defaultColumn: { flex: 1, sortable: true },
};

const orderColumns: Array<ColumnConfig<Order>> = [
  { field: "id", text: "Order", type: "number", width: 90 },
  { field: "date", text: "Date", type: "date" },
  { field: "status", text: "Status" },
  { id: "items", text: "Items", type: "number", valueGetter: ({ data }) => data.lines.length },
  { field: "total", text: "Total", type: "number", valueFormatter: currency },
];

const orderOptions: GridOptions<Order> = {
  rowId: (data) => data.id,
  defaultColumn: { flex: 1, sortable: true },
};
</script>

<style>
.customer-orders {
  height: 200px;
  padding-left: 32px;
}
</style>
tsx
import { Datagrid, formatters, type ColumnConfig, type GridOptions } from "@datagrid/react-ui";
import { createCustomers, type Customer, type Order } from "../data";

const rows = createCustomers(40);
const currency = formatters.currency("EUR", { maximumFractionDigits: 0 });

const columns: Array<ColumnConfig<Customer>> = [
  { field: "company", text: "Company", width: 220 },
  { field: "contact", text: "Contact" },
  { field: "city", text: "City" },
  { id: "orders", text: "Orders", type: "number", valueGetter: ({ data }) => data.orders.length },
  {
    id: "revenue",
    text: "Revenue",
    type: "number",
    valueGetter: ({ data }) => data.orders.reduce((sum, order) => sum + order.total, 0),
    valueFormatter: currency,
  },
];

const options: GridOptions<Customer> = {
  rowId: (data) => data.id,
  defaultColumn: { flex: 1, sortable: true },
};

const orderColumns: Array<ColumnConfig<Order>> = [
  { field: "id", text: "Order", type: "number", width: 90 },
  { field: "date", text: "Date", type: "date" },
  { field: "status", text: "Status" },
  { id: "items", text: "Items", type: "number", valueGetter: ({ data }) => data.lines.length },
  { field: "total", text: "Total", type: "number", valueFormatter: currency },
];

const orderOptions: GridOptions<Order> = {
  rowId: (data) => data.id,
  defaultColumn: { flex: 1, sortable: true },
};

export default function MasterDetailGrid() {
  return (
    <>
      <Datagrid
        columns={columns}
        rows={rows}
        options={options}
        renderDetail={({ data }) => (
          // the nested grid needs a height
          <div className="customer-orders">
            <Datagrid columns={orderColumns} rows={data.orders} options={orderOptions} />
          </div>
        )}
      />
      <style>{`
        .customer-orders {
          height: 200px;
          padding-left: 32px;
        }
      `}</style>
    </>
  );
}

Editing in the detail area ​

The detail area can contain a form to edit fields which are not shown as columns or need more space than a cell. The slot / renderDetail receives the api, so the form can close its own row with api.toggleDetails(row).

On save the task is replaced by a changed copy (immutable update). Because of the rowId the grid recognizes the row and the detail area stays open.

Source
vue
<template>
  <div class="demo">
    <div class="demo-log">{{ log }}</div>
    <Datagrid :columns="columns" :rows="tasks" :options="options">
      <template #detail="{ row, data, api }">
        <form class="task-form" @submit.prevent="save(data)">
          <label>Title <input v-model="draft(data).title" /></label>
          <label>
            Status
            <select v-model="draft(data).status">
              <option v-for="s in statuses" :key="s">{{ s }}</option>
            </select>
          </label>
          <label>Due <input v-model="draft(data).due" type="date" /></label>
          <button type="submit">Save</button>
          <button type="button" @click="api.toggleDetails(row)">Close</button>
        </form>
      </template>
    </Datagrid>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref } from "vue";
import { Datagrid, type ColumnConfig, type GridOptions } from "@datagrid/vue-ui";
import { createTasks, type Task } from "./data";

const tasks = ref(createTasks(30));
const log = ref("Open a row, change the values and save");
const statuses: Array<Task["status"]> = ["todo", "in progress", "review", "done"];

// the form edits a copy of the task until it is saved
const drafts = new Map<number, Task>();
function draft(task: Task): Task {
  if (!drafts.has(task.id)) drafts.set(task.id, reactive({ ...task }));
  return drafts.get(task.id)!;
}

function save(task: Task) {
  const changed = { ...draft(task) };
  drafts.delete(task.id);
  // replace the object, the row keeps its state (open details) because of the rowId
  tasks.value = tasks.value.map((t) => (t.id === task.id ? changed : t));
  log.value = `Saved task ${task.id}: ${changed.title} (${changed.status}, due ${changed.due})`;
}

const columns: Array<ColumnConfig<Task>> = [
  { field: "title", text: "Task", width: 220 },
  { field: "assignee", text: "Assignee" },
  { field: "status", text: "Status" },
  { field: "due", text: "Due", type: "date" },
];

const options: GridOptions<Task> = {
  rowId: (data) => data.id,
  defaultColumn: { flex: 1, sortable: true },
};
</script>

<style>
.task-form {
  display: flex;
  flex-wrap: wrap;
  align-items: end;
  gap: 8px 16px;
  padding: 4px 0 4px 32px;
}

.task-form label {
  display: flex;
  flex-direction: column;
  font-size: 12px;
  color: var(--ut-muted-color);
}

.task-form input,
.task-form select,
.task-form button {
  height: 30px;
  padding: 0 8px;
  border: 1px solid var(--ut-border-color);
  border-radius: 4px;
  background: var(--ut-background);
  color: var(--ut-color);
  font-size: 14px;
}
</style>
tsx
import { useState, type FormEvent } from "react";
import { Datagrid, type ColumnConfig, type GridOptions } from "@datagrid/react-ui";
import { createTasks, type Task } from "../data";

const initialTasks = createTasks(30);
const statuses: Array<Task["status"]> = ["todo", "in progress", "review", "done"];

const columns: Array<ColumnConfig<Task>> = [
  { field: "title", text: "Task", width: 220 },
  { field: "assignee", text: "Assignee" },
  { field: "status", text: "Status" },
  { field: "due", text: "Due", type: "date" },
];

const options: GridOptions<Task> = {
  rowId: (data) => data.id,
  defaultColumn: { flex: 1, sortable: true },
};

export default function MasterDetailForm() {
  const [tasks, setTasks] = useState(initialTasks);
  const [log, setLog] = useState("Open a row, change the values and save");

  function save(task: Task, e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const form = new FormData(e.currentTarget);
    const changed: Task = {
      ...task,
      title: String(form.get("title")),
      status: form.get("status") as Task["status"],
      due: String(form.get("due")),
    };
    // replace the object, the row keeps its state (open details) because of the rowId
    setTasks((tasks) => tasks.map((t) => (t.id === task.id ? changed : t)));
    setLog(`Saved task ${task.id}: ${changed.title} (${changed.status}, due ${changed.due})`);
  }

  return (
    <div className="demo">
      <div className="demo-log">{log}</div>
      <Datagrid
        columns={columns}
        rows={tasks}
        options={options}
        renderDetail={({ row, data, api }) => (
          <form className="task-form" onSubmit={(e) => save(data, e)}>
            <label>
              Title <input name="title" defaultValue={data.title} />
            </label>
            <label>
              Status
              <select name="status" defaultValue={data.status}>
                {statuses.map((s) => (
                  <option key={s}>{s}</option>
                ))}
              </select>
            </label>
            <label>
              Due <input name="due" type="date" defaultValue={data.due} />
            </label>
            <button type="submit">Save</button>
            <button type="button" onClick={() => api.toggleDetails(row)}>
              Close
            </button>
          </form>
        )}
      />
      <style>{`
        .task-form {
          display: flex;
          flex-wrap: wrap;
          align-items: end;
          gap: 8px 16px;
          padding: 4px 0 4px 32px;
        }

        .task-form label {
          display: flex;
          flex-direction: column;
          font-size: 12px;
          color: var(--ut-muted-color);
        }

        .task-form input,
        .task-form select,
        .task-form button {
          height: 30px;
          padding: 0 8px;
          border: 1px solid var(--ut-border-color);
          border-radius: 4px;
          background: var(--ut-background);
          color: var(--ut-color);
          font-size: 14px;
        }
      `}</style>
    </div>
  );
}

Opening details with the API ​

Instead of the expand button, the details can be opened from anywhere with api.toggleDetails(row) – here by clicking a row, and for all rows with the buttons. The height of each detail area depends on its content (the number of order lines), no height has to be configured.

Source
vue
<template>
  <div class="demo">
    <div class="demo-toolbar">
      <button @click="openAll">Open all</button>
      <button @click="closeAll">Close all</button>
      <span>Click a row to show its order lines</span>
    </div>
    <Datagrid
      :columns="columns"
      :rows="rows"
      :options="options"
      @ready="api = $event"
      @cell-click="({ row }) => api?.toggleDetails(row)"
    >
      <template #detail="{ data }">
        <!-- the height of the detail area follows the number of order lines -->
        <div class="order-lines">
          <template v-for="(line, i) in data.lines" :key="i">
            <span>{{ line.quantity }} ×</span>
            <span>{{ line.product }}</span>
            <span>{{ currency(line.quantity * line.price) }}</span>
          </template>
        </div>
      </template>
    </Datagrid>
  </div>
</template>

<script setup lang="ts">
import { shallowRef } from "vue";
import { Datagrid, type ColumnConfig, type Core, type GridOptions } from "@datagrid/vue-ui";
import { createOrders, type Order } from "./data";

const rows = createOrders(60);
const api = shallowRef<Core>();

const currency = (value: number) => value.toLocaleString("en", { style: "currency", currency: "EUR" });

// toggleDetails switches row.showDetails
function openAll() {
  api.value?.getAllRows().forEach((row) => row.showDetails || api.value!.toggleDetails(row));
}

function closeAll() {
  api.value?.getAllRows().forEach((row) => row.showDetails && api.value!.toggleDetails(row));
}

const columns: Array<ColumnConfig<Order>> = [
  { field: "id", text: "Order", type: "number", width: 110 },
  { field: "customer", text: "Customer", flex: 1 },
  { field: "date", text: "Date", type: "date" },
  { field: "status", text: "Status" },
  { field: "total", text: "Total", type: "number", valueFormatter: ({ value }) => currency(value) },
];

const options: GridOptions<Order> = {
  rowId: (data) => data.id,
  defaultColumn: { width: 130, sortable: true },
};
</script>

<style>
.order-lines {
  display: grid;
  grid-template-columns: auto 1fr auto;
  gap: 2px 16px;
  max-width: 420px;
  padding-left: 32px;
}
</style>
tsx
import { Fragment, useRef } from "react";
import { Datagrid, type ColumnConfig, type DatagridHandle, type GridOptions } from "@datagrid/react-ui";
import { createOrders, type Order } from "../data";

const rows = createOrders(60);

const currency = (value: number) => value.toLocaleString("en", { style: "currency", currency: "EUR" });

const columns: Array<ColumnConfig<Order>> = [
  { field: "id", text: "Order", type: "number", width: 110 },
  { field: "customer", text: "Customer", flex: 1 },
  { field: "date", text: "Date", type: "date" },
  { field: "status", text: "Status" },
  { field: "total", text: "Total", type: "number", valueFormatter: ({ value }) => currency(value) },
];

const options: GridOptions<Order> = {
  rowId: (data) => data.id,
  defaultColumn: { width: 130, sortable: true },
};

export default function MasterDetailApi() {
  const grid = useRef<DatagridHandle>(null);

  // toggleDetails switches row.showDetails
  function openAll() {
    const api = grid.current!.api;
    api.getAllRows().forEach((row) => row.showDetails || api.toggleDetails(row));
  }

  function closeAll() {
    const api = grid.current!.api;
    api.getAllRows().forEach((row) => row.showDetails && api.toggleDetails(row));
  }

  return (
    <div className="demo">
      <div className="demo-toolbar">
        <button onClick={openAll}>Open all</button>
        <button onClick={closeAll}>Close all</button>
        <span>Click a row to show its order lines</span>
      </div>
      <Datagrid
        ref={grid}
        columns={columns}
        rows={rows}
        options={options}
        onCellClick={({ row }) => grid.current?.api.toggleDetails(row)}
        renderDetail={({ data }) => (
          // the height of the detail area follows the number of order lines
          <div className="order-lines">
            {data.lines.map((line, i) => (
              <Fragment key={i}>
                <span>{line.quantity} ×</span>
                <span>{line.product}</span>
                <span>{currency(line.quantity * line.price)}</span>
              </Fragment>
            ))}
          </div>
        )}
      />
      <style>{`
        .order-lines {
          display: grid;
          grid-template-columns: auto 1fr auto;
          gap: 2px 16px;
          max-width: 420px;
          padding-left: 32px;
        }
      `}</style>
    </div>
  );
}

Released under the ISC License.