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 (
+
+ );
+}
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">
+
+
+
+ ))}
+ }
+ onClick={handleAddMapping}
+ sx={{ alignSelf: 'flex-start' }}
+ >
+ Add Field Mapping
+
+
+ );
+}
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}
+ />
+ ))
+ )}
+
+ } sx={{ marginTop: 1 }} onClick={handleActionAdd}>
+ Add Selection Action
+
+
+ );
+}
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),
+ }}
+ />
+ )}
+
+
+
+
+
+
+
+ >
+ );
+}
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"