diff --git a/components/src/ActionConfirmationDialog/ActionConfirmationDialog.tsx b/components/src/ActionConfirmationDialog/ActionConfirmationDialog.tsx new file mode 100644 index 0000000..c189a34 --- /dev/null +++ b/components/src/ActionConfirmationDialog/ActionConfirmationDialog.tsx @@ -0,0 +1,97 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material'; +import { ReactElement } from 'react'; + +export interface ActionConfirmationDialogProps { + /** + * Whether the dialog is open + */ + open: boolean; + + /** + * Title of the confirmation dialog + */ + title?: string; + + /** + * Message displayed in the dialog body + */ + message?: string; + + /** + * Label for the confirm button + */ + actionLabel?: string; + + /** + * Number of selected items (displayed in the message if provided) + */ + selectedItemCount?: number; + + /** + * Callback when user confirms the action + */ + onConfirm: () => void; + + /** + * Callback when user cancels the action + */ + onCancel: () => void; + + /** + * Whether the action is currently being executed (shows loading state) + */ + isLoading?: boolean; +} + +/** + * Reusable confirmation dialog for destructive or important selection actions + */ +export function ActionConfirmationDialog({ + open, + title = 'Confirm Action', + message, + actionLabel = 'Confirm', + selectedItemCount, + onConfirm, + onCancel, + isLoading = false, +}: ActionConfirmationDialogProps): ReactElement { + const defaultMessage = selectedItemCount + ? `Are you sure you want to perform this action on ${selectedItemCount} selected item${selectedItemCount > 1 ? 's' : ''}?` + : 'Are you sure you want to perform this action?'; + + return ( + + {title} + + {message || defaultMessage} + + + + + + + ); +} diff --git a/components/src/ActionConfirmationDialog/index.ts b/components/src/ActionConfirmationDialog/index.ts new file mode 100644 index 0000000..53800cf --- /dev/null +++ b/components/src/ActionConfirmationDialog/index.ts @@ -0,0 +1,14 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export * from './ActionConfirmationDialog'; diff --git a/components/src/SelectionActions/ActionConditionEditor.tsx b/components/src/SelectionActions/ActionConditionEditor.tsx new file mode 100644 index 0000000..e9a8aec --- /dev/null +++ b/components/src/SelectionActions/ActionConditionEditor.tsx @@ -0,0 +1,234 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + Box, + FormControl, + FormControlLabel, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import { ReactElement } from 'react'; +import { ActionCondition } from './selection-action-model'; + +export interface ActionConditionEditorProps { + condition?: ActionCondition; + onChange: (condition: ActionCondition | undefined) => void; + availableColumns?: string[]; +} + +type ConditionKind = 'Value' | 'Range' | 'Regex' | 'Misc'; + +/** + * Editor for configuring action visibility conditions + */ +export function ActionConditionEditor({ + condition, + onChange, + availableColumns = [], +}: ActionConditionEditorProps): ReactElement { + const hasCondition = condition !== undefined; + + function handleToggleCondition(enabled: boolean): void { + if (enabled) { + onChange({ + kind: 'Value', + spec: { value: '' }, + }); + } else { + onChange(undefined); + } + } + + function handleKindChange(kind: ConditionKind): void { + switch (kind) { + case 'Value': + onChange({ kind: 'Value', spec: { value: '' } }); + break; + case 'Range': + onChange({ kind: 'Range', spec: {} }); + break; + case 'Regex': + onChange({ kind: 'Regex', spec: { expr: '' } }); + break; + case 'Misc': + onChange({ kind: 'Misc', spec: { value: 'empty' } }); + break; + } + } + + return ( + + handleToggleCondition(e.target.checked)} size="small" /> + } + label="Only show action when condition matches" + /> + + {hasCondition && condition && ( + + + + Condition Type + + + + {condition.kind === 'Value' && ( + onChange({ kind: 'Value', spec: { value } })} + /> + )} + + {condition.kind === 'Range' && ( + onChange({ kind: 'Range', spec: { min, max } })} + /> + )} + + {condition.kind === 'Regex' && ( + onChange({ kind: 'Regex', spec: { expr } })} + /> + )} + + {condition.kind === 'Misc' && ( + onChange({ kind: 'Misc', spec: { value } })} + /> + )} + + {availableColumns.length > 0 && ( + + Condition is evaluated against the values in selected rows. Action is shown if ANY selected row matches. + + )} + + + )} + + {!hasCondition && ( + + Action will always be visible when rows are selected. + + )} + + ); +} + +interface ValueConditionEditorProps { + value: string; + onChange: (value: string) => void; +} + +function ValueConditionEditor({ value, onChange }: ValueConditionEditorProps): ReactElement { + return ( + onChange(e.target.value)} + size="small" + helperText="Row must contain this exact value in any column" + /> + ); +} + +interface RangeConditionEditorProps { + min?: number; + max?: number; + onChange: (min?: number, max?: number) => void; +} + +function RangeConditionEditor({ min, max, onChange }: RangeConditionEditorProps): ReactElement { + return ( + + onChange(e.target.value ? Number(e.target.value) : undefined, max)} + size="small" + sx={{ flex: 1 }} + /> + onChange(min, e.target.value ? Number(e.target.value) : undefined)} + size="small" + sx={{ flex: 1 }} + /> + + ); +} + +interface RegexConditionEditorProps { + expr: string; + onChange: (expr: string) => void; +} + +function RegexConditionEditor({ expr, onChange }: RegexConditionEditorProps): ReactElement { + return ( + onChange(e.target.value)} + size="small" + helperText="Row value must match this regular expression" + placeholder="^(active|running)$" + /> + ); +} + +interface MiscConditionEditorProps { + value: 'empty' | 'null' | 'NaN' | 'true' | 'false'; + onChange: (value: 'empty' | 'null' | 'NaN' | 'true' | 'false') => void; +} + +function MiscConditionEditor({ value, onChange }: MiscConditionEditorProps): ReactElement { + return ( + + Special Value + + + ); +} diff --git a/components/src/SelectionActions/PayloadConfigEditor.tsx b/components/src/SelectionActions/PayloadConfigEditor.tsx new file mode 100644 index 0000000..167158b --- /dev/null +++ b/components/src/SelectionActions/PayloadConfigEditor.tsx @@ -0,0 +1,236 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + Box, + Button, + FormControl, + FormControlLabel, + IconButton, + InputLabel, + MenuItem, + Radio, + RadioGroup, + Select, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { ReactElement, useState } from 'react'; +import AddIcon from 'mdi-material-ui/Plus'; +import DeleteIcon from 'mdi-material-ui/Delete'; +import { FieldMapping } from './selection-action-model'; +import { PayloadPreview } from './PayloadPreview'; + +export interface PayloadConfigEditorProps { + payloadTemplate?: string; + fieldMapping?: FieldMapping[]; + onPayloadTemplateChange: (template: string | undefined) => void; + onFieldMappingChange: (mapping: FieldMapping[] | undefined) => void; + availableColumns?: string[]; + sampleData?: Array>; +} + +type PayloadMode = 'none' | 'template' | 'mapping'; + +/** + * Editor for configuring payload transformation (template or field mapping) + */ +export function PayloadConfigEditor({ + payloadTemplate, + fieldMapping, + onPayloadTemplateChange, + onFieldMappingChange, + availableColumns = [], + sampleData = [], +}: PayloadConfigEditorProps): ReactElement { + const getInitialMode = (): PayloadMode => { + if (payloadTemplate) return 'template'; + if (fieldMapping && fieldMapping.length > 0) return 'mapping'; + return 'none'; + }; + + const [mode, setMode] = useState(getInitialMode()); + + function handleModeChange(newMode: PayloadMode): void { + setMode(newMode); + if (newMode === 'none') { + onPayloadTemplateChange(undefined); + onFieldMappingChange(undefined); + } else if (newMode === 'template') { + onFieldMappingChange(undefined); + if (!payloadTemplate) { + onPayloadTemplateChange('{\n \n}'); + } + } else if (newMode === 'mapping') { + onPayloadTemplateChange(undefined); + if (!fieldMapping || fieldMapping.length === 0) { + onFieldMappingChange([{ source: '', target: '' }]); + } + } + } + + return ( + + + handleModeChange(e.target.value as PayloadMode)}> + } label="Raw Row Data" /> + } label="JSON Template" /> + } label="Field Mapping" /> + + + + {mode === 'none' && ( + + The full row data will be sent as-is without transformation. + + )} + + {mode === 'template' && ( + + )} + + {mode === 'mapping' && ( + + )} + + {/* Preview Section */} + + + ); +} + +interface PayloadTemplateEditorProps { + template: string; + onChange: (template: string) => void; + availableColumns: string[]; +} + +function PayloadTemplateEditor({ template, onChange, availableColumns }: PayloadTemplateEditorProps): ReactElement { + return ( + + onChange(e.target.value)} + multiline + rows={6} + size="small" + placeholder={'{\n "name": "${__data.fields[\\"columnName\\"]}"\n}'} + sx={{ fontFamily: 'monospace' }} + /> + {availableColumns.length > 0 && ( + + + Available columns:{' '} + + + {availableColumns.map((col) => `\${__data.fields["${col}"]}`).join(', ')} + + + )} + + Use {'${varName}'} for dashboard variables and {'${__data.fields["column"]}'} for row data. + + + ); +} + +interface FieldMappingEditorProps { + mapping: FieldMapping[]; + onChange: (mapping: FieldMapping[]) => void; + availableColumns: string[]; +} + +function FieldMappingEditor({ mapping, onChange, availableColumns }: FieldMappingEditorProps): ReactElement { + function handleMappingChange(index: number, field: Partial): void { + const updated = [...mapping]; + const existing = updated[index]; + if (existing) { + updated[index] = { source: existing.source, target: existing.target, ...field }; + onChange(updated); + } + } + + function handleAddMapping(): void { + onChange([...mapping, { source: '', target: '' }]); + } + + function handleDeleteMapping(index: number): void { + const updated = [...mapping]; + updated.splice(index, 1); + onChange(updated); + } + + return ( + + {mapping.map((m, index) => ( + + + Source Column + + + + handleMappingChange(index, { target: e.target.value })} + size="small" + sx={{ flex: 1 }} + /> + handleDeleteMapping(index)} color="error"> + + + + ))} + + + ); +} diff --git a/components/src/SelectionActions/PayloadPreview.tsx b/components/src/SelectionActions/PayloadPreview.tsx new file mode 100644 index 0000000..a630e20 --- /dev/null +++ b/components/src/SelectionActions/PayloadPreview.tsx @@ -0,0 +1,253 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + FormControl, + FormControlLabel, + InputLabel, + MenuItem, + Select, + Switch, + Typography, +} from '@mui/material'; +import { ReactElement, useMemo, useState } from 'react'; +import ExpandMoreIcon from 'mdi-material-ui/ChevronDown'; +import { FieldMapping } from './selection-action-model'; + +export interface PayloadPreviewProps { + payloadTemplate?: string; + fieldMapping?: FieldMapping[]; + sampleData: Array>; + availableColumns: string[]; +} + +/** + * Preview component showing the transformed payload output + */ +export function PayloadPreview({ + payloadTemplate, + fieldMapping, + sampleData, + availableColumns, +}: PayloadPreviewProps): ReactElement { + const [previewRowIndex, setPreviewRowIndex] = useState(0); + const [showBulkPreview, setShowBulkPreview] = useState(false); + + const hasSampleData = sampleData.length > 0; + const hasTransformation = Boolean(payloadTemplate || (fieldMapping && fieldMapping.length > 0)); + + const previewResult = useMemo(() => { + if (!hasTransformation) { + if (!hasSampleData) { + return { + preview: generateMockDataHint(availableColumns), + isValid: true, + isMock: true, + }; + } + // Raw data mode + const data = showBulkPreview ? sampleData.slice(0, 3) : sampleData[previewRowIndex]; + return { + preview: JSON.stringify(data, null, 2), + isValid: true, + isMock: false, + }; + } + + if (!hasSampleData) { + // Show mock data hint + return { + preview: generateMockDataHint(availableColumns), + isValid: true, + isMock: true, + }; + } + + try { + if (payloadTemplate) { + const rows = showBulkPreview ? sampleData.slice(0, 3) : [sampleData[previewRowIndex] ?? {}]; + const transformed = rows.map((row) => applyTemplate(payloadTemplate, row as Record)); + + if (showBulkPreview) { + return { + preview: JSON.stringify(transformed, null, 2), + isValid: true, + isMock: false, + }; + } + return { + preview: JSON.stringify(transformed[0], null, 2), + isValid: true, + isMock: false, + }; + } + + if (fieldMapping && fieldMapping.length > 0) { + const rows = showBulkPreview ? sampleData.slice(0, 3) : [sampleData[previewRowIndex] ?? {}]; + const transformed = rows.map((row) => applyFieldMapping(row as Record, fieldMapping)); + + if (showBulkPreview) { + return { + preview: JSON.stringify(transformed, null, 2), + isValid: true, + isMock: false, + }; + } + return { + preview: JSON.stringify(transformed[0], null, 2), + isValid: true, + isMock: false, + }; + } + + return { preview: '{}', isValid: true, isMock: false }; + } catch (error) { + return { + preview: error instanceof Error ? error.message : 'Invalid transformation', + isValid: false, + isMock: false, + }; + } + }, [ + payloadTemplate, + fieldMapping, + sampleData, + previewRowIndex, + showBulkPreview, + hasSampleData, + hasTransformation, + availableColumns, + ]); + + return ( + + }> + Payload Preview + + + + {hasSampleData && ( + <> + + Preview Row + + + setShowBulkPreview(e.target.checked)} + size="small" + /> + } + label="Show bulk preview (first 3 rows)" + /> + + )} + + + {previewResult.isMock && ( + + Configure query to see preview with real data + + )} + + + {previewResult.preview} + + + + ); +} + +/** + * Generate a mock data hint structure from available columns + */ +function generateMockDataHint(columns: string[]): string { + if (columns.length === 0) { + return '{\n "column1": "",\n "column2": ""\n}'; + } + + const mockObj: Record = {}; + columns.forEach((col) => { + mockObj[col] = ''; + }); + return JSON.stringify(mockObj, null, 2); +} + +/** + * Apply a JSON template with variable substitution + */ +function applyTemplate(template: string, row: Record): unknown { + // Replace ${__data.fields["columnName"]} with actual values + let result = template; + + const fieldRegex = /\$\{__data\.fields\["([^"]+)"\]\}/g; + result = result.replace(fieldRegex, (_, fieldName) => { + const value = row[fieldName]; + if (value === undefined || value === null) { + return 'null'; + } + if (typeof value === 'string') { + return value; + } + return String(value); + }); + + // Try to parse as JSON + try { + return JSON.parse(result); + } catch { + throw new Error(`Invalid JSON after template substitution:\n${result}`); + } +} + +/** + * Apply field mapping to transform row data + */ +function applyFieldMapping(row: Record, mapping: FieldMapping[]): Record { + const result: Record = {}; + for (const { source, target } of mapping) { + if (source && target) { + result[target] = row[source]; + } + } + return result; +} diff --git a/components/src/SelectionActions/SelectionActionForm.tsx b/components/src/SelectionActions/SelectionActionForm.tsx new file mode 100644 index 0000000..cbb512b --- /dev/null +++ b/components/src/SelectionActions/SelectionActionForm.tsx @@ -0,0 +1,348 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + FormControl, + FormControlLabel, + IconButton, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import ArrowDownIcon from 'mdi-material-ui/ArrowDown'; +import ArrowUpIcon from 'mdi-material-ui/ArrowUp'; +import ExpandMoreIcon from 'mdi-material-ui/ChevronDown'; +import DeleteIcon from 'mdi-material-ui/Delete'; +import { ReactElement } from 'react'; +import { ACTION_ICON_OPTIONS, getActionIcon } from '../utils/icon-map'; +import { ActionConditionEditor } from './ActionConditionEditor'; +import { PayloadConfigEditor } from './PayloadConfigEditor'; +import { + ActionCondition, + ActionIcon, + CallbackActionSpec, + FieldMapping, + SelectionAction, + WebhookActionSpec, + isCallbackAction, + isWebhookAction, +} from './selection-action-model'; + +export interface SelectionActionFormProps { + action: SelectionAction; + onChange: (action: SelectionAction) => void; + onDelete: () => void; + onMoveUp: () => void; + onMoveDown: () => void; + isExpanded: boolean; + onToggleExpand: () => void; + isFirst: boolean; + isLast: boolean; + availableColumns?: string[]; + sampleData?: Array>; +} + +/** + * Form for editing a single selection action + */ +export function SelectionActionForm({ + action, + onChange, + onDelete, + onMoveUp, + onMoveDown, + isExpanded, + onToggleExpand, + isFirst, + isLast, + availableColumns = [], + sampleData = [], +}: SelectionActionFormProps): ReactElement { + function handleKindChange(kind: 'callback' | 'webhook'): void { + const newSpec: CallbackActionSpec | WebhookActionSpec = + kind === 'callback' ? { eventName: 'customAction' } : { url: '', method: 'POST' }; + onChange({ ...action, kind, spec: newSpec }); + } + + function handleCallbackSpecChange(spec: CallbackActionSpec): void { + onChange({ ...action, spec }); + } + + function handleWebhookSpecChange(spec: WebhookActionSpec): void { + onChange({ ...action, spec }); + } + + return ( + + }> + + {action.icon && {getActionIcon(action.icon, { fontSize: 'small' })}} + {action.label || 'Unnamed Action'} + + {action.kind} + + { + e.stopPropagation(); + onMoveUp(); + }} + disabled={isFirst} + > + + + { + e.stopPropagation(); + onMoveDown(); + }} + disabled={isLast} + > + + + { + e.stopPropagation(); + onDelete(); + }} + color="error" + > + + + + + + + {/* Basic Settings */} + + onChange({ ...action, id: e.target.value })} + size="small" + sx={{ flex: 1 }} + /> + onChange({ ...action, label: e.target.value })} + size="small" + sx={{ flex: 1 }} + /> + + + + + Icon + + + + + Kind + + + + + {/* Kind-specific settings */} + {isCallbackAction(action) && ( + handleCallbackSpecChange({ ...action.spec, eventName: e.target.value })} + size="small" + helperText="Custom event name dispatched via window.dispatchEvent()" + /> + )} + + {isWebhookAction(action) && } + + {/* Execution Options */} + + Execution Options + + onChange({ ...action, bulkMode: e.target.checked })} + /> + } + label="Bulk Mode (send all selected rows in single request)" + /> + + {/* Confirmation Settings */} + + Confirmation + + onChange({ ...action, requireConfirmation: e.target.checked })} + /> + } + label="Require confirmation before executing" + /> + {action.requireConfirmation && ( + onChange({ ...action, confirmationMessage: e.target.value })} + size="small" + multiline + rows={2} + placeholder="Are you sure you want to perform this action?" + /> + )} + + {/* Conditional Visibility */} + + Conditional Visibility + + onChange({ ...action, condition })} + availableColumns={availableColumns} + /> + + {/* Payload Configuration */} + + Payload Configuration + + + onChange({ ...action, payloadTemplate: template, fieldMapping: undefined }) + } + onFieldMappingChange={(mapping: FieldMapping[] | undefined) => + onChange({ ...action, fieldMapping: mapping, payloadTemplate: undefined }) + } + availableColumns={availableColumns} + sampleData={sampleData} + /> + + + + ); +} + +interface WebhookSpecEditorProps { + spec: WebhookActionSpec; + onChange: (spec: WebhookActionSpec) => void; +} + +function WebhookSpecEditor({ spec, onChange }: WebhookSpecEditorProps): ReactElement { + return ( + + + + Method + + + onChange({ ...spec, url: e.target.value })} + size="small" + sx={{ flex: 1 }} + helperText='Supports variables: ${varName} and ${__data.fields["column"]}' + /> + + + {/* Rate Limiting */} + + Rate Limiting (optional) + + + + onChange({ + ...spec, + rateLimit: { + ...spec.rateLimit, + requestsPerSecond: e.target.value ? Number(e.target.value) : undefined, + }, + }) + } + size="small" + inputProps={{ min: 1 }} + sx={{ flex: 1 }} + /> + + onChange({ + ...spec, + rateLimit: { + ...spec.rateLimit, + maxConcurrent: e.target.value ? Number(e.target.value) : undefined, + }, + }) + } + size="small" + inputProps={{ min: 1 }} + sx={{ flex: 1 }} + /> + + + ); +} diff --git a/components/src/SelectionActions/SelectionActionsEditor.tsx b/components/src/SelectionActions/SelectionActionsEditor.tsx new file mode 100644 index 0000000..a3a46c8 --- /dev/null +++ b/components/src/SelectionActions/SelectionActionsEditor.tsx @@ -0,0 +1,128 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Button, Stack, Typography } from '@mui/material'; +import { ReactElement, useState } from 'react'; +import AddIcon from 'mdi-material-ui/Plus'; +import { SelectionAction } from './selection-action-model'; +import { SelectionActionForm } from './SelectionActionForm'; + +export interface SelectionActionsEditorProps { + selectionActions: SelectionAction[]; + onChange: (selectionActions: SelectionAction[]) => void; + availableColumns?: string[]; + sampleData?: Array>; +} + +/** + * Editor component for managing a list of selection actions + */ +export function SelectionActionsEditor({ + selectionActions, + onChange, + availableColumns = [], + sampleData = [], +}: SelectionActionsEditorProps): ReactElement { + const [expandedIndex, setExpandedIndex] = useState(null); + + function handleActionChange(index: number, action: SelectionAction): void { + const updatedActions = [...selectionActions]; + updatedActions[index] = action; + onChange(updatedActions); + } + + function handleActionAdd(): void { + const newAction: SelectionAction = { + id: `action_${Date.now()}`, + label: `Action ${selectionActions.length + 1}`, + kind: 'callback', + spec: { eventName: 'customAction' }, + }; + onChange([...selectionActions, newAction]); + setExpandedIndex(selectionActions.length); + } + + function handleActionDelete(index: number): void { + const updatedActions = [...selectionActions]; + updatedActions.splice(index, 1); + onChange(updatedActions); + if (expandedIndex === index) { + setExpandedIndex(null); + } else if (expandedIndex !== null && expandedIndex > index) { + setExpandedIndex(expandedIndex - 1); + } + } + + function handleMoveUp(index: number): void { + if (index === 0) return; + const updatedActions = [...selectionActions]; + const temp = updatedActions[index - 1]; + updatedActions[index - 1] = updatedActions[index]!; + updatedActions[index] = temp!; + onChange(updatedActions); + if (expandedIndex === index) { + setExpandedIndex(index - 1); + } else if (expandedIndex === index - 1) { + setExpandedIndex(index); + } + } + + function handleMoveDown(index: number): void { + if (index === selectionActions.length - 1) return; + const updatedActions = [...selectionActions]; + const temp = updatedActions[index]; + updatedActions[index] = updatedActions[index + 1]!; + updatedActions[index + 1] = temp!; + onChange(updatedActions); + if (expandedIndex === index) { + setExpandedIndex(index + 1); + } else if (expandedIndex === index + 1) { + setExpandedIndex(index); + } + } + + function handleToggleExpand(index: number): void { + setExpandedIndex(expandedIndex === index ? null : index); + } + + return ( + + {selectionActions.length === 0 ? ( + + No selection actions defined + + ) : ( + selectionActions.map((action, index) => ( + handleActionChange(index, updatedAction)} + onDelete={() => handleActionDelete(index)} + onMoveUp={() => handleMoveUp(index)} + onMoveDown={() => handleMoveDown(index)} + isExpanded={expandedIndex === index} + onToggleExpand={() => handleToggleExpand(index)} + isFirst={index === 0} + isLast={index === selectionActions.length - 1} + availableColumns={availableColumns} + sampleData={sampleData} + /> + )) + )} + + + + ); +} diff --git a/components/src/SelectionActions/SelectionActionsHeaderDropdown.tsx b/components/src/SelectionActions/SelectionActionsHeaderDropdown.tsx new file mode 100644 index 0000000..f6d2dfc --- /dev/null +++ b/components/src/SelectionActions/SelectionActionsHeaderDropdown.tsx @@ -0,0 +1,201 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + Box, + CircularProgress, + Divider, + IconButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Tooltip, + Typography, +} from '@mui/material'; +import LightningBoltIcon from 'mdi-material-ui/LightningBolt'; +import { ReactElement, useState, MouseEvent } from 'react'; +import { ActionConfirmationDialog } from '../ActionConfirmationDialog'; +import { getActionIcon } from '../utils/icon-map'; +import { useSelectionContextOptional } from './SelectionContext'; +import { SelectionAction } from './selection-action-model'; +import { executeSelectionAction, getVisibleActions } from './action-utils'; + +/** + * Header dropdown component for selection actions. + * Renders a lightning bolt icon that opens a dropdown menu with available selection actions. + * - Returns null if no selectionActions are configured + * - Shows disabled state with tooltip when no items are selected + * - Shows selection count in menu header + * - Uses itemLabel from context for display text (default: "item") + */ +export function SelectionActionsHeaderDropdown(): ReactElement | null { + const context = useSelectionContextOptional(); + const [anchorEl, setAnchorEl] = useState(null); + const [confirmingAction, setConfirmingAction] = useState(null); + + // If no context or no selection actions configured, don't render anything + if (!context || context.selectionActions.length === 0) { + return null; + } + + const { + selectedItems, + selectionActions, + isExecuting, + onExecutingChange, + onActionComplete, + replaceVariables, + getItemId, + itemLabel = 'item', + } = context; + + const isOpen = Boolean(anchorEl); + const hasSelection = selectedItems.length > 0; + const visibleActions = hasSelection ? getVisibleActions(selectionActions, selectedItems) : []; + + // Helper for pluralization + const itemLabelPlural = `${itemLabel}s`; + const itemCount = selectedItems.length; + const itemText = itemCount === 1 ? itemLabel : itemLabelPlural; + + function handleClick(event: MouseEvent): void { + if (hasSelection) { + setAnchorEl(event.currentTarget); + } + } + + function handleClose(): void { + setAnchorEl(null); + } + + async function handleActionClick(action: SelectionAction): Promise { + handleClose(); + + if (action.requireConfirmation) { + setConfirmingAction(action); + return; + } + + await executeAction(action); + } + + async function executeAction(action: SelectionAction): Promise { + onExecutingChange(true); + + try { + const result = await executeSelectionAction(action, selectedItems, { + replaceVariables, + getItemId, + }); + + onActionComplete(result.failedItems); + } finally { + onExecutingChange(false); + setConfirmingAction(null); + } + } + + function handleConfirmationCancel(): void { + setConfirmingAction(null); + } + + async function handleConfirmationConfirm(): Promise { + if (confirmingAction) { + await executeAction(confirmingAction); + } + } + + const tooltipTitle = hasSelection + ? `${itemCount} ${itemText} selected` + : `Select ${itemLabelPlural} to enable actions`; + + return ( + <> + + + + {isExecuting ? ( + + ) : ( + (hasSelection ? theme.palette.primary.main : theme.palette.text.disabled), + }} + /> + )} + + + + + + {/* Menu header with selection count */} + + + {itemCount} {itemText} selected + + + + + {/* Action items */} + {visibleActions.length > 0 ? ( + visibleActions.map((action) => ( + handleActionClick(action)} disabled={isExecuting}> + {action.icon && {getActionIcon(action.icon, { fontSize: 'small' })}} + {action.label} + + )) + ) : ( + + + + No actions available for selection + + + + )} + + + + + ); +} diff --git a/components/src/SelectionActions/SelectionActionsSettingsEditor.tsx b/components/src/SelectionActions/SelectionActionsSettingsEditor.tsx new file mode 100644 index 0000000..a4172f1 --- /dev/null +++ b/components/src/SelectionActions/SelectionActionsSettingsEditor.tsx @@ -0,0 +1,96 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Switch } from '@mui/material'; +import { ChangeEvent, ReactElement } from 'react'; +import { OptionsEditorColumn } from '../OptionsEditorLayout/OptionsEditorColumn'; +import { OptionsEditorControl } from '../OptionsEditorLayout/OptionsEditorControl'; +import { OptionsEditorGrid } from '../OptionsEditorLayout/OptionsEditorGrid'; +import { OptionsEditorGroup } from '../OptionsEditorLayout/OptionsEditorGroup'; +import { SelectionAction, SelectionConfig } from './selection-action-model'; +import { SelectionActionsEditor } from './SelectionActionsEditor'; + +export interface SelectionActionsSettingsEditorValue { + selection?: SelectionConfig; + selectionActions?: SelectionAction[]; +} + +export interface SelectionActionsSettingsEditorProps { + /** + * Current value containing selection and selectionActions configuration + */ + value: SelectionActionsSettingsEditorValue; + /** + * Callback when the configuration changes + */ + onChange: (value: SelectionActionsSettingsEditorValue) => void; + /** + * Available column names for condition and payload editors + */ + availableColumns?: string[]; + /** + * Sample data for payload preview + */ + sampleData?: Array>; +} + +/** + * Reusable settings editor for selection and selection actions configuration. + * Can be used by all table plugins (Table, TimeSeriesTable, LogsTable, TraceTable) + * as well as chart plugins that support selection actions. + * + * Features: + * - Enable/disable selection toggle + * - Selection actions editor (visible when selection is enabled) + */ +export function SelectionActionsSettingsEditor({ + value, + onChange, + availableColumns = [], + sampleData = [], +}: SelectionActionsSettingsEditorProps): ReactElement { + function handleSelectionChange(_event: ChangeEvent, checked: boolean): void { + onChange({ + ...value, + selection: checked ? { enabled: true } : undefined, + }); + } + + function handleSelectionActionsChange(selectionActions: SelectionAction[]): void { + onChange({ + ...value, + selectionActions: selectionActions.length > 0 ? selectionActions : undefined, + }); + } + + return ( + + + + } + /> + {value.selection?.enabled && ( + + )} + + + + ); +} diff --git a/components/src/SelectionActions/SelectionContext.tsx b/components/src/SelectionActions/SelectionContext.tsx new file mode 100644 index 0000000..2fe6c0d --- /dev/null +++ b/components/src/SelectionActions/SelectionContext.tsx @@ -0,0 +1,73 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { createContext, useContext, ReactNode } from 'react'; +import { SelectionAction, SelectionActionError } from './selection-action-model'; + +/** + * Context value for sharing selection state between components and header actions. + * This context is generic and can be used for tables, charts, or any selectable UI. + */ +export interface SelectionContextValue { + /** Currently selected items data */ + selectedItems: Array>; + /** Configured selection actions */ + selectionActions: SelectionAction[]; + /** Whether an action is currently executing */ + isExecuting: boolean; + /** Callback to update executing state */ + onExecutingChange: (executing: boolean) => void; + /** Callback when action completes (with any failed items) */ + onActionComplete: (failedItems: SelectionActionError[]) => void; + /** Function to replace variables in strings (for payload templates) */ + replaceVariables: (text: string) => string; + /** Function to get item ID from item data */ + getItemId: (item: Record, index: number) => string; + /** Label for the selected item type (default: "item"), used for display text like "3 rows selected" */ + itemLabel?: string; +} + +const SelectionContext = createContext(null); + +export interface SelectionProviderProps { + children: ReactNode; + value: SelectionContextValue; +} + +/** + * Provider component for sharing selection state. + * Use this to wrap components that need access to selection actions. + */ +export function SelectionProvider({ children, value }: SelectionProviderProps): ReactNode { + return {children}; +} + +/** + * Hook to access selection context + * @throws Error if used outside of SelectionProvider + */ +export function useSelectionContext(): SelectionContextValue { + const context = useContext(SelectionContext); + if (context === null) { + throw new Error('useSelectionContext must be used within a SelectionProvider'); + } + return context; +} + +/** + * Hook to access selection context, returns null if not in provider. + * Useful for header action components that need to check if context is available. + */ +export function useSelectionContextOptional(): SelectionContextValue | null { + return useContext(SelectionContext); +} diff --git a/components/src/SelectionActions/SelectionErrorIndicator.tsx b/components/src/SelectionActions/SelectionErrorIndicator.tsx new file mode 100644 index 0000000..655f7d4 --- /dev/null +++ b/components/src/SelectionActions/SelectionErrorIndicator.tsx @@ -0,0 +1,97 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Box, IconButton, Tooltip, Typography, useTheme } from '@mui/material'; +import WarningIcon from 'mdi-material-ui/Alert'; +import CloseIcon from 'mdi-material-ui/Close'; +import { ReactElement } from 'react'; +import { SelectionActionError } from './selection-action-model'; + +export interface SelectionErrorIndicatorProps { + /** + * Error information for this item + */ + error: SelectionActionError; + + /** + * Callback when user dismisses the error + */ + onDismiss: (itemId: string) => void; +} + +/** + * Inline error indicator shown when an action fails on an item. + * Displays a warning icon with tooltip containing error details. + * User can click to dismiss. + */ +export function SelectionErrorIndicator({ error, onDismiss }: SelectionErrorIndicatorProps): ReactElement { + const theme = useTheme(); + + function handleDismiss(e: React.MouseEvent): void { + e.stopPropagation(); // Prevent item selection + onDismiss(error.itemId); + } + + return ( + + + Action Failed: {error.actionLabel} + + {error.errorMessage} + {error.timestamp && ( + + {new Date(error.timestamp).toLocaleTimeString()} + + )} + + } + > + + + + + + + + ); +} diff --git a/components/src/SelectionActions/action-utils.ts b/components/src/SelectionActions/action-utils.ts new file mode 100644 index 0000000..07c6117 --- /dev/null +++ b/components/src/SelectionActions/action-utils.ts @@ -0,0 +1,389 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + ActionCondition, + ActionExecutionResult, + FieldMapping, + SelectionAction, + SelectionActionError, + WebhookRateLimitConfig, + isCallbackAction, + isWebhookAction, +} from './selection-action-model'; + +/** + * Substitute variables in a template string + * Supports: + * - ${varName} for dashboard variables + * - ${__data.fields["columnName"]} for item data + * + * @param template The template string with variable placeholders + * @param item Item data for ${__data.fields["columnName"]} substitution + * @param replaceVariables Function for variable replacement (from plugin-system) + */ +export function substituteSelectionVariables( + template: string, + item: Record, + replaceVariables: (text: string) => string +): string { + let result = template; + + // Replace ${__data.fields["columnName"]} with item values + const fieldRegex = /\$\{__data\.fields\["([^"]+)"\]\}/g; + result = result.replace(fieldRegex, (_, fieldName) => { + const value = item[fieldName]; + if (value === undefined || value === null) { + return ''; + } + return String(value); + }); + + // Use replaceVariables for remaining variables + // This supports multi-value variables with formatting (e.g., ${var:csv}, ${var:pipe}) + return replaceVariables(result); +} + +/** + * Apply field mapping to transform item data + */ +export function applyFieldMapping( + item: Record, + fieldMappings: FieldMapping[] +): Record { + const result: Record = {}; + for (const { source, target } of fieldMappings) { + if (source && target) { + result[target] = item[source]; + } + } + return result; +} + +/** + * Build payload for a single item based on action configuration + */ +export function buildPayload( + item: Record, + action: SelectionAction, + replaceVariables: (text: string) => string +): unknown { + if (action.payloadTemplate) { + const substituted = substituteSelectionVariables(action.payloadTemplate, item, replaceVariables); + try { + return JSON.parse(substituted); + } catch { + // If JSON parsing fails, return the raw substituted string + return substituted; + } + } + + if (action.fieldMapping && action.fieldMapping.length > 0) { + return applyFieldMapping(item, action.fieldMapping); + } + + // Return raw item data + return item; +} + +/** + * Build payload for multiple items (bulk mode) + */ +export function buildBulkPayload( + items: Array>, + action: SelectionAction, + replaceVariables: (text: string) => string +): unknown[] { + return items.map((item) => buildPayload(item, action, replaceVariables)); +} + +/** + * Generate a mock data hint structure from available columns + */ +export function generateMockDataHint(columns: string[]): string { + if (columns.length === 0) { + return '{\n "column1": "",\n "column2": ""\n}'; + } + + const mockObj: Record = {}; + columns.forEach((col) => { + mockObj[col] = ''; + }); + return JSON.stringify(mockObj, null, 2); +} + +/** + * Evaluate if a condition matches an item value + */ +export function evaluateCondition(condition: ActionCondition, value: unknown): boolean { + switch (condition.kind) { + case 'Value': + return String(value) === condition.spec.value; + + case 'Range': { + const numValue = Number(value); + if (isNaN(numValue)) return false; + const { min, max } = condition.spec; + if (min !== undefined && numValue < min) return false; + if (max !== undefined && numValue > max) return false; + return true; + } + + case 'Regex': { + try { + const regex = new RegExp(condition.spec.expr); + return regex.test(String(value)); + } catch { + return false; + } + } + + case 'Misc': { + switch (condition.spec.value) { + case 'empty': + return value === '' || value === undefined; + case 'null': + return value === null; + case 'NaN': + return Number.isNaN(value); + case 'true': + return value === true || value === 'true'; + case 'false': + return value === false || value === 'false'; + default: + return false; + } + } + + default: + return false; + } +} + +/** + * Evaluate if an action condition matches any value in an item + */ +export function evaluateActionCondition(condition: ActionCondition, item: Record): boolean { + // Check if condition matches any column value in the item + for (const value of Object.values(item)) { + if (evaluateCondition(condition, value)) { + return true; + } + } + return false; +} + +/** + * Get visible actions based on conditions and selected items + * An action is visible if: + * - It has no condition, OR + * - Its condition matches ANY of the selected items + */ +export function getVisibleActions( + actions: SelectionAction[], + selectedItems: Array> +): SelectionAction[] { + return actions.filter((action) => { + if (!action.condition) { + return true; // No condition means always visible + } + + // Check if condition matches ANY selected item + return selectedItems.some((item) => evaluateActionCondition(action.condition!, item)); + }); +} + +/** + * Simple rate limiter using token bucket algorithm + */ +export class RateLimiter { + private tokens: number; + private lastRefill: number; + private activeRequests: number; + private readonly maxTokens: number; + private readonly refillRate: number; + private readonly maxConcurrent: number; + + constructor(config: WebhookRateLimitConfig = {}) { + this.refillRate = config.requestsPerSecond ?? Infinity; + this.maxConcurrent = config.maxConcurrent ?? Infinity; + this.maxTokens = this.refillRate; + this.tokens = this.maxTokens; + this.lastRefill = Date.now(); + this.activeRequests = 0; + } + + private refillTokens(): void { + const now = Date.now(); + const elapsed = (now - this.lastRefill) / 1000; + this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate); + this.lastRefill = now; + } + + async acquire(): Promise { + // Wait for concurrent limit + while (this.activeRequests >= this.maxConcurrent) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + // Wait for rate limit + this.refillTokens(); + while (this.tokens < 1) { + const waitTime = ((1 - this.tokens) / this.refillRate) * 1000; + await new Promise((resolve) => setTimeout(resolve, Math.max(waitTime, 10))); + this.refillTokens(); + } + + this.tokens -= 1; + this.activeRequests += 1; + } + + release(): void { + this.activeRequests = Math.max(0, this.activeRequests - 1); + } +} + +export interface ExecuteActionOptions { + /** + * Function to replace variables in a string. + * Supports multi-value variables with formatting (e.g., ${var:csv}, ${var:pipe}). + */ + replaceVariables: (text: string) => string; + getItemId?: (item: Record, index: number) => string; +} + +/** + * Execute a selection action on selected items + */ +export async function executeSelectionAction( + action: SelectionAction, + selectedItems: Array>, + options: ExecuteActionOptions +): Promise { + const { replaceVariables, getItemId = (_, index): string => String(index) } = options; + const failedItems: SelectionActionError[] = []; + + if (isCallbackAction(action)) { + // Dispatch custom event + const payload = action.bulkMode + ? buildBulkPayload(selectedItems, action, replaceVariables) + : selectedItems.map((item) => buildPayload(item, action, replaceVariables)); + + const event = new CustomEvent(action.spec.eventName, { + detail: { + items: payload, + action: { + id: action.id, + label: action.label, + }, + timestamp: Date.now(), + }, + }); + + window.dispatchEvent(event); + return { failedItems }; + } + + if (isWebhookAction(action)) { + const rateLimiter = new RateLimiter(action.spec.rateLimit); + + if (action.bulkMode) { + // Single request with all items + const payload = buildBulkPayload(selectedItems, action, replaceVariables); + const url = substituteSelectionVariables(action.spec.url, {}, replaceVariables); + + try { + await rateLimiter.acquire(); + const response = await fetch(url, { + method: action.spec.method || 'POST', + headers: { + 'Content-Type': 'application/json', + ...action.spec.headers, + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + // Mark all items as failed + selectedItems.forEach((item, index) => { + failedItems.push({ + itemId: getItemId(item, index), + actionId: action.id, + actionLabel: action.label, + errorMessage: `HTTP ${response.status}: ${response.statusText}`, + timestamp: Date.now(), + }); + }); + } + } catch (error) { + // Mark all items as failed + selectedItems.forEach((item, index) => { + failedItems.push({ + itemId: getItemId(item, index), + actionId: action.id, + actionLabel: action.label, + errorMessage: error instanceof Error ? error.message : 'Request failed', + timestamp: Date.now(), + }); + }); + } finally { + rateLimiter.release(); + } + } else { + // Individual requests per item with rate limiting + const promises = selectedItems.map(async (item, index) => { + const payload = buildPayload(item, action, replaceVariables); + const url = substituteSelectionVariables(action.spec.url, item, replaceVariables); + const itemId = getItemId(item, index); + + try { + await rateLimiter.acquire(); + const response = await fetch(url, { + method: action.spec.method || 'POST', + headers: { + 'Content-Type': 'application/json', + ...action.spec.headers, + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + failedItems.push({ + itemId, + actionId: action.id, + actionLabel: action.label, + errorMessage: `HTTP ${response.status}: ${response.statusText}`, + timestamp: Date.now(), + }); + } + } catch (error) { + failedItems.push({ + itemId, + actionId: action.id, + actionLabel: action.label, + errorMessage: error instanceof Error ? error.message : 'Request failed', + timestamp: Date.now(), + }); + } finally { + rateLimiter.release(); + } + }); + + await Promise.all(promises); + } + + return { failedItems }; + } + + return { failedItems }; +} diff --git a/components/src/SelectionActions/index.ts b/components/src/SelectionActions/index.ts new file mode 100644 index 0000000..c1bd35f --- /dev/null +++ b/components/src/SelectionActions/index.ts @@ -0,0 +1,31 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Model types +export * from './selection-action-model'; + +// Context and hooks +export * from './SelectionContext'; + +// Utilities +export * from './action-utils'; + +// Components +export * from './SelectionActionsEditor'; +export * from './SelectionActionsSettingsEditor'; +export * from './SelectionActionForm'; +export * from './PayloadConfigEditor'; +export * from './PayloadPreview'; +export * from './ActionConditionEditor'; +export * from './SelectionErrorIndicator'; +export * from './SelectionActionsHeaderDropdown'; diff --git a/components/src/SelectionActions/selection-action-model.ts b/components/src/SelectionActions/selection-action-model.ts new file mode 100644 index 0000000..5078c8e --- /dev/null +++ b/components/src/SelectionActions/selection-action-model.ts @@ -0,0 +1,159 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Selection configuration + */ +export interface SelectionConfig { + enabled: boolean; + variant?: 'single' | 'multiple'; +} + +/** + * Field mapping for payload transformation + */ +export interface FieldMapping { + source: string; + target: string; +} + +/** + * Rate limit configuration for webhook actions + */ +export interface WebhookRateLimitConfig { + requestsPerSecond?: number; + maxConcurrent?: number; +} + +/** + * Available action icons + */ +export type ActionIcon = + | 'send' + | 'delete' + | 'copy' + | 'download' + | 'refresh' + | 'play' + | 'stop' + | 'check' + | 'close' + | 'warning'; + +/** + * Condition types for action visibility + */ +export interface ValueCondition { + kind: 'Value'; + spec: { + value: string; + }; +} + +export interface RangeCondition { + kind: 'Range'; + spec: { + min?: number; + max?: number; + }; +} + +export interface RegexCondition { + kind: 'Regex'; + spec: { + expr: string; + }; +} + +export interface MiscCondition { + kind: 'Misc'; + spec: { + value: 'empty' | 'null' | 'NaN' | 'true' | 'false'; + }; +} + +export type ActionCondition = ValueCondition | RangeCondition | RegexCondition | MiscCondition; + +/** + * Callback action spec - dispatches a custom window event + */ +export interface CallbackActionSpec { + eventName: string; +} + +/** + * Webhook action spec - calls an HTTP endpoint + */ +export interface WebhookActionSpec { + url: string; + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + headers?: Record; + rateLimit?: WebhookRateLimitConfig; +} + +/** + * Selection action definition + */ +export interface SelectionAction { + id: string; + label: string; + icon?: ActionIcon; + kind: 'callback' | 'webhook'; + spec: CallbackActionSpec | WebhookActionSpec; + + // Payload transformation options (mutually exclusive) + payloadTemplate?: string; + fieldMapping?: FieldMapping[]; + + // Execution options + bulkMode?: boolean; + + // Confirmation dialog options + requireConfirmation?: boolean; + confirmationMessage?: string; + + // Conditional visibility (action shown if condition matches ANY selected item) + condition?: ActionCondition; +} + +/** + * Error tracking for failed selection actions + */ +export interface SelectionActionError { + itemId: string; + actionId: string; + actionLabel: string; + errorMessage: string; + timestamp: number; +} + +/** + * Result of action execution + */ +export interface ActionExecutionResult { + failedItems: SelectionActionError[]; +} + +/** + * Type guard for CallbackActionSpec + */ +export function isCallbackAction(action: SelectionAction): action is SelectionAction & { spec: CallbackActionSpec } { + return action.kind === 'callback'; +} + +/** + * Type guard for WebhookActionSpec + */ +export function isWebhookAction(action: SelectionAction): action is SelectionAction & { spec: WebhookActionSpec } { + return action.kind === 'webhook'; +} diff --git a/components/src/Table/Table.tsx b/components/src/Table/Table.tsx index ea8eeb1..3fce63c 100644 --- a/components/src/Table/Table.tsx +++ b/components/src/Table/Table.tsx @@ -62,6 +62,8 @@ export function Table({ pagination, onPaginationChange, rowSelectionVariant = 'standard', + rowErrors, + onRowErrorDismiss, ...otherProps }: TableProps): ReactElement { const theme = useTheme(); @@ -199,6 +201,8 @@ export function Table({ pagination={pagination} onPaginationChange={onPaginationChange} rowCount={table.getRowCount()} + rowErrors={rowErrors} + onRowErrorDismiss={onRowErrorDismiss} /> ); } diff --git a/components/src/Table/VirtualizedTable.tsx b/components/src/Table/VirtualizedTable.tsx index dcf34ad..5d28738 100644 --- a/components/src/Table/VirtualizedTable.tsx +++ b/components/src/Table/VirtualizedTable.tsx @@ -11,20 +11,21 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { Box, TableRow as MuiTableRow, TablePagination } from '@mui/material'; import { Column, HeaderGroup, Row, flexRender } from '@tanstack/react-table'; -import { Box, TablePagination, TableRow as MuiTableRow } from '@mui/material'; -import { TableVirtuoso, TableComponents, TableVirtuosoHandle, TableVirtuosoProps } from 'react-virtuoso'; -import { useRef, useMemo, ReactElement } from 'react'; -import { TableRow } from './TableRow'; -import { TableBody } from './TableBody'; +import { ReactElement, useMemo, useRef } from 'react'; +import { TableComponents, TableVirtuoso, TableVirtuosoHandle, TableVirtuosoProps } from 'react-virtuoso'; +import { SelectionActionError, SelectionErrorIndicator } from '../SelectionActions'; +import { useVirtualizedTableKeyboardNav } from './hooks/useVirtualizedTableKeyboardNav'; import { InnerTable } from './InnerTable'; +import { TableCellConfigs, TableProps, TableRowEventOpts } from './model/table-model'; +import { TableBody } from './TableBody'; +import { TableCell, TableCellProps } from './TableCell'; +import { TableFoot } from './TableFoot'; import { TableHead } from './TableHead'; import { TableHeaderCell } from './TableHeaderCell'; -import { TableCell, TableCellProps } from './TableCell'; +import { TableRow } from './TableRow'; import { VirtualizedTableContainer } from './VirtualizedTableContainer'; -import { TableCellConfigs, TableProps, TableRowEventOpts } from './model/table-model'; -import { useVirtualizedTableKeyboardNav } from './hooks/useVirtualizedTableKeyboardNav'; -import { TableFoot } from './TableFoot'; type TableCellPosition = { row: number; @@ -41,6 +42,14 @@ export type VirtualizedTableProps = Required< headers: Array>; cellConfigs?: TableCellConfigs; rowCount: number; + /** + * Map of row IDs to error information for rows where action execution failed. + */ + rowErrors?: Record; + /** + * Callback fired when a user dismisses an error indicator on a row. + */ + onRowErrorDismiss?: (rowId: string) => void; }; // Separating out the virtualized table because we may want a paginated table @@ -62,6 +71,8 @@ export function VirtualizedTable({ pagination, onPaginationChange, rowCount, + rowErrors, + onRowErrorDismiss, }: VirtualizedTableProps): ReactElement { const virtuosoRef = useRef(null); // Use a ref for these values because they are only needed for keyboard @@ -214,6 +225,9 @@ export function VirtualizedTable({ return null; } + // Check if this row has an error + const rowError = rowErrors?.[row.id]; + return ( <> {row.getVisibleCells().map((cell, i, cells) => { @@ -262,6 +276,9 @@ export function VirtualizedTable({ {} ); + // Show error indicator in the first non-checkbox cell + const showErrorIndicator = rowError && i === 0; + return ( ({ adjacentCellsValuesMap={adjacentCellsValuesMap} > {cellConfig?.text || cellContent} + {showErrorIndicator && onRowErrorDismiss && ( + + )} ); })} diff --git a/components/src/Table/model/table-model.ts b/components/src/Table/model/table-model.ts index 4e0de99..b0ca12f 100644 --- a/components/src/Table/model/table-model.ts +++ b/components/src/Table/model/table-model.ts @@ -25,6 +25,8 @@ import { } from '@tanstack/react-table'; import { CSSProperties } from 'react'; +import { SelectionActionError } from '../../SelectionActions/selection-action-model'; + export const DEFAULT_COLUMN_WIDTH = 150; export const DEFAULT_COLUMN_HEIGHT = 40; @@ -168,6 +170,17 @@ export interface TableProps { * is enabled. If not set, a default color is used. */ getCheckboxColor?: (rowData: TableData) => string; + + /** + * Map of row IDs to error information for rows where action execution failed. + * Used to display inline error indicators on failed rows. + */ + rowErrors?: Record; + + /** + * Callback fired when a user dismisses an error indicator on a row. + */ + onRowErrorDismiss?: (rowId: string) => void; } function calculateTableCellHeight(lineHeight: CSSProperties['lineHeight'], paddingY: string): number { diff --git a/components/src/index.ts b/components/src/index.ts index 77aad8b..43a0a1a 100644 --- a/components/src/index.ts +++ b/components/src/index.ts @@ -48,3 +48,5 @@ export * from './theme'; export * from './TransformsEditor'; export * from './RefreshIntervalPicker'; export * from './ValueMappingEditor'; +export * from './SelectionActions'; +export * from './ActionConfirmationDialog'; diff --git a/components/src/utils/icon-map.ts b/components/src/utils/icon-map.ts new file mode 100644 index 0000000..122ee8f --- /dev/null +++ b/components/src/utils/icon-map.ts @@ -0,0 +1,73 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SendIcon from 'mdi-material-ui/Send'; +import DeleteIcon from 'mdi-material-ui/Delete'; +import ContentCopyIcon from 'mdi-material-ui/ContentCopy'; +import DownloadIcon from 'mdi-material-ui/Download'; +import RefreshIcon from 'mdi-material-ui/Refresh'; +import PlayIcon from 'mdi-material-ui/Play'; +import StopIcon from 'mdi-material-ui/Stop'; +import CheckIcon from 'mdi-material-ui/Check'; +import CloseIcon from 'mdi-material-ui/Close'; +import WarningIcon from 'mdi-material-ui/Alert'; +import { ComponentType, createElement, ReactElement } from 'react'; +import { SvgIconProps } from '@mui/material'; +import { ActionIcon } from '../SelectionActions/selection-action-model'; + +/** + * Mapping of action icon names to MUI icon components + */ +export const ACTION_ICON_MAP: Record> = { + send: SendIcon, + delete: DeleteIcon, + copy: ContentCopyIcon, + download: DownloadIcon, + refresh: RefreshIcon, + play: PlayIcon, + stop: StopIcon, + check: CheckIcon, + close: CloseIcon, + warning: WarningIcon, +}; + +/** + * Options for the icon selector dropdown in editors + */ +export const ACTION_ICON_OPTIONS: Array<{ value: ActionIcon; label: string }> = [ + { value: 'send', label: 'Send' }, + { value: 'delete', label: 'Delete' }, + { value: 'copy', label: 'Copy' }, + { value: 'download', label: 'Download' }, + { value: 'refresh', label: 'Refresh' }, + { value: 'play', label: 'Play' }, + { value: 'stop', label: 'Stop' }, + { value: 'check', label: 'Check' }, + { value: 'close', label: 'Close' }, + { value: 'warning', label: 'Warning' }, +]; + +/** + * Get the icon component for a given action icon name + */ +export function getActionIcon(iconName: ActionIcon, props?: SvgIconProps): ReactElement { + const IconComponent = ACTION_ICON_MAP[iconName]; + return createElement(IconComponent, props); +} + +/** + * Get the icon component class for a given action icon name + */ +export function getActionIconComponent(iconName: ActionIcon): ComponentType { + return ACTION_ICON_MAP[iconName]; +} diff --git a/components/src/utils/index.ts b/components/src/utils/index.ts index bf7af48..735c231 100644 --- a/components/src/utils/index.ts +++ b/components/src/utils/index.ts @@ -19,3 +19,4 @@ export * from './component-ids'; export * from './format'; export * from './theme-gen'; export * from './memo'; +export * from './icon-map'; diff --git a/cue/common/selection-action.cue b/cue/common/selection-action.cue new file mode 100644 index 0000000..247142a --- /dev/null +++ b/cue/common/selection-action.cue @@ -0,0 +1,110 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package common + +import ( + "strings" +) + +// Selection configuration +#selectionConfig: { + enabled: bool + variant?: "single" | "multiple" +} + +// Field mapping for payload transformation +#fieldMapping: { + source: strings.MinRunes(1) + target: strings.MinRunes(1) +} + +// Rate limit configuration for webhook actions +#rateLimitConfig: { + requestsPerSecond?: number & > 0 + maxConcurrent?: number & > 0 +} + +// Condition types for action visibility (reusing pattern from table cell conditions) +#valueCondition: { + kind: "Value" + spec: { + value: strings.MinRunes(1) + } +} + +#rangeCondition: { + kind: "Range" + spec: { + min?: number + max?: number & >= min + } +} + +#regexCondition: { + kind: "Regex" + spec: { + expr: strings.MinRunes(1) + } +} + +#miscCondition: { + kind: "Misc" + spec: { + value: "empty" | "null" | "NaN" | "true" | "false" + } +} + +#actionCondition: { + #valueCondition | #rangeCondition | #regexCondition | #miscCondition +} + +// Action kind-specific specs +#callbackActionSpec: { + eventName: strings.MinRunes(1) +} + +#webhookActionSpec: { + url: strings.MinRunes(1) + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" + headers?: { + [string]: string + } + rateLimit?: #rateLimitConfig +} + +// Available action icons +#actionIcon: "send" | "delete" | "copy" | "download" | "refresh" | "play" | "stop" | "check" | "close" | "warning" + +// Selection action definition +#selectionAction: { + id: strings.MinRunes(1) + label: strings.MinRunes(1) + icon?: #actionIcon + kind: "callback" | "webhook" + spec: #callbackActionSpec | #webhookActionSpec + + // Payload transformation options (mutually exclusive) + payloadTemplate?: string + fieldMapping?: [...#fieldMapping] + + // Execution options + bulkMode?: bool + + // Confirmation dialog options + requireConfirmation?: bool + confirmationMessage?: string + + // Conditional visibility (action shown if condition matches ANY selected row) + condition?: #actionCondition +} diff --git a/scripts/link-with-perses/link-with-perses.sh b/scripts/link-with-perses/link-with-perses.sh index ba4bd40..be8163f 100755 --- a/scripts/link-with-perses/link-with-perses.sh +++ b/scripts/link-with-perses/link-with-perses.sh @@ -4,7 +4,7 @@ # link-with-perses.sh # ============================================================================= # This script manages file-based npm dependencies between this project (shared) -# and an external project (e.g., perses). It allows developers to link local +# and external projects (perses or plugins). It allows developers to link local # workspace packages as file dependencies for local development. # # Usage: @@ -15,10 +15,15 @@ # unlink - Restore original npm package versions in the external project # status - Show current link status of workspace packages # -# Options: -# -p, --path Path to the perses repository root (default: ../perses) -# The app is expected at /ui/app +# Target Options (mutually exclusive): +# --perses [path] Link to perses/ui/app (default: ../perses) +# --plugins [path] Link to plugins root (default: ../plugins) +# +# Other Options: # -h, --help Show this help message +# -d, --debug Show detailed error output when commands fail +# +# If no target is specified, defaults to --perses for backward compatibility. # ============================================================================= set -e @@ -34,11 +39,23 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Get the root of the shared project (two levels up from scripts/link-with-perses/) SHARED_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -# Default perses repository root (sibling directory) -DEFAULT_PERSES_ROOT="$(cd "$SHARED_ROOT/.." && pwd)/perses" +# Parent directory for sibling projects +PARENT_DIR="$(cd "$SHARED_ROOT/.." && pwd)" + +# Default paths for each target +DEFAULT_PERSES_ROOT="$PARENT_DIR/perses" +DEFAULT_PLUGINS_ROOT="$PARENT_DIR/plugins" + +# Target mode: "perses" (default) or "plugins" +TARGET_MODE="perses" -# Relative path from perses root to the app -APP_RELATIVE_PATH="ui/app" +# Target-specific configuration (set by configure_target_mode) +PACKAGE_JSON_RELATIVE_PATH="" +LOCK_RELATIVE_PATH="" +WORKSPACE_FLAG="" +DEP_SECTION="" +BACKUP_FILE="" +BACKUP_LOCK_FILE="" # Workspace packages defined in this project WORKSPACE_PACKAGES=( @@ -48,13 +65,35 @@ WORKSPACE_PACKAGES=( "@perses-dev/explore:explore" ) -# Backup file for storing original versions -BACKUP_FILE=".perses-shared-link-bk.json" -BACKUP_LOCK_FILE=".perses-shared-link-lock-bk.json" - # Debug flag (set via --debug flag) DEBUG=false +# ============================================================================= +# Target Configuration +# ============================================================================= + +# Configure paths and settings based on target mode +configure_target_mode() { + case "$TARGET_MODE" in + perses) + PACKAGE_JSON_RELATIVE_PATH="ui/app" + LOCK_RELATIVE_PATH="ui" + WORKSPACE_FLAG="-w app" + DEP_SECTION="dependencies" + BACKUP_FILE=".perses-shared-link-bk.json" + BACKUP_LOCK_FILE=".perses-shared-link-lock-bk.json" + ;; + plugins) + PACKAGE_JSON_RELATIVE_PATH="." + LOCK_RELATIVE_PATH="." + WORKSPACE_FLAG="" + DEP_SECTION="devDependencies" + BACKUP_FILE=".plugins-shared-link-bk.json" + BACKUP_LOCK_FILE=".plugins-shared-link-lock-bk.json" + ;; + esac +} + # ============================================================================= # Helper Functions # ============================================================================= @@ -63,21 +102,29 @@ print_help() { echo "Usage: $0 [options]" echo "" echo "Commands:" - echo " link Install workspace packages as file references in the perses app" - echo " unlink Restore original npm package versions in the perses app" + echo " link Install workspace packages as file references in the target project" + echo " unlink Restore original npm package versions in the target project" echo " status Show current link status of workspace packages" echo "" - echo "Options:" - echo " -p, --path Path to the perses repository root (default: ../perses)" - echo " The app is expected at /$APP_RELATIVE_PATH" + echo "Target Options (mutually exclusive):" + echo " -p, --perses [path] Link to perses/ui/app (default: ../perses)" + echo " --plugins [path] Link to plugins root (default: ../plugins)" + echo "" + echo "Other Options:" echo " -d, --debug Show detailed error output when commands fail" echo " -h, --help Show this help message" echo "" + echo "If no target is specified, defaults to --perses for backward compatibility." + echo "" echo "Examples:" - echo " $0 link # Link with default perses at ../perses" - echo " $0 link -p ~/projects/perses # Link with custom perses location" - echo " $0 unlink # Unlink and restore original versions" - echo " $0 status # Check current link status" + echo " $0 link # Link with perses at ../perses (default)" + echo " $0 link --perses # Same as above, explicit" + echo " $0 link --perses ~/projects/perses # Link with custom perses location" + echo " $0 link --plugins # Link with plugins at ../plugins" + echo " $0 link --plugins ~/projects/plugins # Link with custom plugins location" + echo " $0 unlink # Unlink perses (default)" + echo " $0 unlink --plugins # Unlink plugins" + echo " $0 status --plugins # Check plugins link status" } log_warning() { @@ -120,11 +167,17 @@ package_exists_in_external() { } # Get current version of a package in external project +# Prioritizes the target's dependency section based on DEP_SECTION get_current_version() { local package_name="$1" local external_package_json="$2" - local version=$(jq -r ".dependencies[\"$package_name\"] // .devDependencies[\"$package_name\"] // empty" "$external_package_json") + local version + if [[ "$DEP_SECTION" == "devDependencies" ]]; then + version=$(jq -r ".devDependencies[\"$package_name\"] // .dependencies[\"$package_name\"] // empty" "$external_package_json") + else + version=$(jq -r ".dependencies[\"$package_name\"] // .devDependencies[\"$package_name\"] // empty" "$external_package_json") + fi echo "$version" } @@ -191,9 +244,12 @@ build_if_needed() { # ============================================================================= show_status() { - local perses_root="$1" - local app_path="$perses_root/$APP_RELATIVE_PATH" - local external_package_json="$app_path/package.json" + local target_root="$1" + local package_json_path="$target_root" + if [[ "$PACKAGE_JSON_RELATIVE_PATH" != "." ]]; then + package_json_path="$target_root/$PACKAGE_JSON_RELATIVE_PATH" + fi + local external_package_json="$package_json_path/package.json" if [[ ! -f "$external_package_json" ]]; then log_error "package.json not found at: $external_package_json" @@ -204,7 +260,7 @@ show_status() { local total_count=0 echo "" - echo "Status ($app_path):" + echo "Status ($package_json_path) [target: $TARGET_MODE]:" for workspace in "${WORKSPACE_PACKAGES[@]}"; do local package_name=$(get_package_name "$workspace") @@ -238,13 +294,13 @@ show_status() { # ============================================================================= do_link() { - local perses_root="$1" - local app_path="$perses_root/$APP_RELATIVE_PATH" - local ui_path="$perses_root/ui" - local external_package_json="$app_path/package.json" - local external_package_lock="$ui_path/package-lock.json" - local backup_path="$app_path/$BACKUP_FILE" - local backup_lock_path="$ui_path/$BACKUP_LOCK_FILE" + local target_root="$1" + local package_json_path="$target_root/$PACKAGE_JSON_RELATIVE_PATH" + local lock_path="$target_root/$LOCK_RELATIVE_PATH" + local external_package_json="$package_json_path/package.json" + local external_package_lock="$lock_path/package-lock.json" + local backup_path="$package_json_path/$BACKUP_FILE" + local backup_lock_path="$lock_path/$BACKUP_LOCK_FILE" if [[ ! -f "$external_package_json" ]]; then log_error "package.json not found at: $external_package_json" @@ -277,7 +333,7 @@ do_link() { # Link each workspace package local already_linked=0 - echo "Linking packages to $app_path..." + echo "Linking packages to $package_json_path [$TARGET_MODE]..." # Temporarily disable exit on error to handle npm install failures gracefully. # npm install may fail (non-zero exit code) but still succeed in linking the package. @@ -306,7 +362,15 @@ do_link() { # Capture both stdout and stderr for debugging local npm_output - npm_output=$(cd "$ui_path" && npm install "file:$npm_package_path" --save -w app 2>&1) + # Build npm install command based on target mode + local npm_cmd="npm install \"file:$npm_package_path\" --save" + if [[ -n "$WORKSPACE_FLAG" ]]; then + npm_cmd="$npm_cmd $WORKSPACE_FLAG" + fi + if [[ "$DEP_SECTION" == "devDependencies" ]]; then + npm_cmd="$npm_cmd --save-dev" + fi + npm_output=$(cd "$lock_path" && eval "$npm_cmd" 2>&1) local npm_exit_code=$? # Check if package was actually linked (npm on Windows may return error but still succeed) @@ -322,8 +386,8 @@ do_link() { log_error "npm install failed with exit code $npm_exit_code" echo " Package path: $package_path" echo " NPM package path: $npm_package_path" - echo " UI path: $ui_path" - echo " Command: npm install \"file:$npm_package_path\" --save -w app" + echo " Lock path: $lock_path" + echo " Command: $npm_cmd" echo " Output:" echo "$npm_output" | sed 's/^/ /' echo "" @@ -352,15 +416,19 @@ do_link() { if [[ $failed_count -gt 0 && "$DEBUG" != true ]]; then echo "" log_warning "Some packages failed to link. Run with --debug flag for detailed error information:" - echo " $0 link --debug" + echo " $0 link --$TARGET_MODE --debug" fi # Show status - show_status "$perses_root" + show_status "$target_root" # Show next steps on success if [[ $failed_count -eq 0 ]]; then - printf "\nNow you can start the app dev server, in shared mode, from Perses:\n\ncd %s\nnpm run start:shared\n" "$app_path" + if [[ "$TARGET_MODE" == "perses" ]]; then + printf "\nNow you can start the app dev server, in shared mode, from Perses:\n\ncd %s\nnpm run start:shared\n" "$package_json_path" + else + printf "\nPackages linked successfully to plugins.\n" + fi fi } @@ -369,13 +437,13 @@ do_link() { # ============================================================================= do_unlink() { - local perses_root="$1" - local app_path="$perses_root/$APP_RELATIVE_PATH" - local ui_path="$perses_root/ui" - local external_package_json="$app_path/package.json" - local external_package_lock="$ui_path/package-lock.json" - local backup_path="$app_path/$BACKUP_FILE" - local backup_lock_path="$ui_path/$BACKUP_LOCK_FILE" + local target_root="$1" + local package_json_path="$target_root/$PACKAGE_JSON_RELATIVE_PATH" + local lock_path="$target_root/$LOCK_RELATIVE_PATH" + local external_package_json="$package_json_path/package.json" + local external_package_lock="$lock_path/package-lock.json" + local backup_path="$package_json_path/$BACKUP_FILE" + local backup_lock_path="$lock_path/$BACKUP_LOCK_FILE" if [[ ! -f "$external_package_json" ]]; then log_error "package.json not found at: $external_package_json" @@ -384,7 +452,7 @@ do_unlink() { if [[ ! -f "$backup_path" ]]; then log_error "No backup file found. Cannot restore original versions." - echo " Manually restore: cd $app_path && npm install @perses-dev/components@ ..." + echo " Manually restore: cd $package_json_path && npm install @perses-dev/components@ ..." exit 1 fi @@ -392,8 +460,8 @@ do_unlink() { local backup_count=$(jq 'keys | length' "$backup_path" 2>/dev/null) if [[ -z "$backup_count" || "$backup_count" -eq 0 ]]; then log_error "Backup file is empty. Cannot restore original versions." - echo " Manually restore: cd $app_path && npm install @perses-dev/components@ ..." - echo " Or: rm $backup_path && cd $app_path && npm install" + echo " Manually restore: cd $package_json_path && npm install @perses-dev/components@ ..." + echo " Or: rm $backup_path && cd $package_json_path && npm install" exit 1 fi @@ -422,8 +490,9 @@ do_unlink() { echo -n " Restoring $package_name@$original_version... " # Update package.json directly using jq instead of npm install + # Use the correct dependency section based on target mode local tmp=$(mktemp) - if jq ".dependencies[\"$package_name\"] = \"$original_version\"" "$external_package_json" > "$tmp" 2>/dev/null; then + if jq ".$DEP_SECTION[\"$package_name\"] = \"$original_version\"" "$external_package_json" > "$tmp" 2>/dev/null; then mv "$tmp" "$external_package_json" printf "${GREEN}done${NC}\n" unlinked_any=true @@ -450,14 +519,14 @@ do_unlink() { if [[ "$all_unlinked" == true ]]; then rm -f "$backup_path" - # Restore package-lock.json if backup exists and run npm install from ui root + # Restore package-lock.json if backup exists and run npm install from lock root if [[ -f "$backup_lock_path" ]]; then echo " Restoring package-lock.json..." cp "$backup_lock_path" "$external_package_lock" rm -f "$backup_lock_path" fi echo " Running npm install from workspace root..." - (cd "$ui_path" && npm install --silent 2>/dev/null) + (cd "$lock_path" && npm install --silent 2>/dev/null) fi fi @@ -465,11 +534,11 @@ do_unlink() { if [[ ${#missing_backups[@]} -gt 0 ]]; then echo "" log_warning "Missing backup for: ${missing_backups[*]}" - echo " Manually restore: cd $app_path && npm install @" + echo " Manually restore: cd $package_json_path && npm install @" fi # Show status - show_status "$perses_root" + show_status "$target_root" } # ============================================================================= @@ -480,7 +549,8 @@ main() { check_jq local command="" - local perses_root="$DEFAULT_PERSES_ROOT" + local target_root="" + local target_specified=false # Parse arguments while [[ $# -gt 0 ]]; do @@ -493,14 +563,37 @@ main() { DEBUG=true shift ;; - -p|--path) - if [[ -n "$2" && ! "$2" =~ ^- ]]; then - perses_root="$2" + -p|--perses) + if [[ "$target_specified" == true ]]; then + log_error "Cannot specify both --perses and --plugins" + exit 1 + fi + TARGET_MODE="perses" + target_specified=true + # Check if next argument is a path (not a flag or command) + if [[ -n "$2" && ! "$2" =~ ^- && ! "$2" =~ ^(link|unlink|status)$ ]]; then + target_root="$2" shift 2 else - log_error "Option -p requires a path argument" + target_root="$DEFAULT_PERSES_ROOT" + shift + fi + ;; + --plugins) + if [[ "$target_specified" == true ]]; then + log_error "Cannot specify both --perses and --plugins" exit 1 fi + TARGET_MODE="plugins" + target_specified=true + # Check if next argument is a path (not a flag or command) + if [[ -n "$2" && ! "$2" =~ ^- && ! "$2" =~ ^(link|unlink|status)$ ]]; then + target_root="$2" + shift 2 + else + target_root="$DEFAULT_PLUGINS_ROOT" + shift + fi ;; -h|--help) print_help @@ -514,38 +607,47 @@ main() { esac done + # Default to perses if no target specified + if [[ "$target_specified" == false ]]; then + TARGET_MODE="perses" + target_root="$DEFAULT_PERSES_ROOT" + fi + + # Configure paths and settings based on target mode + configure_target_mode + # Resolve to absolute path - if [[ ! "$perses_root" = /* ]]; then - perses_root="$(cd "$SHARED_ROOT" && cd "$perses_root" 2>/dev/null && pwd)" || { - log_error "Cannot resolve path: $perses_root" + if [[ ! "$target_root" = /* ]]; then + target_root="$(cd "$SHARED_ROOT" && cd "$target_root" 2>/dev/null && pwd)" || { + log_error "Cannot resolve path: $target_root" exit 1 } fi - # Check if perses root exists - if [[ ! -d "$perses_root" ]]; then - log_error "Perses repository not found at: $perses_root" + # Check if target root exists + if [[ ! -d "$target_root" ]]; then + log_error "Target repository not found at: $target_root" exit 1 fi - # Check if app directory exists - local app_path="$perses_root/$APP_RELATIVE_PATH" - if [[ ! -d "$app_path" ]]; then - log_error "Perses app directory not found at: $app_path" - log_error "Make sure the perses repository has the expected structure." + # Check if package.json directory exists + local package_json_path="$target_root/$PACKAGE_JSON_RELATIVE_PATH" + if [[ ! -d "$package_json_path" ]]; then + log_error "Target directory not found at: $package_json_path" + log_error "Make sure the repository has the expected structure." exit 1 fi # Execute command (default to 'link' if no command specified) case "$command" in link|"") - do_link "$perses_root" + do_link "$target_root" ;; unlink) - do_unlink "$perses_root" + do_unlink "$target_root" ;; status) - show_status "$perses_root" + show_status "$target_root" ;; *) log_error "Unknown command: $command"