@object-ui/components 3.0.3 → 3.1.0

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.
Files changed (54) hide show
  1. package/.turbo/turbo-build.log +12 -12
  2. package/dist/index.css +1 -1
  3. package/dist/index.js +24701 -22929
  4. package/dist/index.umd.cjs +37 -37
  5. package/dist/src/custom/config-field-renderer.d.ts +21 -0
  6. package/dist/src/custom/config-panel-renderer.d.ts +81 -0
  7. package/dist/src/custom/config-row.d.ts +27 -0
  8. package/dist/src/custom/index.d.ts +5 -0
  9. package/dist/src/custom/mobile-dialog-content.d.ts +20 -0
  10. package/dist/src/custom/navigation-overlay.d.ts +8 -0
  11. package/dist/src/custom/section-header.d.ts +31 -0
  12. package/dist/src/debug/DebugPanel.d.ts +39 -0
  13. package/dist/src/debug/index.d.ts +9 -0
  14. package/dist/src/hooks/use-config-draft.d.ts +46 -0
  15. package/dist/src/index.d.ts +4 -0
  16. package/dist/src/renderers/action/action-bar.d.ts +23 -0
  17. package/dist/src/types/config-panel.d.ts +92 -0
  18. package/dist/src/ui/sheet.d.ts +2 -0
  19. package/dist/src/ui/sidebar.d.ts +4 -0
  20. package/package.json +17 -17
  21. package/src/__tests__/__snapshots__/snapshot-critical.test.tsx.snap +3 -3
  22. package/src/__tests__/action-bar.test.tsx +172 -0
  23. package/src/__tests__/config-field-renderer.test.tsx +307 -0
  24. package/src/__tests__/config-panel-renderer.test.tsx +580 -0
  25. package/src/__tests__/config-primitives.test.tsx +106 -0
  26. package/src/__tests__/mobile-accessibility.test.tsx +120 -0
  27. package/src/__tests__/navigation-overlay.test.tsx +97 -0
  28. package/src/__tests__/use-config-draft.test.tsx +295 -0
  29. package/src/custom/config-field-renderer.tsx +276 -0
  30. package/src/custom/config-panel-renderer.tsx +306 -0
  31. package/src/custom/config-row.tsx +50 -0
  32. package/src/custom/index.ts +5 -0
  33. package/src/custom/mobile-dialog-content.tsx +67 -0
  34. package/src/custom/navigation-overlay.tsx +42 -4
  35. package/src/custom/section-header.tsx +68 -0
  36. package/src/debug/DebugPanel.tsx +313 -0
  37. package/src/debug/__tests__/DebugPanel.test.tsx +134 -0
  38. package/src/debug/index.ts +10 -0
  39. package/src/hooks/use-config-draft.ts +127 -0
  40. package/src/index.css +4 -0
  41. package/src/index.ts +15 -0
  42. package/src/renderers/action/action-bar.tsx +202 -0
  43. package/src/renderers/action/index.ts +1 -0
  44. package/src/renderers/complex/__tests__/data-table-airtable-ux.test.tsx +239 -0
  45. package/src/renderers/complex/__tests__/data-table.test.ts +16 -0
  46. package/src/renderers/complex/data-table.tsx +346 -43
  47. package/src/renderers/data-display/breadcrumb.tsx +3 -2
  48. package/src/renderers/form/form.tsx +4 -4
  49. package/src/renderers/navigation/header-bar.tsx +69 -10
  50. package/src/stories/ConfigPanel.stories.tsx +232 -0
  51. package/src/types/config-panel.ts +101 -0
  52. package/src/ui/dialog.tsx +20 -3
  53. package/src/ui/sheet.tsx +6 -3
  54. package/src/ui/sidebar.tsx +93 -9
@@ -0,0 +1,10 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ export { DebugPanel } from './DebugPanel';
10
+ export type { DebugPanelProps, DebugPanelTab } from './DebugPanel';
@@ -0,0 +1,127 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import { useState, useEffect, useCallback, useRef } from 'react';
10
+
11
+ export interface UseConfigDraftOptions {
12
+ /** Panel mode: 'create' starts dirty; 'edit' starts clean */
13
+ mode?: 'create' | 'edit';
14
+ /** Optional callback invoked on every field change */
15
+ onUpdate?: (field: string, value: any) => void;
16
+ /** Maximum undo history size (default: 50) */
17
+ maxHistory?: number;
18
+ }
19
+
20
+ export interface UseConfigDraftReturn<T extends Record<string, any>> {
21
+ /** The mutable draft copy */
22
+ draft: T;
23
+ /** Whether the draft differs from the source */
24
+ isDirty: boolean;
25
+ /** Update a single field in the draft */
26
+ updateField: (field: string, value: any) => void;
27
+ /** Revert draft back to source */
28
+ discard: () => void;
29
+ /** Low-level setter (use updateField for individual changes) */
30
+ setDraft: React.Dispatch<React.SetStateAction<T>>;
31
+ /** Undo the last change */
32
+ undo: () => void;
33
+ /** Redo a previously undone change */
34
+ redo: () => void;
35
+ /** Whether undo is available */
36
+ canUndo: boolean;
37
+ /** Whether redo is available */
38
+ canRedo: boolean;
39
+ }
40
+
41
+ /**
42
+ * Generic draft-state management for configuration panels.
43
+ *
44
+ * Mirrors the proven draft → save / discard pattern from ViewConfigPanel
45
+ * while being reusable across Dashboard, Form, Page, Report, and any
46
+ * future config panel. Includes undo/redo history support.
47
+ *
48
+ * @param source - The "committed" configuration object.
49
+ * @param options - Optional mode and change callback.
50
+ */
51
+ export function useConfigDraft<T extends Record<string, any>>(
52
+ source: T,
53
+ options?: UseConfigDraftOptions,
54
+ ): UseConfigDraftReturn<T> {
55
+ const maxHistory = options?.maxHistory ?? 50;
56
+ const [draft, setDraft] = useState<T>({ ...source });
57
+ const [isDirty, setIsDirty] = useState(options?.mode === 'create');
58
+ const pastRef = useRef<T[]>([]);
59
+ const futureRef = useRef<T[]>([]);
60
+ const [, forceRender] = useState(0);
61
+
62
+ // Reset draft when source identity changes
63
+ useEffect(() => {
64
+ setDraft({ ...source });
65
+ setIsDirty(options?.mode === 'create');
66
+ pastRef.current = [];
67
+ futureRef.current = [];
68
+ }, [source]); // eslint-disable-line react-hooks/exhaustive-deps
69
+
70
+ const updateField = useCallback(
71
+ (field: string, value: any) => {
72
+ setDraft((prev) => {
73
+ pastRef.current = [...pastRef.current.slice(-(maxHistory - 1)), prev];
74
+ futureRef.current = [];
75
+ return { ...prev, [field]: value };
76
+ });
77
+ setIsDirty(true);
78
+ forceRender((n) => n + 1);
79
+ options?.onUpdate?.(field, value);
80
+ },
81
+ [options?.onUpdate, maxHistory], // eslint-disable-line react-hooks/exhaustive-deps
82
+ );
83
+
84
+ const undo = useCallback(() => {
85
+ if (pastRef.current.length === 0) return;
86
+ setDraft((prev) => {
87
+ const past = [...pastRef.current];
88
+ const previous = past.pop()!;
89
+ pastRef.current = past;
90
+ futureRef.current = [prev, ...futureRef.current];
91
+ return previous;
92
+ });
93
+ forceRender((n) => n + 1);
94
+ }, []);
95
+
96
+ const redo = useCallback(() => {
97
+ if (futureRef.current.length === 0) return;
98
+ setDraft((prev) => {
99
+ const future = [...futureRef.current];
100
+ const next = future.shift()!;
101
+ futureRef.current = future;
102
+ pastRef.current = [...pastRef.current, prev];
103
+ return next;
104
+ });
105
+ forceRender((n) => n + 1);
106
+ }, []);
107
+
108
+ const discard = useCallback(() => {
109
+ setDraft({ ...source });
110
+ setIsDirty(false);
111
+ pastRef.current = [];
112
+ futureRef.current = [];
113
+ forceRender((n) => n + 1);
114
+ }, [source]);
115
+
116
+ return {
117
+ draft,
118
+ isDirty,
119
+ updateField,
120
+ discard,
121
+ setDraft,
122
+ undo,
123
+ redo,
124
+ canUndo: pastRef.current.length > 0,
125
+ canRedo: futureRef.current.length > 0,
126
+ };
127
+ }
package/src/index.css CHANGED
@@ -74,11 +74,15 @@
74
74
 
75
75
  --radius: 0.5rem;
76
76
 
77
+ --config-panel-width: 320px;
78
+
77
79
  --chart-1: 12 76% 61%;
78
80
  --chart-2: 173 58% 39%;
79
81
  --chart-3: 197 37% 24%;
80
82
  --chart-4: 43 74% 66%;
81
83
  --chart-5: 27 87% 67%;
84
+
85
+ font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
82
86
  }
83
87
 
84
88
  .dark {
package/src/index.ts CHANGED
@@ -23,6 +23,18 @@ export { registerPlaceholders } from './renderers/placeholders';
23
23
  export * from './ui';
24
24
  export * from './custom';
25
25
 
26
+ // Export hooks
27
+ export { useConfigDraft } from './hooks/use-config-draft';
28
+ export type { UseConfigDraftOptions, UseConfigDraftReturn } from './hooks/use-config-draft';
29
+
30
+ // Export config panel types
31
+ export type {
32
+ ControlType,
33
+ ConfigField,
34
+ ConfigSection,
35
+ ConfigPanelSchema,
36
+ } from './types/config-panel';
37
+
26
38
  // Export an init function to ensure components are registered
27
39
  // This is a workaround for bundlers that might tree-shake side-effect imports
28
40
  export function initializeComponents() {
@@ -30,3 +42,6 @@ export function initializeComponents() {
30
42
  // Simply importing this module should register all components
31
43
  return true;
32
44
  }
45
+
46
+ // Debug panel (tree-shakeable — only included when imported)
47
+ export * from './debug';
@@ -0,0 +1,202 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * action:bar — Location-aware action toolbar.
11
+ *
12
+ * Renders a set of ActionSchema items filtered by a given location.
13
+ * Each action is rendered using its `component` type (action:button, action:icon,
14
+ * action:menu, action:group) via the ComponentRegistry. Actions beyond the
15
+ * `maxVisible` threshold are grouped into an overflow "More" dropdown.
16
+ *
17
+ * This is the "bridge" component that connects ActionSchema metadata to the UI,
18
+ * enabling server-driven action rendering at list_toolbar, record_header,
19
+ * list_item, record_more, record_related, and global_nav locations.
20
+ *
21
+ * @example
22
+ * ```tsx
23
+ * <SchemaRenderer schema={{
24
+ * type: 'action:bar',
25
+ * location: 'record_header',
26
+ * actions: [
27
+ * { name: 'mark_complete', label: 'Mark Complete', type: 'script', icon: 'check', component: 'action:button' },
28
+ * { name: 'delete', label: 'Delete', type: 'api', icon: 'trash-2', variant: 'destructive', component: 'action:button' },
29
+ * ],
30
+ * }} />
31
+ * ```
32
+ */
33
+
34
+ import React, { forwardRef, useMemo } from 'react';
35
+ import { ComponentRegistry } from '@object-ui/core';
36
+ import type { ActionSchema, ActionLocation, ActionComponent } from '@object-ui/types';
37
+ import { useCondition } from '@object-ui/react';
38
+ import { cn } from '../../lib/utils';
39
+
40
+ export interface ActionBarSchema {
41
+ type: 'action:bar';
42
+ /** Actions to render */
43
+ actions?: ActionSchema[];
44
+ /** Filter actions by this location */
45
+ location?: ActionLocation;
46
+ /** Maximum visible inline actions before overflow into "More" menu (default: 3) */
47
+ maxVisible?: number;
48
+ /** Visibility condition expression */
49
+ visible?: string;
50
+ /** Layout direction */
51
+ direction?: 'horizontal' | 'vertical';
52
+ /** Gap between items (Tailwind gap class, default: 'gap-2') */
53
+ gap?: string;
54
+ /** Button variant for all actions (can be overridden per-action) */
55
+ variant?: string;
56
+ /** Button size for all actions (can be overridden per-action) */
57
+ size?: string;
58
+ /** Custom CSS class */
59
+ className?: string;
60
+ [key: string]: any;
61
+ }
62
+
63
+ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema; [key: string]: any }>(
64
+ ({ schema, className, ...props }, ref) => {
65
+ const {
66
+ 'data-obj-id': dataObjId,
67
+ 'data-obj-type': dataObjType,
68
+ style,
69
+ ...rest
70
+ } = props;
71
+
72
+ const isVisible = useCondition(schema.visible ? `\${${schema.visible}}` : undefined);
73
+
74
+ // Filter actions by location
75
+ const filteredActions = useMemo(() => {
76
+ const actions = schema.actions || [];
77
+ if (!schema.location) return actions;
78
+ return actions.filter(
79
+ a => !a.locations || a.locations.length === 0 || a.locations.includes(schema.location!),
80
+ );
81
+ }, [schema.actions, schema.location]);
82
+
83
+ // Split into visible inline actions and overflow
84
+ const maxVisible = schema.maxVisible ?? 3;
85
+ const { inlineActions, overflowActions } = useMemo(() => {
86
+ if (filteredActions.length <= maxVisible) {
87
+ return { inlineActions: filteredActions, overflowActions: [] as ActionSchema[] };
88
+ }
89
+ return {
90
+ inlineActions: filteredActions.slice(0, maxVisible),
91
+ overflowActions: filteredActions.slice(maxVisible),
92
+ };
93
+ }, [filteredActions, maxVisible]);
94
+
95
+ if (schema.visible && !isVisible) return null;
96
+ if (filteredActions.length === 0) return null;
97
+
98
+ const direction = schema.direction || 'horizontal';
99
+ const gap = schema.gap || 'gap-2';
100
+
101
+ // Render overflow menu for excess actions
102
+ const MenuRenderer = overflowActions.length > 0 ? ComponentRegistry.get('action:menu') : null;
103
+ const overflowMenu = MenuRenderer ? (
104
+ <MenuRenderer
105
+ schema={{
106
+ type: 'action:menu' as const,
107
+ actions: overflowActions,
108
+ variant: schema.variant || 'ghost',
109
+ size: schema.size || 'sm',
110
+ }}
111
+ />
112
+ ) : null;
113
+
114
+ return (
115
+ <div
116
+ ref={ref}
117
+ className={cn(
118
+ 'flex items-center',
119
+ direction === 'vertical' ? 'flex-col items-stretch' : 'flex-row flex-wrap',
120
+ gap,
121
+ schema.className,
122
+ className,
123
+ )}
124
+ role="toolbar"
125
+ aria-label="Actions"
126
+ {...rest}
127
+ {...{ 'data-obj-id': dataObjId, 'data-obj-type': dataObjType, style }}
128
+ >
129
+ {inlineActions.map((action) => {
130
+ const componentType: ActionComponent = action.component || 'action:button';
131
+ const Renderer = ComponentRegistry.get(componentType);
132
+ if (!Renderer) return null;
133
+
134
+ return (
135
+ <Renderer
136
+ key={action.name}
137
+ schema={{
138
+ ...action,
139
+ type: componentType,
140
+ variant: action.variant || schema.variant,
141
+ size: action.size || schema.size,
142
+ }}
143
+ />
144
+ );
145
+ })}
146
+
147
+ {overflowActions.length > 0 && overflowMenu}
148
+ </div>
149
+ );
150
+ },
151
+ );
152
+
153
+ ActionBarRenderer.displayName = 'ActionBarRenderer';
154
+
155
+ ComponentRegistry.register('action:bar', ActionBarRenderer, {
156
+ namespace: 'action',
157
+ label: 'Action Bar',
158
+ inputs: [
159
+ { name: 'actions', type: 'object', label: 'Actions' },
160
+ {
161
+ name: 'location',
162
+ type: 'enum',
163
+ label: 'Location',
164
+ enum: ['list_toolbar', 'list_item', 'record_header', 'record_more', 'record_related', 'global_nav'],
165
+ },
166
+ {
167
+ name: 'maxVisible',
168
+ type: 'number',
169
+ label: 'Max Visible Actions',
170
+ defaultValue: 3,
171
+ },
172
+ {
173
+ name: 'direction',
174
+ type: 'enum',
175
+ label: 'Direction',
176
+ enum: ['horizontal', 'vertical'],
177
+ defaultValue: 'horizontal',
178
+ },
179
+ {
180
+ name: 'variant',
181
+ type: 'enum',
182
+ label: 'Default Variant',
183
+ enum: ['default', 'secondary', 'outline', 'ghost'],
184
+ defaultValue: 'outline',
185
+ },
186
+ {
187
+ name: 'size',
188
+ type: 'enum',
189
+ label: 'Default Size',
190
+ enum: ['sm', 'md', 'lg'],
191
+ defaultValue: 'sm',
192
+ },
193
+ { name: 'className', type: 'string', label: 'CSS Class', advanced: true },
194
+ ],
195
+ defaultProps: {
196
+ maxVisible: 3,
197
+ direction: 'horizontal',
198
+ variant: 'outline',
199
+ size: 'sm',
200
+ actions: [],
201
+ },
202
+ });
@@ -16,3 +16,4 @@ import './action-button';
16
16
  import './action-icon';
17
17
  import './action-menu';
18
18
  import './action-group';
19
+ import './action-bar';
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Data Table - Airtable UX Enhancements Tests
3
+ *
4
+ * Tests for row hover expand button, column header context menu,
5
+ * and hide column functionality.
6
+ */
7
+ import { describe, it, expect, vi } from 'vitest';
8
+ import { render, screen, waitFor, fireEvent } from '@testing-library/react';
9
+ import '@testing-library/jest-dom';
10
+ import React from 'react';
11
+ import { SchemaRenderer } from '@object-ui/react';
12
+
13
+ // Import data-table to ensure it's registered
14
+ import '../data-table';
15
+
16
+ const baseSchema = {
17
+ type: 'data-table',
18
+ columns: [
19
+ { header: 'Name', accessorKey: 'name' },
20
+ { header: 'Email', accessorKey: 'email' },
21
+ { header: 'Role', accessorKey: 'role' },
22
+ ],
23
+ data: [
24
+ { id: 1, name: 'Alice', email: 'alice@test.com', role: 'Admin' },
25
+ { id: 2, name: 'Bob', email: 'bob@test.com', role: 'User' },
26
+ ],
27
+ pagination: false,
28
+ searchable: false,
29
+ showRowNumbers: true,
30
+ };
31
+
32
+ // =========================================================================
33
+ // 1. Row hover expand button
34
+ // =========================================================================
35
+ describe('Row hover expand button', () => {
36
+ it('should render expand buttons on rows when onRowClick is configured', async () => {
37
+ const onRowClick = vi.fn();
38
+ render(
39
+ <SchemaRenderer
40
+ schema={{ ...baseSchema, onRowClick }}
41
+ />
42
+ );
43
+
44
+ await waitFor(() => {
45
+ expect(screen.getByText('Alice')).toBeInTheDocument();
46
+ });
47
+
48
+ // Expand buttons should exist (hidden via CSS, visible on hover)
49
+ const expandButtons = screen.getAllByTestId('row-expand-button');
50
+ expect(expandButtons.length).toBe(2);
51
+ });
52
+
53
+ it('should not render expand buttons when onRowClick is not configured', async () => {
54
+ render(<SchemaRenderer schema={baseSchema} />);
55
+
56
+ await waitFor(() => {
57
+ expect(screen.getByText('Alice')).toBeInTheDocument();
58
+ });
59
+
60
+ expect(screen.queryAllByTestId('row-expand-button')).toHaveLength(0);
61
+ });
62
+
63
+ it('should call onRowClick when expand button is clicked', async () => {
64
+ const onRowClick = vi.fn();
65
+ render(
66
+ <SchemaRenderer
67
+ schema={{ ...baseSchema, onRowClick }}
68
+ />
69
+ );
70
+
71
+ await waitFor(() => {
72
+ expect(screen.getByText('Alice')).toBeInTheDocument();
73
+ });
74
+
75
+ const expandButtons = screen.getAllByTestId('row-expand-button');
76
+ fireEvent.click(expandButtons[0]);
77
+
78
+ expect(onRowClick).toHaveBeenCalledTimes(1);
79
+ expect(onRowClick).toHaveBeenCalledWith(
80
+ expect.objectContaining({ name: 'Alice' })
81
+ );
82
+ });
83
+ });
84
+
85
+ // =========================================================================
86
+ // 2. Column header context menu
87
+ // =========================================================================
88
+ describe('Column header context menu', () => {
89
+ it('should show context menu on right-click of column header', async () => {
90
+ render(<SchemaRenderer schema={baseSchema} />);
91
+
92
+ await waitFor(() => {
93
+ expect(screen.getByText('Name')).toBeInTheDocument();
94
+ });
95
+
96
+ // Right-click on the "Name" column header
97
+ const nameHeader = screen.getByText('Name').closest('th');
98
+ expect(nameHeader).toBeTruthy();
99
+ fireEvent.contextMenu(nameHeader!);
100
+
101
+ // Context menu should appear
102
+ await waitFor(() => {
103
+ expect(screen.getByTestId('column-context-menu')).toBeInTheDocument();
104
+ });
105
+
106
+ // Should contain Sort and Hide options
107
+ expect(screen.getByText('Sort ascending')).toBeInTheDocument();
108
+ expect(screen.getByText('Sort descending')).toBeInTheDocument();
109
+ expect(screen.getByText('Hide column')).toBeInTheDocument();
110
+ });
111
+
112
+ it('should hide column when "Hide column" is clicked', async () => {
113
+ render(<SchemaRenderer schema={baseSchema} />);
114
+
115
+ await waitFor(() => {
116
+ expect(screen.getByText('Email')).toBeInTheDocument();
117
+ });
118
+
119
+ // Right-click on the "Email" column header
120
+ const emailHeader = screen.getByText('Email').closest('th');
121
+ fireEvent.contextMenu(emailHeader!);
122
+
123
+ await waitFor(() => {
124
+ expect(screen.getByTestId('column-context-menu')).toBeInTheDocument();
125
+ });
126
+
127
+ // Click "Hide column"
128
+ fireEvent.click(screen.getByText('Hide column'));
129
+
130
+ // The "Email" column should no longer be visible
131
+ await waitFor(() => {
132
+ expect(screen.queryByText('Email')).not.toBeInTheDocument();
133
+ });
134
+
135
+ // Other columns should still be visible
136
+ expect(screen.getByText('Name')).toBeInTheDocument();
137
+ expect(screen.getByText('Role')).toBeInTheDocument();
138
+ });
139
+
140
+ it('should sort ascending when "Sort ascending" is clicked from context menu', async () => {
141
+ render(
142
+ <SchemaRenderer
143
+ schema={{
144
+ ...baseSchema,
145
+ sortable: true,
146
+ }}
147
+ />
148
+ );
149
+
150
+ await waitFor(() => {
151
+ expect(screen.getByText('Name')).toBeInTheDocument();
152
+ });
153
+
154
+ // Right-click on the "Name" column header
155
+ const nameHeader = screen.getByText('Name').closest('th');
156
+ fireEvent.contextMenu(nameHeader!);
157
+
158
+ await waitFor(() => {
159
+ expect(screen.getByTestId('column-context-menu')).toBeInTheDocument();
160
+ });
161
+
162
+ // Click "Sort ascending"
163
+ fireEvent.click(screen.getByText('Sort ascending'));
164
+
165
+ // Context menu should close
166
+ await waitFor(() => {
167
+ expect(screen.queryByTestId('column-context-menu')).not.toBeInTheDocument();
168
+ });
169
+ });
170
+ });
171
+
172
+ // =========================================================================
173
+ // 3. Group/row hover styling
174
+ // =========================================================================
175
+ describe('Row group hover class', () => {
176
+ it('should apply group/row hover class to table rows', async () => {
177
+ render(<SchemaRenderer schema={baseSchema} />);
178
+
179
+ await waitFor(() => {
180
+ expect(screen.getByText('Alice')).toBeInTheDocument();
181
+ });
182
+
183
+ // Data rows should have group/row class for hover effects
184
+ const aliceRow = screen.getByText('Alice').closest('tr');
185
+ expect(aliceRow).toHaveClass('group/row');
186
+ });
187
+ });
188
+
189
+ // =========================================================================
190
+ // 5. Filler rows behavior
191
+ // =========================================================================
192
+ describe('Filler rows', () => {
193
+ it('should not render filler rows when pagination is disabled', async () => {
194
+ // pagination: false with pageSize: 10 and only 2 data rows
195
+ // should NOT produce empty filler rows
196
+ render(
197
+ <SchemaRenderer
198
+ schema={{ ...baseSchema, pagination: false, pageSize: 10 }}
199
+ />
200
+ );
201
+
202
+ await waitFor(() => {
203
+ expect(screen.getByText('Alice')).toBeInTheDocument();
204
+ });
205
+
206
+ const table = screen.getByRole('table');
207
+ const tbody = table.querySelector('tbody');
208
+ const allRows = tbody!.querySelectorAll('tr');
209
+
210
+ // Should only have data rows (2) + add-record row if any, no filler rows
211
+ // With 2 data items and no add-record, expect exactly 2 rows
212
+ const fillerRows = Array.from(allRows).filter(
213
+ (row) => row.querySelector('td[class*="p-0"]') && row.textContent === ''
214
+ );
215
+ expect(fillerRows).toHaveLength(0);
216
+ });
217
+
218
+ it('should render filler rows when pagination is enabled and page is not full', async () => {
219
+ render(
220
+ <SchemaRenderer
221
+ schema={{ ...baseSchema, pagination: true, pageSize: 5, searchable: false }}
222
+ />
223
+ );
224
+
225
+ await waitFor(() => {
226
+ expect(screen.getByText('Alice')).toBeInTheDocument();
227
+ });
228
+
229
+ const table = screen.getByRole('table');
230
+ const tbody = table.querySelector('tbody');
231
+ const allRows = tbody!.querySelectorAll('tr');
232
+
233
+ // With 2 data rows and pageSize 5, expect 3 filler rows (5 - 2 = 3)
234
+ const fillerRows = Array.from(allRows).filter(
235
+ (row) => row.querySelector('td[class*="p-0"]') && row.textContent === ''
236
+ );
237
+ expect(fillerRows).toHaveLength(3);
238
+ });
239
+ });
@@ -57,4 +57,20 @@ describe('Data Table Component', () => {
57
57
  expect(config?.defaultProps?.exportable).toBe(true);
58
58
  expect(config?.defaultProps?.rowActions).toBe(true);
59
59
  });
60
+
61
+ it('should have showAddRow and onAddRecord properties in schema', () => {
62
+ const config = ComponentRegistry.getConfig('data-table');
63
+ expect(config).toBeDefined();
64
+ // Verify the DataTableSchema type supports add-record properties
65
+ // by checking that the component accepts these props without error
66
+ const testSchema: import('@object-ui/types').DataTableSchema = {
67
+ type: 'data-table',
68
+ columns: [],
69
+ data: [],
70
+ showAddRow: true,
71
+ onAddRecord: () => {},
72
+ };
73
+ expect(testSchema.showAddRow).toBe(true);
74
+ expect(typeof testSchema.onAddRecord).toBe('function');
75
+ });
60
76
  });