# Column Grouping Plugin

> Group columns visually under shared parent headers.

The Column Grouping plugin enables visual grouping of columns under shared headers.

## Installation

```ts
import '@toolbox-web/grid/features/grouping-columns';
```

## Basic Usage

There are three ways to define column groups, from most to least recommended:

### Option 1: Feature config `columnGroups` (Recommended)

Define everything in one place — groups, renderers, and plugin behavior:

#### TypeScript

```ts
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/grouping-columns';

const grid = queryGrid('tbw-grid');
grid.gridConfig = {
  columns: [
    { field: 'firstName', header: 'First Name' },
    { field: 'lastName', header: 'Last Name' },
    { field: 'email', header: 'Email' },
    { field: 'department', header: 'Department' },
    { field: 'title', header: 'Title' },
    { field: 'salary', header: 'Salary' },
  ],
  features: {
    groupingColumns: {
      columnGroups: [
        { header: 'Personal Info', children: ['firstName', 'lastName', 'email'] },
        { header: 'Work Info', children: ['department', 'title', 'salary'] },
      ],
    },
  },
};
```

#### React

```tsx
import '@toolbox-web/grid-react/features/grouping-columns';
import { DataGrid } from '@toolbox-web/grid-react';

function MyGrid({ data }) {
  return (
    <DataGrid
      rows={data}
      columns={[
        { field: 'firstName', header: 'First Name' },
        { field: 'lastName', header: 'Last Name' },
        { field: 'email', header: 'Email' },
        { field: 'department', header: 'Department' },
        { field: 'title', header: 'Title' },
        { field: 'salary', header: 'Salary' },
      ]}
      groupingColumns={{
        columnGroups: [
          { header: 'Personal Info', children: ['firstName', 'lastName', 'email'] },
          { header: 'Work Info', children: ['department', 'title', 'salary'] },
        ],
      }}
      style={{ height: '400px' }}
    />
  );
}
```

#### Vue

```html
<script setup>
import '@toolbox-web/grid-vue/features/grouping-columns';
import { TbwGrid, TbwGridColumn } from '@toolbox-web/grid-vue';

const data = [...];
</script>

<template>
  <TbwGrid
    :rows="data"
    :grouping-columns="{
      columnGroups: [
        { header: 'Personal Info', children: ['firstName', 'lastName', 'email'] },
        { header: 'Work Info', children: ['department', 'title', 'salary'] },
      ],
    }"
    style="height: 400px"
  >
    <TbwGridColumn field="firstName" header="First Name" />
    <TbwGridColumn field="lastName" header="Last Name" />
    <TbwGridColumn field="email" header="Email" />
    <TbwGridColumn field="department" header="Department" />
    <TbwGridColumn field="title" header="Title" />
    <TbwGridColumn field="salary" header="Salary" />
  </TbwGrid>
</template>
```

#### Angular

```typescript
import { GridGroupingColumnsDirective } from '@toolbox-web/grid-angular/features/grouping-columns';
import { Component } from '@angular/core';
import { Grid } from '@toolbox-web/grid-angular';
import type { ColumnConfig } from '@toolbox-web/grid';

@Component({
  selector: 'app-my-grid',
  imports: [Grid, GridGroupingColumnsDirective],
  template: `
    <tbw-grid
      [rows]="rows"
      [columns]="columns"
      [groupingColumns]="{
        columnGroups: [
          { header: 'Personal Info', children: ['firstName', 'lastName', 'email'] },
          { header: 'Work Info', children: ['department', 'title', 'salary'] },
        ]
      }"
      style="height: 400px; display: block;">
    </tbw-grid>
  `,
})
export class MyGridComponent {
  rows = [...];

  columns: ColumnConfig[] = [
    { field: 'firstName', header: 'First Name' },
    { field: 'lastName', header: 'Last Name' },
    { field: 'email', header: 'Email' },
    { field: 'department', header: 'Department' },
    { field: 'title', header: 'Title' },
    { field: 'salary', header: 'Salary' },
  ];
}
```

:::tip[Auto-generated IDs]
The `id` property on each group is optional. When omitted, it is auto-generated as a slug of the `header` (e.g. `'Personal Info'` → `'personal-info'`). Provide an explicit `id` when you need a stable identifier for programmatic access or custom renderers.
:::

### Option 2: Grid config `columnGroups`

If your groups are part of a server-provided layout or shared data model, define them on `gridConfig.columnGroups`:

```ts
grid.gridConfig = {
  columnGroups: [
    { header: 'Personal Info', children: ['firstName', 'lastName', 'email'] },
    { header: 'Work Info', children: ['department', 'title', 'salary'] },
  ],
  columns: [...],
  features: { groupingColumns: true },
};
```

:::note[Precedence]
If `columnGroups` is defined in **both** the feature config and `gridConfig`, the feature config wins and a console warning is emitted.
:::

### Option 3: Inline `group` property

For simple cases, assign group membership directly on each column. The `group` string becomes the group id:

```ts
grid.gridConfig = {
  columns: [
    { field: 'firstName', header: 'First Name', group: 'personal' },
    { field: 'lastName', header: 'Last Name', group: 'personal' },
    { field: 'department', header: 'Department', group: 'work' },
  ],
  features: { groupingColumns: true },
};
```

You can also pass an object with an explicit label:

```ts
{ field: 'firstName', header: 'First Name', group: { id: 'personal', label: 'Personal Info' } }
```

:::caution
The inline `group` property is strictly for declaring group membership. It does not support custom renderers — use `columnGroups` with `renderer` or `groupHeaderRenderer` for that.
:::

## Demos

### Default Column Groups

```ts
// GroupingColumnsDefaultDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/grouping-columns';

const container = document.getElementById('grouping-columns-default-demo');
if (container) {
  const firstNames = ['Alice', 'Bob', 'Carol', 'David', 'Emma', 'Frank', 'Grace', 'Henry', 'Ivy', 'Jack'];
  const lastNames = ['Johnson', 'Smith', 'Williams', 'Brown', 'Davis', 'Miller', 'Wilson', 'Moore', 'Taylor', 'Anderson'];
  const departments = ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance'];
  const titlesByDept = {
    Engineering: ['Senior Engineer', 'Software Engineer', 'Lead Engineer', 'DevOps Engineer', 'QA Engineer'],
    Marketing: ['Marketing Manager', 'Content Writer', 'Brand Manager', 'SEO Specialist'],
    Sales: ['Sales Rep', 'Sales Manager', 'Account Executive'],
    HR: ['HR Manager', 'Recruiter', 'Training Coordinator'],
    Finance: ['Accountant', 'Financial Analyst', 'Controller'],
  };
  function generateData(count: number) {
    return Array.from({ length: count }, (_, i) => {
      const firstName = firstNames[i % firstNames.length];
      const lastName = lastNames[i % lastNames.length];
      const department = departments[i % departments.length];
      const titles = titlesByDept[department];
      const title = titles[i % titles.length];
      return {
        id: i + 1,
        firstName,
        lastName,
        email: `${firstName.toLowerCase()}@example.com`,
        department,
        title,
        salary: 50000 + Math.floor((i * 3456) % 70000),
      };
    });
  }

  const sampleData = generateData(20);
  const columns = [
    { field: 'id', header: 'ID', type: 'number' },
    { field: 'firstName', header: 'First Name', group: { id: 'personal', label: 'Personal Info' } },
    { field: 'lastName', header: 'Last Name', group: { id: 'personal', label: 'Personal Info' } },
    { field: 'email', header: 'Email', group: { id: 'personal', label: 'Personal Info' } },
    { field: 'department', header: 'Department', group: { id: 'work', label: 'Work Info' } },
    { field: 'title', header: 'Title', group: { id: 'work', label: 'Work Info' } },
    { field: 'salary', header: 'Salary', type: 'number', group: { id: 'work', label: 'Work Info' } },
  ];

  const grid = queryGrid('tbw-grid', container)!;

  function rebuild(showGroupBorders = true) {
    grid.gridConfig = {
      columns,
      features: { groupingColumns: { showGroupBorders } },
    };
    grid.rows = sampleData;
  }

  rebuild();

  container.addEventListener('control-change', ((e: CustomEvent) => {
    rebuild(e.detail.allValues.showGroupBorders as boolean);
  }) as EventListener);
}
```

### Custom Group Header Renderer

Use `groupHeaderRenderer` to customize the content of group header cells. The renderer is called once per group, receives a `GroupHeaderRenderParams` object (including the group `id`), and can return an `HTMLElement`, an HTML string, or `void` to keep the default label.

HTML strings returned from the renderer are automatically sanitized (scripts and event-handler attributes are stripped) — see [Production Checklist › Security](/grid/guides/production-checklist.md#security).

Since the renderer receives `params.id`, a single function can differentiate rendering per group:

#### TypeScript

```ts
grid.gridConfig = {
  columns: [...],
  features: {
    groupingColumns: {
      columnGroups: [
        { header: 'Personal Info', children: ['firstName', 'lastName', 'email'] },
        { header: 'Work Info', children: ['department', 'title', 'salary'] },
      ],
      groupHeaderRenderer: (params) => {
        const icons: Record<string, string> = {
          'personal-info': '👤',
          'work-info': '💼',
        };
        const icon = icons[params.id] ?? '📁';
        return `${icon} <strong>${params.label}</strong> (${params.columns.length})`;
      },
    },
  },
};
```

#### React

```tsx
import '@toolbox-web/grid-react/features/grouping-columns';
import { DataGrid } from '@toolbox-web/grid-react';

const icons: Record<string, string> = {
  'personal-info': '👤',
  'work-info': '💼',
};

<DataGrid
  rows={data}
  columns={columns}
  groupingColumns={{
    columnGroups: [
      { header: 'Personal Info', children: ['firstName', 'lastName', 'email'] },
      { header: 'Work Info', children: ['department', 'title', 'salary'] },
    ],
    groupHeaderRenderer: (params) => (
      <span>
        {icons[params.id] ?? '📁'} <strong>{params.label}</strong> ({params.columns.length})
      </span>
    ),
  }}
/>
```

#### Vue

```html
<script setup>
import { h } from 'vue';
import '@toolbox-web/grid-vue/features/grouping-columns';
import { TbwGrid } from '@toolbox-web/grid-vue';

const icons: Record<string, string> = {
  'personal-info': '👤',
  'work-info': '💼',
};

const groupingConfig = {
  columnGroups: [
    { header: 'Personal Info', children: ['firstName', 'lastName', 'email'] },
    { header: 'Work Info', children: ['department', 'title', 'salary'] },
  ],
  groupHeaderRenderer: (params) =>
    h('span', [
      `${icons[params.id] ?? '📁'} `,
      h('strong', params.label),
      ` (${params.columns.length})`,
    ]),
};
</script>

<template>
  <TbwGrid :rows="data" :grouping-columns="groupingConfig">
    <!-- columns -->
  </TbwGrid>
</template>
```

#### Angular

You can pass a component class as the renderer. The adapter bridges it automatically.

```typescript
import { GridGroupingColumnsDirective } from '@toolbox-web/grid-angular/features/grouping-columns';
import { Component, input } from '@angular/core';
import { Grid } from '@toolbox-web/grid-angular';
import type { ColumnConfig } from '@toolbox-web/grid';

// Group header component — receives params as inputs
@Component({
  selector: 'app-group-header',
  template: `{{ icon() }} <strong>{{ label() }}</strong> ({{ columns().length }})`,
})
export class GroupHeaderComponent {
  id = input.required<string>();
  label = input.required<string>();
  columns = input.required<ColumnConfig[]>();
  firstIndex = input.required<number>();
  isImplicit = input.required<boolean>();

  private icons: Record<string, string> = {
    'personal-info': '👤',
    'work-info': '💼',
  };

  icon = () => this.icons[this.id()] ?? '📁';
}

@Component({
  selector: 'app-my-grid',
  imports: [Grid, GridGroupingColumnsDirective],
  template: `
    <tbw-grid
      [rows]="rows"
      [columns]="columns"
      [groupingColumns]="groupingConfig"
      style="height: 400px; display: block;">
    </tbw-grid>
  `,
})
export class MyGridComponent {
  rows = [...];
  columns = [...];

  groupingConfig = {
    columnGroups: [
      { header: 'Personal Info', children: ['firstName', 'lastName', 'email'] },
      { header: 'Work Info', children: ['department', 'title', 'salary'] },
    ],
    groupHeaderRenderer: GroupHeaderComponent,
  };
}
```

You can also define a `renderer` directly on individual group definitions. A per-group renderer takes precedence over `groupHeaderRenderer` for that specific group:

#### TypeScript

```ts
grid.gridConfig = {
  columns: [...],
  features: {
    groupingColumns: {
      columnGroups: [
        {
          header: 'Personal Info',
          children: ['firstName', 'lastName', 'email'],
          renderer: (params) => `👤 <strong>${params.label}</strong>`,
        },
        { header: 'Work Info', children: ['department', 'title', 'salary'] },
      ],
      // Fallback for groups without their own renderer (Work Info in this case)
      groupHeaderRenderer: (params) => `<strong>${params.label}</strong>`,
    },
  },
};
```

#### React

```tsx
import '@toolbox-web/grid-react/features/grouping-columns';
import { DataGrid } from '@toolbox-web/grid-react';

<DataGrid
  rows={data}
  columns={columns}
  groupingColumns={{
    columnGroups: [
      {
        header: 'Personal Info',
        children: ['firstName', 'lastName', 'email'],
        renderer: (params) => (
          <span>👤 <strong>{params.label}</strong></span>
        ),
      },
      { header: 'Work Info', children: ['department', 'title', 'salary'] },
    ],
    // Fallback for groups without their own renderer (Work Info in this case)
    groupHeaderRenderer: (params) => <strong>{params.label}</strong>,
  }}
/>
```

#### Vue

```html
<script setup>
import { h } from 'vue';
import '@toolbox-web/grid-vue/features/grouping-columns';
import { TbwGrid } from '@toolbox-web/grid-vue';

const groupingConfig = {
  columnGroups: [
    {
      header: 'Personal Info',
      children: ['firstName', 'lastName', 'email'],
      renderer: (params) => h('span', ['👤 ', h('strong', params.label)]),
    },
    { header: 'Work Info', children: ['department', 'title', 'salary'] },
  ],
  // Fallback for groups without their own renderer (Work Info in this case)
  groupHeaderRenderer: (params) => h('strong', params.label),
};
</script>

<template>
  <TbwGrid :rows="data" :grouping-columns="groupingConfig">
    <!-- columns -->
  </TbwGrid>
</template>
```

#### Angular

Per-group renderers can also be component classes:

```typescript
import { GridGroupingColumnsDirective } from '@toolbox-web/grid-angular/features/grouping-columns';
import { Component, input } from '@angular/core';
import { Grid } from '@toolbox-web/grid-angular';
import type { ColumnConfig } from '@toolbox-web/grid';

@Component({
  selector: 'app-personal-group-header',
  template: `👤 <strong>{{ label() }}</strong>`,
})
export class PersonalGroupHeaderComponent {
  id = input.required<string>();
  label = input.required<string>();
  columns = input.required<ColumnConfig[]>();
  firstIndex = input.required<number>();
  isImplicit = input.required<boolean>();
}

@Component({
  selector: 'app-default-group-header',
  template: `<strong>{{ label() }}</strong>`,
})
export class DefaultGroupHeaderComponent {
  id = input.required<string>();
  label = input.required<string>();
  columns = input.required<ColumnConfig[]>();
  firstIndex = input.required<number>();
  isImplicit = input.required<boolean>();
}

@Component({
  selector: 'app-my-grid',
  imports: [Grid, GridGroupingColumnsDirective],
  template: `
    <tbw-grid
      [rows]="rows"
      [columns]="columns"
      [groupingColumns]="groupingConfig"
      style="height: 400px; display: block;">
    </tbw-grid>
  `,
})
export class MyGridComponent {
  rows = [...];
  columns = [...];

  groupingConfig = {
    columnGroups: [
      {
        header: 'Personal Info',
        children: ['firstName', 'lastName', 'email'],
        renderer: PersonalGroupHeaderComponent,
      },
      { header: 'Work Info', children: ['department', 'title', 'salary'] },
    ],
    // Fallback for groups without their own renderer
    groupHeaderRenderer: DefaultGroupHeaderComponent,
  };
}
```

```ts
// GroupingColumnsCustomRendererDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/grouping-columns';

const container = document.getElementById('grouping-columns-custom-renderer-demo');
if (container) {
  const firstNames = ['Alice', 'Bob', 'Carol', 'David', 'Emma', 'Frank', 'Grace', 'Henry', 'Ivy', 'Jack'];
  const lastNames = ['Johnson', 'Smith', 'Williams', 'Brown', 'Davis', 'Miller', 'Wilson', 'Moore', 'Taylor', 'Anderson'];
  const departments = ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance'];
  const titlesByDept: Record<string, string[]> = {
    Engineering: ['Senior Engineer', 'Software Engineer', 'Lead Engineer', 'DevOps Engineer', 'QA Engineer'],
    Marketing: ['Marketing Manager', 'Content Writer', 'Brand Manager', 'SEO Specialist'],
    Sales: ['Sales Rep', 'Sales Manager', 'Account Executive'],
    HR: ['HR Manager', 'Recruiter', 'Training Coordinator'],
    Finance: ['Accountant', 'Financial Analyst', 'Controller'],
  };

  function generateData(count: number) {
    return Array.from({ length: count }, (_, i) => {
      const firstName = firstNames[i % firstNames.length];
      const lastName = lastNames[i % lastNames.length];
      const department = departments[i % departments.length];
      const titles = titlesByDept[department];
      const title = titles[i % titles.length];
      return {
        id: i + 1,
        firstName,
        lastName,
        email: `${firstName.toLowerCase()}@example.com`,
        department,
        title,
        salary: 50000 + Math.floor((i * 3456) % 70000),
      };
    });
  }

  const sampleData = generateData(20);
  const columns = [
    { field: 'id', header: 'ID', type: 'number' as const },
    { field: 'firstName', header: 'First Name', group: { id: 'personal', label: 'Personal Info' } },
    { field: 'lastName', header: 'Last Name', group: { id: 'personal', label: 'Personal Info' } },
    { field: 'email', header: 'Email', group: { id: 'personal', label: 'Personal Info' } },
    { field: 'department', header: 'Department', group: { id: 'work', label: 'Work Info' } },
    { field: 'title', header: 'Title', group: { id: 'work', label: 'Work Info' } },
    { field: 'salary', header: 'Salary', type: 'number' as const, group: { id: 'work', label: 'Work Info' } },
  ];

  const icons: Record<string, string> = {
    personal: '👤',
    work: '💼',
  };

  const grid = queryGrid('tbw-grid', container)!;
  grid.gridConfig = {
    columns,
    features: {
      groupingColumns: {
        groupHeaderRenderer: (params) => {
          const icon = icons[params.id] ?? '📁';
          const el = document.createElement('span');
          el.style.cssText = 'display: flex; align-items: center; gap: 0.4em;';
          el.innerHTML = `<span>${icon}</span> <strong>${params.label}</strong> <span style="opacity: 0.6; font-size: 0.85em;">(${params.columns.length} columns)</span>`;
          return el;
        },
      },
    },
  };
  grid.rows = sampleData;
}
```

## Configuration Options

### Where to configure what

| What | Where | Notes |
| --- | --- | --- |
| Group definitions | `features.groupingColumns.columnGroups` | Recommended — keeps everything in one place |
| Group definitions (alt) | `gridConfig.columnGroups` | Useful for server-driven layouts |
| Group membership | `columns[].group` | Simplest — just assigns a column to a group |
| Custom rendering | `groupHeaderRenderer` or per-group `renderer` | On the feature config or group definition |
| Plugin behavior | `showGroupBorders`, `lockGroupOrder` | On the feature config |

### Type reference

- **Plugin options**: [`GroupingColumnsConfig`](./interfaces/groupingcolumnsconfig/) — `columnGroups`, `groupHeaderRenderer`, `showGroupBorders`, `lockGroupOrder`
- **Group definition**: [`ColumnGroupDefinition`](./interfaces/columngroupdefinition/) — `id` (optional), `header`, `children`, `renderer`
- **Renderer params**: [`GroupHeaderRenderParams`](./interfaces/groupheaderrenderparams/) — `id`, `label`, `columns`, `firstIndex`, `isImplicit`
- **Runtime group data**: [`ColumnGroup`](./interfaces/columngroup/) — computed group objects returned by `getGroups()`
- **Column config `group`**: `string` (group ID) or `{ id: string; label?: string }`

## Programmatic API

The plugin provides these methods on the plugin instance:

```ts
const plugin = grid.getPluginByName('groupingColumns');

// Check if grouping is active
plugin.isGroupingActive(); // boolean

// Get all computed groups
plugin.getGroups(); // ColumnGroup[]

// Get columns in a specific group
plugin.getGroupColumns('personal'); // ColumnConfig[]

// Force refresh of column groups
plugin.refresh();
```
## Usage with Column Reordering

When using `GroupingColumnsPlugin` together with the [Reorder Columns](../reorder-columns/) plugin, column reordering does not enforce group boundaries by default. Users can drag columns out of their groups, and when they do, the group **fragments** — each contiguous run of columns belonging to the same group gets its own header cell. If the user drags the columns back together, the group header merges automatically.

This means groups never "break" — they split into fragments that remain visually connected to their group identity. Utility columns (like selection checkboxes or row numbers) that land between two fragments of the same group are visually absorbed into the surrounding group header.

Group header cells are also draggable — dragging a group header moves all columns in that fragment as a block. If a group is fragmented, each fragment can be dragged independently. The [Visibility Panel](../visibility/) also reflects fragmented groups, showing each fragment as a separate section in the column list.

### Built-in: `lockGroupOrder`

Set `lockGroupOrder: true` to automatically prevent columns from being moved outside their group:

```ts
grid.gridConfig = {
  features: {
    groupingColumns: { lockGroupOrder: true },
  },
};
```

This blocks moves that would break group contiguity in both header drag-and-drop and the visibility panel.

### Manual: `column-move` Event

For more control, the `column-move` event is **cancelable**, so you can implement custom validation:

```ts
grid.on('column-move', ({ field, fromIndex, toIndex, columnOrder }, e) => {

  // Example: prevent moves that break group boundaries
  if (!isValidMoveWithinGroup(field, fromIndex, toIndex)) {
    e.preventDefault(); // Column snaps back to original position
    return;
  }

  // Persist the new order
  saveColumnOrder(columnOrder);
});
```

The `column-move` event includes the complete `columnOrder` array, making it easy to persist user preferences.

## Styling

The column grouping plugin supports CSS custom properties for theming. Override these on `tbw-grid` or a parent container:

### CSS Custom Properties

| Property | Default | Description |
| --- | --- | --- |
| `--tbw-grouping-columns-header-bg` | `var(--tbw-color-header-bg)` | Group header row background |
| `--tbw-grouping-columns-border` | `var(--tbw-color-border)` | Group borders |
| `--tbw-grouping-columns-separator` | `var(--tbw-color-border-strong)` | Separator between groups |
| `--tbw-button-padding-sm` | `0.25rem 0.5rem` | Group header cell padding |
| `--tbw-font-size-sm` | `0.9285em` | Group header font size |

### Example

```css
tbw-grid {
  /* Custom column grouping styling */
  --tbw-grouping-columns-header-bg: #e3f2fd;
  --tbw-grouping-columns-border: #90caf9;
  --tbw-grouping-columns-separator: #1976d2;
}
```

### CSS Classes

The column grouping plugin uses these class names:

| Class | Element |
| --- | --- |
| `.header-group-row` | Group header row container |
| `.header-group-row.no-borders` | Borderless mode |
| `.header-group-cell` | Individual group header cell |
| `.header-row .cell.grouped` | Column under a group |
| `.header-row .cell.group-end` | Last column in a group |

## Integration with Exports

When the [ExportPlugin](../export/) is installed alongside grouping columns, group headers automatically appear in **Excel** and **JSON** exports (since 2.10.0). CSV stays flat. See [Column Groups in Exports](../export/#column-groups-in-exports) for the full behaviour matrix.

The mechanism is the generic `collectHeaderRows` plugin query — any plugin can contribute extra header rows the same way. Style group rows independently via `excelStyles.groupHeaderStyle`.

## See Also

- **[Export](../export/)** — Column groups appear in Excel/JSON exports
- **[Row Grouping](../grouping-rows/)** — Group rows by field values
- **[Pinned Columns](../pinned-columns/)** — Sticky columns
- **[Reorder Columns](../reorder-columns/)** — Drag-and-drop column reordering
