@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.
- package/.turbo/turbo-build.log +12 -12
- package/dist/index.css +1 -1
- package/dist/index.js +24701 -22929
- package/dist/index.umd.cjs +37 -37
- package/dist/src/custom/config-field-renderer.d.ts +21 -0
- package/dist/src/custom/config-panel-renderer.d.ts +81 -0
- package/dist/src/custom/config-row.d.ts +27 -0
- package/dist/src/custom/index.d.ts +5 -0
- package/dist/src/custom/mobile-dialog-content.d.ts +20 -0
- package/dist/src/custom/navigation-overlay.d.ts +8 -0
- package/dist/src/custom/section-header.d.ts +31 -0
- package/dist/src/debug/DebugPanel.d.ts +39 -0
- package/dist/src/debug/index.d.ts +9 -0
- package/dist/src/hooks/use-config-draft.d.ts +46 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/renderers/action/action-bar.d.ts +23 -0
- package/dist/src/types/config-panel.d.ts +92 -0
- package/dist/src/ui/sheet.d.ts +2 -0
- package/dist/src/ui/sidebar.d.ts +4 -0
- package/package.json +17 -17
- package/src/__tests__/__snapshots__/snapshot-critical.test.tsx.snap +3 -3
- package/src/__tests__/action-bar.test.tsx +172 -0
- package/src/__tests__/config-field-renderer.test.tsx +307 -0
- package/src/__tests__/config-panel-renderer.test.tsx +580 -0
- package/src/__tests__/config-primitives.test.tsx +106 -0
- package/src/__tests__/mobile-accessibility.test.tsx +120 -0
- package/src/__tests__/navigation-overlay.test.tsx +97 -0
- package/src/__tests__/use-config-draft.test.tsx +295 -0
- package/src/custom/config-field-renderer.tsx +276 -0
- package/src/custom/config-panel-renderer.tsx +306 -0
- package/src/custom/config-row.tsx +50 -0
- package/src/custom/index.ts +5 -0
- package/src/custom/mobile-dialog-content.tsx +67 -0
- package/src/custom/navigation-overlay.tsx +42 -4
- package/src/custom/section-header.tsx +68 -0
- package/src/debug/DebugPanel.tsx +313 -0
- package/src/debug/__tests__/DebugPanel.test.tsx +134 -0
- package/src/debug/index.ts +10 -0
- package/src/hooks/use-config-draft.ts +127 -0
- package/src/index.css +4 -0
- package/src/index.ts +15 -0
- package/src/renderers/action/action-bar.tsx +202 -0
- package/src/renderers/action/index.ts +1 -0
- package/src/renderers/complex/__tests__/data-table-airtable-ux.test.tsx +239 -0
- package/src/renderers/complex/__tests__/data-table.test.ts +16 -0
- package/src/renderers/complex/data-table.tsx +346 -43
- package/src/renderers/data-display/breadcrumb.tsx +3 -2
- package/src/renderers/form/form.tsx +4 -4
- package/src/renderers/navigation/header-bar.tsx +69 -10
- package/src/stories/ConfigPanel.stories.tsx +232 -0
- package/src/types/config-panel.ts +101 -0
- package/src/ui/dialog.tsx +20 -3
- package/src/ui/sheet.tsx +6 -3
- 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
|
+
});
|
|
@@ -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
|
});
|