@object-ui/components 3.0.3 → 3.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/.turbo/turbo-build.log +12 -12
- package/CHANGELOG.md +9 -0
- package/dist/index.css +1 -1
- package/dist/index.js +24932 -23139
- 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/filter-builder.d.ts +1 -1
- 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 +25 -0
- package/dist/src/renderers/action/action-button.d.ts +1 -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 +206 -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__/filter-builder.test.tsx +409 -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/filter-builder.tsx +76 -25
- 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 +221 -0
- package/src/renderers/action/action-button.tsx +17 -6
- 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,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,221 @@
|
|
|
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
|
+
import { useIsMobile } from '../../hooks/use-mobile';
|
|
40
|
+
|
|
41
|
+
export interface ActionBarSchema {
|
|
42
|
+
type: 'action:bar';
|
|
43
|
+
/** Actions to render */
|
|
44
|
+
actions?: ActionSchema[];
|
|
45
|
+
/** Filter actions by this location */
|
|
46
|
+
location?: ActionLocation;
|
|
47
|
+
/** Maximum visible inline actions before overflow into "More" menu (default: 3) */
|
|
48
|
+
maxVisible?: number;
|
|
49
|
+
/** Maximum visible inline actions on mobile devices (default: 1). Desktop uses maxVisible instead. */
|
|
50
|
+
mobileMaxVisible?: number;
|
|
51
|
+
/** Visibility condition expression */
|
|
52
|
+
visible?: string;
|
|
53
|
+
/** Layout direction */
|
|
54
|
+
direction?: 'horizontal' | 'vertical';
|
|
55
|
+
/** Gap between items (Tailwind gap class, default: 'gap-2') */
|
|
56
|
+
gap?: string;
|
|
57
|
+
/** Button variant for all actions (can be overridden per-action) */
|
|
58
|
+
variant?: string;
|
|
59
|
+
/** Button size for all actions (can be overridden per-action) */
|
|
60
|
+
size?: string;
|
|
61
|
+
/** Custom CSS class */
|
|
62
|
+
className?: string;
|
|
63
|
+
[key: string]: any;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema; [key: string]: any }>(
|
|
67
|
+
({ schema, className, ...props }, ref) => {
|
|
68
|
+
const {
|
|
69
|
+
'data-obj-id': dataObjId,
|
|
70
|
+
'data-obj-type': dataObjType,
|
|
71
|
+
style,
|
|
72
|
+
data,
|
|
73
|
+
...rest
|
|
74
|
+
} = props;
|
|
75
|
+
|
|
76
|
+
const isVisible = useCondition(schema.visible ? `\${${schema.visible}}` : undefined);
|
|
77
|
+
const isMobile = useIsMobile();
|
|
78
|
+
|
|
79
|
+
// Filter actions by location and deduplicate by name
|
|
80
|
+
const filteredActions = useMemo(() => {
|
|
81
|
+
const actions = schema.actions || [];
|
|
82
|
+
const located = !schema.location
|
|
83
|
+
? actions
|
|
84
|
+
: actions.filter(
|
|
85
|
+
a => !a.locations || a.locations.length === 0 || a.locations.includes(schema.location!),
|
|
86
|
+
);
|
|
87
|
+
// Deduplicate by action name — keep first occurrence
|
|
88
|
+
const seen = new Set<string>();
|
|
89
|
+
return located.filter(a => {
|
|
90
|
+
if (!a.name) return true;
|
|
91
|
+
if (seen.has(a.name)) return false;
|
|
92
|
+
seen.add(a.name);
|
|
93
|
+
return true;
|
|
94
|
+
});
|
|
95
|
+
}, [schema.actions, schema.location]);
|
|
96
|
+
|
|
97
|
+
// Split into visible inline actions and overflow
|
|
98
|
+
// On mobile, show fewer actions inline (default: 1)
|
|
99
|
+
const maxVisible = isMobile
|
|
100
|
+
? (schema.mobileMaxVisible ?? 1)
|
|
101
|
+
: (schema.maxVisible ?? 3);
|
|
102
|
+
const { inlineActions, overflowActions } = useMemo(() => {
|
|
103
|
+
if (filteredActions.length <= maxVisible) {
|
|
104
|
+
return { inlineActions: filteredActions, overflowActions: [] as ActionSchema[] };
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
inlineActions: filteredActions.slice(0, maxVisible),
|
|
108
|
+
overflowActions: filteredActions.slice(maxVisible),
|
|
109
|
+
};
|
|
110
|
+
}, [filteredActions, maxVisible]);
|
|
111
|
+
|
|
112
|
+
if (schema.visible && !isVisible) return null;
|
|
113
|
+
if (filteredActions.length === 0) return null;
|
|
114
|
+
|
|
115
|
+
const direction = schema.direction || 'horizontal';
|
|
116
|
+
const gap = schema.gap || 'gap-2';
|
|
117
|
+
|
|
118
|
+
// Render overflow menu for excess actions
|
|
119
|
+
const MenuRenderer = overflowActions.length > 0 ? ComponentRegistry.get('action:menu') : null;
|
|
120
|
+
const overflowMenu = MenuRenderer ? (
|
|
121
|
+
<MenuRenderer
|
|
122
|
+
schema={{
|
|
123
|
+
type: 'action:menu' as const,
|
|
124
|
+
actions: overflowActions,
|
|
125
|
+
variant: schema.variant || 'ghost',
|
|
126
|
+
size: schema.size || 'sm',
|
|
127
|
+
}}
|
|
128
|
+
/>
|
|
129
|
+
) : null;
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
ref={ref}
|
|
134
|
+
className={cn(
|
|
135
|
+
'flex items-center',
|
|
136
|
+
direction === 'vertical' ? 'flex-col items-stretch' : 'flex-row flex-wrap',
|
|
137
|
+
gap,
|
|
138
|
+
schema.className,
|
|
139
|
+
className,
|
|
140
|
+
)}
|
|
141
|
+
role="toolbar"
|
|
142
|
+
aria-label="Actions"
|
|
143
|
+
{...rest}
|
|
144
|
+
{...{ 'data-obj-id': dataObjId, 'data-obj-type': dataObjType, style }}
|
|
145
|
+
>
|
|
146
|
+
{inlineActions.map((action) => {
|
|
147
|
+
const componentType: ActionComponent = action.component || 'action:button';
|
|
148
|
+
const Renderer = ComponentRegistry.get(componentType);
|
|
149
|
+
if (!Renderer) return null;
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<Renderer
|
|
153
|
+
key={action.name}
|
|
154
|
+
schema={{
|
|
155
|
+
...action,
|
|
156
|
+
type: componentType,
|
|
157
|
+
actionType: action.type,
|
|
158
|
+
variant: action.variant || schema.variant,
|
|
159
|
+
size: action.size || schema.size,
|
|
160
|
+
}}
|
|
161
|
+
data={data}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
})}
|
|
165
|
+
|
|
166
|
+
{overflowActions.length > 0 && overflowMenu}
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
ActionBarRenderer.displayName = 'ActionBarRenderer';
|
|
173
|
+
|
|
174
|
+
ComponentRegistry.register('action:bar', ActionBarRenderer, {
|
|
175
|
+
namespace: 'action',
|
|
176
|
+
label: 'Action Bar',
|
|
177
|
+
inputs: [
|
|
178
|
+
{ name: 'actions', type: 'object', label: 'Actions' },
|
|
179
|
+
{
|
|
180
|
+
name: 'location',
|
|
181
|
+
type: 'enum',
|
|
182
|
+
label: 'Location',
|
|
183
|
+
enum: ['list_toolbar', 'list_item', 'record_header', 'record_more', 'record_related', 'global_nav'],
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: 'maxVisible',
|
|
187
|
+
type: 'number',
|
|
188
|
+
label: 'Max Visible Actions',
|
|
189
|
+
defaultValue: 3,
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
name: 'direction',
|
|
193
|
+
type: 'enum',
|
|
194
|
+
label: 'Direction',
|
|
195
|
+
enum: ['horizontal', 'vertical'],
|
|
196
|
+
defaultValue: 'horizontal',
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'variant',
|
|
200
|
+
type: 'enum',
|
|
201
|
+
label: 'Default Variant',
|
|
202
|
+
enum: ['default', 'secondary', 'outline', 'ghost'],
|
|
203
|
+
defaultValue: 'outline',
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: 'size',
|
|
207
|
+
type: 'enum',
|
|
208
|
+
label: 'Default Size',
|
|
209
|
+
enum: ['sm', 'md', 'lg'],
|
|
210
|
+
defaultValue: 'sm',
|
|
211
|
+
},
|
|
212
|
+
{ name: 'className', type: 'string', label: 'CSS Class', advanced: true },
|
|
213
|
+
],
|
|
214
|
+
defaultProps: {
|
|
215
|
+
maxVisible: 3,
|
|
216
|
+
direction: 'horizontal',
|
|
217
|
+
variant: 'outline',
|
|
218
|
+
size: 'sm',
|
|
219
|
+
actions: [],
|
|
220
|
+
},
|
|
221
|
+
});
|
|
@@ -28,7 +28,7 @@ import { Loader2 } from 'lucide-react';
|
|
|
28
28
|
import { resolveIcon } from './resolve-icon';
|
|
29
29
|
|
|
30
30
|
export interface ActionButtonProps {
|
|
31
|
-
schema: ActionSchema & { type: string; className?: string };
|
|
31
|
+
schema: ActionSchema & { type: string; className?: string; actionType?: string };
|
|
32
32
|
className?: string;
|
|
33
33
|
/** Override context for this specific action */
|
|
34
34
|
context?: Record<string, any>;
|
|
@@ -41,15 +41,19 @@ const ActionButtonRenderer = forwardRef<HTMLButtonElement, ActionButtonProps>(
|
|
|
41
41
|
'data-obj-id': dataObjId,
|
|
42
42
|
'data-obj-type': dataObjType,
|
|
43
43
|
style,
|
|
44
|
+
data,
|
|
44
45
|
...rest
|
|
45
46
|
} = props;
|
|
46
47
|
|
|
47
48
|
const { execute } = useAction();
|
|
48
49
|
const [loading, setLoading] = useState(false);
|
|
49
50
|
|
|
50
|
-
//
|
|
51
|
-
const
|
|
52
|
-
|
|
51
|
+
// Record data may be passed from SchemaRenderer (e.g. DetailView passes record data)
|
|
52
|
+
const recordData = data != null && typeof data === 'object' ? data as Record<string, any> : {};
|
|
53
|
+
|
|
54
|
+
// Evaluate visibility and enabled conditions with record data context
|
|
55
|
+
const isVisible = useCondition(schema.visible ? `\${${schema.visible}}` : undefined, recordData);
|
|
56
|
+
const isEnabled = useCondition(schema.enabled ? `\${${schema.enabled}}` : undefined, recordData);
|
|
53
57
|
|
|
54
58
|
// Resolve icon
|
|
55
59
|
const Icon = resolveIcon(schema.icon);
|
|
@@ -63,14 +67,21 @@ const ActionButtonRenderer = forwardRef<HTMLButtonElement, ActionButtonProps>(
|
|
|
63
67
|
setLoading(true);
|
|
64
68
|
|
|
65
69
|
try {
|
|
70
|
+
// Route params correctly:
|
|
71
|
+
// - Array of objects with name+type → ActionParamDef[] → pass as actionParams for collection
|
|
72
|
+
// - Otherwise → pass as actual param values
|
|
73
|
+
const paramsPayload = Array.isArray(schema.params)
|
|
74
|
+
? { actionParams: schema.params as any }
|
|
75
|
+
: { params: schema.params as Record<string, any> | undefined };
|
|
76
|
+
|
|
66
77
|
await execute({
|
|
67
|
-
type: schema.type,
|
|
78
|
+
type: schema.actionType || schema.type,
|
|
68
79
|
name: schema.name,
|
|
69
80
|
target: schema.target,
|
|
70
81
|
execute: schema.execute,
|
|
71
82
|
endpoint: schema.endpoint,
|
|
72
83
|
method: schema.method,
|
|
73
|
-
|
|
84
|
+
...paramsPayload,
|
|
74
85
|
confirmText: schema.confirmText,
|
|
75
86
|
successMessage: schema.successMessage,
|
|
76
87
|
errorMessage: schema.errorMessage,
|
|
@@ -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
|
+
});
|