myoperator-ui 0.0.67 → 0.0.68
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/dist/index.js +926 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -3014,6 +3014,908 @@ const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(
|
|
|
3014
3014
|
Toggle.displayName = "Toggle"
|
|
3015
3015
|
|
|
3016
3016
|
export { Toggle, toggleVariants }
|
|
3017
|
+
`, prefix)
|
|
3018
|
+
}
|
|
3019
|
+
]
|
|
3020
|
+
},
|
|
3021
|
+
"event-selector": {
|
|
3022
|
+
name: "event-selector",
|
|
3023
|
+
description: "A component for selecting webhook events with groups, categories, and tri-state checkboxes",
|
|
3024
|
+
dependencies: [
|
|
3025
|
+
"clsx",
|
|
3026
|
+
"tailwind-merge"
|
|
3027
|
+
],
|
|
3028
|
+
internalDependencies: [
|
|
3029
|
+
"checkbox",
|
|
3030
|
+
"collapsible"
|
|
3031
|
+
],
|
|
3032
|
+
isMultiFile: true,
|
|
3033
|
+
directory: "event-selector",
|
|
3034
|
+
mainFile: "event-selector.tsx",
|
|
3035
|
+
files: [
|
|
3036
|
+
{
|
|
3037
|
+
name: "event-selector.tsx",
|
|
3038
|
+
content: prefixTailwindClasses(`import * as React from "react"
|
|
3039
|
+
import { cn } from "../../../lib/utils"
|
|
3040
|
+
import { EventGroupComponent } from "./event-group"
|
|
3041
|
+
import type { EventSelectorProps, EventCategory, EventGroup } from "./types"
|
|
3042
|
+
|
|
3043
|
+
/**
|
|
3044
|
+
* EventSelector - A component for selecting webhook events
|
|
3045
|
+
*
|
|
3046
|
+
* Install via CLI:
|
|
3047
|
+
* \`\`\`bash
|
|
3048
|
+
* npx myoperator-ui add event-selector
|
|
3049
|
+
* \`\`\`
|
|
3050
|
+
*
|
|
3051
|
+
* Or import directly from npm:
|
|
3052
|
+
* \`\`\`tsx
|
|
3053
|
+
* import { EventSelector } from "@myoperator/ui"
|
|
3054
|
+
* \`\`\`
|
|
3055
|
+
*
|
|
3056
|
+
* @example
|
|
3057
|
+
* \`\`\`tsx
|
|
3058
|
+
* <EventSelector
|
|
3059
|
+
* events={events}
|
|
3060
|
+
* groups={groups}
|
|
3061
|
+
* selectedEvents={selected}
|
|
3062
|
+
* onSelectionChange={setSelected}
|
|
3063
|
+
* />
|
|
3064
|
+
* \`\`\`
|
|
3065
|
+
*/
|
|
3066
|
+
export const EventSelector = React.forwardRef<HTMLDivElement, EventSelectorProps>(
|
|
3067
|
+
(
|
|
3068
|
+
{
|
|
3069
|
+
events,
|
|
3070
|
+
groups,
|
|
3071
|
+
categories,
|
|
3072
|
+
selectedEvents: controlledSelected,
|
|
3073
|
+
onSelectionChange,
|
|
3074
|
+
defaultSelectedEvents = [],
|
|
3075
|
+
title = "Events",
|
|
3076
|
+
description = "Select which events should trigger this webhook",
|
|
3077
|
+
emptyGroupMessage,
|
|
3078
|
+
renderEmptyGroup,
|
|
3079
|
+
className,
|
|
3080
|
+
...props
|
|
3081
|
+
},
|
|
3082
|
+
ref
|
|
3083
|
+
) => {
|
|
3084
|
+
// Controlled vs uncontrolled state
|
|
3085
|
+
const [internalSelected, setInternalSelected] = React.useState<string[]>(
|
|
3086
|
+
defaultSelectedEvents
|
|
3087
|
+
)
|
|
3088
|
+
|
|
3089
|
+
const isControlled = controlledSelected !== undefined
|
|
3090
|
+
const selectedEvents = isControlled ? controlledSelected : internalSelected
|
|
3091
|
+
|
|
3092
|
+
const handleSelectionChange = React.useCallback(
|
|
3093
|
+
(newSelection: string[]) => {
|
|
3094
|
+
if (!isControlled) {
|
|
3095
|
+
setInternalSelected(newSelection)
|
|
3096
|
+
}
|
|
3097
|
+
onSelectionChange?.(newSelection)
|
|
3098
|
+
},
|
|
3099
|
+
[isControlled, onSelectionChange]
|
|
3100
|
+
)
|
|
3101
|
+
|
|
3102
|
+
// Get events for a specific group
|
|
3103
|
+
const getEventsForGroup = (groupId: string) => {
|
|
3104
|
+
return events.filter((event) => event.group === groupId)
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
// Get groups for a specific category
|
|
3108
|
+
const getGroupsForCategory = (category: EventCategory): EventGroup[] => {
|
|
3109
|
+
return category.groups
|
|
3110
|
+
.map((groupId) => groups.find((g) => g.id === groupId))
|
|
3111
|
+
.filter((g): g is EventGroup => g !== undefined)
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
// Calculate total selected count
|
|
3115
|
+
const totalSelected = selectedEvents.length
|
|
3116
|
+
|
|
3117
|
+
// Render groups without categories
|
|
3118
|
+
const renderGroups = (groupsToRender: EventGroup[]) => {
|
|
3119
|
+
return groupsToRender.map((group) => (
|
|
3120
|
+
<EventGroupComponent
|
|
3121
|
+
key={group.id}
|
|
3122
|
+
group={group}
|
|
3123
|
+
events={getEventsForGroup(group.id)}
|
|
3124
|
+
selectedEvents={selectedEvents}
|
|
3125
|
+
onSelectionChange={handleSelectionChange}
|
|
3126
|
+
emptyGroupMessage={emptyGroupMessage}
|
|
3127
|
+
renderEmptyGroup={renderEmptyGroup}
|
|
3128
|
+
/>
|
|
3129
|
+
))
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
// Render categories with nested groups
|
|
3133
|
+
const renderCategories = () => {
|
|
3134
|
+
// Ensure categories is an array before using array methods
|
|
3135
|
+
if (!categories || !Array.isArray(categories) || categories.length === 0) {
|
|
3136
|
+
return renderGroups(groups)
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
// Get groups that belong to categories
|
|
3140
|
+
const groupsInCategories = new Set(categories.flatMap((c) => c.groups))
|
|
3141
|
+
|
|
3142
|
+
// Get orphan groups (not in any category)
|
|
3143
|
+
const orphanGroups = groups.filter((g) => !groupsInCategories.has(g.id))
|
|
3144
|
+
|
|
3145
|
+
return (
|
|
3146
|
+
<>
|
|
3147
|
+
{categories.map((category) => {
|
|
3148
|
+
const categoryGroups = getGroupsForCategory(category)
|
|
3149
|
+
const categoryEventIds = categoryGroups.flatMap((g) =>
|
|
3150
|
+
getEventsForGroup(g.id).map((e) => e.id)
|
|
3151
|
+
)
|
|
3152
|
+
const selectedInCategory = categoryEventIds.filter((id) =>
|
|
3153
|
+
selectedEvents.includes(id)
|
|
3154
|
+
)
|
|
3155
|
+
|
|
3156
|
+
return (
|
|
3157
|
+
<div
|
|
3158
|
+
key={category.id}
|
|
3159
|
+
className="border border-[#E5E7EB] rounded-lg overflow-hidden"
|
|
3160
|
+
>
|
|
3161
|
+
{/* Category Header - no checkbox, just label */}
|
|
3162
|
+
<div className="flex items-center justify-between p-4 bg-white border-b border-[#E5E7EB]">
|
|
3163
|
+
<div className="flex items-center gap-3">
|
|
3164
|
+
{category.icon && (
|
|
3165
|
+
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-[#F3F4F6]">
|
|
3166
|
+
{category.icon}
|
|
3167
|
+
</div>
|
|
3168
|
+
)}
|
|
3169
|
+
<span className="font-medium text-[#333333]">
|
|
3170
|
+
{category.name}
|
|
3171
|
+
</span>
|
|
3172
|
+
</div>
|
|
3173
|
+
{selectedInCategory.length > 0 && (
|
|
3174
|
+
<span className="text-sm text-[#6B7280]">
|
|
3175
|
+
{selectedInCategory.length} Selected
|
|
3176
|
+
</span>
|
|
3177
|
+
)}
|
|
3178
|
+
</div>
|
|
3179
|
+
{/* Category Groups */}
|
|
3180
|
+
<div className="p-4 space-y-3 bg-[#F9FAFB]">
|
|
3181
|
+
{renderGroups(categoryGroups)}
|
|
3182
|
+
</div>
|
|
3183
|
+
</div>
|
|
3184
|
+
)
|
|
3185
|
+
})}
|
|
3186
|
+
{/* Render orphan groups outside categories */}
|
|
3187
|
+
{orphanGroups.length > 0 && (
|
|
3188
|
+
<div className="space-y-3">{renderGroups(orphanGroups)}</div>
|
|
3189
|
+
)}
|
|
3190
|
+
</>
|
|
3191
|
+
)
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
return (
|
|
3195
|
+
<div
|
|
3196
|
+
ref={ref}
|
|
3197
|
+
className={cn("w-full", className)}
|
|
3198
|
+
{...props}
|
|
3199
|
+
>
|
|
3200
|
+
{/* Header */}
|
|
3201
|
+
<div className="flex items-start justify-between mb-4">
|
|
3202
|
+
<div>
|
|
3203
|
+
<h3 className="text-base font-semibold text-[#333333]">{title}</h3>
|
|
3204
|
+
{description && (
|
|
3205
|
+
<p className="text-sm text-[#6B7280] mt-1">{description}</p>
|
|
3206
|
+
)}
|
|
3207
|
+
</div>
|
|
3208
|
+
<span className="text-sm font-medium text-[#333333]">
|
|
3209
|
+
{totalSelected} Selected
|
|
3210
|
+
</span>
|
|
3211
|
+
</div>
|
|
3212
|
+
|
|
3213
|
+
{/* Groups */}
|
|
3214
|
+
<div className="space-y-3">{renderCategories()}</div>
|
|
3215
|
+
</div>
|
|
3216
|
+
)
|
|
3217
|
+
}
|
|
3218
|
+
)
|
|
3219
|
+
EventSelector.displayName = "EventSelector"
|
|
3220
|
+
`, prefix)
|
|
3221
|
+
},
|
|
3222
|
+
{
|
|
3223
|
+
name: "event-group.tsx",
|
|
3224
|
+
content: prefixTailwindClasses(`import * as React from "react"
|
|
3225
|
+
import { cn } from "../../../lib/utils"
|
|
3226
|
+
import { Checkbox, type CheckedState } from "../checkbox"
|
|
3227
|
+
import {
|
|
3228
|
+
Collapsible,
|
|
3229
|
+
CollapsibleItem,
|
|
3230
|
+
CollapsibleTrigger,
|
|
3231
|
+
CollapsibleContent,
|
|
3232
|
+
} from "../collapsible"
|
|
3233
|
+
import { EventItemComponent } from "./event-item"
|
|
3234
|
+
import type { EventGroupComponentProps } from "./types"
|
|
3235
|
+
|
|
3236
|
+
/**
|
|
3237
|
+
* Event group with collapsible section and group-level checkbox
|
|
3238
|
+
*/
|
|
3239
|
+
export const EventGroupComponent = React.forwardRef<
|
|
3240
|
+
HTMLDivElement,
|
|
3241
|
+
EventGroupComponentProps & React.HTMLAttributes<HTMLDivElement>
|
|
3242
|
+
>(
|
|
3243
|
+
(
|
|
3244
|
+
{
|
|
3245
|
+
group,
|
|
3246
|
+
events,
|
|
3247
|
+
selectedEvents,
|
|
3248
|
+
onSelectionChange,
|
|
3249
|
+
emptyGroupMessage = "No events available",
|
|
3250
|
+
renderEmptyGroup,
|
|
3251
|
+
className,
|
|
3252
|
+
...props
|
|
3253
|
+
},
|
|
3254
|
+
ref
|
|
3255
|
+
) => {
|
|
3256
|
+
// Calculate selection state for this group
|
|
3257
|
+
const groupEventIds = events.map((e) => e.id)
|
|
3258
|
+
const selectedInGroup = groupEventIds.filter((id) =>
|
|
3259
|
+
selectedEvents.includes(id)
|
|
3260
|
+
)
|
|
3261
|
+
const allSelected = groupEventIds.length > 0 && selectedInGroup.length === groupEventIds.length
|
|
3262
|
+
const someSelected = selectedInGroup.length > 0 && selectedInGroup.length < groupEventIds.length
|
|
3263
|
+
|
|
3264
|
+
const checkboxState: CheckedState = allSelected
|
|
3265
|
+
? true
|
|
3266
|
+
: someSelected
|
|
3267
|
+
? "indeterminate"
|
|
3268
|
+
: false
|
|
3269
|
+
|
|
3270
|
+
const selectedCount = selectedInGroup.length
|
|
3271
|
+
|
|
3272
|
+
// Handle group checkbox click
|
|
3273
|
+
const handleGroupCheckbox = () => {
|
|
3274
|
+
if (allSelected) {
|
|
3275
|
+
// Deselect all events in this group
|
|
3276
|
+
onSelectionChange(selectedEvents.filter((id) => !groupEventIds.includes(id)))
|
|
3277
|
+
} else {
|
|
3278
|
+
// Select all events in this group
|
|
3279
|
+
const newSelection = [...selectedEvents]
|
|
3280
|
+
groupEventIds.forEach((id) => {
|
|
3281
|
+
if (!newSelection.includes(id)) {
|
|
3282
|
+
newSelection.push(id)
|
|
3283
|
+
}
|
|
3284
|
+
})
|
|
3285
|
+
onSelectionChange(newSelection)
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3289
|
+
// Handle individual event selection
|
|
3290
|
+
const handleEventSelection = (eventId: string, selected: boolean) => {
|
|
3291
|
+
if (selected) {
|
|
3292
|
+
onSelectionChange([...selectedEvents, eventId])
|
|
3293
|
+
} else {
|
|
3294
|
+
onSelectionChange(selectedEvents.filter((id) => id !== eventId))
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
return (
|
|
3299
|
+
<div
|
|
3300
|
+
ref={ref}
|
|
3301
|
+
className={cn("bg-[#F9FAFB] rounded-lg", className)}
|
|
3302
|
+
{...props}
|
|
3303
|
+
>
|
|
3304
|
+
<Collapsible type="multiple">
|
|
3305
|
+
<CollapsibleItem value={group.id}>
|
|
3306
|
+
<CollapsibleTrigger
|
|
3307
|
+
showChevron={true}
|
|
3308
|
+
className="w-full p-4 hover:bg-[#F3F4F6] rounded-lg"
|
|
3309
|
+
>
|
|
3310
|
+
<div className="flex items-center gap-3 flex-1">
|
|
3311
|
+
<Checkbox
|
|
3312
|
+
checked={checkboxState}
|
|
3313
|
+
onCheckedChange={handleGroupCheckbox}
|
|
3314
|
+
onClick={(e) => e.stopPropagation()}
|
|
3315
|
+
aria-label={\`Select all \${group.name}\`}
|
|
3316
|
+
/>
|
|
3317
|
+
<div className="flex flex-col items-start text-left flex-1 min-w-0">
|
|
3318
|
+
<div className="flex items-center gap-2">
|
|
3319
|
+
{group.icon && (
|
|
3320
|
+
<span className="text-[#6B7280]">{group.icon}</span>
|
|
3321
|
+
)}
|
|
3322
|
+
<span className="font-medium text-[#333333]">
|
|
3323
|
+
{group.name}
|
|
3324
|
+
</span>
|
|
3325
|
+
</div>
|
|
3326
|
+
<span className="text-sm text-[#6B7280] mt-0.5">
|
|
3327
|
+
{group.description}
|
|
3328
|
+
</span>
|
|
3329
|
+
</div>
|
|
3330
|
+
{selectedCount > 0 && (
|
|
3331
|
+
<span className="text-sm text-[#6B7280] whitespace-nowrap">
|
|
3332
|
+
{selectedCount} Selected
|
|
3333
|
+
</span>
|
|
3334
|
+
)}
|
|
3335
|
+
</div>
|
|
3336
|
+
</CollapsibleTrigger>
|
|
3337
|
+
<CollapsibleContent>
|
|
3338
|
+
<div className="border-t border-[#E5E7EB]">
|
|
3339
|
+
{events.length > 0 ? (
|
|
3340
|
+
events.map((event) => (
|
|
3341
|
+
<EventItemComponent
|
|
3342
|
+
key={event.id}
|
|
3343
|
+
event={event}
|
|
3344
|
+
isSelected={selectedEvents.includes(event.id)}
|
|
3345
|
+
onSelectionChange={(selected) =>
|
|
3346
|
+
handleEventSelection(event.id, selected)
|
|
3347
|
+
}
|
|
3348
|
+
/>
|
|
3349
|
+
))
|
|
3350
|
+
) : renderEmptyGroup ? (
|
|
3351
|
+
<div className="py-4 px-8">
|
|
3352
|
+
{renderEmptyGroup(group)}
|
|
3353
|
+
</div>
|
|
3354
|
+
) : (
|
|
3355
|
+
<div className="py-4 px-8 text-sm text-[#6B7280] italic">
|
|
3356
|
+
{emptyGroupMessage}
|
|
3357
|
+
</div>
|
|
3358
|
+
)}
|
|
3359
|
+
</div>
|
|
3360
|
+
</CollapsibleContent>
|
|
3361
|
+
</CollapsibleItem>
|
|
3362
|
+
</Collapsible>
|
|
3363
|
+
</div>
|
|
3364
|
+
)
|
|
3365
|
+
}
|
|
3366
|
+
)
|
|
3367
|
+
EventGroupComponent.displayName = "EventGroupComponent"
|
|
3368
|
+
`, prefix)
|
|
3369
|
+
},
|
|
3370
|
+
{
|
|
3371
|
+
name: "event-item.tsx",
|
|
3372
|
+
content: prefixTailwindClasses(`import * as React from "react"
|
|
3373
|
+
import { cn } from "../../../lib/utils"
|
|
3374
|
+
import { Checkbox } from "../checkbox"
|
|
3375
|
+
import type { EventItemComponentProps } from "./types"
|
|
3376
|
+
|
|
3377
|
+
/**
|
|
3378
|
+
* Individual event item with checkbox
|
|
3379
|
+
*/
|
|
3380
|
+
export const EventItemComponent = React.forwardRef<
|
|
3381
|
+
HTMLDivElement,
|
|
3382
|
+
EventItemComponentProps & React.HTMLAttributes<HTMLDivElement>
|
|
3383
|
+
>(({ event, isSelected, onSelectionChange, className, ...props }, ref) => {
|
|
3384
|
+
return (
|
|
3385
|
+
<div
|
|
3386
|
+
ref={ref}
|
|
3387
|
+
className={cn(
|
|
3388
|
+
"flex items-start gap-3 py-2 pl-8 pr-4",
|
|
3389
|
+
className
|
|
3390
|
+
)}
|
|
3391
|
+
{...props}
|
|
3392
|
+
>
|
|
3393
|
+
<Checkbox
|
|
3394
|
+
checked={isSelected}
|
|
3395
|
+
onCheckedChange={(checked) => onSelectionChange(checked === true)}
|
|
3396
|
+
aria-label={\`Select \${event.name}\`}
|
|
3397
|
+
/>
|
|
3398
|
+
<div className="flex-1 min-w-0">
|
|
3399
|
+
<div className="text-sm font-medium text-[#333333]">{event.name}</div>
|
|
3400
|
+
<div className="text-sm text-[#6B7280] mt-0.5 leading-relaxed">
|
|
3401
|
+
{event.description}
|
|
3402
|
+
</div>
|
|
3403
|
+
</div>
|
|
3404
|
+
</div>
|
|
3405
|
+
)
|
|
3406
|
+
})
|
|
3407
|
+
EventItemComponent.displayName = "EventItemComponent"
|
|
3408
|
+
`, prefix)
|
|
3409
|
+
},
|
|
3410
|
+
{
|
|
3411
|
+
name: "types.ts",
|
|
3412
|
+
content: prefixTailwindClasses(`import * as React from "react"
|
|
3413
|
+
|
|
3414
|
+
/**
|
|
3415
|
+
* Represents an individual event item
|
|
3416
|
+
*/
|
|
3417
|
+
export interface EventItem {
|
|
3418
|
+
/** Unique identifier for the event */
|
|
3419
|
+
id: string
|
|
3420
|
+
/** Display name of the event (e.g., "Call.Initiated") */
|
|
3421
|
+
name: string
|
|
3422
|
+
/** Description of when this event is triggered */
|
|
3423
|
+
description: string
|
|
3424
|
+
/** Group ID this event belongs to */
|
|
3425
|
+
group: string
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
/**
|
|
3429
|
+
* Represents a group of events
|
|
3430
|
+
*/
|
|
3431
|
+
export interface EventGroup {
|
|
3432
|
+
/** Unique identifier for the group */
|
|
3433
|
+
id: string
|
|
3434
|
+
/** Display name of the group (e.g., "In-Call Events") */
|
|
3435
|
+
name: string
|
|
3436
|
+
/** Description of the group */
|
|
3437
|
+
description: string
|
|
3438
|
+
/** Optional icon to display next to the group name */
|
|
3439
|
+
icon?: React.ReactNode
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3442
|
+
/**
|
|
3443
|
+
* Optional top-level category that can contain multiple groups
|
|
3444
|
+
*/
|
|
3445
|
+
export interface EventCategory {
|
|
3446
|
+
/** Unique identifier for the category */
|
|
3447
|
+
id: string
|
|
3448
|
+
/** Display name of the category (e.g., "Call Events (Voice)") */
|
|
3449
|
+
name: string
|
|
3450
|
+
/** Optional icon to display next to the category name */
|
|
3451
|
+
icon?: React.ReactNode
|
|
3452
|
+
/** Array of group IDs that belong to this category */
|
|
3453
|
+
groups: string[]
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
/**
|
|
3457
|
+
* Props for the EventSelector component
|
|
3458
|
+
*/
|
|
3459
|
+
export interface EventSelectorProps {
|
|
3460
|
+
// Data
|
|
3461
|
+
/** Array of event items */
|
|
3462
|
+
events: EventItem[]
|
|
3463
|
+
/** Array of event groups */
|
|
3464
|
+
groups: EventGroup[]
|
|
3465
|
+
/** Optional array of categories for top-level grouping */
|
|
3466
|
+
categories?: EventCategory[]
|
|
3467
|
+
|
|
3468
|
+
// State (controlled mode)
|
|
3469
|
+
/** Array of selected event IDs (controlled) */
|
|
3470
|
+
selectedEvents?: string[]
|
|
3471
|
+
/** Callback when selection changes */
|
|
3472
|
+
onSelectionChange?: (selectedIds: string[]) => void
|
|
3473
|
+
|
|
3474
|
+
// State (uncontrolled mode)
|
|
3475
|
+
/** Default selected events for uncontrolled usage */
|
|
3476
|
+
defaultSelectedEvents?: string[]
|
|
3477
|
+
|
|
3478
|
+
// Customization
|
|
3479
|
+
/** Title displayed at the top (default: "Events") */
|
|
3480
|
+
title?: string
|
|
3481
|
+
/** Description displayed below the title */
|
|
3482
|
+
description?: string
|
|
3483
|
+
/** Message shown when a group has no events */
|
|
3484
|
+
emptyGroupMessage?: string
|
|
3485
|
+
/** Custom render function for empty group state (overrides emptyGroupMessage) */
|
|
3486
|
+
renderEmptyGroup?: (group: EventGroup) => React.ReactNode
|
|
3487
|
+
|
|
3488
|
+
// Styling
|
|
3489
|
+
/** Additional CSS classes for the root element */
|
|
3490
|
+
className?: string
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
/**
|
|
3494
|
+
* Internal props for EventGroup component
|
|
3495
|
+
*/
|
|
3496
|
+
export interface EventGroupComponentProps {
|
|
3497
|
+
/** The group data */
|
|
3498
|
+
group: EventGroup
|
|
3499
|
+
/** Events that belong to this group */
|
|
3500
|
+
events: EventItem[]
|
|
3501
|
+
/** Currently selected event IDs */
|
|
3502
|
+
selectedEvents: string[]
|
|
3503
|
+
/** Callback to update selected events */
|
|
3504
|
+
onSelectionChange: (selectedIds: string[]) => void
|
|
3505
|
+
/** Message shown when group has no events */
|
|
3506
|
+
emptyGroupMessage?: string
|
|
3507
|
+
/** Custom render function for empty group state */
|
|
3508
|
+
renderEmptyGroup?: (group: EventGroup) => React.ReactNode
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
/**
|
|
3512
|
+
* Internal props for EventItem component
|
|
3513
|
+
*/
|
|
3514
|
+
export interface EventItemComponentProps {
|
|
3515
|
+
/** The event data */
|
|
3516
|
+
event: EventItem
|
|
3517
|
+
/** Whether this event is selected */
|
|
3518
|
+
isSelected: boolean
|
|
3519
|
+
/** Callback when selection changes */
|
|
3520
|
+
onSelectionChange: (selected: boolean) => void
|
|
3521
|
+
}
|
|
3522
|
+
`, prefix)
|
|
3523
|
+
}
|
|
3524
|
+
]
|
|
3525
|
+
},
|
|
3526
|
+
"key-value-input": {
|
|
3527
|
+
name: "key-value-input",
|
|
3528
|
+
description: "A component for managing key-value pairs with validation and duplicate detection",
|
|
3529
|
+
dependencies: [
|
|
3530
|
+
"clsx",
|
|
3531
|
+
"tailwind-merge",
|
|
3532
|
+
"lucide-react"
|
|
3533
|
+
],
|
|
3534
|
+
internalDependencies: [
|
|
3535
|
+
"button",
|
|
3536
|
+
"input"
|
|
3537
|
+
],
|
|
3538
|
+
isMultiFile: true,
|
|
3539
|
+
directory: "key-value-input",
|
|
3540
|
+
mainFile: "key-value-input.tsx",
|
|
3541
|
+
files: [
|
|
3542
|
+
{
|
|
3543
|
+
name: "key-value-input.tsx",
|
|
3544
|
+
content: prefixTailwindClasses(`import * as React from "react"
|
|
3545
|
+
import { Plus } from "lucide-react"
|
|
3546
|
+
import { cn } from "../../../lib/utils"
|
|
3547
|
+
import { Button } from "../button"
|
|
3548
|
+
import { KeyValueRow } from "./key-value-row"
|
|
3549
|
+
import type { KeyValueInputProps, KeyValuePair } from "./types"
|
|
3550
|
+
|
|
3551
|
+
// Helper to generate unique IDs
|
|
3552
|
+
const generateId = () =>
|
|
3553
|
+
\`kv-\${Date.now()}-\${Math.random().toString(36).substr(2, 9)}\`
|
|
3554
|
+
|
|
3555
|
+
/**
|
|
3556
|
+
* KeyValueInput - A component for managing key-value pairs
|
|
3557
|
+
*
|
|
3558
|
+
* Install via CLI:
|
|
3559
|
+
* \`\`\`bash
|
|
3560
|
+
* npx myoperator-ui add key-value-input
|
|
3561
|
+
* \`\`\`
|
|
3562
|
+
*
|
|
3563
|
+
* Or import directly from npm:
|
|
3564
|
+
* \`\`\`tsx
|
|
3565
|
+
* import { KeyValueInput } from "@myoperator/ui"
|
|
3566
|
+
* \`\`\`
|
|
3567
|
+
*
|
|
3568
|
+
* @example
|
|
3569
|
+
* \`\`\`tsx
|
|
3570
|
+
* <KeyValueInput
|
|
3571
|
+
* title="HTTP Headers"
|
|
3572
|
+
* description="Add custom headers for the webhook request"
|
|
3573
|
+
* value={headers}
|
|
3574
|
+
* onChange={setHeaders}
|
|
3575
|
+
* />
|
|
3576
|
+
* \`\`\`
|
|
3577
|
+
*/
|
|
3578
|
+
export const KeyValueInput = React.forwardRef<
|
|
3579
|
+
HTMLDivElement,
|
|
3580
|
+
KeyValueInputProps
|
|
3581
|
+
>(
|
|
3582
|
+
(
|
|
3583
|
+
{
|
|
3584
|
+
title,
|
|
3585
|
+
description,
|
|
3586
|
+
addButtonText = "Add Header",
|
|
3587
|
+
maxItems = 10,
|
|
3588
|
+
keyPlaceholder = "Key",
|
|
3589
|
+
valuePlaceholder = "Value",
|
|
3590
|
+
keyLabel = "Key",
|
|
3591
|
+
valueLabel = "Value",
|
|
3592
|
+
value: controlledValue,
|
|
3593
|
+
onChange,
|
|
3594
|
+
defaultValue = [],
|
|
3595
|
+
className,
|
|
3596
|
+
...props
|
|
3597
|
+
},
|
|
3598
|
+
ref
|
|
3599
|
+
) => {
|
|
3600
|
+
// Controlled vs uncontrolled state
|
|
3601
|
+
const [internalPairs, setInternalPairs] =
|
|
3602
|
+
React.useState<KeyValuePair[]>(defaultValue)
|
|
3603
|
+
|
|
3604
|
+
const isControlled = controlledValue !== undefined
|
|
3605
|
+
const pairs = isControlled ? controlledValue : internalPairs
|
|
3606
|
+
|
|
3607
|
+
// Track which keys have been touched for validation
|
|
3608
|
+
const [touchedKeys, setTouchedKeys] = React.useState<Set<string>>(new Set())
|
|
3609
|
+
|
|
3610
|
+
const handlePairsChange = React.useCallback(
|
|
3611
|
+
(newPairs: KeyValuePair[]) => {
|
|
3612
|
+
if (!isControlled) {
|
|
3613
|
+
setInternalPairs(newPairs)
|
|
3614
|
+
}
|
|
3615
|
+
onChange?.(newPairs)
|
|
3616
|
+
},
|
|
3617
|
+
[isControlled, onChange]
|
|
3618
|
+
)
|
|
3619
|
+
|
|
3620
|
+
// Check for duplicate keys (case-insensitive)
|
|
3621
|
+
const getDuplicateKeys = React.useCallback((): Set<string> => {
|
|
3622
|
+
const keyCount = new Map<string, number>()
|
|
3623
|
+
pairs.forEach((pair) => {
|
|
3624
|
+
if (pair.key.trim()) {
|
|
3625
|
+
const key = pair.key.toLowerCase()
|
|
3626
|
+
keyCount.set(key, (keyCount.get(key) || 0) + 1)
|
|
3627
|
+
}
|
|
3628
|
+
})
|
|
3629
|
+
const duplicates = new Set<string>()
|
|
3630
|
+
keyCount.forEach((count, key) => {
|
|
3631
|
+
if (count > 1) duplicates.add(key)
|
|
3632
|
+
})
|
|
3633
|
+
return duplicates
|
|
3634
|
+
}, [pairs])
|
|
3635
|
+
|
|
3636
|
+
const duplicateKeys = getDuplicateKeys()
|
|
3637
|
+
|
|
3638
|
+
// Add new row
|
|
3639
|
+
const handleAdd = () => {
|
|
3640
|
+
if (pairs.length >= maxItems) return
|
|
3641
|
+
const newPair: KeyValuePair = {
|
|
3642
|
+
id: generateId(),
|
|
3643
|
+
key: "",
|
|
3644
|
+
value: "",
|
|
3645
|
+
}
|
|
3646
|
+
handlePairsChange([...pairs, newPair])
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
// Update key
|
|
3650
|
+
const handleKeyChange = (id: string, key: string) => {
|
|
3651
|
+
handlePairsChange(
|
|
3652
|
+
pairs.map((pair) => (pair.id === id ? { ...pair, key } : pair))
|
|
3653
|
+
)
|
|
3654
|
+
setTouchedKeys((prev) => new Set(prev).add(id))
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
// Update value
|
|
3658
|
+
const handleValueChange = (id: string, value: string) => {
|
|
3659
|
+
handlePairsChange(
|
|
3660
|
+
pairs.map((pair) => (pair.id === id ? { ...pair, value } : pair))
|
|
3661
|
+
)
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
// Delete row
|
|
3665
|
+
const handleDelete = (id: string) => {
|
|
3666
|
+
handlePairsChange(pairs.filter((pair) => pair.id !== id))
|
|
3667
|
+
setTouchedKeys((prev) => {
|
|
3668
|
+
const next = new Set(prev)
|
|
3669
|
+
next.delete(id)
|
|
3670
|
+
return next
|
|
3671
|
+
})
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
const isAtLimit = pairs.length >= maxItems
|
|
3675
|
+
const addButtonTitle = isAtLimit
|
|
3676
|
+
? \`Maximum of \${maxItems} items allowed\`
|
|
3677
|
+
: undefined
|
|
3678
|
+
|
|
3679
|
+
return (
|
|
3680
|
+
<div ref={ref} className={cn("w-full", className)} {...props}>
|
|
3681
|
+
{/* Header */}
|
|
3682
|
+
<div className="mb-3">
|
|
3683
|
+
<h3 className="text-base font-semibold text-[#333333]">{title}</h3>
|
|
3684
|
+
{description && (
|
|
3685
|
+
<p className="text-sm text-[#6B7280] mt-1">{description}</p>
|
|
3686
|
+
)}
|
|
3687
|
+
</div>
|
|
3688
|
+
|
|
3689
|
+
{/* Content Container with Background - only show when there are items */}
|
|
3690
|
+
{pairs.length > 0 && (
|
|
3691
|
+
<div className="bg-[#F9FAFB] rounded-lg p-4 mb-4">
|
|
3692
|
+
{/* Column Headers */}
|
|
3693
|
+
<div className="flex items-center gap-3 mb-3">
|
|
3694
|
+
<div className="flex-1">
|
|
3695
|
+
<span className="text-sm font-medium text-[#333333]">
|
|
3696
|
+
{keyLabel}
|
|
3697
|
+
<span className="text-[#FF3B3B] ml-0.5">*</span>
|
|
3698
|
+
</span>
|
|
3699
|
+
</div>
|
|
3700
|
+
<div className="flex-1">
|
|
3701
|
+
<span className="text-sm font-medium text-[#333333]">
|
|
3702
|
+
{valueLabel}
|
|
3703
|
+
</span>
|
|
3704
|
+
</div>
|
|
3705
|
+
{/* Spacer for delete button column */}
|
|
3706
|
+
<div className="w-8 flex-shrink-0" />
|
|
3707
|
+
</div>
|
|
3708
|
+
|
|
3709
|
+
{/* Rows */}
|
|
3710
|
+
<div className="space-y-3">
|
|
3711
|
+
{pairs.map((pair) => (
|
|
3712
|
+
<KeyValueRow
|
|
3713
|
+
key={pair.id}
|
|
3714
|
+
pair={pair}
|
|
3715
|
+
isDuplicateKey={duplicateKeys.has(pair.key.toLowerCase())}
|
|
3716
|
+
isKeyEmpty={touchedKeys.has(pair.id) && !pair.key.trim()}
|
|
3717
|
+
keyPlaceholder={keyPlaceholder}
|
|
3718
|
+
valuePlaceholder={valuePlaceholder}
|
|
3719
|
+
onKeyChange={handleKeyChange}
|
|
3720
|
+
onValueChange={handleValueChange}
|
|
3721
|
+
onDelete={handleDelete}
|
|
3722
|
+
/>
|
|
3723
|
+
))}
|
|
3724
|
+
</div>
|
|
3725
|
+
</div>
|
|
3726
|
+
)}
|
|
3727
|
+
|
|
3728
|
+
{/* Add Button using dashed variant - outside the gray container */}
|
|
3729
|
+
<Button
|
|
3730
|
+
type="button"
|
|
3731
|
+
variant="dashed"
|
|
3732
|
+
onClick={handleAdd}
|
|
3733
|
+
disabled={isAtLimit}
|
|
3734
|
+
title={addButtonTitle}
|
|
3735
|
+
className="w-full justify-center"
|
|
3736
|
+
>
|
|
3737
|
+
<Plus className="h-4 w-4" />
|
|
3738
|
+
{addButtonText}
|
|
3739
|
+
</Button>
|
|
3740
|
+
|
|
3741
|
+
{/* Limit indicator */}
|
|
3742
|
+
{isAtLimit && (
|
|
3743
|
+
<p className="text-xs text-[#6B7280] mt-2 text-center">
|
|
3744
|
+
Maximum of {maxItems} items reached
|
|
3745
|
+
</p>
|
|
3746
|
+
)}
|
|
3747
|
+
</div>
|
|
3748
|
+
)
|
|
3749
|
+
}
|
|
3750
|
+
)
|
|
3751
|
+
KeyValueInput.displayName = "KeyValueInput"
|
|
3752
|
+
`, prefix)
|
|
3753
|
+
},
|
|
3754
|
+
{
|
|
3755
|
+
name: "key-value-row.tsx",
|
|
3756
|
+
content: prefixTailwindClasses(`import * as React from "react"
|
|
3757
|
+
import { Trash2 } from "lucide-react"
|
|
3758
|
+
import { cn } from "../../../lib/utils"
|
|
3759
|
+
import { Input } from "../input"
|
|
3760
|
+
import { Button } from "../button"
|
|
3761
|
+
import type { KeyValueRowProps } from "./types"
|
|
3762
|
+
|
|
3763
|
+
/**
|
|
3764
|
+
* Individual key-value pair row with inputs and delete button
|
|
3765
|
+
*/
|
|
3766
|
+
export const KeyValueRow = React.forwardRef<
|
|
3767
|
+
HTMLDivElement,
|
|
3768
|
+
KeyValueRowProps & React.HTMLAttributes<HTMLDivElement>
|
|
3769
|
+
>(
|
|
3770
|
+
(
|
|
3771
|
+
{
|
|
3772
|
+
pair,
|
|
3773
|
+
isDuplicateKey,
|
|
3774
|
+
isKeyEmpty,
|
|
3775
|
+
keyPlaceholder = "Key",
|
|
3776
|
+
valuePlaceholder = "Value",
|
|
3777
|
+
onKeyChange,
|
|
3778
|
+
onValueChange,
|
|
3779
|
+
onDelete,
|
|
3780
|
+
className,
|
|
3781
|
+
...props
|
|
3782
|
+
},
|
|
3783
|
+
ref
|
|
3784
|
+
) => {
|
|
3785
|
+
// Determine if key input should show error state
|
|
3786
|
+
const keyHasError = isDuplicateKey || isKeyEmpty
|
|
3787
|
+
|
|
3788
|
+
// Determine error message
|
|
3789
|
+
const errorMessage = isDuplicateKey
|
|
3790
|
+
? "Duplicate key"
|
|
3791
|
+
: isKeyEmpty
|
|
3792
|
+
? "Key is required"
|
|
3793
|
+
: null
|
|
3794
|
+
|
|
3795
|
+
return (
|
|
3796
|
+
<div
|
|
3797
|
+
ref={ref}
|
|
3798
|
+
className={cn("flex items-start gap-3", className)}
|
|
3799
|
+
{...props}
|
|
3800
|
+
>
|
|
3801
|
+
{/* Key Input */}
|
|
3802
|
+
<div className="flex-1">
|
|
3803
|
+
<Input
|
|
3804
|
+
value={pair.key}
|
|
3805
|
+
onChange={(e) => onKeyChange(pair.id, e.target.value)}
|
|
3806
|
+
placeholder={keyPlaceholder}
|
|
3807
|
+
state={keyHasError ? "error" : "default"}
|
|
3808
|
+
aria-label="Key"
|
|
3809
|
+
/>
|
|
3810
|
+
{errorMessage && (
|
|
3811
|
+
<span className="text-xs text-[#FF3B3B] mt-1 block">
|
|
3812
|
+
{errorMessage}
|
|
3813
|
+
</span>
|
|
3814
|
+
)}
|
|
3815
|
+
</div>
|
|
3816
|
+
|
|
3817
|
+
{/* Value Input */}
|
|
3818
|
+
<div className="flex-1">
|
|
3819
|
+
<Input
|
|
3820
|
+
value={pair.value}
|
|
3821
|
+
onChange={(e) => onValueChange(pair.id, e.target.value)}
|
|
3822
|
+
placeholder={valuePlaceholder}
|
|
3823
|
+
aria-label="Value"
|
|
3824
|
+
/>
|
|
3825
|
+
</div>
|
|
3826
|
+
|
|
3827
|
+
{/* Delete Button */}
|
|
3828
|
+
<Button
|
|
3829
|
+
type="button"
|
|
3830
|
+
variant="ghost"
|
|
3831
|
+
size="icon"
|
|
3832
|
+
onClick={() => onDelete(pair.id)}
|
|
3833
|
+
className="text-gray-400 hover:text-[#EF4444] hover:bg-[#FEF2F2] flex-shrink-0"
|
|
3834
|
+
aria-label="Delete row"
|
|
3835
|
+
>
|
|
3836
|
+
<Trash2 className="h-4 w-4" />
|
|
3837
|
+
</Button>
|
|
3838
|
+
</div>
|
|
3839
|
+
)
|
|
3840
|
+
}
|
|
3841
|
+
)
|
|
3842
|
+
KeyValueRow.displayName = "KeyValueRow"
|
|
3843
|
+
`, prefix)
|
|
3844
|
+
},
|
|
3845
|
+
{
|
|
3846
|
+
name: "types.ts",
|
|
3847
|
+
content: prefixTailwindClasses(`import * as React from "react"
|
|
3848
|
+
|
|
3849
|
+
/**
|
|
3850
|
+
* Represents a single key-value pair
|
|
3851
|
+
*/
|
|
3852
|
+
export interface KeyValuePair {
|
|
3853
|
+
/** Unique identifier for the pair */
|
|
3854
|
+
id: string
|
|
3855
|
+
/** The key (e.g., header name) */
|
|
3856
|
+
key: string
|
|
3857
|
+
/** The value (e.g., header value) */
|
|
3858
|
+
value: string
|
|
3859
|
+
}
|
|
3860
|
+
|
|
3861
|
+
/**
|
|
3862
|
+
* Props for the KeyValueInput component
|
|
3863
|
+
*/
|
|
3864
|
+
export interface KeyValueInputProps {
|
|
3865
|
+
// Customization
|
|
3866
|
+
/** Title displayed at the top (e.g., "HTTP Headers") */
|
|
3867
|
+
title: string
|
|
3868
|
+
/** Description displayed below the title */
|
|
3869
|
+
description?: string
|
|
3870
|
+
/** Text for the add button (default: "Add Header") */
|
|
3871
|
+
addButtonText?: string
|
|
3872
|
+
/** Maximum number of items allowed (default: 10) */
|
|
3873
|
+
maxItems?: number
|
|
3874
|
+
/** Placeholder for key input */
|
|
3875
|
+
keyPlaceholder?: string
|
|
3876
|
+
/** Placeholder for value input */
|
|
3877
|
+
valuePlaceholder?: string
|
|
3878
|
+
/** Label for key column header (default: "Key") */
|
|
3879
|
+
keyLabel?: string
|
|
3880
|
+
/** Label for value column header (default: "Value") */
|
|
3881
|
+
valueLabel?: string
|
|
3882
|
+
|
|
3883
|
+
// State (controlled mode)
|
|
3884
|
+
/** Array of key-value pairs (controlled) */
|
|
3885
|
+
value?: KeyValuePair[]
|
|
3886
|
+
/** Callback when pairs change */
|
|
3887
|
+
onChange?: (pairs: KeyValuePair[]) => void
|
|
3888
|
+
|
|
3889
|
+
// State (uncontrolled mode)
|
|
3890
|
+
/** Default key-value pairs for uncontrolled usage */
|
|
3891
|
+
defaultValue?: KeyValuePair[]
|
|
3892
|
+
|
|
3893
|
+
// Styling
|
|
3894
|
+
/** Additional CSS classes for the root element */
|
|
3895
|
+
className?: string
|
|
3896
|
+
}
|
|
3897
|
+
|
|
3898
|
+
/**
|
|
3899
|
+
* Internal props for KeyValueRow component
|
|
3900
|
+
*/
|
|
3901
|
+
export interface KeyValueRowProps {
|
|
3902
|
+
/** The key-value pair data */
|
|
3903
|
+
pair: KeyValuePair
|
|
3904
|
+
/** Whether the key is a duplicate */
|
|
3905
|
+
isDuplicateKey: boolean
|
|
3906
|
+
/** Whether key is empty (for validation) */
|
|
3907
|
+
isKeyEmpty: boolean
|
|
3908
|
+
/** Placeholder for key input */
|
|
3909
|
+
keyPlaceholder?: string
|
|
3910
|
+
/** Placeholder for value input */
|
|
3911
|
+
valuePlaceholder?: string
|
|
3912
|
+
/** Callback when key changes */
|
|
3913
|
+
onKeyChange: (id: string, key: string) => void
|
|
3914
|
+
/** Callback when value changes */
|
|
3915
|
+
onValueChange: (id: string, value: string) => void
|
|
3916
|
+
/** Callback when row is deleted */
|
|
3917
|
+
onDelete: (id: string) => void
|
|
3918
|
+
}
|
|
3017
3919
|
`, prefix)
|
|
3018
3920
|
}
|
|
3019
3921
|
]
|
|
@@ -3152,10 +4054,26 @@ async function add(components, options) {
|
|
|
3152
4054
|
try {
|
|
3153
4055
|
const installed = [];
|
|
3154
4056
|
const dependencies = /* @__PURE__ */ new Set();
|
|
3155
|
-
|
|
4057
|
+
const installedComponents = /* @__PURE__ */ new Set();
|
|
4058
|
+
const installComponent = async (componentName) => {
|
|
4059
|
+
if (installedComponents.has(componentName)) {
|
|
4060
|
+
return;
|
|
4061
|
+
}
|
|
3156
4062
|
const component = registry[componentName];
|
|
4063
|
+
if (!component) {
|
|
4064
|
+
spinner.warn(`Component ${componentName} not found in registry`);
|
|
4065
|
+
return;
|
|
4066
|
+
}
|
|
4067
|
+
if (component.internalDependencies && component.internalDependencies.length > 0) {
|
|
4068
|
+
spinner.text = `Installing dependencies for ${componentName}...`;
|
|
4069
|
+
for (const depName of component.internalDependencies) {
|
|
4070
|
+
await installComponent(depName);
|
|
4071
|
+
}
|
|
4072
|
+
}
|
|
4073
|
+
spinner.text = `Installing ${componentName}...`;
|
|
4074
|
+
const targetDir = component.isMultiFile ? path2.join(componentsDir, component.directory) : componentsDir;
|
|
3157
4075
|
for (const file of component.files) {
|
|
3158
|
-
const filePath = path2.join(
|
|
4076
|
+
const filePath = path2.join(targetDir, file.name);
|
|
3159
4077
|
if (await fs2.pathExists(filePath)) {
|
|
3160
4078
|
if (!options.overwrite) {
|
|
3161
4079
|
spinner.warn(`${file.name} already exists. Use --overwrite to replace.`);
|
|
@@ -3164,11 +4082,16 @@ async function add(components, options) {
|
|
|
3164
4082
|
}
|
|
3165
4083
|
await fs2.ensureDir(path2.dirname(filePath));
|
|
3166
4084
|
await fs2.writeFile(filePath, file.content);
|
|
3167
|
-
|
|
4085
|
+
const relativePath = component.isMultiFile ? `${component.directory}/${file.name}` : file.name;
|
|
4086
|
+
installed.push(relativePath);
|
|
3168
4087
|
}
|
|
3169
4088
|
if (component.dependencies) {
|
|
3170
4089
|
component.dependencies.forEach((dep) => dependencies.add(dep));
|
|
3171
4090
|
}
|
|
4091
|
+
installedComponents.add(componentName);
|
|
4092
|
+
};
|
|
4093
|
+
for (const componentName of components) {
|
|
4094
|
+
await installComponent(componentName);
|
|
3172
4095
|
}
|
|
3173
4096
|
spinner.succeed("Components installed successfully!");
|
|
3174
4097
|
if (installed.length > 0) {
|