@waysnx/ui-grid-builder
v0.2.1
Published
Data grid component from WaysNX - sortable, filterable, paginated grid with column types and actions
Maintainers
Readme
@waysnx/ui-grid-builder
Data grid component from WaysNX — sortable, filterable, paginated grid with column types, row actions, and column visibility toggle. Built on TanStack Table (headless).
Table of Contents
- Installation
- Two Ways to Use
- Quick Start (Manual Columns)
- Props
- Column Definition
- Action Definition
- Column Types
- Filtering
- Row Selection
- Schema-Driven Grid
- Theming
- Security
- Peer Dependencies
Installation
npm install @waysnx/ui-grid-builder @tanstack/react-tableImport CSS
import '@waysnx/ui-grid-builder/dist/index.css';Two Ways to Use
- Manual columns — define
GridColumn[]directly in code (full control) - Schema-driven — pass a JSON Schema to
schemaToGridConfig()and get columns + settings auto-generated (zero-config, works with the WaysNX Grid Builder UI)
Both approaches use the same <Grid> component. Schema-driven just automates the column/settings creation.
Quick Start
import { Grid } from '@waysnx/ui-grid-builder';
import '@waysnx/ui-grid-builder/dist/index.css';
const columns = [
{ key: 'name', title: 'Name', type: 'text' },
{ key: 'email', title: 'Email', type: 'email' },
{ key: 'salary', title: 'Salary', type: 'currency', currencySymbol: '$', decimals: 0, align: 'right' },
{ key: 'joinDate', title: 'Join Date', type: 'date', dateFormat: 'dd/MM/yyyy' },
{ key: 'status', title: 'Status', type: 'badge',
badgeMap: {
active: { label: 'Active', color: '#166534', bg: '#dcfce7' },
inactive: { label: 'Inactive', color: '#991b1b', bg: '#fee2e2' },
}
},
];
const actions = [
{ label: 'Edit', icon: '✏️', variant: 'primary', onClick: (row) => console.log('Edit', row) },
{ label: 'Delete', icon: '🗑️', variant: 'destructive', onClick: (row) => console.log('Delete', row) },
];
<Grid
title="Employees"
data={employees}
columns={columns}
pageSize={10}
actions={actions}
showColumnToggle
showGlobalFilter
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | - | Grid title + total record count in toolbar |
| data | Record<string, any>[] | required | Array of data objects |
| columns | GridColumn[] | required | Column definitions |
| pageSize | number | 10 | Initial number of rows shown per page |
| pageSizeOptions | number[] | [5,10,25,50] | Options in the "Rows per page" dropdown. Ensure pageSize is included in this array to avoid a mismatch |
pageSize & pageSizeOptions examples:
// Default
<Grid pageSize={10} pageSizeOptions={[5, 10, 25, 50]} />
// Small datasets
<Grid pageSize={5} pageSizeOptions={[5, 10, 15, 20]} />
// Large datasets
<Grid pageSize={25} pageSizeOptions={[10, 25, 50, 100]} />
// Custom
<Grid pageSize={20} pageSizeOptions={[10, 20, 50, 100, 200]} />Note:
pageSizeshould always be one of the values inpageSizeOptions. IfpageSize={50}andpageSizeOptionsis not provided, it works correctly since50is in the default[5, 10, 25, 50]. IfpageSize={100}withoutpageSizeOptions, the dropdown won't show 100 as selected — passpageSizeOptions={[25, 50, 100]}to fix. |actions|GridAction[]| - | Row action buttons | |loading|boolean|false| Show loading skeleton | |emptyMessage|string|'No records found'| Empty state message | |showColumnToggle|boolean|true| Column visibility toggle | |showColumnFilter|boolean|true| Show filter icons in column headers. Setfalseto hide all filters globally | |showGlobalFilter|boolean|false| Show global search box in toolbar that filters across all columns | |actionsAsMenu|boolean|true| Show actions as a⋮kebab dropdown instead of inline buttons | |showRowSelection|boolean|false| Show checkbox/radio column for row selection | |selectionMode|'checkbox' \| 'radio'|'checkbox'| Multi-select (checkbox) or single-select (radio) | |selectionActions|GridAction[]| - | Actions shown in selection bar when rows are selected | |onSelectionChange|(rows) => void| - | Called when selection changes with selected row data | |toolbarActions|ReactNode| - | Extra toolbar buttons (Add, Download, etc.) | |onRowClick|(row) => void| - | Row click handler | |serverSide|boolean|false| Enable server-side pagination | |totalCount|number| - | Total records across all pages (required whenserverSideis true) | |onPageFetch|(params) => void| - | Called on page/size change with{ pageIndex, pageSize }|
Column Definition
interface GridColumn {
key: string; // matches data field
title: string; // header label
type?: GridColumnType; // text | number | currency | percentage | email | date | boolean | badge | image | custom
render?: (value, row) => ReactNode; // custom renderer
width?: string; // e.g. '150px'
align?: 'left' | 'center' | 'right'; // default: 'left'
sortable?: boolean; // default: true
filterable?: boolean; // default: true — set false to hide filter icon for this column
visible?: boolean; // default: true
dateFormat?: string; // e.g. 'dd/MM/yyyy' (for date type)
currencySymbol?: string; // e.g. '$', '€' (for currency type)
currencyPosition?: 'start' | 'end'; // default: 'start'
decimals?: number; // decimal places (currency default: 2, percentage default: 1)
badgeMap?: Record<string, { label?: string; color: string; bg: string }>;
// Maps cell value → { label, text color, background color }
// Example:
// badgeMap: {
// active: { label: 'Active', color: '#166534', bg: '#dcfce7' },
// inactive: { label: 'Inactive', color: '#991b1b', bg: '#fee2e2' },
// pending: { label: 'Pending', color: '#92400e', bg: '#fef3c7' },
// }
}Action Definition
interface GridAction {
label: string; // button text (pass '' for icon-only)
icon?: ReactNode; // icon element (emoji, SVG, etc.)
variant?: 'primary' | 'secondary' | 'destructive' | 'ghost';
onClick: (row: Record<string, any>) => void;
// Note: for selectionActions, onClick receives the array of selected rows
hidden?: (row: Record<string, any>) => boolean; // conditionally hide action per row
}Icon-only actions
const actions = [
{ label: '', icon: '✏️', variant: 'primary', onClick: (row) => edit(row) },
{ label: '', icon: '🗑️', variant: 'destructive', onClick: (row) => del(row),
hidden: (row) => !row.active },
{ label: '', icon: '👁️', onClick: (row) => view(row) },
];Using icon libraries
Since icon accepts any ReactNode, you can use any icon library:
// FontAwesome
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPen, faTrash, faEye } from '@fortawesome/free-solid-svg-icons';
const actions = [
{ label: 'Edit', icon: <FontAwesomeIcon icon={faPen} />, variant: 'primary', onClick: ... },
{ label: 'Delete', icon: <FontAwesomeIcon icon={faTrash} />, variant: 'destructive', onClick: ... },
];
// Lucide
import { Pencil, Trash2 } from 'lucide-react';
const actions = [
{ label: 'Edit', icon: <Pencil size={14} />, onClick: ... },
];
// Plain SVG or emoji also work
{ label: 'Edit', icon: '✏️', onClick: ... }Column Types
| Type | Renders as |
|---|---|
| text | Plain string |
| number | Locale-formatted number |
| currency | Formatted with symbol e.g. $95,000 (use currencySymbol, currencyPosition, decimals) |
| percentage | Value with % e.g. 3.5% (use decimals for decimal places) |
| email | Clickable mailto link (invalid email values render as plain text for security) |
| date | Formatted date (use dateFormat) |
Supported dateFormat tokens:
| Token | Description | Example |
|---|---|---|
| yyyy | 4-digit year | 2026 |
| yy | 2-digit year | 26 |
| MMMM | Full month name | March |
| MMM | Short month name | Mar |
| MM | 2-digit month (01–12) | 03 |
| M | Month without padding | 3 |
| EEEE | Full day name | Tuesday |
| EEE | Short day name | Tue |
| dd | 2-digit day (01–31) | 31 |
| d | Day without padding | 5 |
| HH | 24h hours padded (00–23) | 14 |
| hh | 12h hours padded (01–12) | 02 |
| h | 12h hours unpadded | 2 |
| mm | Minutes padded (00–59) | 30 |
| ss | Seconds padded (00–59) | 45 |
| aa / a | AM/PM | PM |
Common formats:
dateFormat: 'dd/MM/yyyy' // → 31/03/2026
dateFormat: 'MM/dd/yyyy' // → 03/31/2026 (US)
dateFormat: 'yyyy-MM-dd' // → 2026-03-31 (ISO)
dateFormat: 'dd MMM yyyy' // → 31 Mar 2026
dateFormat: 'dd MMMM yyyy' // → 31 March 2026
dateFormat: 'EEE, dd MMM yyyy' // → Tue, 31 Mar 2026
dateFormat: 'EEEE, MMMM d, yyyy' // → Tuesday, March 31, 2026
dateFormat: 'dd/MM/yy' // → 31/03/26 (2-digit year)
dateFormat: 'dd/MM/yyyy HH:mm' // → 31/03/2026 14:30
dateFormat: 'dd/MM/yyyy h:mm aa' // → 31/03/2026 2:30 PM
dateFormat: 'dd/MM/yyyy HH:mm:ss' // → 31/03/2026 14:30:45If dateFormat is not specified, falls back to toLocaleDateString().
| boolean | ✓ (green) / ✗ (red) |
| image | 36×36 thumbnail |
| custom | Use render function |
| badge | Colored pill badge (use badgeMap to map values to colors) |
Filtering
Each filterable column shows a ▽ icon in the header. Clicking it opens a dropdown with:
- Operator — Contains, Equals, Starts with, Ends with
- Input — filter value
- Clear button
Global control
<Grid showColumnFilter={false} ... /> // hide all filter iconsPer-column control
const columns = [
{ key: 'name', title: 'Name', type: 'text' }, // filterable (default)
{ key: 'avatar', title: 'Photo', type: 'image',
filterable: false }, // no filter icon
{ key: 'active', title: 'Active', type: 'boolean',
filterable: false }, // no filter icon
];Both can be combined — showColumnFilter={false} overrides all, filterable: false on a column hides just that column's filter.
Global search (all columns)
<Grid showGlobalFilter ... />Shows a search box in the toolbar that filters across all visible columns simultaneously. Default is false.
Row Selection
Enable checkbox or radio selection with showRowSelection. Selected rows appear highlighted and a selection bar shows above the grid.
Checkbox (multi-select)
<Grid
showRowSelection
selectionMode="checkbox" // default
selectionActions={[
{
label: 'Export',
icon: '📤',
onClick: (selectedRows) => {
// selectedRows is the array of all selected row data objects
console.log(selectedRows);
}
},
{ label: 'Delete', icon: '🗑️', variant: 'destructive',
onClick: (selectedRows) => deleteRows(selectedRows.map(r => r.id)) },
]}
onSelectionChange={(rows) => setSelected(rows)}
/>Radio (single-select)
<Grid
showRowSelection
selectionMode="radio"
onSelectionChange={(rows) => setSelected(rows[0])}
/>Server-Side Pagination
By default, pagination is client-side (all data loaded upfront). Enable serverSide to load only the current page's data from your API.
import { Grid } from '@waysnx/ui-grid-builder';
import { useState, useEffect } from 'react';
function EmployeeGrid() {
const [data, setData] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const fetchPage = ({ pageIndex, pageSize }) => {
setLoading(true);
fetch(`/api/employees?page=${pageIndex + 1}&limit=${pageSize}`)
.then(r => r.json())
.then(res => {
setData(res.data); // current page rows only
setTotalCount(res.total); // total records across all pages
setLoading(false);
});
};
useEffect(() => { fetchPage({ pageIndex: 0, pageSize: 10 }); }, []);
return (
<Grid
serverSide
data={data}
totalCount={totalCount}
onPageFetch={fetchPage}
loading={loading}
columns={columns}
pageSize={10}
title="Employees"
/>
);
}| Prop | Type | Default | Description |
|---|---|---|---|
| serverSide | boolean | false | Enable server-side pagination |
| totalCount | number | — | Total records across all pages (required when serverSide is true) |
| onPageFetch | (params) => void | — | Called on page/size change with { pageIndex, pageSize } |
How it works:
- Grid renders whatever
datais passed (assumes it's the current page) - Pagination uses
totalCountfor page math ("1–10 of 500") - On page change or page size change,
onPageFetchis called — your app fetches the new page - Sorting and filtering still work client-side on the loaded page
- No HTTP requests are made by the library (SSRF-safe)
For schema-driven grids, use x-grid-pagination.serverSide:
{
"x-grid-pagination": {
"pageSize": 10,
"serverSide": true
}
}Schema-Driven Grid
Generate grid columns and settings from a JSON Schema using schemaToGridConfig. This is the runtime consumer for configurations produced by the WaysNX Grid Builder UI or any OpenAPI-compatible schema.
Basic Usage
import { Grid, schemaToGridConfig } from '@waysnx/ui-grid-builder';
import type { GridSchema } from '@waysnx/ui-grid-builder';
const schema: GridSchema = {
type: 'object',
'x-grid-settings': {
title: 'Employees',
},
'x-grid-pagination': {
pageSize: 10,
},
'x-grid-filters': {
showGlobalFilter: true,
},
properties: {
name: { type: 'string', title: 'Name' },
email: { type: 'string', format: 'email', title: 'Email' },
salary: { type: 'number', title: 'Salary', 'x-currency-symbol': '$' },
joinDate: { type: 'string', format: 'date', title: 'Join Date', 'x-date-format': 'dd/MM/yyyy' },
active: { type: 'boolean', title: 'Active' },
},
};
const { columns, gridProps, actionDefs } = schemaToGridConfig(schema);
<Grid columns={columns} data={data} {...gridProps} />Auto Type Inference
Column types are inferred from JSON Schema type + format:
| Schema type | Schema format | Grid column type |
|---|---|---|
| string | — | text |
| string | email | email |
| string | date / date-time | date |
| number / integer | — | number |
| number + x-currency-symbol | — | currency |
| boolean | — | boolean |
Override with x-grid-type for types that can't be inferred: badge, image, percentage, custom.
Schema Property Extensions (x-grid-*)
| Extension | Type | Description |
|---|---|---|
| x-grid-type | GridColumnType | Force column type (overrides auto-inference) |
| x-grid-width | string | Column width e.g. '60px' |
| x-grid-align | 'left' \| 'center' \| 'right' | Text alignment (auto: right for number/currency) |
| x-grid-sortable | boolean | Enable/disable sorting |
| x-grid-filterable | boolean | Enable/disable column filter |
| x-grid-visible | boolean | Show/hide column by default |
| x-grid-decimals | number | Decimal places for currency/percentage |
| x-grid-badge-map | Record<string, {label, color, bg}> | Badge color mapping |
| x-currency-symbol | string | Currency symbol (also triggers currency type) |
| x-currency-position | 'start' \| 'end' | Currency symbol position |
| x-date-format | string | Date format tokens |
Grid-Level Settings (Grouped)
Settings are organized into logical groups. Flat x-grid-* properties are still supported for backward compatibility but grouped format is recommended.
x-grid-settings — General
| Key | Type | Default | Description |
|---|---|---|---|
| title | string | — | Grid title |
| emptyMessage | string | 'No records found' | Empty state text |
x-grid-pagination — Pagination
| Key | Type | Default | Description |
|---|---|---|---|
| pageSize | number | 10 | Rows per page |
| pageSizeOptions | number[] | [5,10,25,50] | Page size dropdown options |
| serverSide | boolean | false | Enable server-side pagination (future) |
x-grid-filters — Filtering
| Key | Type | Default | Description |
|---|---|---|---|
| showGlobalFilter | boolean | false | Show global search |
| showColumnFilter | boolean | true | Show per-column filters |
x-grid-columns — Column Controls
| Key | Type | Default | Description |
|---|---|---|---|
| showColumnToggle | boolean | true | Show column visibility toggle |
x-grid-actions — Row Actions
| Key | Type | Default | Description |
|---|---|---|---|
| actionsAsMenu | boolean | true | Kebab menu vs inline buttons |
| items | GridActionDef[] | [] | Action definitions (name, label, icon, variant) |
x-grid-selection — Row Selection
| Key | Type | Default | Description |
|---|---|---|---|
| enabled | boolean | false | Enable row selection |
| mode | 'checkbox' \| 'radio' | 'checkbox' | Selection mode |
Example:
{
"type": "object",
"x-grid-settings": { "title": "Employees" },
"x-grid-pagination": { "pageSize": 10, "pageSizeOptions": [5, 10, 25] },
"x-grid-filters": { "showGlobalFilter": true },
"x-grid-columns": { "showColumnToggle": true },
"x-grid-actions": {
"actionsAsMenu": true,
"items": [
{ "name": "edit", "label": "Edit", "icon": "✏️", "variant": "primary" },
{ "name": "delete", "label": "Delete", "icon": "🗑️", "variant": "destructive" }
]
},
"x-grid-selection": { "enabled": true, "mode": "checkbox" },
"properties": { ... }
}Action Definitions
Actions are defined in x-grid-actions.items. The consuming app maps them to handlers:
{
"x-grid-actions": {
"actionsAsMenu": true,
"items": [
{ "name": "edit", "label": "Edit", "icon": "✏️", "variant": "primary" },
{ "name": "delete", "label": "Delete", "icon": "🗑️", "variant": "destructive" }
]
}
}const { columns, gridProps, actionDefs } = schemaToGridConfig(schema);
// Map action definitions to handlers
const actions = actionDefs.map(def => ({
...def,
onClick: (row) => {
switch (def.name) {
case 'edit': openEditModal(row); break;
case 'delete': handleDelete(row.id); break;
}
},
}));
<Grid columns={columns} data={data} {...gridProps} actions={actions} />Return Value
schemaToGridConfig returns:
interface GridConfig {
columns: GridColumn[]; // Column definitions
gridProps: Partial<GridProps>; // Grid settings (title, pageSize, filters, etc.)
actionDefs: GridActionDef[]; // Action metadata (name, label, icon, variant)
}Overriding Schema Values
User props override schema values since {...gridProps} is spread before explicit props:
const { columns, gridProps } = schemaToGridConfig(schema);
<Grid
columns={columns}
data={data}
{...gridProps}
pageSize={5} // overrides schema's x-grid-page-size
/>Custom Renderers
The custom column type requires a render function which can't come from JSON. Override after conversion:
const { columns } = schemaToGridConfig(schema);
const nameCol = columns.find(c => c.key === 'name');
if (nameCol) {
nameCol.render = (value, row) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<img src={row.avatar} style={{ width: 28, borderRadius: '50%' }} />
<span>{value}</span>
</div>
);
}Complete Example
import { Grid, schemaToGridConfig } from '@waysnx/ui-grid-builder';
import type { GridSchema } from '@waysnx/ui-grid-builder';
import { useState, useEffect } from 'react';
const schema: GridSchema = {
type: 'object',
'x-grid-settings': { title: 'Employees' },
'x-grid-pagination': { pageSize: 10, pageSizeOptions: [5, 10, 25] },
'x-grid-filters': { showGlobalFilter: true },
'x-grid-columns': { showColumnToggle: true },
'x-grid-actions': {
actionsAsMenu: true,
items: [
{ name: 'edit', label: 'Edit', icon: '✏️', variant: 'primary' },
{ name: 'delete', label: 'Delete', icon: '🗑️', variant: 'destructive' },
],
},
'x-grid-selection': { enabled: true, mode: 'checkbox' },
properties: {
avatar: { type: 'string', title: 'Photo', 'x-grid-type': 'image', 'x-grid-width': '60px', 'x-grid-sortable': false, 'x-grid-filterable': false },
name: { type: 'string', title: 'Name' },
email: { type: 'string', format: 'email', title: 'Email' },
salary: { type: 'number', title: 'Salary', 'x-currency-symbol': '$', 'x-grid-decimals': 0, 'x-grid-align': 'right' },
joinDate: { type: 'string', format: 'date', title: 'Join Date', 'x-date-format': 'dd/MM/yyyy' },
status: { type: 'string', title: 'Status', 'x-grid-type': 'badge', 'x-grid-badge-map': {
active: { label: 'Active', color: '#166534', bg: '#dcfce7' },
inactive: { label: 'Inactive', color: '#991b1b', bg: '#fee2e2' },
}},
active: { type: 'boolean', title: 'Active', 'x-grid-width': '80px' },
},
};
function EmployeeGrid() {
const [data, setData] = useState([]);
const { columns, gridProps, actionDefs } = schemaToGridConfig(schema);
const actions = actionDefs.map(def => ({
...def,
onClick: (row) => alert(`${def.name}: ${row.name}`),
}));
useEffect(() => {
fetch('/api/employees').then(r => r.json()).then(setData);
}, []);
return (
<Grid
columns={columns}
data={data}
{...gridProps}
actions={actions}
toolbarActions={<button onClick={() => alert('Add')}>+ Add</button>}
onRowClick={(row) => console.log('Clicked:', row)}
/>
);
}TypeScript Types
import type {
GridSchema,
GridSchemaProperty,
GridActionDef,
GridConfig,
GridSettingsGroup,
GridPaginationGroup,
GridFiltersGroup,
GridColumnsGroup,
GridActionsGroup,
GridSelectionGroup,
} from '@waysnx/ui-grid-builder';Theming
Uses the same CSS variables as all WaysNX libraries:
:root {
--wx-color-primary: #f19924;
--wx-color-text: #1e293b;
--wx-color-surface: #ffffff;
--wx-color-border: #e2e8f0;
}These are the same variables used across all WaysNX libraries. See the Theming Guide for the full list.
Security
This library is designed with security as a top priority:
- No XSS vulnerabilities — All cell values are rendered via React JSX (auto-escaped). No
dangerouslySetInnerHTMLor direct DOM manipulation. Badge labels, formatted values, and custom renderers all go through React's safe rendering pipeline. - No SSRF vulnerabilities — The library makes zero HTTP requests. Data is always passed via the
dataprop. For schema-driven grids, the consuming app controls all API calls. - No Code Injection — No
eval(),new Function(), or dynamic code execution. Filter operators use string comparison, not executed code. Schema extensions are read as plain data, never evaluated.
Peer Dependencies
react>= 18react-dom>= 18@tanstack/react-table^8.0.0
License
MIT © WaysNX Technologies
