storage-apitables
v0.2.0
Published
Server-driven React data tables with per-table Redux, filters, sorting, row/bulk actions, and host-injected UI. npm: https://www.npmjs.com/package/storage-apitables
Maintainers
Readme
storage-apitables
Server-driven data tables for React — UI, Redux state, filters, sorting, row actions, bulk actions, and custom-control modals.
The backend defines columns, filters, sorting, and actions as JSON. This package renders them with a dedicated Redux store per table instance. You inject your axios client, design-system components, and feature-specific modals via ApiTablesHostProvider.
- npm: https://www.npmjs.com/package/storage-apitables
- Docs: ApiTables frontend reference
Author
Mohamed Hasan — @mhmdhasan
Frontend documentation and npm package by Mohamed Hasan while at Storage IT Solutions. ApiTables is a broader team effort; this package covers the UI module and its frontend logic only.
What you get
| Layer | Included |
| --- | --- |
| Entry | ReactApiTable, ApiTablesController, ApiTablesComponent |
| State | Per-table Redux (tableCore, tableColumns, bulkActions, rowActions) |
| Data | useTableStructure, useTableFetcher, useUtilsProvider |
| UI | Toolbar, inline or sheet filters, sorting, pagination, column visibility, row/bulk actions |
| Modals | Confirm/view row data, static column viewers, custom-control router |
Not included: backend APIs, database schema, or app-specific custom-control forms — those stay in your host app and register through config.
Install
pnpm add storage-apitables
pnpm add react react-dom react-redux @reduxjs/toolkit axios react-data-table-component
pnpm add sonner # optional — used for toasts when configuredMental model
- Structure —
useTableStructureGETs/api-table/control-tables/load-table/{name}(columns, filters, actions, filter layout) - State —
ApiTablesProvidercreates one isolated Redux store per table - Filters — inline layout maps filters into column headers; sheet layout uses a side panel; unmapped filters use an overflow popover
- Query —
useTableFetcherPOSTs rich filter objects, sorts, pagination, and optional routeparams - Actions —
useUtilsProviderhandles row/bulk POSTs and refresh rules
The reference implementation lives in bolesa-app-ui at app/ApiTables/. Sync source changes into this package with scripts/sync-from-app.sh before publishing.
Quick start
1. Bootstrap the host config (once per app)
Call setExternalStoreGetter at app startup if you use global Redux bridges:
// app/providers.tsx or similar
import { setExternalStoreGetter } from 'storage-apitables';
import { store } from '@/store/store';
setExternalStoreGetter(() => store);2. Create a shared host provider
Centralize config so every table page does not repeat the component map:
// app/ApiTablesHost.tsx
'use client';
import { useSelector } from 'react-redux';
import { ApiTablesHostProvider, type ApiTablesConfig } from 'storage-apitables';
import { axiosTable } from '@/lib/axios';
import { store } from '@/store/store';
import { Link, usePathname } from '@/i18n/navigation';
import { useLocale, useTranslations } from 'next-intl';
import useSecureLS from '@/hooks/useSecureLS';
import Popup from '@/components/Popup';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
// …import every primitive listed in "Required components" below
import { EditProduct } from '@/features/tables/custom-controls/EditProduct';
import { AddToCreditBulkModal } from '@/features/tables/custom-controls/AddToCreditBulkModal';
const config: ApiTablesConfig = {
axiosTable,
getExternalStore: () => store,
useOutScopeRefresher: () => useSelector((s: { app: { outScopeTableRefresher: unknown } }) => s.app.outScopeTableRefresher),
useLocale,
useTranslations,
usePathname,
useSecureLS,
Link,
components: {
Button,
Card,
Badge,
Popup,
// …full map — see "Required components"
},
customControls: [
{ match: '/cl/products', component: EditProduct },
{ match: '/cl/categories', component: EditCategory },
],
bulkActionModals: [
{ actionKey: 'create_withdrawal_bulk', component: AddToCreditBulkModal },
],
onBulkActionSuccess: (action, response) => {
// e.g. update cart count after bulk reload on specific routes
},
setMainLoader: ({ status, msg, icon }) => {
store.dispatch(_setMainLoader({ status, msg, icon }));
},
};
export function ApiTablesHost({ children }: { children: React.ReactNode }) {
return <ApiTablesHostProvider config={config}>{children}</ApiTablesHostProvider>;
}Wrap your layout or individual pages:
<ApiTablesHost>
{children}
</ApiTablesHost>3. Add a table page
'use client';
import { useEffect } from 'react';
import { ReactApiTable, useTableStructure } from 'storage-apitables';
export default function OrdersTablePage() {
const { getTableStructure, tableStructure, tableStructureLoading } = useTableStructure();
useEffect(() => {
getTableStructure({
table: '/api-table/control-tables/load-table/orders',
});
}, [getTableStructure]);
return (
<ReactApiTable
table={tableStructure}
tableStructureLoading={tableStructureLoading}
/>
);
}useTableStructure must run inside ApiTablesHostProvider when the table mounts — either wrap the page or a parent layout.
ApiTablesConfig reference
| Field | Required | Purpose |
| --- | --- | --- |
| axiosTable | Yes | GET structure, POST query-table, row/bulk actions |
| components | Yes | Design-system primitives (see below) |
| getExternalStore | Recommended | Global Redux for getExternalState() bridges |
| useOutScopeRefresher | Recommended | Remounts per-table Redux when global state changes |
| useLocale | Recommended | Sends ln header on structure requests |
| usePathname | Recommended | Re-fetches rows when route changes |
| useTranslations | Recommended | Filter labels and UI copy |
| useSecureLS | Optional | Persists page size and column visibility |
| Link | If using redirect actions | i18n-aware link for redirect row actions |
| customControls | If using OpenModalForm | URL-matched modal forms |
| bulkActionModals | If using custom bulk modals | Matched by action_key |
| rowActionModals | Optional | Extra response modals (e.g. announcement preview) |
| customElements | Optional | String-keyed slots (withdrawal_requests, …) |
| axiosAuth | Optional | 401 logout endpoint |
| onUnauthorized | Optional | Custom 401 handler |
| onBulkActionSuccess | Optional | Cart count, route-specific side effects |
| setMainLoader | Optional | Global loading overlay during bulk POST |
| toast | Optional | { success, error } — otherwise silent |
| downloadBlob / downloadPDF / generatePDF | Optional | Export and invoice actions |
Required components map
Map your design system into config.components. Keys are case-sensitive and must match these names:
| Key | Used in |
| --- | --- |
| Button | Toolbar, filters, actions, modals |
| Badge | Sort chips, applied filters, cells |
| Card | Loading shell |
| Skeleton | Table loaders |
| Popup | All modals |
| FullPageTableLoader | Structure loading state |
| Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue | Filters, page size |
| Input, InputWrapper | Filter fields |
| Label | Filter form labels |
| Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger | Filter panel |
| Tooltip, TooltipContent, TooltipTrigger | Filter hints |
| Popover, PopoverContent, PopoverTrigger | Row actions overflow, column visibility |
| DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger | Bulk actions |
| Checkbox | Row selection, column visibility |
| Switch | Boolean filters, toggle actions |
| Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow | View-row modal |
| MultiSelect, MultiSelectContent, MultiSelectGroup, MultiSelectItem, MultiSelectTrigger, MultiSelectValue | Multi-select filters |
| DatePicker | Date filters |
| TagsInput | Tag filters |
| LoadingButton | Async action buttons |
| CustomPagination | Pagination bar |
| AppCurrency | Currency custom elements |
Missing a key throws at runtime when that UI renders.
Custom controls
Row actions with onSuccess: "OpenModalForm" open a modal after a prefetch POST. Register forms by URL substring — first match wins:
customControls: [
{ match: '/cl/products', component: EditProduct },
{ match: /\/announces\/batches/, component: EditAnnouncements },
],Control component props:
type CustomControlProps = {
action: Record<string, unknown>; // server payload + metadata
closeModal: () => void;
rowRefetch?: (row: Record<string, unknown>) => void;
};Read form defaults from action.payload or legacy action_payload. Submit via useUtilsProvider() from inside the control.
Bulk action modals
When selectedBulkAction.action_key matches, a custom modal renders instead of the default confirmation:
bulkActionModals: [
{ actionKey: 'create_withdrawal_bulk', component: AddToCreditBulkModal },
],Filter layouts
The backend chooses a default layout; users can switch when allowed.
| Layout | UI | When to use |
| --- | --- | --- |
| inline (default) | Filters render in a synthetic first row aligned with visible columns | Dense tables where filters map to column data_src |
| sheet | Filters live in a side panel (Sheet) opened from the toolbar | Many filters or filters that do not map to columns |
Structure fields (camelCase or snake_case both accepted):
| Field | Default | Purpose |
| --- | --- | --- |
| filtersLayout / filters_layout | "inline" | Default layout |
| filterLayoutSwitchable / filter_layout_switchable | true | Show inline/sheet toggle in toolbar |
When switchable, the user preference is persisted as {tableName}_filter_layout ("inline" or "sheet") via useSecureLS.
Which filters appear where
| Filter | Inline column row | Additional popover | Sheet |
| --- | --- | --- | --- |
| filter_name matches a visible column data_src | Yes | No | Yes |
| Unmapped filters (no matching column) | No | Yes (inline mode) | Yes |
| type: "range" | No | No | No |
| Legacy created_at date filter | No | No | No |
| Paired dates (pair_with, or *_from / *_to) | No | Yes / sheet | Yes |
Align filter_name with column data_src when you want a filter in the inline header row.
Supported filter types
text, select, boolean, null, number, multi_select, date
Filter schema:
{
"type": "text",
"filter_name": "sku",
"label": "SKU",
"pair_with": false,
"props": {
"operators": ["=", "like"],
"options": {},
"select_options": { "1": "Active", "0": "Inactive" }
}
}Query-table filter payload
Applied filters are not flat key→value. Each active filter is sent as a rich object keyed by filter_name:
{
"perPage": 25,
"page": 1,
"filters": {
"sku": {
"key": "sku",
"value": "ABC-123",
"label": "SKU",
"type": "text",
"operator": "like",
"valueLable": "ABC-123",
"props": { "operators": ["=", "like"] }
}
},
"sorts": {
"label": "Created at",
"direction": "desc",
"created_at": "desc"
},
"params": { "batch_id": 42 }
}Conventions:
- Dates sent to the API use
dd-MM-yyyy; ranges use[from, to] - Tags with operator
inare comma-split into an array paramscomes from<ReactApiTable params={…} />— use for route-scoped tables (id,batch_id, …)
Advanced page options
<ReactApiTable
table={tableStructure}
tableStructureLoading={tableStructureLoading}
params={{ warehouse_id: '12' }} // forwarded to query-table POST
customElement={<MyToolbarSlot />} // React node above table
// customElement="withdrawal_requests" // or string key → customElements map
controller={<MyCustomController />} // replace default ApiTablesController
/>Exported API
// Host setup
ApiTablesHostProvider, useApiTablesConfig, useApiTablesComponent
setExternalStoreGetter, getExternalState, createTableStore
// Page wiring
ReactApiTable, useTableStructure
// useLoadTableStructure — in bolesa-app-ui only; defers fetch during route transitions
// Lower-level (custom integrations)
ApiTablesProvider, ApiTablesController, ApiTablesComponent
useTableFetcher, useUtilsProvider
// Redux slice actions (advanced)
// tableCoreSlice, tableColumnsSlice, bulkActionsSlice, rowActionsSlice exportsBackend contract
Your API must expose:
| Endpoint | Method | Purpose |
| --- | --- | --- |
| /api-table/control-tables/load-table/{tableName} | GET | Columns, filters, actions JSON |
| /api-table/control-tables/query-table/{tableName} | POST | Rows, pagination, optional bindings |
Row and bulk action URLs come from the structure payload. See the table structure docs for field shapes.
Structure GET
Headers: ln: en (or current locale) when useLocale is configured.
Response (top-level fields consumed by the frontend):
{
"tableName": "products",
"columns": [],
"filters": [],
"bulkActions": [],
"rowActions": [],
"filtersLayout": "inline",
"filterLayoutSwitchable": true
}Column schema:
{
"label": "SKU",
"data_src": "sku",
"type": "text",
"sortable": true,
"showable": true
}Column types: text, text_truncate, link, boolean, html, datalist, barcode, actions.
When rowActions includes general_actions, actions are nested per column name; per-row overrides may also appear on each item as row.actions.
Query POST response
{
"items": [{ "id": "1", "sku": "ABC", "actions": {} }],
"pagination": {
"current_page": 1,
"from": 1,
"to": 25,
"total": 100,
"last_page": 4,
"per_page": 25,
"links": [],
"next_page_url": null,
"prev_page_url": null
},
"bindings": {}
}bindings is optional aggregate metadata (status counts, summaries) for page-specific widgets — not row data. Stored in Redux as tableBindings.
Action URLs
Backend paths often include a leading /api. The frontend strips /api before calling axiosTable, because the axios instance baseURL already ends with /api:
action.action.api.replace(/^\/api/, '');Document both the raw backend path and the effective URL your axios client will hit.
Row actions
| action_type | Frontend behavior |
| --- | --- |
| toggle | Switch control; POST on change |
| redirect | Link to redirect_routes.api (requires ApiTablesConfig.Link) |
| external_redirect | Opens redirect_routes.api in a new tab |
| copy | Copies copy_value to clipboard |
| default / custom_control | POST → confirmation, view modal, or custom control |
Bulk POST body:
{
"filters": { /* same appliedFilters object as query-table */ },
"selected_ids": ["row-id-1", "row-id-2"]
}Non-custom row actions typically POST { "selected_ids": [] }; row IDs are usually embedded in the action URL from the backend.
Action success responses
The frontend checks response.data.success (nested under data), not top-level success:
{
"data": {
"success": true,
"message": "Updated",
"row": {},
"url": "...",
"filename": "...",
"invoice_number": "..."
},
"items": [],
"cart": { "total_items_count": 0 }
}| onSuccess | Frontend behavior |
| --- | --- |
| reload / deleteRow | Re-fetch table |
| refetchData | Merge response.items into current rows |
| refetchRow | Replace single row from response.data.row |
| downloadData / download_pdf | Download file |
| generatePdf / downloadPdf | Client PDF generation |
| OpenModalForm | Open custom control modal with prefetch response |
| DisplayOnModal | Show view-row modal |
Excel bulk export: action_key === "export-excel-blob" uses responseType: "blob". Stream downloads: { success, type: "stream", data: { url }, file_name }.
Auth
Cookie session (withCredentials: true in the host axios client). Structure GET sends ln for localized labels. On 401, configure axiosAuth or onUnauthorized.
Client persistence (SecureLS)
| Key | Purpose |
| --- | --- |
| api_tables_page_size | Global page size (10 / 25 / 50 / 100) |
| {tableName}_tb | Visible column keys (JSON array) |
| {tableName}_filter_layout | "inline" or "sheet" when layout is switchable |
Troubleshooting
| Symptom | Fix |
| --- | --- |
| useApiTablesConfig must be used within ApiTablesHostProvider | Wrap page or layout with ApiTablesHostProvider |
| components.X is required | Add missing key to config.components |
| ApiTablesConfig.Link is required | Provide Link or avoid redirect row actions |
| Table never loads rows | Confirm tableName in structure response; check axiosTable base URL |
| Inline filter missing under column | Set filter_name equal to column data_src and ensure column is visible |
| Filter only in popover, not in header | Filter name does not match a visible column — expected for unmapped filters |
| created_at filter ignored | Use created_at_from / created_at_to instead of legacy created_at |
| Scoped table returns wrong rows | Pass route context via <ReactApiTable params={{ id }} /> |
| Custom control modal empty | Add customControls match for the action URL |
| Action succeeds but UI shows error | Return data.success: true in the action response body |
| 401 not handled | Set axiosAuth or onUnauthorized |
| Filters untranslated | Provide useTranslations (namespace ApiTables in bolesa-app-ui) |
Author & links
| | | | --- | --- | | Author | Mohamed Hasan | | GitHub | github.com/mhmdhasan | | Package | npmjs.com/package/storage-apitables | | Documentation | apitablesdocs.bolesa.net |
License
MIT · © Mohamed Hasan
