@sio-group/ui-datatable 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @sio-group/ui-datatable
2
2
 
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies
8
+ - @sio-group/ui-modal@0.4.1
9
+
3
10
  ## 0.1.0
4
11
 
5
12
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -284,6 +284,7 @@ var BooleanCell = ({
284
284
  }) => /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
285
285
  import_ui_core2.Button,
286
286
  {
287
+ className: "boolean",
287
288
  color: value ? "success" : "error",
288
289
  variant: column.format === "button" ? "primary" : "link",
289
290
  onClick: () => updateData?.(item.id, {
@@ -336,7 +337,6 @@ var import_react3 = require("react");
336
337
  var import_ui_core3 = require("@sio-group/ui-core");
337
338
  var import_jsx_runtime7 = require("react/jsx-runtime");
338
339
  var InlineInputCell = ({
339
- column,
340
340
  formField,
341
341
  item,
342
342
  value,
@@ -459,7 +459,6 @@ var renderValue = ({ value, column, item, formFields, updateData }) => {
459
459
  return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
460
460
  InlineInputCell,
461
461
  {
462
- column,
463
462
  value,
464
463
  item,
465
464
  formField,
@@ -485,7 +484,7 @@ var renderValue = ({ value, column, item, formFields, updateData }) => {
485
484
  DateCell,
486
485
  {
487
486
  column,
488
- value
487
+ value: String(value)
489
488
  }
490
489
  );
491
490
  }
package/dist/index.js CHANGED
@@ -258,6 +258,7 @@ var BooleanCell = ({
258
258
  }) => /* @__PURE__ */ jsx5(
259
259
  Button2,
260
260
  {
261
+ className: "boolean",
261
262
  color: value ? "success" : "error",
262
263
  variant: column.format === "button" ? "primary" : "link",
263
264
  onClick: () => updateData?.(item.id, {
@@ -310,7 +311,6 @@ import { useEffect as useEffect2, useState as useState3 } from "react";
310
311
  import { Button as Button3 } from "@sio-group/ui-core";
311
312
  import { Fragment as Fragment3, jsx as jsx7, jsxs as jsxs4 } from "react/jsx-runtime";
312
313
  var InlineInputCell = ({
313
- column,
314
314
  formField,
315
315
  item,
316
316
  value,
@@ -433,7 +433,6 @@ var renderValue = ({ value, column, item, formFields, updateData }) => {
433
433
  return /* @__PURE__ */ jsx8(
434
434
  InlineInputCell,
435
435
  {
436
- column,
437
436
  value,
438
437
  item,
439
438
  formField,
@@ -459,7 +458,7 @@ var renderValue = ({ value, column, item, formFields, updateData }) => {
459
458
  DateCell,
460
459
  {
461
460
  column,
462
- value
461
+ value: String(value)
463
462
  }
464
463
  );
465
464
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sio-group/ui-datatable",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -33,7 +33,7 @@
33
33
  "dependencies": {
34
34
  "@sio-group/ui-core": "0.4.0",
35
35
  "@sio-group/ui-pagination": "0.1.1",
36
- "@sio-group/ui-modal": "0.4.0"
36
+ "@sio-group/ui-modal": "0.4.1"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@vitejs/plugin-react": "^4",
@@ -1,11 +1,5 @@
1
- import {Entity} from "../types";
2
1
  import {useState} from "react";
3
-
4
- interface DataTableControlsProps {
5
- currentSearch?: string | null;
6
- handleSearch: (query: string) => void;
7
- entity?: Entity
8
- }
2
+ import {DataTableControlsProps} from "../types/data-table-props";
9
3
 
10
4
  export const DataTableControls = ({
11
5
  currentSearch,
@@ -1,12 +1,5 @@
1
- import {Column} from "../../types";
2
1
  import {Button} from "@sio-group/ui-core";
3
-
4
- interface BooleanCellProps<T extends { id: number | string }> {
5
- item: T;
6
- column: Column<T>;
7
- value: T[keyof T];
8
- updateData?: (id: string | number, values: Partial<T>) => void;
9
- }
2
+ import {BooleanCellProps} from "../../types/data-table-props";
10
3
 
11
4
  export const BooleanCell = <T extends { id: string | number }> ({
12
5
  column,
@@ -15,6 +8,7 @@ export const BooleanCell = <T extends { id: string | number }> ({
15
8
  updateData,
16
9
  }: BooleanCellProps<T>) => (
17
10
  <Button
11
+ className="boolean"
18
12
  color={value ? "success" : "error"}
19
13
  variant={column.format === "button" ? "primary" : "link"}
20
14
  onClick={() =>
@@ -1,17 +1,8 @@
1
- import {KeyboardEventHandler, useEffect, useState} from "react";
2
- import {Column, FormField} from "../../types";
1
+ import {useEffect, useState} from "react";
3
2
  import {Button} from "@sio-group/ui-core";
4
-
5
- interface InlineInputCellProps<T extends { id: string | number }> {
6
- column: Column<T>;
7
- formField: FormField;
8
- item: T;
9
- value: T[keyof T];
10
- updateData?: (id: string | number, values: Partial<T>) => void;
11
- }
3
+ import {InlineInputCellProps} from "../../types/data-table-props";
12
4
 
13
5
  export const InlineInputCell = <T extends { id: string | number }>({
14
- column,
15
6
  formField,
16
7
  item,
17
8
  value,
@@ -0,0 +1,304 @@
1
+ import React from 'react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { render } from '@testing-library/react';
4
+ import {renderValue} from "../utils/render-value";
5
+
6
+ interface TestItem {
7
+ id: number;
8
+ name: string;
9
+ email: string;
10
+ status: string;
11
+ active: boolean;
12
+ createdAt: string;
13
+ tags: string[];
14
+ role: { name: string };
15
+ meta: { key: string; value: string };
16
+ }
17
+
18
+ const baseColumn = { name: 'name' as keyof TestItem, label: 'Naam' };
19
+ const baseItem: TestItem = {
20
+ id: 1,
21
+ name: 'Alice',
22
+ email: 'alice@example.com',
23
+ status: 'active',
24
+ active: true,
25
+ createdAt: '2024-01-15T10:00:00.000Z',
26
+ tags: ['admin', 'user'],
27
+ role: { name: 'Administrator' },
28
+ meta: { key: 'foo', value: 'bar' },
29
+ };
30
+
31
+ const renderCell = (props: Parameters<typeof renderValue>[0]) => {
32
+ const result = renderValue(props);
33
+ const { container } = render(<>{result}</>);
34
+ return container;
35
+ };
36
+
37
+ // ─────────────────────────────────────────────
38
+ // Null / undefined / empty
39
+ // ─────────────────────────────────────────────
40
+
41
+ describe('empty values', () => {
42
+ it('renders EmptyCell for null', () => {
43
+ const container = renderCell({ value: null, column: baseColumn, item: baseItem });
44
+ expect(container.querySelector('.empty')).toBeTruthy();
45
+ });
46
+
47
+ it('renders EmptyCell for undefined', () => {
48
+ const container = renderCell({ value: undefined, column: baseColumn, item: baseItem });
49
+ expect(container.querySelector('.empty')).toBeTruthy();
50
+ });
51
+
52
+ it('renders EmptyCell for empty array', () => {
53
+ const container = renderCell({ value: [], column: baseColumn, item: baseItem });
54
+ expect(container.querySelector('.empty')).toBeTruthy();
55
+ });
56
+ });
57
+
58
+ // ─────────────────────────────────────────────
59
+ // String / number fallback
60
+ // ─────────────────────────────────────────────
61
+
62
+ describe('string and number values', () => {
63
+ it('renders a string value', () => {
64
+ const container = renderCell({ value: 'Alice', column: baseColumn, item: baseItem });
65
+ expect(container.textContent).toBe('Alice');
66
+ });
67
+
68
+ it('renders a number value as string', () => {
69
+ const container = renderCell({ value: 42, column: baseColumn, item: baseItem });
70
+ expect(container.textContent).toBe('42');
71
+ });
72
+ });
73
+
74
+ // ─────────────────────────────────────────────
75
+ // Format: email
76
+ // ─────────────────────────────────────────────
77
+
78
+ describe('format: email', () => {
79
+ it('renders a mailto link', () => {
80
+ const container = renderCell({
81
+ value: 'alice@example.com',
82
+ column: { ...baseColumn, format: 'email' },
83
+ item: baseItem,
84
+ });
85
+
86
+ const link = container.querySelector('a');
87
+ expect(link).toBeTruthy();
88
+ expect(link?.getAttribute('href')).toBe('mailto:alice@example.com');
89
+ expect(link?.textContent).toBe('alice@example.com');
90
+ });
91
+ });
92
+
93
+ // ─────────────────────────────────────────────
94
+ // Format: date / datetime
95
+ // ─────────────────────────────────────────────
96
+
97
+ describe('format: date and datetime', () => {
98
+ it('renders a localized date string for format: date', () => {
99
+ const container = renderCell({
100
+ value: '2024-01-15T10:00:00.000Z',
101
+ column: { ...baseColumn, format: 'date' },
102
+ item: baseItem,
103
+ });
104
+
105
+ // Should contain year and month — exact format depends on locale
106
+ expect(container.textContent).toMatch(/2024/);
107
+ });
108
+
109
+ it('renders a localized datetime string for format: datetime', () => {
110
+ const container = renderCell({
111
+ value: '2024-01-15T10:00:00.000Z',
112
+ column: { ...baseColumn, format: 'datetime' },
113
+ item: baseItem,
114
+ });
115
+
116
+ expect(container.textContent).toMatch(/2024/);
117
+ });
118
+ });
119
+
120
+ // ─────────────────────────────────────────────
121
+ // Format: boolean / button
122
+ // ─────────────────────────────────────────────
123
+
124
+ describe('format: boolean and button', () => {
125
+ it('renders a BooleanCell for format: boolean', () => {
126
+ const container = renderCell({
127
+ value: true,
128
+ column: { ...baseColumn, format: 'boolean' },
129
+ item: baseItem,
130
+ });
131
+
132
+ expect(container.querySelector('.boolean')).toBeTruthy();
133
+ });
134
+
135
+ it('renders a BooleanCell for format: button', () => {
136
+ const container = renderCell({
137
+ value: false,
138
+ column: { ...baseColumn, format: 'button' },
139
+ item: baseItem,
140
+ });
141
+
142
+ expect(container.querySelector('.boolean')).toBeTruthy();
143
+ });
144
+
145
+ it('calls updateData when BooleanCell is clicked', async () => {
146
+ const updateData = vi.fn();
147
+ const container = renderCell({
148
+ value: true,
149
+ column: { ...baseColumn, format: 'boolean' },
150
+ item: baseItem,
151
+ updateData,
152
+ });
153
+
154
+ const button = container.querySelector('button');
155
+ button?.click();
156
+
157
+ expect(updateData).toHaveBeenCalledWith(baseItem.id, { name: !true });
158
+ });
159
+ });
160
+
161
+ // ─────────────────────────────────────────────
162
+ // Format: pill
163
+ // ─────────────────────────────────────────────
164
+
165
+ describe('format: pill', () => {
166
+ it('renders a Pill with status and label from value', () => {
167
+ const container = renderCell({
168
+ value: { status: 'success', label: 'Actief' },
169
+ column: { ...baseColumn, format: 'pill' },
170
+ item: baseItem,
171
+ });
172
+
173
+ const pill = container.querySelector('.pill');
174
+ expect(pill).toBeTruthy();
175
+ expect(pill?.textContent).toBe('Actief');
176
+ expect(pill?.className).toContain('pill--success');
177
+ });
178
+ });
179
+
180
+ // ─────────────────────────────────────────────
181
+ // Arrays
182
+ // ─────────────────────────────────────────────
183
+
184
+ describe('array values', () => {
185
+ it('renders each string item in its own div', () => {
186
+ const container = renderCell({
187
+ value: ['admin', 'user'],
188
+ column: baseColumn,
189
+ item: baseItem,
190
+ });
191
+
192
+ const divs = container.querySelectorAll('div');
193
+ expect(divs).toHaveLength(2);
194
+ expect(divs[0].textContent).toBe('admin');
195
+ expect(divs[1].textContent).toBe('user');
196
+ });
197
+
198
+ it('renders each object item using key-value pairs', () => {
199
+ const container = renderCell({
200
+ value: [{ name: 'Admin' }, { name: 'User' }],
201
+ column: baseColumn,
202
+ item: baseItem,
203
+ });
204
+
205
+ expect(container.textContent).toContain('Admin');
206
+ expect(container.textContent).toContain('User');
207
+ });
208
+
209
+ it('renders only the specified key when format.key is set', () => {
210
+ const container = renderCell({
211
+ value: [{ name: 'Admin', id: 1 }, { name: 'User', id: 2 }],
212
+ column: { ...baseColumn, format: { key: 'name' } },
213
+ item: baseItem,
214
+ });
215
+
216
+ expect(container.textContent).toContain('Admin');
217
+ expect(container.textContent).toContain('User');
218
+ expect(container.textContent).not.toContain('1');
219
+ expect(container.textContent).not.toContain('2');
220
+ });
221
+ });
222
+
223
+ // ─────────────────────────────────────────────
224
+ // Objects
225
+ // ─────────────────────────────────────────────
226
+
227
+ describe('object values', () => {
228
+ it('renders all key-value pairs for a plain object', () => {
229
+ const container = renderCell({
230
+ value: { key: 'foo', value: 'bar' },
231
+ column: baseColumn,
232
+ item: baseItem,
233
+ });
234
+
235
+ expect(container.textContent).toContain('key');
236
+ expect(container.textContent).toContain('foo');
237
+ expect(container.textContent).toContain('value');
238
+ expect(container.textContent).toContain('bar');
239
+ });
240
+
241
+ it('renders only the specified key when format.key is set', () => {
242
+ const container = renderCell({
243
+ value: { name: 'Administrator', id: 5 },
244
+ column: { ...baseColumn, format: { key: 'name' } },
245
+ item: baseItem,
246
+ });
247
+
248
+ expect(container.textContent).toBe('Administrator');
249
+ expect(container.textContent).not.toContain('5');
250
+ });
251
+
252
+ it('renders EmptyCell for empty object', () => {
253
+ const container = renderCell({
254
+ value: {},
255
+ column: baseColumn,
256
+ item: baseItem,
257
+ });
258
+
259
+ expect(container.querySelector('.empty')).toBeTruthy();
260
+ });
261
+
262
+ it('falls through to String(value) when format.key does not exist on object', () => {
263
+ const container = renderCell({
264
+ value: { name: 'Alice' },
265
+ column: { ...baseColumn, format: { key: 'nonexistent' } },
266
+ item: baseItem,
267
+ });
268
+
269
+ // Returns empty string for missing key
270
+ expect(container.textContent).toBe('');
271
+ });
272
+ });
273
+
274
+ // ─────────────────────────────────────────────
275
+ // Inline editing (formFields)
276
+ // ─────────────────────────────────────────────
277
+
278
+ describe('inline editing', () => {
279
+ it('renders InlineInputCell when a matching formField is found', () => {
280
+ const formFields = [{ name: 'name', type: 'text' as const }];
281
+ const container = renderCell({
282
+ value: 'Alice',
283
+ column: baseColumn,
284
+ item: baseItem,
285
+ formFields,
286
+ });
287
+
288
+ // InlineInputCell should be rendered — check for edit button or input
289
+ expect(container.querySelector('button[aria-label="inline edit field"]')).toBeTruthy();
290
+ });
291
+
292
+ it('does not render InlineInputCell when no matching formField', () => {
293
+ const formFields = [{ name: 'email', type: 'text' as const }];
294
+ const container = renderCell({
295
+ value: 'Alice',
296
+ column: baseColumn, // name: 'name', no matching formField for 'name'
297
+ item: baseItem,
298
+ formFields,
299
+ });
300
+
301
+ expect(container.querySelector('button[aria-label="inline edit field"]')).toBeFalsy();
302
+ expect(container.textContent).toBe('Alice');
303
+ });
304
+ });
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom';
@@ -0,0 +1,464 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { renderHook, act } from '@testing-library/react';
3
+ import {useDataTable} from "../hooks/useDataTable";
4
+
5
+ interface TestItem {
6
+ id: number;
7
+ name: string;
8
+ email: string;
9
+ status: string;
10
+ age: number;
11
+ }
12
+
13
+ const mockData: TestItem[] = [
14
+ { id: 1, name: 'Alice', email: 'alice@example.com', status: 'active', age: 30 },
15
+ { id: 2, name: 'Bob', email: 'bob@example.com', status: 'inactive', age: 25 },
16
+ { id: 3, name: 'Charlie', email: 'charlie@example.com', status: 'active', age: 35 },
17
+ { id: 4, name: 'Diana', email: 'diana@example.com', status: 'inactive', age: 28 },
18
+ { id: 5, name: 'Eve', email: 'eve@example.com', status: 'active', age: 22 },
19
+ ];
20
+
21
+ const mockPagination = {
22
+ currentPage: 1,
23
+ pageCount: 3,
24
+ total: 15,
25
+ from: 1,
26
+ to: 5,
27
+ };
28
+
29
+ // ─────────────────────────────────────────────
30
+ // Mode detection
31
+ // ─────────────────────────────────────────────
32
+
33
+ describe('mode detection', () => {
34
+ it('is client-side when no pagination is provided', () => {
35
+ const { result } = renderHook(() =>
36
+ useDataTable({ data: mockData })
37
+ );
38
+
39
+ expect(result.current.pagedData).toEqual(mockData);
40
+ });
41
+
42
+ it('is server-side when pagination object is provided', () => {
43
+ const { result } = renderHook(() =>
44
+ useDataTable({ data: mockData, pagination: mockPagination })
45
+ );
46
+
47
+ // Server-side returns data as-is
48
+ expect(result.current.pagedData).toEqual(mockData);
49
+ expect(result.current.paginationMeta).toEqual(mockPagination);
50
+ });
51
+ });
52
+
53
+ // ─────────────────────────────────────────────
54
+ // showSearch
55
+ // ─────────────────────────────────────────────
56
+
57
+ describe('showSearch', () => {
58
+ it('is false by default (client-side)', () => {
59
+ const { result } = renderHook(() =>
60
+ useDataTable({ data: mockData })
61
+ );
62
+
63
+ expect(result.current.showSearch).toBe(false);
64
+ });
65
+
66
+ it('is true when clientSearchKeys is provided (client-side)', () => {
67
+ const { result } = renderHook(() =>
68
+ useDataTable({ data: mockData, clientSearchKeys: ['name'] })
69
+ );
70
+
71
+ expect(result.current.showSearch).toBe(true);
72
+ });
73
+
74
+ it('is false when clientSearchKeys is empty array (client-side)', () => {
75
+ const { result } = renderHook(() =>
76
+ useDataTable({ data: mockData, clientSearchKeys: [] })
77
+ );
78
+
79
+ expect(result.current.showSearch).toBe(false);
80
+ });
81
+
82
+ it('is false by default (server-side)', () => {
83
+ const { result } = renderHook(() =>
84
+ useDataTable({ data: mockData, pagination: mockPagination })
85
+ );
86
+
87
+ expect(result.current.showSearch).toBe(false);
88
+ });
89
+
90
+ it('is true when onSearch is provided (server-side)', () => {
91
+ const { result } = renderHook(() =>
92
+ useDataTable({
93
+ data: mockData,
94
+ pagination: mockPagination,
95
+ onSearch: vi.fn(),
96
+ })
97
+ );
98
+
99
+ expect(result.current.showSearch).toBe(true);
100
+ });
101
+ });
102
+
103
+ // ─────────────────────────────────────────────
104
+ // showPagination
105
+ // ─────────────────────────────────────────────
106
+
107
+ describe('showPagination', () => {
108
+ it('is false by default (client-side)', () => {
109
+ const { result } = renderHook(() =>
110
+ useDataTable({ data: mockData })
111
+ );
112
+
113
+ expect(result.current.showPagination).toBe(false);
114
+ });
115
+
116
+ it('is true when clientPageSize is provided (client-side)', () => {
117
+ const { result } = renderHook(() =>
118
+ useDataTable({ data: mockData, clientPageSize: 2 })
119
+ );
120
+
121
+ expect(result.current.showPagination).toBe(true);
122
+ });
123
+
124
+ it('is false by default (server-side)', () => {
125
+ const { result } = renderHook(() =>
126
+ useDataTable({ data: mockData, pagination: mockPagination })
127
+ );
128
+
129
+ expect(result.current.showPagination).toBe(false);
130
+ });
131
+
132
+ it('is true when onPaginate is provided (server-side)', () => {
133
+ const { result } = renderHook(() =>
134
+ useDataTable({
135
+ data: mockData,
136
+ pagination: mockPagination,
137
+ onPaginate: vi.fn(),
138
+ })
139
+ );
140
+
141
+ expect(result.current.showPagination).toBe(true);
142
+ });
143
+ });
144
+
145
+ // ─────────────────────────────────────────────
146
+ // Client-side search
147
+ // ─────────────────────────────────────────────
148
+
149
+ describe('client-side search', () => {
150
+ it('returns all data when search is empty', () => {
151
+ const { result } = renderHook(() =>
152
+ useDataTable({ data: mockData, clientSearchKeys: ['name'] })
153
+ );
154
+
155
+ expect(result.current.pagedData).toHaveLength(5);
156
+ });
157
+
158
+ it('filters data across multiple search keys', () => {
159
+ const { result } = renderHook(() =>
160
+ useDataTable({ data: mockData, clientSearchKeys: ['name', 'email'] })
161
+ );
162
+
163
+ act(() => result.current.handleSearch('example.com'));
164
+
165
+ expect(result.current.pagedData).toHaveLength(5);
166
+ });
167
+
168
+ it('resets to page 1 when search changes', () => {
169
+ const { result } = renderHook(() =>
170
+ useDataTable({ data: mockData, clientSearchKeys: ['name'], clientPageSize: 2 })
171
+ );
172
+
173
+ // Go to page 2
174
+ act(() => result.current.handlePaginate(2));
175
+ expect(result.current.paginationMeta?.currentPage).toBe(2);
176
+
177
+ // Search — should reset to page 1
178
+ act(() => result.current.handleSearch('alice'));
179
+ expect(result.current.paginationMeta?.currentPage).toBe(1);
180
+ });
181
+
182
+ it('updates currentSearch', () => {
183
+ const { result } = renderHook(() =>
184
+ useDataTable({ data: mockData, clientSearchKeys: ['name'] })
185
+ );
186
+
187
+ act(() => result.current.handleSearch('bob'));
188
+
189
+ expect(result.current.currentSearch).toBe('bob');
190
+ });
191
+ });
192
+
193
+ // ─────────────────────────────────────────────
194
+ // Client-side sort
195
+ // ─────────────────────────────────────────────
196
+
197
+ describe('client-side sort', () => {
198
+ it('sorts ascending by string field', () => {
199
+ const { result } = renderHook(() =>
200
+ useDataTable({ data: mockData })
201
+ );
202
+
203
+ act(() => result.current.handleSort({ name: 'name', direction: 'asc' }));
204
+
205
+ const names = result.current.pagedData.map((i) => i.name);
206
+ expect(names).toEqual([...names].sort());
207
+ });
208
+
209
+ it('resets sort when null is passed', () => {
210
+ const { result } = renderHook(() =>
211
+ useDataTable({ data: mockData })
212
+ );
213
+
214
+ act(() => result.current.handleSort({ name: 'name', direction: 'asc' }));
215
+ act(() => result.current.handleSort(null));
216
+
217
+ expect(result.current.currentSort).toBeNull();
218
+ expect(result.current.pagedData).toEqual(mockData);
219
+ });
220
+
221
+ it('updates currentSort', () => {
222
+ const { result } = renderHook(() =>
223
+ useDataTable({ data: mockData })
224
+ );
225
+
226
+ const sort = { name: 'name' as keyof TestItem, direction: 'asc' as const };
227
+ act(() => result.current.handleSort(sort));
228
+
229
+ expect(result.current.currentSort).toEqual(sort);
230
+ });
231
+ });
232
+
233
+ // ─────────────────────────────────────────────
234
+ // Client-side pagination
235
+ // ─────────────────────────────────────────────
236
+
237
+ describe('client-side pagination', () => {
238
+ it('slices data for the first page', () => {
239
+ const { result } = renderHook(() =>
240
+ useDataTable({ data: mockData, clientPageSize: 2 })
241
+ );
242
+
243
+ expect(result.current.pagedData).toHaveLength(2);
244
+ expect(result.current.pagedData[0].id).toBe(1);
245
+ expect(result.current.pagedData[1].id).toBe(2);
246
+ });
247
+
248
+ it('slices data for subsequent pages', () => {
249
+ const { result } = renderHook(() =>
250
+ useDataTable({ data: mockData, clientPageSize: 2 })
251
+ );
252
+
253
+ act(() => result.current.handlePaginate(2));
254
+
255
+ expect(result.current.pagedData).toHaveLength(2);
256
+ expect(result.current.pagedData[0].id).toBe(3);
257
+ expect(result.current.pagedData[1].id).toBe(4);
258
+ });
259
+
260
+ it('handles last page with fewer items', () => {
261
+ const { result } = renderHook(() =>
262
+ useDataTable({ data: mockData, clientPageSize: 2 })
263
+ );
264
+
265
+ act(() => result.current.handlePaginate(3));
266
+
267
+ expect(result.current.pagedData).toHaveLength(1);
268
+ expect(result.current.pagedData[0].id).toBe(5);
269
+ });
270
+
271
+ it('calculates paginationMeta correctly', () => {
272
+ const { result } = renderHook(() =>
273
+ useDataTable({ data: mockData, clientPageSize: 2 })
274
+ );
275
+
276
+ expect(result.current.paginationMeta).toEqual({
277
+ currentPage: 1,
278
+ pageCount: 3,
279
+ total: 5,
280
+ from: 1,
281
+ to: 2,
282
+ });
283
+ });
284
+
285
+ it('updates paginationMeta on page change', () => {
286
+ const { result } = renderHook(() =>
287
+ useDataTable({ data: mockData, clientPageSize: 2 })
288
+ );
289
+
290
+ act(() => result.current.handlePaginate(2));
291
+
292
+ expect(result.current.paginationMeta?.currentPage).toBe(2);
293
+ expect(result.current.paginationMeta?.from).toBe(3);
294
+ expect(result.current.paginationMeta?.to).toBe(4);
295
+ });
296
+
297
+ it('returns all data when clientPageSize is not set', () => {
298
+ const { result } = renderHook(() =>
299
+ useDataTable({ data: mockData })
300
+ );
301
+
302
+ expect(result.current.pagedData).toHaveLength(5);
303
+ });
304
+ });
305
+
306
+ // ─────────────────────────────────────────────
307
+ // Server-side delegation
308
+ // ─────────────────────────────────────────────
309
+
310
+ describe('server-side delegation', () => {
311
+ it('delegates search to onSearch callback', () => {
312
+ const onSearch = vi.fn();
313
+ const { result } = renderHook(() =>
314
+ useDataTable({ data: mockData, pagination: mockPagination, onSearch })
315
+ );
316
+
317
+ act(() => result.current.handleSearch('alice'));
318
+
319
+ expect(onSearch).toHaveBeenCalledWith('alice');
320
+ expect(onSearch).toHaveBeenCalledTimes(1);
321
+ });
322
+
323
+ it('delegates sort to onSort callback', () => {
324
+ const onSort = vi.fn();
325
+ const { result } = renderHook(() =>
326
+ useDataTable({ data: mockData, pagination: mockPagination, onSort })
327
+ );
328
+
329
+ const sort = { name: 'name' as keyof TestItem, direction: 'asc' as const };
330
+ act(() => result.current.handleSort(sort));
331
+
332
+ expect(onSort).toHaveBeenCalledWith(sort);
333
+ expect(onSort).toHaveBeenCalledTimes(1);
334
+ });
335
+
336
+ it('delegates null sort to onSort callback', () => {
337
+ const onSort = vi.fn();
338
+ const { result } = renderHook(() =>
339
+ useDataTable({ data: mockData, pagination: mockPagination, onSort })
340
+ );
341
+
342
+ act(() => result.current.handleSort(null));
343
+
344
+ expect(onSort).toHaveBeenCalledWith(null);
345
+ });
346
+
347
+ it('delegates pagination to onPaginate callback', () => {
348
+ const onPaginate = vi.fn();
349
+ const { result } = renderHook(() =>
350
+ useDataTable({ data: mockData, pagination: mockPagination, onPaginate })
351
+ );
352
+
353
+ act(() => result.current.handlePaginate(2));
354
+
355
+ expect(onPaginate).toHaveBeenCalledWith(2);
356
+ expect(onPaginate).toHaveBeenCalledTimes(1);
357
+ });
358
+
359
+ it('returns data as-is without filtering', () => {
360
+ const { result } = renderHook(() =>
361
+ useDataTable({ data: mockData, pagination: mockPagination })
362
+ );
363
+
364
+ expect(result.current.pagedData).toEqual(mockData);
365
+ });
366
+
367
+ it('returns server pagination meta as-is', () => {
368
+ const { result } = renderHook(() =>
369
+ useDataTable({ data: mockData, pagination: mockPagination })
370
+ );
371
+
372
+ expect(result.current.paginationMeta).toEqual(mockPagination);
373
+ });
374
+
375
+ it('uses controlled searchValue for currentSearch', () => {
376
+ const { result } = renderHook(() =>
377
+ useDataTable({
378
+ data: mockData,
379
+ pagination: mockPagination,
380
+ onSearch: vi.fn(),
381
+ searchValue: 'alice',
382
+ })
383
+ );
384
+
385
+ expect(result.current.currentSearch).toBe('alice');
386
+ });
387
+
388
+ it('uses controlled sortValue for currentSort', () => {
389
+ const sort = { name: 'name' as keyof TestItem, direction: 'asc' as const };
390
+ const { result } = renderHook(() =>
391
+ useDataTable({
392
+ data: mockData,
393
+ pagination: mockPagination,
394
+ onSort: vi.fn(),
395
+ sortValue: sort,
396
+ })
397
+ );
398
+
399
+ expect(result.current.currentSort).toEqual(sort);
400
+ });
401
+
402
+ it('does not filter data client-side even when clientSearchKeys is set', () => {
403
+ const onSearch = vi.fn();
404
+ const { result } = renderHook(() =>
405
+ useDataTable({
406
+ data: mockData,
407
+ pagination: mockPagination,
408
+ onSearch,
409
+ clientSearchKeys: ['name'],
410
+ })
411
+ );
412
+
413
+ act(() => result.current.handleSearch('alice'));
414
+
415
+ // Data should not be filtered locally
416
+ expect(result.current.pagedData).toHaveLength(5);
417
+ // Callback should be called instead
418
+ expect(onSearch).toHaveBeenCalledWith('alice');
419
+ });
420
+ });
421
+
422
+ // ─────────────────────────────────────────────
423
+ // Edge cases
424
+ // ─────────────────────────────────────────────
425
+
426
+ describe('edge cases', () => {
427
+ it('handles empty data array', () => {
428
+ const { result } = renderHook(() =>
429
+ useDataTable({ data: [] })
430
+ );
431
+
432
+ expect(result.current.pagedData).toEqual([]);
433
+ });
434
+
435
+ it('handles empty data with pagination', () => {
436
+ const { result } = renderHook(() =>
437
+ useDataTable({ data: [], clientPageSize: 20 })
438
+ );
439
+
440
+ expect(result.current.pagedData).toHaveLength(0);
441
+ expect(result.current.paginationMeta?.total).toBe(0);
442
+ expect(result.current.paginationMeta?.pageCount).toBe(0);
443
+ });
444
+
445
+ it('handles clientPageSize larger than dataset', () => {
446
+ const { result } = renderHook(() =>
447
+ useDataTable({ data: mockData, clientPageSize: 100 })
448
+ );
449
+
450
+ expect(result.current.pagedData).toHaveLength(5);
451
+ expect(result.current.paginationMeta?.pageCount).toBe(1);
452
+ });
453
+
454
+ it('does not mutate original data when sorting', () => {
455
+ const originalData = [...mockData];
456
+ const { result } = renderHook(() =>
457
+ useDataTable({ data: mockData })
458
+ );
459
+
460
+ act(() => result.current.handleSort({ name: 'name', direction: 'desc' }));
461
+
462
+ expect(mockData).toEqual(originalData);
463
+ });
464
+ });
@@ -29,4 +29,24 @@ export interface DataTableProps<T extends { id: string | number }> {
29
29
  striped?: boolean,
30
30
  hover?: boolean,
31
31
  style?: CSSProperties,
32
+ }
33
+
34
+ export interface DataTableControlsProps {
35
+ currentSearch?: string | null;
36
+ handleSearch: (query: string) => void;
37
+ entity?: Entity
38
+ }
39
+
40
+ export interface BooleanCellProps<T extends { id: number | string }> {
41
+ item: T;
42
+ column: Column<T>;
43
+ value: T[keyof T];
44
+ updateData?: (id: string | number, values: Partial<T>) => void;
45
+ }
46
+
47
+ export interface InlineInputCellProps<T extends { id: string | number }> {
48
+ formField: FormField;
49
+ item: T;
50
+ value: T[keyof T];
51
+ updateData?: (id: string | number, values: Partial<T>) => void;
32
52
  }
@@ -0,0 +1,7 @@
1
+ export interface RenderValueProps <T extends { id: string | number }> {
2
+ value: T[keyof T];
3
+ column: Column<T>;
4
+ item: T;
5
+ formFields?: FormField[];
6
+ updateData?: (id: (string | number), values: Partial<T>) => void;
7
+ }
@@ -1,4 +1,4 @@
1
- import {Column, FormField} from "../types";
1
+ import {FormField} from "../types";
2
2
  import {EmptyCell} from "../components/cell-types/EmptyCell";
3
3
  import {BooleanCell} from "../components/cell-types/BooleanCell";
4
4
  import {Link, Pill} from "@sio-group/ui-core";
@@ -6,14 +6,7 @@ import {renderObject} from "./render-object";
6
6
  import {DateCell} from "../components/cell-types/DateCell";
7
7
  import {isPillValue} from "./is-pill-value";
8
8
  import {InlineInputCell} from "../components/cell-types/InlineInputCell";
9
-
10
- interface RenderValueProps <T extends { id: string | number }> {
11
- value: T[keyof T];
12
- column: Column<T>;
13
- item: T;
14
- formFields?: FormField[];
15
- updateData?: (id: (string | number), values: Partial<T>) => void;
16
- }
9
+ import {RenderValueProps} from "../types/render-value-props";
17
10
 
18
11
  export const renderValue = <T extends { id: string | number }> ({value, column, item, formFields, updateData}: RenderValueProps<T>) => {
19
12
  const formatKey: string | undefined = typeof column.format === 'object' ? column.format.key : undefined;
@@ -22,7 +15,6 @@ export const renderValue = <T extends { id: string | number }> ({value, column,
22
15
  if (formField) {
23
16
  return (
24
17
  <InlineInputCell
25
- column={column}
26
18
  value={value}
27
19
  item={item}
28
20
  formField={formField}
@@ -49,7 +41,7 @@ export const renderValue = <T extends { id: string | number }> ({value, column,
49
41
  return (
50
42
  <DateCell
51
43
  column={column}
52
- value={value}
44
+ value={String(value)}
53
45
  />
54
46
  );
55
47
  }
@@ -0,0 +1 @@
1
+ {"root":["./src/index.ts","./src/hooks/usedatatable.ts","./src/types/action-cell-props.d.ts","./src/types/action-menu.d.ts","./src/types/column.d.ts","./src/types/data-table-body-props.d.ts","./src/types/data-table-header-props.d.ts","./src/types/data-table-props.d.ts","./src/types/entity.d.ts","./src/types/form-field.d.ts","./src/types/index.ts","./src/types/pagination-meta.d.ts","./src/types/render-value-props.d.ts","./src/types/sort-state.d.ts","./src/types/table-cell-props.d.ts","./src/types/use-data-table-props.d.ts","./src/types/use-data-table-return.d.ts","./src/utils/is-pill-value.ts","./src/components/actioncell.tsx","./src/components/datatable.tsx","./src/components/datatablebody.tsx","./src/components/datatablecontrols.tsx","./src/components/datatableheader.tsx","./src/components/defaultsorticon.tsx","./src/components/tablecell.tsx","./src/components/cell-types/booleancell.tsx","./src/components/cell-types/datecell.tsx","./src/components/cell-types/emptycell.tsx","./src/components/cell-types/inlineinputcell.tsx","./src/utils/render-object.tsx","./src/utils/render-value.tsx"],"errors":true,"version":"5.9.3"}
package/vitest.config.ts CHANGED
@@ -4,6 +4,6 @@ export default defineConfig({
4
4
  test: {
5
5
  environment: "jsdom",
6
6
  globals: true,
7
- //setupFiles: "./test/setup.ts"
7
+ setupFiles: "./src/tests/setup.ts"
8
8
  }
9
9
  });