Skip to content

Commit a5ec84c

Browse files
committed
feat: add action icon and menu components for enhanced UI actions
- Introduced ActionIconRenderer for icon-only action buttons with tooltip support. - Added ActionMenuRenderer for dropdown menus to handle multiple actions. - Implemented resolveIcon utility to map kebab-case icon names to PascalCase. - Created ActionContext to provide a shared ActionRunner instance and handlers across components. - Developed comprehensive tests for ActionRunner to ensure robust action execution and error handling.
1 parent 44c3079 commit a5ec84c

13 files changed

Lines changed: 2151 additions & 63 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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:button — Smart action button driven by ActionSchema.
11+
*
12+
* Renders a Shadcn Button wired to the ActionRunner. Supports:
13+
* - All 5 spec action types (script, url, modal, flow, api)
14+
* - Conditional visibility & enabled state
15+
* - Loading indicator during async execution
16+
* - Icon rendering via Lucide
17+
* - Variant / size / className overrides from schema
18+
*/
19+
20+
import React, { forwardRef, useCallback, useState } from 'react';
21+
import { ComponentRegistry } from '@object-ui/core';
22+
import type { ActionSchema } from '@object-ui/types';
23+
import { useAction } from '@object-ui/react';
24+
import { useCondition } from '@object-ui/react';
25+
import { Button } from '../../ui';
26+
import { cn } from '../../lib/utils';
27+
import { Loader2 } from 'lucide-react';
28+
import { resolveIcon } from './resolve-icon';
29+
30+
export interface ActionButtonProps {
31+
schema: ActionSchema & { type: string; className?: string };
32+
className?: string;
33+
/** Override context for this specific action */
34+
context?: Record<string, any>;
35+
[key: string]: any;
36+
}
37+
38+
const ActionButtonRenderer = forwardRef<HTMLButtonElement, ActionButtonProps>(
39+
({ schema, className, context: localContext, ...props }, ref) => {
40+
const {
41+
'data-obj-id': dataObjId,
42+
'data-obj-type': dataObjType,
43+
style,
44+
...rest
45+
} = props;
46+
47+
const { execute } = useAction();
48+
const [loading, setLoading] = useState(false);
49+
50+
// Evaluate visibility and enabled conditions
51+
const isVisible = useCondition(schema.visible ? `\${${schema.visible}}` : undefined);
52+
const isEnabled = useCondition(schema.enabled ? `\${${schema.enabled}}` : undefined);
53+
54+
// Resolve icon
55+
const Icon = resolveIcon(schema.icon);
56+
57+
// Map schema variant to Shadcn button variant
58+
const variant = schema.variant === 'primary' ? 'default' : (schema.variant || 'default');
59+
const size = schema.size === 'md' ? 'default' : (schema.size || 'default');
60+
61+
const handleClick = useCallback(async () => {
62+
if (loading) return;
63+
setLoading(true);
64+
65+
try {
66+
await execute({
67+
type: schema.type,
68+
name: schema.name,
69+
target: schema.target,
70+
execute: schema.execute,
71+
endpoint: schema.endpoint,
72+
method: schema.method,
73+
params: schema.params as Record<string, any> | undefined,
74+
confirmText: schema.confirmText,
75+
successMessage: schema.successMessage,
76+
errorMessage: schema.errorMessage,
77+
refreshAfter: schema.refreshAfter,
78+
toast: schema.toast,
79+
...localContext,
80+
});
81+
} finally {
82+
setLoading(false);
83+
}
84+
}, [schema, execute, loading, localContext]);
85+
86+
if (schema.visible && !isVisible) return null;
87+
88+
return (
89+
<Button
90+
ref={ref}
91+
type="button"
92+
variant={variant as any}
93+
size={size as any}
94+
className={cn(schema.className, className)}
95+
disabled={(schema.enabled ? !isEnabled : false) || loading}
96+
onClick={handleClick}
97+
{...rest}
98+
{...{ 'data-obj-id': dataObjId, 'data-obj-type': dataObjType, style }}
99+
>
100+
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
101+
{!loading && Icon && <Icon className={cn('h-4 w-4', schema.label && 'mr-2')} />}
102+
{schema.label}
103+
</Button>
104+
);
105+
},
106+
);
107+
108+
ActionButtonRenderer.displayName = 'ActionButtonRenderer';
109+
110+
ComponentRegistry.register('action:button', ActionButtonRenderer, {
111+
namespace: 'action',
112+
label: 'Action Button',
113+
inputs: [
114+
{ name: 'name', type: 'string', label: 'Action Name' },
115+
{ name: 'label', type: 'string', label: 'Label', defaultValue: 'Action' },
116+
{ name: 'icon', type: 'string', label: 'Icon' },
117+
{
118+
name: 'type',
119+
type: 'enum',
120+
label: 'Action Type',
121+
enum: ['script', 'url', 'modal', 'flow', 'api'],
122+
defaultValue: 'script',
123+
},
124+
{ name: 'target', type: 'string', label: 'Target' },
125+
{
126+
name: 'variant',
127+
type: 'enum',
128+
label: 'Variant',
129+
enum: ['default', 'primary', 'secondary', 'destructive', 'outline', 'ghost'],
130+
defaultValue: 'default',
131+
},
132+
{
133+
name: 'size',
134+
type: 'enum',
135+
label: 'Size',
136+
enum: ['sm', 'md', 'lg'],
137+
defaultValue: 'md',
138+
},
139+
{ name: 'className', type: 'string', label: 'CSS Class', advanced: true },
140+
],
141+
defaultProps: {
142+
label: 'Action',
143+
type: 'script',
144+
variant: 'default',
145+
size: 'md',
146+
},
147+
});

0 commit comments

Comments
 (0)