Skip to content

Pinned rows ​

Pinned rows stay visible at the top (below the header) or at the bottom of the grid. They are not sorted, filtered or selected. Typical use cases are summaries, totals calculated by the server, targets or reference values.

vue
<Datagrid :pinnedTopRows="topRows" :pinnedBottomRows="bottomRows" ... />
tsx
<Datagrid pinnedTopRows={topRows} pinnedBottomRows={bottomRows} ... />

The rows can contain any data, the columns render them like normal rows (valueGetter, formatters, slots and cellClass are applied).

Target row and total row ​

A pinned top row with a fixed target, combined with the built-in total row at the bottom. Filter the rows: the target stays, the totals change.

Source
vue
<template>
  <Datagrid
    :columns="columns"
    :rows="rows"
    :options="options"
    :pinnedTopRows="top"
  />
</template>

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

const rows = createEmployees(200);

// pinned rows are not sorted or filtered, they can contain any data
const top = [{ firstName: "Target", lastName: "", salary: 80000, bonus: 5000 }];

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

const columns: Array<ColumnConfig<Employee>> = [
  { field: "firstName", text: "First name" },
  { field: "lastName", text: "Last name" },
  { field: "department", text: "Department" },
  { field: "salary", text: "Salary", type: "number", aggFunc: "avg", valueFormatter: currency },
  { field: "bonus", text: "Bonus", type: "number", aggFunc: "sum", valueFormatter: currency },
];

const options: GridOptions<Employee> = {
  // total row with the aggregated values of all (filtered) rows
  totalRow: "bottom",
  defaultColumn: { sortable: true, filterable: true, flex: 1 },
};
</script>
tsx
import { Datagrid, formatters, type ColumnConfig, type GridOptions } from "@datagrid/react-ui";
import { createEmployees, type Employee } from "../data";

const rows = createEmployees(200);

// pinned rows are not sorted or filtered, they can contain any data
const top = [{ firstName: "Target", lastName: "", salary: 80000, bonus: 5000 }] as Array<Employee>;

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

const columns: Array<ColumnConfig<Employee>> = [
  { field: "firstName", text: "First name" },
  { field: "lastName", text: "Last name" },
  { field: "department", text: "Department" },
  { field: "salary", text: "Salary", type: "number", aggFunc: "avg", valueFormatter: currency },
  { field: "bonus", text: "Bonus", type: "number", aggFunc: "sum", valueFormatter: currency },
];

const options: GridOptions<Employee> = {
  // total row with the aggregated values of all (filtered) rows
  totalRow: "bottom",
  defaultColumn: { sortable: true, filterable: true, flex: 1 },
};

export default function PinnedRows() {
  return <Datagrid columns={columns} rows={rows} options={options} pinnedTopRows={top} />;
}

Summary rows calculated by the application ​

When the summary is more than one aggregation per column (e.g. costs, budget and the difference), calculate the rows yourself and pass them as pinnedBottomRows. Recalculate them whenever the data changes – here after editing a cell (double click a quarter). cellClass works for pinned rows too (item.pinned is "top" or "bottom"), so negative values are highlighted.

Source
vue
<template>
  <div class="demo">
    <div class="demo-toolbar">Double click a quarter to change the costs, the summary rows are calculated again.</div>
    <Datagrid
      v-model:rows="rows"
      :columns="columns"
      :options="options"
      :pinnedBottomRows="summary"
    />
  </div>
</template>

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

type BudgetLine = { id: number; category: string; q1: number; q2: number; q3: number; q4: number };

const quarters = ["q1", "q2", "q3", "q4"] as const;

const rows = ref<Array<BudgetLine>>([
  { id: 1, category: "Salaries", q1: 42000, q2: 42000, q3: 45000, q4: 45000 },
  { id: 2, category: "Rent", q1: 6000, q2: 6000, q3: 6000, q4: 6000 },
  { id: 3, category: "Software", q1: 2400, q2: 2600, q3: 2600, q4: 3100 },
  { id: 4, category: "Hardware", q1: 5000, q2: 1200, q3: 800, q4: 7500 },
  { id: 5, category: "Marketing", q1: 3000, q2: 4500, q3: 6000, q4: 9000 },
  { id: 6, category: "Travel", q1: 1500, q2: 2200, q3: 900, q4: 2500 },
  { id: 7, category: "Training", q1: 800, q2: 1600, q3: 0, q4: 2000 },
]);

const budget: BudgetLine = { id: -1, category: "Budget", q1: 62000, q2: 62000, q3: 62000, q4: 72000 };

// summary rows are calculated by the application whenever the rows change
const summary = computed(() => {
  const total: BudgetLine = { id: -2, category: "Total costs", q1: 0, q2: 0, q3: 0, q4: 0 };
  for (const row of rows.value) {
    for (const q of quarters) total[q] += row[q];
  }
  const remaining: BudgetLine = { id: -3, category: "Remaining", q1: 0, q2: 0, q3: 0, q4: 0 };
  for (const q of quarters) remaining[q] = budget[q] - total[q];
  return [total, budget, remaining];
});

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

const columns: Array<ColumnConfig<BudgetLine>> = [
  { field: "category", text: "Category", width: 150 },
  ...quarters.map((q) => ({ field: q, text: q.toUpperCase(), type: "number", editable: true, valueFormatter: currency })),
  {
    id: "year",
    text: "Year",
    type: "number",
    valueGetter: ({ data }) => data.q1 + data.q2 + data.q3 + data.q4,
    valueFormatter: currency,
  },
];

const options: GridOptions<BudgetLine> = {
  rowId: (data) => data.id,
  selection: "Cell",
  defaultColumn: {
    flex: 1,
    // pinned rows use the same cell classes as normal rows
    cellClass: ({ item, value }) => (item.pinned && typeof value === "number" && value < 0 ? "budget-negative" : undefined),
  },
};
</script>

<style>
.budget-negative {
  color: #d03030;
  font-weight: 600;
}
</style>
tsx
import { useMemo, useState } from "react";
import { Datagrid, formatters, type ColumnConfig, type GridOptions } from "@datagrid/react-ui";

type BudgetLine = { id: number; category: string; q1: number; q2: number; q3: number; q4: number };

const quarters = ["q1", "q2", "q3", "q4"] as const;

const initialRows: Array<BudgetLine> = [
  { id: 1, category: "Salaries", q1: 42000, q2: 42000, q3: 45000, q4: 45000 },
  { id: 2, category: "Rent", q1: 6000, q2: 6000, q3: 6000, q4: 6000 },
  { id: 3, category: "Software", q1: 2400, q2: 2600, q3: 2600, q4: 3100 },
  { id: 4, category: "Hardware", q1: 5000, q2: 1200, q3: 800, q4: 7500 },
  { id: 5, category: "Marketing", q1: 3000, q2: 4500, q3: 6000, q4: 9000 },
  { id: 6, category: "Travel", q1: 1500, q2: 2200, q3: 900, q4: 2500 },
  { id: 7, category: "Training", q1: 800, q2: 1600, q3: 0, q4: 2000 },
];

const budget: BudgetLine = { id: -1, category: "Budget", q1: 62000, q2: 62000, q3: 62000, q4: 72000 };

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

const columns: Array<ColumnConfig<BudgetLine>> = [
  { field: "category", text: "Category", width: 150 },
  ...quarters.map((q) => ({ field: q, text: q.toUpperCase(), type: "number", editable: true, valueFormatter: currency })),
  {
    id: "year",
    text: "Year",
    type: "number",
    valueGetter: ({ data }) => data.q1 + data.q2 + data.q3 + data.q4,
    valueFormatter: currency,
  },
];

const options: GridOptions<BudgetLine> = {
  rowId: (data) => data.id,
  selection: "Cell",
  defaultColumn: {
    flex: 1,
    // pinned rows use the same cell classes as normal rows
    cellClass: ({ item, value }) => (item.pinned && typeof value === "number" && value < 0 ? "budget-negative" : undefined),
  },
};

export default function PinnedRowsSummary() {
  const [rows, setRows] = useState(initialRows);

  // summary rows are calculated by the application whenever the rows change
  const summary = useMemo(() => {
    const total: BudgetLine = { id: -2, category: "Total costs", q1: 0, q2: 0, q3: 0, q4: 0 };
    for (const row of rows) {
      for (const q of quarters) total[q] += row[q];
    }
    const remaining: BudgetLine = { id: -3, category: "Remaining", q1: 0, q2: 0, q3: 0, q4: 0 };
    for (const q of quarters) remaining[q] = budget[q] - total[q];
    return [total, budget, remaining];
  }, [rows]);

  return (
    <div className="demo">
      <div className="demo-toolbar">Double click a quarter to change the costs, the summary rows are calculated again.</div>
      <Datagrid rows={rows} onRowsChange={setRows} columns={columns} options={options} pinnedBottomRows={summary} />
      <style>{`
        .budget-negative {
          color: #d03030;
          font-weight: 600;
        }
      `}</style>
    </div>
  );
}

Total row ​

totalRow: "bottom" (or "top") adds a row with the aggregated values of all filtered rows. Columns need an aggFunc: a built-in name (sum, avg, min, max, count, first, last) or a function (values, rows) => value. Use the total row when the totals should follow the filters of the user; item.isTotal is true in formatters and cell classes of the total row.

Source
vue
<template>
  <div class="demo">
    <div class="demo-toolbar">
      <label>
        Total row
        <select v-model="position">
          <option value="top">top</option>
          <option value="bottom">bottom</option>
          <option value="">none</option>
        </select>
      </label>
      <span>Filter the rows, the total row only contains the visible rows.</span>
    </div>
    <Datagrid :columns="columns" :rows="rows" :options="options" />
  </div>
</template>

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

const rows = createEmployees(300);
const position = ref<"top" | "bottom" | "">("top");

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

const columns: Array<ColumnConfig<Employee>> = [
  { field: "lastName", text: "Name", width: 170, aggFunc: "count", valueFormatter: ({ value, item }) => (item.isTotal ? `${value} employees` : value) },
  { field: "department", text: "Department" },
  { field: "salary", text: "Salary (avg)", type: "number", aggFunc: "avg", valueFormatter: currency },
  { field: "bonus", text: "Bonus (sum)", type: "number", aggFunc: "sum", valueFormatter: currency },
  { field: "rating", text: "Best rating", type: "number", aggFunc: "max" },
  {
    field: "remote",
    text: "Remote",
    type: "boolean",
    // custom aggregation: share of remote employees
    aggFunc: (values) => `${Math.round((values.filter(Boolean).length / (values.length || 1)) * 100)} %`,
  },
];

const options = computed<GridOptions<Employee>>(() => ({
  totalRow: position.value || undefined,
  defaultColumn: { flex: 1, sortable: true, filterable: true },
}));
</script>
tsx
import { useMemo, useState } from "react";
import { Datagrid, formatters, type ColumnConfig, type GridOptions } from "@datagrid/react-ui";
import { createEmployees, type Employee } from "../data";

const rows = createEmployees(300);

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

const columns: Array<ColumnConfig<Employee>> = [
  { field: "lastName", text: "Name", width: 170, aggFunc: "count", valueFormatter: ({ value, item }) => (item.isTotal ? `${value} employees` : value) },
  { field: "department", text: "Department" },
  { field: "salary", text: "Salary (avg)", type: "number", aggFunc: "avg", valueFormatter: currency },
  { field: "bonus", text: "Bonus (sum)", type: "number", aggFunc: "sum", valueFormatter: currency },
  { field: "rating", text: "Best rating", type: "number", aggFunc: "max" },
  {
    field: "remote",
    text: "Remote",
    type: "boolean",
    // custom aggregation: share of remote employees
    aggFunc: (values) => `${Math.round((values.filter(Boolean).length / (values.length || 1)) * 100)} %`,
  },
];

export default function PinnedRowsTotal() {
  const [position, setPosition] = useState<"top" | "bottom" | "">("top");

  const options = useMemo<GridOptions<Employee>>(
    () => ({
      totalRow: position || undefined,
      defaultColumn: { flex: 1, sortable: true, filterable: true },
    }),
    [position]
  );

  return (
    <div className="demo">
      <div className="demo-toolbar">
        <label>
          Total row{" "}
          <select value={position} onChange={(e) => setPosition(e.target.value as "top" | "bottom" | "")}>
            <option value="top">top</option>
            <option value="bottom">bottom</option>
            <option value="">none</option>
          </select>
        </label>
        <span>Filter the rows, the total row only contains the visible rows.</span>
      </div>
      <Datagrid columns={columns} rows={rows} options={options} />
    </div>
  );
}

CSS classes ​

Pinned rows get the classes ut-row-pinned and ut-row-pinned-top / ut-row-pinned-bottom, the total row ut-row-total.

Released under the ISC License.