diff --git a/components/src/Table/Table.tsx b/components/src/Table/Table.tsx index ea8eeb1..2a0d1ee 100644 --- a/components/src/Table/Table.tsx +++ b/components/src/Table/Table.tsx @@ -11,23 +11,23 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { Stack, useTheme } from '@mui/material'; import { - useReactTable, - getCoreRowModel, ColumnDef, - RowSelectionState, OnChangeFn, Row, - Table as TanstackTable, + RowSelectionState, SortingState, - getSortedRowModel, + Table as TanstackTable, + getCoreRowModel, getPaginationRowModel, + getSortedRowModel, + useReactTable, } from '@tanstack/react-table'; -import { useTheme } from '@mui/material'; import { ReactElement, useCallback, useMemo } from 'react'; -import { VirtualizedTable } from './VirtualizedTable'; import { TableCheckbox } from './TableCheckbox'; -import { TableProps, persesColumnsToTanstackColumns, DEFAULT_COLUMN_WIDTH } from './model/table-model'; +import { VirtualizedTable } from './VirtualizedTable'; +import { DEFAULT_COLUMN_WIDTH, TableProps, persesColumnsToTanstackColumns } from './model/table-model'; const DEFAULT_GET_ROW_ID = (data: unknown, index: number): string => { return `${index}`; @@ -59,6 +59,8 @@ export function Table({ getRowId = DEFAULT_GET_ROW_ID, rowSelection = DEFAULT_ROW_SELECTION, sorting = DEFAULT_SORTING, + getItemActions, + hasItemActions, pagination, onPaginationChange, rowSelectionVariant = 'standard', @@ -111,6 +113,21 @@ export function Table({ onSortingChange?.(newSorting); }; + const actionsColumn: ColumnDef = useMemo(() => { + return { + id: 'itemActions', + header: 'Actions', + cell: ({ row }): ReactElement => { + return ( + + {getItemActions?.({ id: row.id, data: row.original })} + + ); + }, + enableSorting: false, + }; + }, [getItemActions]); + const checkboxColumn: ColumnDef = useMemo(() => { return { id: 'checkboxRowSelect', @@ -146,12 +163,16 @@ export function Table({ const tableColumns: Array> = useMemo(() => { const initTableColumns = persesColumnsToTanstackColumns(columns); + if (hasItemActions) { + initTableColumns.unshift(actionsColumn); + } + if (checkboxSelection) { initTableColumns.unshift(checkboxColumn); } return initTableColumns; - }, [checkboxColumn, checkboxSelection, columns]); + }, [checkboxColumn, checkboxSelection, columns, hasItemActions, actionsColumn]); const table = useReactTable({ data, diff --git a/components/src/Table/TableCell.tsx b/components/src/Table/TableCell.tsx index 68ecd56..99a8b32 100644 --- a/components/src/Table/TableCell.tsx +++ b/components/src/Table/TableCell.tsx @@ -12,14 +12,15 @@ // limitations under the License. import { + Box, + Link, TableCell as MuiTableCell, - styled, TableCellProps as MuiTableCellProps, - Box, + styled, useTheme, - Link, } from '@mui/material'; import { ReactElement, useEffect, useMemo, useRef } from 'react'; +import { hasDataFieldPatterns, replaceDataFields } from '../utils/data-field-interpolation'; import { DataLink, TableCellAlignment, TableDensity, getTableCellLayout } from './model/table-model'; const StyledMuiTableCell = styled(MuiTableCell)(({ theme }) => ({ @@ -129,15 +130,11 @@ export function TableCell({ const modifiedDataLink = useMemo((): DataLink | undefined => { if (!dataLink) return undefined; - let url = dataLink.url; - const regex = /\$\{__data\.fields\["(.+)?"\]\}/; - if (adjacentCellsValuesMap && regex.test(dataLink.url)) { - Object.entries(adjacentCellsValuesMap).forEach(([key, value]) => { - const placeholder = `\${__data.fields["${key}"]}`; - url = url.replaceAll(placeholder, encodeURIComponent(value)); - }); + if (adjacentCellsValuesMap && hasDataFieldPatterns(dataLink.url)) { + const { text } = replaceDataFields(dataLink.url, adjacentCellsValuesMap, { urlEncode: true }); + return { ...dataLink, url: text }; } - return { ...dataLink, url }; + return dataLink; }, [dataLink, adjacentCellsValuesMap]); return ( diff --git a/components/src/Table/model/table-model.ts b/components/src/Table/model/table-model.ts index 4e0de99..e2aa3b5 100644 --- a/components/src/Table/model/table-model.ts +++ b/components/src/Table/model/table-model.ts @@ -23,7 +23,7 @@ import { RowSelectionState, SortingState, } from '@tanstack/react-table'; -import { CSSProperties } from 'react'; +import { CSSProperties, ReactNode } from 'react'; export const DEFAULT_COLUMN_WIDTH = 150; export const DEFAULT_COLUMN_HEIGHT = 40; @@ -168,6 +168,16 @@ export interface TableProps { * is enabled. If not set, a default color is used. */ getCheckboxColor?: (rowData: TableData) => string; + + /** + * Item actions to render for each row. + */ + getItemActions?: ({ id, data }: { id: string; data: unknown }) => ReactNode[]; + + /** + * Item actions should be created + */ + hasItemActions?: boolean; } function calculateTableCellHeight(lineHeight: CSSProperties['lineHeight'], paddingY: string): number { diff --git a/components/src/context/ItemActionsProvider.test.tsx b/components/src/context/ItemActionsProvider.test.tsx new file mode 100644 index 0000000..8f3bbb2 --- /dev/null +++ b/components/src/context/ItemActionsProvider.test.tsx @@ -0,0 +1,458 @@ +// 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 { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ReactNode } from 'react'; +import { ItemActionsProvider, useItemActions } from './ItemActionsProvider'; + +// Test component that exposes action state and operations +function TestConsumer({ + onRender, +}: { + onRender?: (state: ReturnType>) => void; +}): JSX.Element { + const actions = useItemActions(); + onRender?.(actions); + + const deleteStatus = actions.actionStatuses.get('delete'); + const deleteItemStatus = deleteStatus?.itemStatuses?.get('item-1'); + + return ( +
+
{String(actions.hasContext)}
+
{actions.actionStatuses.size}
+
{String(deleteStatus?.loading ?? false)}
+
{deleteStatus?.error?.message ?? ''}
+
{String(deleteStatus?.success ?? false)}
+
{String(deleteItemStatus?.loading ?? false)}
+
{deleteItemStatus?.error?.message ?? ''}
+
{String(deleteItemStatus?.success ?? false)}
+ + + + + + + + +
+ ); +} + +describe('ItemActionsProvider', () => { + describe('without provider', () => { + it('should return hasContext as false when used outside provider', () => { + render(); + + expect(screen.getByTestId('has-context')).toHaveTextContent('false'); + expect(screen.getByTestId('action-count')).toHaveTextContent('0'); + }); + + it('should have no-op functions when used outside provider', () => { + render(); + + // These should not throw and should not change state + userEvent.click(screen.getByTestId('set-action-loading')); + userEvent.click(screen.getByTestId('set-item-loading')); + userEvent.click(screen.getByTestId('clear-action')); + + expect(screen.getByTestId('action-count')).toHaveTextContent('0'); + expect(screen.getByTestId('delete-loading')).toHaveTextContent('false'); + }); + }); + + const renderWithProvider = (children: ReactNode): ReturnType => { + return render({children}); + }; + + describe('with provider', () => { + it('should return hasContext as true when used inside provider', () => { + renderWithProvider(); + + expect(screen.getByTestId('has-context')).toHaveTextContent('true'); + }); + + it('should start with empty action statuses', () => { + renderWithProvider(); + + expect(screen.getByTestId('action-count')).toHaveTextContent('0'); + expect(screen.getByTestId('delete-loading')).toHaveTextContent('false'); + expect(screen.getByTestId('delete-success')).toHaveTextContent('false'); + }); + + describe('setActionStatus - action-level', () => { + it('should set loading state for action', async () => { + renderWithProvider(); + + userEvent.click(screen.getByTestId('set-action-loading')); + + await waitFor(() => { + expect(screen.getByTestId('delete-loading')).toHaveTextContent('true'); + }); + expect(screen.getByTestId('action-count')).toHaveTextContent('1'); + }); + + it('should set success state for action', async () => { + renderWithProvider(); + + userEvent.click(screen.getByTestId('set-action-success')); + + await waitFor(() => { + expect(screen.getByTestId('delete-success')).toHaveTextContent('true'); + }); + expect(screen.getByTestId('delete-loading')).toHaveTextContent('false'); + }); + + it('should set error state for action', async () => { + renderWithProvider(); + + userEvent.click(screen.getByTestId('set-action-error')); + + await waitFor(() => { + expect(screen.getByTestId('delete-error')).toHaveTextContent('Delete failed'); + }); + expect(screen.getByTestId('delete-loading')).toHaveTextContent('false'); + }); + + it('should update existing action status', async () => { + renderWithProvider(); + + userEvent.click(screen.getByTestId('set-action-loading')); + await waitFor(() => { + expect(screen.getByTestId('delete-loading')).toHaveTextContent('true'); + }); + + userEvent.click(screen.getByTestId('set-action-success')); + await waitFor(() => { + expect(screen.getByTestId('delete-success')).toHaveTextContent('true'); + }); + expect(screen.getByTestId('delete-loading')).toHaveTextContent('false'); + }); + }); + + describe('setActionStatus - item-level', () => { + it('should set loading state for item', async () => { + renderWithProvider(); + + userEvent.click(screen.getByTestId('set-item-loading')); + + await waitFor(() => { + expect(screen.getByTestId('delete-item-1-loading')).toHaveTextContent('true'); + }); + expect(screen.getByTestId('action-count')).toHaveTextContent('1'); + }); + + it('should set success state for item', async () => { + renderWithProvider(); + + userEvent.click(screen.getByTestId('set-item-success')); + + await waitFor(() => { + expect(screen.getByTestId('delete-item-1-success')).toHaveTextContent('true'); + }); + expect(screen.getByTestId('delete-item-1-loading')).toHaveTextContent('false'); + }); + + it('should set error state for item', async () => { + renderWithProvider(); + + userEvent.click(screen.getByTestId('set-item-error')); + + await waitFor(() => { + expect(screen.getByTestId('delete-item-1-error')).toHaveTextContent('Item failed'); + }); + expect(screen.getByTestId('delete-item-1-loading')).toHaveTextContent('false'); + }); + + it('should not affect action-level status when setting item status', async () => { + renderWithProvider(); + + userEvent.click(screen.getByTestId('set-action-loading')); + await waitFor(() => { + expect(screen.getByTestId('delete-loading')).toHaveTextContent('true'); + }); + + userEvent.click(screen.getByTestId('set-item-success')); + await waitFor(() => { + expect(screen.getByTestId('delete-item-1-success')).toHaveTextContent('true'); + }); + + // Action-level loading should still be true + expect(screen.getByTestId('delete-loading')).toHaveTextContent('true'); + }); + + it('should maintain multiple item statuses', async () => { + let capturedState: ReturnType> | undefined; + renderWithProvider( + { + capturedState = state; + }} + /> + ); + + userEvent.click(screen.getByTestId('set-item-loading')); + await waitFor(() => { + expect(screen.getByTestId('delete-item-1-loading')).toHaveTextContent('true'); + }); + + // Manually set another item status + capturedState?.setActionStatus('delete', { loading: true }, 'item-2'); + + await waitFor(() => { + const deleteStatus = capturedState?.actionStatuses.get('delete'); + expect(deleteStatus?.itemStatuses?.size).toBe(2); + expect(deleteStatus?.itemStatuses?.get('item-1')?.loading).toBe(true); + expect(deleteStatus?.itemStatuses?.get('item-2')?.loading).toBe(true); + }); + }); + }); + + describe('clearActionStatus', () => { + it('should clear specific action status', async () => { + renderWithProvider(); + + userEvent.click(screen.getByTestId('set-action-loading')); + await waitFor(() => { + expect(screen.getByTestId('delete-loading')).toHaveTextContent('true'); + }); + expect(screen.getByTestId('action-count')).toHaveTextContent('1'); + + userEvent.click(screen.getByTestId('clear-action')); + + await waitFor(() => { + expect(screen.getByTestId('action-count')).toHaveTextContent('0'); + }); + expect(screen.getByTestId('delete-loading')).toHaveTextContent('false'); + }); + + it('should clear all action statuses', async () => { + let capturedState: ReturnType> | undefined; + renderWithProvider( + { + capturedState = state; + }} + /> + ); + + userEvent.click(screen.getByTestId('set-action-loading')); + await waitFor(() => { + expect(screen.getByTestId('delete-loading')).toHaveTextContent('true'); + }); + + // Add another action + capturedState?.setActionStatus('update', { loading: true }); + await waitFor(() => { + expect(capturedState?.actionStatuses.size).toBe(2); + }); + + userEvent.click(screen.getByTestId('clear-all')); + + await waitFor(() => { + expect(screen.getByTestId('action-count')).toHaveTextContent('0'); + }); + }); + + it('should not fail when clearing non-existent action', () => { + renderWithProvider(); + + expect(screen.getByTestId('action-count')).toHaveTextContent('0'); + + userEvent.click(screen.getByTestId('clear-action')); + + expect(screen.getByTestId('action-count')).toHaveTextContent('0'); + }); + }); + + describe('actionStatuses map', () => { + it('should contain action status with item statuses', async () => { + let capturedState: ReturnType> | undefined; + renderWithProvider( + { + capturedState = state; + }} + /> + ); + + userEvent.click(screen.getByTestId('set-item-success')); + + await waitFor(() => { + const deleteStatus = capturedState?.actionStatuses.get('delete'); + expect(deleteStatus?.itemStatuses?.get('item-1')).toEqual({ + loading: false, + success: true, + }); + }); + }); + + it('should handle multiple actions independently', async () => { + let capturedState: ReturnType> | undefined; + renderWithProvider( + { + capturedState = state; + }} + /> + ); + + userEvent.click(screen.getByTestId('set-action-loading')); + await waitFor(() => { + expect(screen.getByTestId('delete-loading')).toHaveTextContent('true'); + }); + + capturedState?.setActionStatus('update', { loading: false, success: true }); + + await waitFor(() => { + expect(capturedState?.actionStatuses.size).toBe(2); + expect(capturedState?.actionStatuses.get('delete')?.loading).toBe(true); + expect(capturedState?.actionStatuses.get('update')?.success).toBe(true); + }); + }); + }); + }); + + describe('multiple consumers', () => { + it('should share action state between consumers', async () => { + function Consumer1(): JSX.Element { + const actions = useItemActions(); + const deleteStatus = actions.actionStatuses.get('delete'); + + return ( +
+
{String(deleteStatus?.loading ?? false)}
+ +
+ ); + } + + function Consumer2(): JSX.Element { + const actions = useItemActions(); + const deleteStatus = actions.actionStatuses.get('delete'); + + return ( +
+
{String(deleteStatus?.loading ?? false)}
+
+ ); + } + + render( + + + + + ); + + expect(screen.getByTestId('consumer1-loading')).toHaveTextContent('false'); + expect(screen.getByTestId('consumer2-loading')).toHaveTextContent('false'); + + userEvent.click(screen.getByTestId('consumer1-set-loading')); + + await waitFor(() => { + expect(screen.getByTestId('consumer1-loading')).toHaveTextContent('true'); + expect(screen.getByTestId('consumer2-loading')).toHaveTextContent('true'); + }); + }); + }); + + describe('complex scenarios', () => { + it('should handle mixed action and item statuses', async () => { + let capturedState: ReturnType> | undefined; + renderWithProvider( + { + capturedState = state; + }} + /> + ); + + // Set action-level loading + userEvent.click(screen.getByTestId('set-action-loading')); + await waitFor(() => { + expect(screen.getByTestId('delete-loading')).toHaveTextContent('true'); + }); + + // Set item-level success + userEvent.click(screen.getByTestId('set-item-success')); + await waitFor(() => { + expect(screen.getByTestId('delete-item-1-success')).toHaveTextContent('true'); + }); + + // Both should coexist + const deleteStatus = capturedState?.actionStatuses.get('delete'); + expect(deleteStatus?.loading).toBe(true); + expect(deleteStatus?.itemStatuses?.get('item-1')?.success).toBe(true); + }); + + it('should handle updating item status multiple times', async () => { + renderWithProvider(); + + userEvent.click(screen.getByTestId('set-item-loading')); + await waitFor(() => { + expect(screen.getByTestId('delete-item-1-loading')).toHaveTextContent('true'); + }); + + userEvent.click(screen.getByTestId('set-item-success')); + await waitFor(() => { + expect(screen.getByTestId('delete-item-1-success')).toHaveTextContent('true'); + }); + expect(screen.getByTestId('delete-item-1-loading')).toHaveTextContent('false'); + + userEvent.click(screen.getByTestId('set-item-error')); + await waitFor(() => { + expect(screen.getByTestId('delete-item-1-error')).toHaveTextContent('Item failed'); + }); + // Success should be preserved unless explicitly cleared + expect(screen.getByTestId('delete-item-1-success')).toHaveTextContent('true'); + }); + }); +}); diff --git a/components/src/context/ItemActionsProvider.tsx b/components/src/context/ItemActionsProvider.tsx new file mode 100644 index 0000000..4b6031a --- /dev/null +++ b/components/src/context/ItemActionsProvider.tsx @@ -0,0 +1,107 @@ +// 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, ReactElement, ReactNode, useCallback, useContext, useMemo, useState } from 'react'; + +export interface ItemActionStatus { + loading: boolean; + error?: Error; + success?: boolean; +} + +export interface ActionStatus { + loading: boolean; + error?: Error; + success?: boolean; + itemStatuses?: Map; +} + +export interface ActionState { + actionStatuses: Map; + setActionStatus: (actionName: string, status: Partial, itemId?: Id) => void; + clearActionStatus: (actionName?: string) => void; +} + +export interface ItemActionsProviderProps { + children: ReactNode; +} + +const ItemActionsContext = createContext | undefined>(undefined); + +export function ItemActionsProvider({ children }: ItemActionsProviderProps): ReactElement { + const [actionStatuses, setActionStatuses] = useState(new Map()); + + const setActionStatus = useCallback((actionName: string, status: Partial, itemId?: unknown) => { + setActionStatuses((prev) => { + const newMap = new Map(prev); + const existingStatus = newMap.get(actionName) || { loading: false }; + + if (itemId !== undefined) { + // Update item-level status for individual actions + const itemStatuses = new Map(existingStatus.itemStatuses || new Map()); + const existingItemStatus = itemStatuses.get(itemId) || { loading: false }; + itemStatuses.set(itemId, { ...existingItemStatus, ...status } as ItemActionStatus); + newMap.set(actionName, { ...existingStatus, itemStatuses }); + } else { + // Update action-level status for batch actions + newMap.set(actionName, { ...existingStatus, ...status }); + } + + return newMap; + }); + }, []); + + const clearActionStatus = useCallback((actionName?: string) => { + setActionStatuses((prev) => { + if (actionName === undefined) { + return new Map(); + } + if (!prev.has(actionName)) return prev; + const newMap = new Map(prev); + newMap.delete(actionName); + return newMap; + }); + }, []); + + const ctx = useMemo>( + () => ({ + actionStatuses, + setActionStatus, + clearActionStatus, + }), + [actionStatuses, setActionStatus, clearActionStatus] + ); + + return {children}; +} + +const noOp = (): void => {}; +const emptyActionStatuses = new Map(); +const defaultState: ActionState & { hasContext: false } = { + actionStatuses: emptyActionStatuses, + setActionStatus: noOp, + clearActionStatus: noOp, + hasContext: false, +}; + +export function useItemActions(): ActionState & { hasContext: boolean } { + const ctx = useContext(ItemActionsContext); + + const memoizedResult = useMemo(() => { + if (!ctx) return defaultState as ActionState & { hasContext: false }; + + return { ...ctx, hasContext: true } as ActionState & { hasContext: true }; + }, [ctx]); + + return memoizedResult; +} diff --git a/components/src/context/SelectionProvider.test.tsx b/components/src/context/SelectionProvider.test.tsx new file mode 100644 index 0000000..8c6c6a8 --- /dev/null +++ b/components/src/context/SelectionProvider.test.tsx @@ -0,0 +1,313 @@ +// 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 { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ReactNode } from 'react'; +import { SelectionProvider, useSelection } from './SelectionProvider'; + +interface TestItem { + id: string; + name: string; +} + +// Test component that exposes selection state and actions +function TestConsumer({ + getId, + onRender, +}: { + getId?: (item: TestItem, index: number) => string; + onRender?: (state: ReturnType>) => void; +}): JSX.Element { + const selection = useSelection({ getId }); + onRender?.(selection); + + return ( +
+
{String(selection.hasContext)}
+
{selection.selectionMap.size}
+
{Array.from(selection.selectionMap.keys()).join(',')}
+ + + + + +
{String(selection.isSelected({ id: 'item-1', name: 'Item 1' }, 0))}
+
+ ); +} + +describe('SelectionProvider', () => { + describe('without provider', () => { + it('should return hasContext as false when used outside provider', () => { + render(); + + expect(screen.getByTestId('has-context')).toHaveTextContent('false'); + expect(screen.getByTestId('selection-count')).toHaveTextContent('0'); + }); + + it('should have no-op functions when used outside provider', () => { + render(); + + // These should not throw and should not change state + userEvent.click(screen.getByTestId('toggle-item-1')); + userEvent.click(screen.getByTestId('set-selection')); + userEvent.click(screen.getByTestId('clear-selection')); + + expect(screen.getByTestId('selection-count')).toHaveTextContent('0'); + expect(screen.getByTestId('is-item-1-selected')).toHaveTextContent('false'); + }); + }); + + describe('with provider', () => { + const renderWithProvider = (children: ReactNode): ReturnType => { + return render({children}); + }; + + it('should return hasContext as true when used inside provider', () => { + renderWithProvider(); + + expect(screen.getByTestId('has-context')).toHaveTextContent('true'); + }); + + it('should start with empty selection', () => { + renderWithProvider(); + + expect(screen.getByTestId('selection-count')).toHaveTextContent('0'); + expect(screen.getByTestId('selected-ids')).toHaveTextContent(''); + }); + + describe('toggleSelection', () => { + it('should add item to selection when not selected', () => { + renderWithProvider(); + + userEvent.click(screen.getByTestId('toggle-item-1')); + + expect(screen.getByTestId('selection-count')).toHaveTextContent('1'); + expect(screen.getByTestId('selected-ids')).toHaveTextContent('item-1'); + }); + + it('should remove item from selection when already selected', () => { + renderWithProvider(); + + userEvent.click(screen.getByTestId('toggle-item-1')); + expect(screen.getByTestId('selection-count')).toHaveTextContent('1'); + + userEvent.click(screen.getByTestId('toggle-item-1')); + expect(screen.getByTestId('selection-count')).toHaveTextContent('0'); + expect(screen.getByTestId('selected-ids')).toHaveTextContent(''); + }); + + it('should allow multiple items to be selected', () => { + renderWithProvider(); + + userEvent.click(screen.getByTestId('toggle-item-1')); + userEvent.click(screen.getByTestId('toggle-item-2')); + + expect(screen.getByTestId('selection-count')).toHaveTextContent('2'); + expect(screen.getByTestId('selected-ids')).toHaveTextContent('item-1,item-2'); + }); + }); + + describe('setSelection', () => { + it('should replace entire selection with new items', () => { + renderWithProvider(); + + // First add some items + userEvent.click(screen.getByTestId('toggle-item-1')); + userEvent.click(screen.getByTestId('toggle-item-2')); + expect(screen.getByTestId('selection-count')).toHaveTextContent('2'); + + // Now set a new selection + userEvent.click(screen.getByTestId('set-selection')); + + expect(screen.getByTestId('selection-count')).toHaveTextContent('2'); + expect(screen.getByTestId('selected-ids')).toHaveTextContent('item-3,item-4'); + }); + }); + + describe('removeFromSelection', () => { + it('should remove specific item from selection', () => { + renderWithProvider(); + + userEvent.click(screen.getByTestId('toggle-item-1')); + userEvent.click(screen.getByTestId('toggle-item-2')); + expect(screen.getByTestId('selection-count')).toHaveTextContent('2'); + + userEvent.click(screen.getByTestId('remove-item-1')); + + expect(screen.getByTestId('selection-count')).toHaveTextContent('1'); + expect(screen.getByTestId('selected-ids')).toHaveTextContent('item-2'); + }); + + it('should not change selection when removing non-existent item', () => { + renderWithProvider(); + + userEvent.click(screen.getByTestId('toggle-item-2')); + expect(screen.getByTestId('selection-count')).toHaveTextContent('1'); + + userEvent.click(screen.getByTestId('remove-item-1')); + + expect(screen.getByTestId('selection-count')).toHaveTextContent('1'); + expect(screen.getByTestId('selected-ids')).toHaveTextContent('item-2'); + }); + }); + + describe('clearSelection', () => { + it('should remove all items from selection', () => { + renderWithProvider(); + + userEvent.click(screen.getByTestId('toggle-item-1')); + userEvent.click(screen.getByTestId('toggle-item-2')); + expect(screen.getByTestId('selection-count')).toHaveTextContent('2'); + + userEvent.click(screen.getByTestId('clear-selection')); + + expect(screen.getByTestId('selection-count')).toHaveTextContent('0'); + expect(screen.getByTestId('selected-ids')).toHaveTextContent(''); + }); + }); + + describe('isSelected', () => { + it('should return true when item is selected', () => { + renderWithProvider( item.id} />); + + userEvent.click(screen.getByTestId('toggle-item-1')); + + expect(screen.getByTestId('is-item-1-selected')).toHaveTextContent('true'); + }); + + it('should return false when item is not selected', () => { + renderWithProvider( item.id} />); + + expect(screen.getByTestId('is-item-1-selected')).toHaveTextContent('false'); + }); + + it('should use custom getId function', async () => { + const customGetId = jest.fn((item: TestItem) => item.id); + renderWithProvider(); + + userEvent.click(screen.getByTestId('toggle-item-1')); + + // Check isSelected which uses the getId function + expect(screen.getByTestId('is-item-1-selected')).toHaveTextContent('true'); + }); + + it('should use index as default id when no getId provided', () => { + function IndexBasedConsumer(): JSX.Element { + const selection = useSelection(); + + return ( +
+ +
{String(selection.isSelected({ id: 'a', name: 'A' }, 0))}
+
{String(selection.isSelected({ id: 'b', name: 'B' }, 1))}
+
+ ); + } + + renderWithProvider(); + + expect(screen.getByTestId('is-index-0-selected')).toHaveTextContent('false'); + expect(screen.getByTestId('is-index-1-selected')).toHaveTextContent('false'); + }); + }); + + describe('selectionMap', () => { + it('should contain selected items with their data', () => { + let capturedState: ReturnType> | undefined; + renderWithProvider( + item.id} + onRender={(state) => { + capturedState = state; + }} + /> + ); + + userEvent.click(screen.getByTestId('toggle-item-1')); + + expect(capturedState?.selectionMap.get('item-1')).toEqual({ id: 'item-1', name: 'Item 1' }); + }); + }); + }); + + describe('multiple consumers', () => { + it('should share selection state between consumers', () => { + function Consumer1(): JSX.Element { + const selection = useSelection({ getId: (item) => item.id }); + return ( +
+
{selection.selectionMap.size}
+ +
+ ); + } + + function Consumer2(): JSX.Element { + const selection = useSelection({ getId: (item) => item.id }); + return ( +
+
{selection.selectionMap.size}
+
+ ); + } + + render( + + + + + ); + + expect(screen.getByTestId('consumer1-count')).toHaveTextContent('0'); + expect(screen.getByTestId('consumer2-count')).toHaveTextContent('0'); + + userEvent.click(screen.getByTestId('consumer1-toggle')); + + expect(screen.getByTestId('consumer1-count')).toHaveTextContent('1'); + expect(screen.getByTestId('consumer2-count')).toHaveTextContent('1'); + }); + }); +}); diff --git a/components/src/context/SelectionProvider.tsx b/components/src/context/SelectionProvider.tsx new file mode 100644 index 0000000..7d17bbc --- /dev/null +++ b/components/src/context/SelectionProvider.tsx @@ -0,0 +1,158 @@ +// 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, + ReactElement, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +export type GetIdFn = (item: T, index: number) => Id; + +export interface SelectionState { + selectionMap: Map; + setSelection: (items: Array<{ id: Id; item: T }>) => void; + toggleSelection: (item: T, id: Id) => void; + removeFromSelection: (id: Id) => void; + clearSelection: () => void; + isSelected: (item: T, index: number) => boolean; +} + +export interface SelectionProviderProps { + children: ReactNode; +} + +export interface UseSelectionOptions { + getId?: GetIdFn; +} + +interface InternalState extends SelectionState { + registerGetId: (fn: GetIdFn) => void; +} + +const SelectionContext = createContext | undefined>(undefined); + +const defaultGetId = (_item: unknown, index: number): unknown => index; + +/** + * Provides selection state to descendant components. + */ +export function SelectionProvider({ children }: SelectionProviderProps): ReactElement { + const [selectionMap, setSelectionMap] = useState(new Map()); + const getIdRef = useRef>(defaultGetId); + + // Allows consumers to register their own getId function. by default we use the index. + const registerGetId = useCallback((fn: GetIdFn) => { + getIdRef.current = fn; + }, []); + + const setSelection = useCallback((items: Array<{ id: unknown; item: unknown }>) => { + const newMap = new Map(); + items.forEach(({ item, id }) => newMap.set(id, item)); + setSelectionMap(newMap); + }, []); + + const toggleSelection = useCallback((item: unknown, id: unknown) => { + setSelectionMap((prev) => { + const newMap = new Map(prev); + if (newMap.has(id)) { + newMap.delete(id); + } else { + newMap.set(id, item); + } + return newMap; + }); + }, []); + + const removeFromSelection = useCallback((id: unknown) => { + setSelectionMap((prev) => { + if (!prev.has(id)) return prev; + const newMap = new Map(prev); + newMap.delete(id); + return newMap; + }); + }, []); + + const clearSelection = useCallback(() => setSelectionMap(new Map()), []); + + const isSelected = useCallback( + (item: unknown, index: number) => selectionMap.has(getIdRef.current(item, index)), + [selectionMap] + ); + + const ctx = useMemo>( + () => ({ + selectionMap, + setSelection, + toggleSelection, + removeFromSelection, + clearSelection, + isSelected, + registerGetId, + }), + [selectionMap, setSelection, toggleSelection, removeFromSelection, clearSelection, isSelected, registerGetId] + ); + + return {children}; +} + +/** + * No-op functions and empty map for when there is no context available. + * This allows components to use the hook without crashing given the optional nature of the provider. + */ +const noOp = (): void => {}; +const noOpIsSelected = (): boolean => false; +const emptyMap = new Map(); +const defaultState: SelectionState & { hasContext: false } = { + selectionMap: emptyMap, + setSelection: noOp, + toggleSelection: noOp, + removeFromSelection: noOp, + clearSelection: noOp, + isSelected: noOpIsSelected, + hasContext: false, +}; + +/** + * Hook to access selection state from context. + * If used outside of a SelectionProvider, returns no-op functions and an empty selection map. + * + * @param options Optional configuration for the selection hook. + * @param options.getId Function to get the unique identifier for an item, this allows the selection state to identify items. + */ +export function useSelection( + options?: UseSelectionOptions +): SelectionState & { hasContext: boolean } { + const ctx = useContext(SelectionContext); + + useEffect(() => { + if (ctx && options?.getId) { + ctx.registerGetId(options.getId as GetIdFn); + } + }, [ctx, options?.getId]); + + const memoizedResult = useMemo(() => { + if (!ctx) return defaultState as SelectionState & { hasContext: false }; + + const { registerGetId: _, ...rest } = ctx; + return { ...rest, hasContext: true } as SelectionState & { hasContext: true }; + }, [ctx]); + + return memoizedResult; +} diff --git a/components/src/context/index.ts b/components/src/context/index.ts index fa16ff6..04dc621 100644 --- a/components/src/context/index.ts +++ b/components/src/context/index.ts @@ -12,5 +12,7 @@ // limitations under the License. export * from './ChartsProvider'; +export * from './ItemActionsProvider'; +export * from './SelectionProvider'; export * from './SnackbarProvider'; export * from './TimeZoneProvider'; diff --git a/components/src/utils/data-field-interpolation.test.ts b/components/src/utils/data-field-interpolation.test.ts new file mode 100644 index 0000000..022815a --- /dev/null +++ b/components/src/utils/data-field-interpolation.test.ts @@ -0,0 +1,563 @@ +// 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 { + replaceDataFields, + replaceDataFieldsBatch, + hasBatchPatterns, + hasIndexedPatterns, + hasDataFieldPatterns, + extractFieldNames, +} from './data-field-interpolation'; + +describe('replaceDataFields()', () => { + describe('basic field replacement', () => { + it('replaces single field with URL encoding', () => { + const result = replaceDataFields('https://example.com?name=${__data.fields["name"]}', { name: 'hello world' }); + expect(result.text).toBe('https://example.com?name=hello%20world'); + expect(result.errors).toBeUndefined(); + }); + + it('replaces multiple fields', () => { + const result = replaceDataFields('https://example.com?name=${__data.fields["name"]}&id=${__data.fields["id"]}', { + name: 'test', + id: '123', + }); + expect(result.text).toBe('https://example.com?name=test&id=123'); + expect(result.errors).toBeUndefined(); + }); + + it('handles special characters with URL encoding', () => { + const result = replaceDataFields('${__data.fields["value"]}', { value: 'hello&world=test' }); + expect(result.text).toBe('hello%26world%3Dtest'); + }); + + it('can disable URL encoding', () => { + const result = replaceDataFields('${__data.fields["value"]}', { value: 'hello&world' }, { urlEncode: false }); + expect(result.text).toBe('hello&world'); + }); + }); + + describe('value conversion', () => { + it('converts numbers to strings', () => { + const result = replaceDataFields('${__data.fields["count"]}', { count: 42 }); + expect(result.text).toBe('42'); + }); + + it('converts booleans to strings', () => { + const result = replaceDataFields('${__data.fields["active"]}', { active: true }); + expect(result.text).toBe('true'); + }); + + it('converts objects to JSON strings', () => { + const result = replaceDataFields('${__data.fields["data"]}', { data: { key: 'value' } }); + expect(result.text).toBe('%7B%22key%22%3A%22value%22%7D'); + }); + + it('handles null values as empty string', () => { + const result = replaceDataFields('${__data.fields["value"]}', { value: null }); + expect(result.text).toBe(''); + }); + + it('handles undefined values as empty string', () => { + const result = replaceDataFields('${__data.fields["value"]}', {}); + expect(result.text).toBe(''); + expect(result.errors).toEqual(['Field "value" not found in data']); + }); + }); + + describe('format specifiers', () => { + it('applies csv format', () => { + const result = replaceDataFields('${__data.fields["value"]:csv}', { value: 'test' }); + expect(result.text).toBe('test'); + }); + + it('applies pipe format', () => { + const result = replaceDataFields('${__data.fields["value"]:pipe}', { value: 'test' }); + expect(result.text).toBe('test'); + }); + + it('applies regex format with escaping', () => { + const result = replaceDataFields('${__data.fields["value"]:regex}', { value: 'test.value' }); + expect(result.text).toBe('(test\\.value)'); + }); + + describe('special urlEncode and format handling', () => { + it('should not encode when urlEncode is true and format is RAW', () => { + const item = { foo: 'a b/c?d' }; + const template = '${__data.fields["foo"]:raw}'; + const result = replaceDataFields(template, item, { urlEncode: true }); + expect(result.text).toBe('a b/c?d'); + }); + + it('should avoid double encoding when urlEncode is true and format is queryparam', () => { + const item = { foo: 'a b/c?d' }; + const template = "${__data.fields['foo']:queryparam}"; + const result = replaceDataFields(template, item, { urlEncode: true }); + // Should only encode once, not double encode + expect(result.text).toBe('foo=a%20b%2Fc%3Fd'); + }); + + it('should avoid double encoding when urlEncode is true and format is percentencode', () => { + const item = { foo: 'a b/c?d' }; + const template = '${__data.fields["foo"]:percentencode}'; + const result = replaceDataFields(template, item, { urlEncode: true }); + // Should only encode once, not double encode + expect(result.text).toBe('a%20b%2Fc%3Fd'); + }); + + it('should encode when urlEncode is true and no format is specified', () => { + const item = { foo: 'a b/c?d' }; + const template = '${__data.fields["foo"]}'; + const result = replaceDataFields(template, item, { urlEncode: true }); + expect(result.text).toBe('a%20b%2Fc%3Fd'); + }); + + it('should not encode when urlEncode is false and no format is specified', () => { + const item = { foo: 'a b/c?d' }; + const template = '${__data.fields["foo"]}'; + const result = replaceDataFields(template, item, { urlEncode: false }); + expect(result.text).toBe('a b/c?d'); + }); + }); + it('applies json format', () => { + const result = replaceDataFields('${__data.fields["value"]:json}', { value: 'test' }); + expect(result.text).toBe('["test"]'); + }); + }); + + describe('error handling', () => { + it('reports missing field error', () => { + const result = replaceDataFields('${__data.fields["missing"]}', { other: 'value' }); + expect(result.errors).toEqual(['Field "missing" not found in data']); + }); + + it('reports multiple missing field errors', () => { + const result = replaceDataFields('${__data.fields["missing1"]} ${__data.fields["missing2"]}', { other: 'value' }); + expect(result.errors).toEqual(['Field "missing1" not found in data', 'Field "missing2" not found in data']); + }); + }); + + describe('index, count and complete data options', () => { + it('replaces __data', () => { + const result = replaceDataFields('Item ${__data}', { name: 'test', lastname: 'foo' }, { index: 2 }); + expect(result.text).toBe('Item "test","foo"'); + }); + + it('replaces __data:json', () => { + const result = replaceDataFields('Item ${__data:json}', { name: 'test', lastname: 'foo' }, { index: 2 }); + expect(result.text).toBe('Item {"name":"test","lastname":"foo"}'); + }); + + it('replaces __data.index when index option provided', () => { + const result = replaceDataFields( + 'Item ${__data.index}: ${__data.fields["name"]}', + { name: 'test' }, + { index: 2 } + ); + expect(result.text).toBe('Item 2: test'); + }); + + it('replaces __data.count when count option provided', () => { + const result = replaceDataFields('Total: ${__data.count}', {}, { count: 5 }); + expect(result.text).toBe('Total: 5'); + }); + + it('replaces both index and count together', () => { + const result = replaceDataFields( + '${__data.index} of ${__data.count}: ${__data.fields["name"]}', + { name: 'Alice' }, + { index: 0, count: 3 } + ); + expect(result.text).toBe('0 of 3: Alice'); + }); + + it('leaves __data.index unchanged when index option not provided', () => { + const result = replaceDataFields('Index: ${__data.index}', {}); + expect(result.text).toBe('Index: ${__data.index}'); + }); + + it('leaves __data.count unchanged when count option not provided', () => { + const result = replaceDataFields('Count: ${__data.count}', {}); + expect(result.text).toBe('Count: ${__data.count}'); + }); + }); +}); + +describe('replaceDataFieldsBatch()', () => { + const items = [ + { name: 'Alice', id: '1' }, + { name: 'Bob', id: '2' }, + { name: 'Charlie', id: '3' }, + ]; + + describe('indexed access', () => { + it('replaces indexed field access', () => { + const result = replaceDataFieldsBatch('First: ${__data[0].fields["name"]}', items); + expect(result.text).toBe('First: Alice'); + }); + + it('replaces multiple indexed accesses', () => { + const result = replaceDataFieldsBatch('${__data[0].fields["name"]} and ${__data[1].fields["name"]}', items); + expect(result.text).toBe('Alice and Bob'); + }); + + it('reports out of bounds error', () => { + const result = replaceDataFieldsBatch('${__data[10].fields["name"]}', items); + expect(result.text).toBe('${__data[10].fields["name"]}'); + expect(result.errors).toEqual(['Index 10 out of bounds (0-2)']); + }); + }); + + describe('aggregated access', () => { + it('aggregates field values with default CSV format', () => { + const result = replaceDataFieldsBatch('Names: ${__data.fields["name"]}', items); + expect(result.text).toBe('Names: Alice,Bob,Charlie'); + }); + + it('aggregates field values with pipe format', () => { + const result = replaceDataFieldsBatch('${__data.fields["name"]:pipe}', items); + expect(result.text).toBe('Alice|Bob|Charlie'); + }); + + it('aggregates field values with json format', () => { + const result = replaceDataFieldsBatch('${__data.fields["name"]:json}', items); + expect(result.text).toBe('["Alice","Bob","Charlie"]'); + }); + + it('aggregates field values with regex format', () => { + const result = replaceDataFieldsBatch('${__data.fields["name"]:regex}', items); + expect(result.text).toBe('(Alice|Bob|Charlie)'); + }); + + it('replace all values comma separated', () => { + const result = replaceDataFieldsBatch('${__data}', items); + expect(result.text).toBe('{"name":"Alice","id":"1"},{"name":"Bob","id":"2"},{"name":"Charlie","id":"3"}'); + }); + + it('replace all values as JSON', () => { + const result = replaceDataFieldsBatch('${__data:json}', items); + expect(result.text).toBe('[{"name":"Alice","id":"1"},{"name":"Bob","id":"2"},{"name":"Charlie","id":"3"}]'); + }); + + it('replace all values as JSON removing surrounding quotes to generate a valid JSON', () => { + const result = replaceDataFieldsBatch('{"data":"${__data:json}"}', items); + expect(result.text).toBe( + '{"data":[{"name":"Alice","id":"1"},{"name":"Bob","id":"2"},{"name":"Charlie","id":"3"}]}' + ); + }); + + it('replace all values as JSON keeping surrounding quotes if they are not surrounding the full_data pattern', () => { + const result = replaceDataFieldsBatch('{"data":"${__data} something"}', items); + expect(result.text).toBe( + '{"data":"{"name":"Alice","id":"1"},{"name":"Bob","id":"2"},{"name":"Charlie","id":"3"} something"}' + ); + }); + }); + + describe('combined patterns', () => { + it('handles both indexed and aggregated patterns', () => { + const result = replaceDataFieldsBatch( + 'First: ${__data[0].fields["name"]}, All: ${__data.fields["id"]:pipe}', + items + ); + expect(result.text).toBe('First: Alice, All: 1|2|3'); + }); + }); + + describe('__data.count in batch mode', () => { + it('replaces __data.count with items length', () => { + const result = replaceDataFieldsBatch('Total: ${__data.count} items', items); + expect(result.text).toBe('Total: 3 items'); + }); + + it('combines __data.count with field patterns', () => { + const result = replaceDataFieldsBatch('${__data.count} items: ${__data.fields["name"]:pipe}', items); + expect(result.text).toBe('3 items: Alice|Bob|Charlie'); + }); + }); +}); + +describe('pattern detection helpers', () => { + describe('hasBatchPatterns()', () => { + it('returns true for indexed patterns', () => { + expect(hasBatchPatterns('${__data[0].fields["name"]}')).toBe(true); + }); + + it('returns true for format specifiers', () => { + expect(hasBatchPatterns('${__data.fields["name"]:csv}')).toBe(true); + }); + + it('returns false for simple patterns', () => { + expect(hasBatchPatterns('${__data.fields["name"]}')).toBe(false); + }); + }); + + describe('hasIndexedPatterns()', () => { + it('returns true for indexed patterns', () => { + expect(hasIndexedPatterns('${__data[0].fields["name"]}')).toBe(true); + }); + + it('returns false for non-indexed patterns', () => { + expect(hasIndexedPatterns('${__data.fields["name"]:csv}')).toBe(false); + }); + }); + + describe('hasDataFieldPatterns()', () => { + it('returns true for single field patterns', () => { + expect(hasDataFieldPatterns('${__data.fields["name"]}')).toBe(true); + }); + + it('returns true for indexed patterns', () => { + expect(hasDataFieldPatterns('${__data[0].fields["name"]}')).toBe(true); + }); + + it('returns false for plain text', () => { + expect(hasDataFieldPatterns('hello world')).toBe(false); + }); + + it('returns false for variable patterns', () => { + expect(hasDataFieldPatterns('${var1}')).toBe(false); + }); + }); + + describe('extractFieldNames()', () => { + it('extracts field names from single patterns', () => { + expect(extractFieldNames('${__data.fields["name"]} ${__data.fields["id"]}')).toEqual(['name', 'id']); + }); + + it('extracts field names from indexed patterns', () => { + expect(extractFieldNames('${__data[0].fields["name"]} ${__data[1].fields["value"]}')).toEqual(['name', 'value']); + }); + + it('extracts unique field names', () => { + expect(extractFieldNames('${__data.fields["name"]} ${__data[0].fields["name"]}')).toEqual(['name']); + }); + + it('extracts field names with format specifiers', () => { + expect(extractFieldNames('${__data.fields["name"]:csv}')).toEqual(['name']); + }); + + it('extracts field names from dot notation', () => { + expect(extractFieldNames('${__data.fields.name} ${__data.fields.value}')).toEqual(['name', 'value']); + }); + + it('extracts unique field names from mixed notations', () => { + expect(extractFieldNames('${__data.fields.name} ${__data.fields["name"]}')).toEqual(['name']); + }); + }); +}); + +describe('dot notation support', () => { + describe('replaceDataFields() with dot notation', () => { + it('replaces field using dot notation', () => { + const result = replaceDataFields('https://example.com?name=${__data.fields.name}', { name: 'hello world' }); + expect(result.text).toBe('https://example.com?name=hello%20world'); + expect(result.errors).toBeUndefined(); + }); + + it('replaces multiple fields using dot notation', () => { + const result = replaceDataFields('https://example.com?name=${__data.fields.name}&id=${__data.fields.id}', { + name: 'test', + id: '123', + }); + expect(result.text).toBe('https://example.com?name=test&id=123'); + expect(result.errors).toBeUndefined(); + }); + + it('handles mixed bracket and dot notation', () => { + const result = replaceDataFields('${__data.fields.name} - ${__data.fields["id"]}', { + name: 'test', + id: '123', + }); + expect(result.text).toBe('test - 123'); + expect(result.errors).toBeUndefined(); + }); + + it('applies format specifier with dot notation', () => { + const result = replaceDataFields('${__data.fields.value:regex}', { value: 'test.value' }); + expect(result.text).toBe('(test\\.value)'); + }); + + it('reports missing field error with dot notation', () => { + const result = replaceDataFields('${__data.fields.missing}', { other: 'value' }); + expect(result.errors).toEqual(['Field "missing" not found in data']); + }); + + it('can disable URL encoding with dot notation', () => { + const result = replaceDataFields('${__data.fields.value}', { value: 'hello&world' }, { urlEncode: false }); + expect(result.text).toBe('hello&world'); + }); + + it('supports underscore in field names with dot notation', () => { + const result = replaceDataFields('${__data.fields.my_field}', { my_field: 'value' }); + expect(result.text).toBe('value'); + }); + + it('supports alphanumeric field names with dot notation', () => { + const result = replaceDataFields('${__data.fields.field123}', { field123: 'value' }); + expect(result.text).toBe('value'); + }); + }); + + describe('replaceDataFieldsBatch() with dot notation', () => { + const items = [ + { name: 'Alice', id: '1' }, + { name: 'Bob', id: '2' }, + { name: 'Charlie', id: '3' }, + ]; + + it('aggregates field values with dot notation and default CSV format', () => { + const result = replaceDataFieldsBatch('Names: ${__data.fields.name}', items); + expect(result.text).toBe('Names: Alice,Bob,Charlie'); + }); + + it('aggregates field values with dot notation and pipe format', () => { + const result = replaceDataFieldsBatch('${__data.fields.name:pipe}', items); + expect(result.text).toBe('Alice|Bob|Charlie'); + }); + + it('handles mixed bracket and dot notation in batch mode', () => { + const result = replaceDataFieldsBatch('${__data.fields.name:csv} / ${__data.fields["id"]:pipe}', items); + expect(result.text).toBe('Alice,Bob,Charlie / 1|2|3'); + }); + }); + + describe('pattern detection with dot notation', () => { + it('hasBatchPatterns returns true for dot notation with format', () => { + expect(hasBatchPatterns('${__data.fields.name:csv}')).toBe(true); + }); + + it('hasBatchPatterns returns false for dot notation without format', () => { + expect(hasBatchPatterns('${__data.fields.name}')).toBe(false); + }); + + it('hasDataFieldPatterns returns true for dot notation', () => { + expect(hasDataFieldPatterns('${__data.fields.name}')).toBe(true); + }); + + it('hasDataFieldPatterns returns true for dot notation with format', () => { + expect(hasDataFieldPatterns('${__data.fields.name:csv}')).toBe(true); + }); + }); + + describe('bracket notation', () => { + it('still supports double quotes', () => { + const result = replaceDataFields('${__data.fields["name"]}', { name: 'test' }); + expect(result.text).toBe('test'); + }); + + it('still supports single quotes', () => { + const result = replaceDataFields("${__data.fields['name']}", { name: 'test' }); + expect(result.text).toBe('test'); + }); + + it('bracket notation allows special characters in field names', () => { + const result = replaceDataFields('${__data.fields["field-name"]}', { 'field-name': 'value' }); + expect(result.text).toBe('value'); + }); + + it('bracket notation allows dots in field names', () => { + const result = replaceDataFields('${__data.fields["field.name"]}', { 'field.name': 'value' }); + expect(result.text).toBe('value'); + }); + + it('bracket notation allows spaces in field names', () => { + const result = replaceDataFields('${__data.fields["field name"]}', { 'field name': 'value' }); + expect(result.text).toBe('value'); + }); + }); + + describe('nested field access', () => { + describe('replaceDataFields() with nested fields', () => { + it('accesses nested object properties using dot path', () => { + const result = replaceDataFields('${__data.fields["foo.bar"]}', { foo: { bar: 'nested value' } }); + expect(result.text).toBe('nested%20value'); + expect(result.errors).toBeUndefined(); + }); + + it('accesses deeply nested object properties', () => { + const result = replaceDataFields('${__data.fields["a.b.c.d"]}', { + a: { b: { c: { d: 'deep value' } } }, + }); + expect(result.text).toBe('deep%20value'); + }); + + it('returns empty string for missing nested path', () => { + const result = replaceDataFields('${__data.fields["foo.missing"]}', { foo: { bar: 'value' } }); + expect(result.text).toBe(''); + expect(result.errors).toEqual(['Field "foo.missing" not found in data']); + }); + + it('returns empty string when intermediate path is not an object', () => { + const result = replaceDataFields('${__data.fields["foo.bar.baz"]}', { foo: { bar: 'string value' } }); + expect(result.text).toBe(''); + }); + + it('returns empty string when intermediate path is null', () => { + const result = replaceDataFields('${__data.fields["foo.bar"]}', { foo: null }); + expect(result.text).toBe(''); + }); + + it('prefers literal key over nested access', () => { + const result = replaceDataFields('${__data.fields["foo.bar"]}', { + 'foo.bar': 'literal key', + foo: { bar: 'nested value' }, + }); + expect(result.text).toBe('literal%20key'); + }); + + it('serializes nested object values to JSON', () => { + const result = replaceDataFields( + '${__data.fields["foo.bar"]}', + { foo: { bar: { nested: 'object' } } }, + { urlEncode: false } + ); + expect(result.text).toBe('{"nested":"object"}'); + }); + + it('handles nested access with dot notation syntax', () => { + const result = replaceDataFields('${__data.fields.user}', { + user: { name: 'Alice', age: 30 }, + }); + // Dot notation returns the whole object since "user" is the field name + expect(result.text).toBe('%7B%22name%22%3A%22Alice%22%2C%22age%22%3A30%7D'); + }); + + it('applies format specifier to nested field values', () => { + const result = replaceDataFields('${__data.fields["foo.bar"]:regex}', { foo: { bar: 'test.value' } }); + expect(result.text).toBe('(test\\.value)'); + }); + }); + + describe('replaceDataFieldsBatch() with nested fields', () => { + const items = [{ user: { name: 'Alice' } }, { user: { name: 'Bob' } }, { user: { name: 'Charlie' } }]; + + it('aggregates nested field values with default CSV format', () => { + const result = replaceDataFieldsBatch('Names: ${__data.fields["user.name"]}', items); + expect(result.text).toBe('Names: Alice,Bob,Charlie'); + }); + + it('aggregates nested field values with pipe format', () => { + const result = replaceDataFieldsBatch('${__data.fields["user.name"]:pipe}', items); + expect(result.text).toBe('Alice|Bob|Charlie'); + }); + + it('handles indexed access with nested fields', () => { + // Note: indexed access doesn't support nested paths currently (uses bracket notation for index) + const result = replaceDataFieldsBatch('${__data[0].fields["name"]}', [{ name: 'Alice' }, { name: 'Bob' }]); + expect(result.text).toBe('Alice'); + }); + }); + }); +}); diff --git a/components/src/utils/data-field-interpolation.ts b/components/src/utils/data-field-interpolation.ts new file mode 100644 index 0000000..38adc2f --- /dev/null +++ b/components/src/utils/data-field-interpolation.ts @@ -0,0 +1,344 @@ +// 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 { interpolate, InterpolationFormat } from './variable-interpolation'; + +/** + * Data item with its fields (used for selection/row data) + */ +export type DataItem = Record; + +/** + * Result of interpolation with data fields + */ +export interface InterpolationResult { + text: string; + errors?: string[]; +} + +/** + * Options for data field replacement + */ +export interface ReplaceDataFieldsOptions { + /** + * Whether to URL encode values. Defaults to true. + */ + urlEncode?: boolean; + /** + * Current index (0-based) for ${__data.index} replacement. + */ + index?: number; + /** + * Total count for ${__data.count} replacement. + */ + count?: number; +} + +// Regex patterns for data field interpolation +// Matches: ${__data.fields["fieldName"]} or ${__data.fields['fieldName']} or ${__data.fields.fieldName} with optional format ${__data.fields["fieldName"]:format} +const SINGLE_FIELD_REGEX = + /\$\{__data\.fields(?:\[(?:"|')([^"']+)(?:"|')\]|\.([a-zA-Z_][a-zA-Z0-9_]*))(?::([a-z]+))?\}/g; + +// Matches: ${__data[0].fields["fieldName"]} or ${__data[0].fields['fieldName']} for indexed access +const INDEXED_FIELD_REGEX = /\$\{__data\[(\d+)\]\.fields\[(?:"|')([^"']+)(?:"|')\]\}/g; + +// Matches: ${__data.index} +const DATA_INDEX_REGEX = /\$\{__data\.index\}/g; + +// Matches: ${__data.count} +const DATA_COUNT_REGEX = /\$\{__data\.count\}/g; + +// Matches: ${__data} or ${__data:format} or '${__data:format}' or "${__data:format}" +const FULL_DATA_REGEX = /("|')?\$\{__data(?::([a-z]+))?\}("|')?/g; + +/** + * Get field value from a data item, converting to string. + * Supports nested field access using dot notation in field names (e.g., "foo.bar" accesses item.foo.bar). + * For backward compatibility, first checks if the exact key exists before attempting nested access. + */ +function getFieldValue(item: DataItem, fieldName: string): string { + // First, try direct property access (for backward compatibility with literal dot keys) + if (Object.prototype.hasOwnProperty.call(item, fieldName)) { + const value = item[fieldName]; + if (value === undefined || value === null) { + return ''; + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); + } + + // If not found and contains dots, try nested access + if (fieldName.includes('.')) { + const parts = fieldName.split('.'); + let current: unknown = item; + + for (const part of parts) { + if (current === null || current === undefined) { + return ''; + } + if (typeof current !== 'object') { + return ''; + } + current = (current as Record)[part]; + } + + if (current === undefined || current === null) { + return ''; + } + if (typeof current === 'object') { + return JSON.stringify(current); + } + return String(current); + } + + // Field not found + return ''; +} + +/** + * Parse format string to InterpolationFormat enum + */ +export function parseFormat(format: string | undefined): InterpolationFormat | undefined { + if (!format) return undefined; + const lowerFormat = format.toLowerCase(); + return Object.values(InterpolationFormat).find((f) => f === lowerFormat); +} + +/** + * Replace data field placeholders in a template string with values from a single data item. + * + * Supports: + * - ${__data.fields["fieldName"]} - field value from the item (URL encoded by default) + * - ${__data.fields["fieldName"]:format} - field value with format specifier + * - ${__data.index} - current index (0-based) if provided in options + * - ${__data.count} - total count if provided in options + * - ${__data} - full data array + * - ${__data:format} - full data array with format specifier + * + * @param template - The template string with placeholders + * @param item - The data item containing field values + * @param options - Optional configuration for replacement behavior + * @returns InterpolationResult with the interpolated text and any errors + */ +export function replaceDataFields( + template: string, + item: DataItem, + options: ReplaceDataFieldsOptions = {} +): InterpolationResult { + const { urlEncode = true, index, count } = options; + let result = template; + const errors: string[] = []; + + // Replace ${__data.index} if provided + if (index !== undefined) { + result = result.replaceAll(DATA_INDEX_REGEX, String(index)); + } + + // Replace ${__data.count} if provided + if (count !== undefined) { + result = result.replaceAll(DATA_COUNT_REGEX, String(count)); + } + + // Replace full data placeholder with optional format: ${__data} or ${__data:format} + result = result.replaceAll( + FULL_DATA_REGEX, + (_match, _leadingQuote: string | undefined, format: string | undefined, _trailingQuote: string | undefined) => { + const interpolationFormat = parseFormat(format) ?? InterpolationFormat.RAW; + let interpolationResult: string = ''; + + if (interpolationFormat === InterpolationFormat.JSON) { + interpolationResult = JSON.stringify(item); + } else { + interpolationResult = interpolate( + Object.values(item).map((v) => JSON.stringify(v)), + '', + interpolationFormat + ); + } + + return interpolationResult; + } + ); + + // Reset regex lastIndex + SINGLE_FIELD_REGEX.lastIndex = 0; + + // Replace __data.fields["fieldName"] or __data.fields.fieldName patterns + result = result.replaceAll( + SINGLE_FIELD_REGEX, + (_match, bracketField: string | undefined, dotField: string | undefined, format: string | undefined) => { + const fieldName = bracketField ?? dotField ?? ''; + const value = getFieldValue(item, fieldName); + if (value === '' && item[fieldName] === undefined) { + errors.push(`Field "${fieldName}" not found in data`); + } + const interpolationFormat = parseFormat(format); + if (interpolationFormat) { + return interpolate([value], fieldName, interpolationFormat); + } + + if (urlEncode) { + // override: disable urlEncode for RAW format + if (interpolationFormat === InterpolationFormat.RAW) { + return value; + } + + // avoid double encoding for queryparam and percentencode formats + if ( + interpolationFormat !== InterpolationFormat.QUERYPARAM && + interpolationFormat !== InterpolationFormat.PERCENTENCODE + ) { + return encodeURIComponent(value); + } + } + + return value; + } + ); + + return { text: result, errors: errors.length > 0 ? errors : undefined }; +} + +/** + * Replace data field placeholders in a template string with values from multiple data items (batch mode). + * + * Supports: + * - ${__data[0].fields["fieldName"]} - field value from specific item by index + * - ${__data.fields["fieldName"]} - aggregated field values (defaults to CSV format) + * - ${__data.fields["fieldName"]:csv} - aggregated field values with format specifier + * - ${__data.count} - total number of items + * - ${__data} - full data array + * - ${__data:format} - full data array with format specifier + * + * @param template - The template string with placeholders + * @param items - Array of data items containing field values + * @param options - Optional configuration for replacement behavior + * @returns InterpolationResult with the interpolated text and any errors + */ +export function replaceDataFieldsBatch( + template: string, + items: DataItem[], + options: ReplaceDataFieldsOptions = {} +): InterpolationResult { + const { urlEncode = true } = options; + let result = template; + const errors: string[] = []; + + // Replace __data.count with items length + result = result.replaceAll(DATA_COUNT_REGEX, String(items.length)); + + // Reset regex lastIndex + INDEXED_FIELD_REGEX.lastIndex = 0; + SINGLE_FIELD_REGEX.lastIndex = 0; + + // Replace full data placeholder with optional format + result = result.replaceAll( + FULL_DATA_REGEX, + (_match, leadingQuote: string | undefined, format: string | undefined, trailingQuote: string | undefined) => { + const interpolationFormat = parseFormat(format) ?? InterpolationFormat.RAW; + + const interpolationResult = interpolate( + Object.values(items).map((e) => JSON.stringify(e)), + '', + interpolationFormat + ); + + if (!leadingQuote || !trailingQuote) { + // preserve quotes as they were not surrounding the full_data pattern + return `${leadingQuote ?? ''}${interpolationResult}${trailingQuote ?? ''}`; + } + + return interpolationResult; + } + ); + + // Replace indexed access: ${__data[0].fields["fieldName"]} or ${__data[0].fields['fieldName']} + result = result.replaceAll(INDEXED_FIELD_REGEX, (match, indexStr: string, fieldName: string) => { + const idx = parseInt(indexStr, 10); + if (idx < 0 || idx >= items.length) { + errors.push(`Index ${idx} out of bounds (0-${items.length - 1})`); + return match; + } + const value = getFieldValue(items[idx]!, fieldName); + if (value === '' && items[idx]![fieldName] === undefined) { + errors.push(`Field "${fieldName}" not found in data at index ${idx}`); + } + return urlEncode ? encodeURIComponent(value) : value; + }); + + // Replace aggregated access with format: ${__data.fields["fieldName"]:csv} or ${__data.fields.fieldName:csv} + result = result.replaceAll( + SINGLE_FIELD_REGEX, + (_match, bracketField: string | undefined, dotField: string | undefined, format: string | undefined) => { + const fieldName = bracketField ?? dotField ?? ''; + const values = items.map((item) => getFieldValue(item, fieldName)); + const interpolationFormat = parseFormat(format) || InterpolationFormat.CSV; + return interpolate(values, fieldName, interpolationFormat); + } + ); + + return { text: result, errors: errors.length > 0 ? errors : undefined }; +} + +/** + * Check if a template contains batch-only patterns (indexed access or format specifiers) + */ +export function hasBatchPatterns(template: string): boolean { + // Reset regex lastIndex + INDEXED_FIELD_REGEX.lastIndex = 0; + return ( + INDEXED_FIELD_REGEX.test(template) || + /\$\{__data\.fields(?:\[["'][^"']+["']\]|\.[a-zA-Z_][a-zA-Z0-9_]*):[a-z]+\}/.test(template) + ); +} + +/** + * Check if a template contains indexed access patterns + */ +export function hasIndexedPatterns(template: string): boolean { + INDEXED_FIELD_REGEX.lastIndex = 0; + return INDEXED_FIELD_REGEX.test(template); +} + +/** + * Check if a template contains data field patterns + */ +export function hasDataFieldPatterns(template: string): boolean { + SINGLE_FIELD_REGEX.lastIndex = 0; + INDEXED_FIELD_REGEX.lastIndex = 0; + return SINGLE_FIELD_REGEX.test(template) || INDEXED_FIELD_REGEX.test(template); +} + +/** + * Extract all field names referenced in a template + */ +export function extractFieldNames(template: string): string[] { + const fields = new Set(); + + // Reset regex lastIndex + SINGLE_FIELD_REGEX.lastIndex = 0; + INDEXED_FIELD_REGEX.lastIndex = 0; + + let match; + while ((match = SINGLE_FIELD_REGEX.exec(template)) !== null) { + // Group 1 is bracket notation, group 2 is dot notation + fields.add(match[1] ?? match[2]!); + } + while ((match = INDEXED_FIELD_REGEX.exec(template)) !== null) { + fields.add(match[2]!); + } + + return Array.from(fields); +} diff --git a/components/src/utils/index.ts b/components/src/utils/index.ts index bf7af48..b225aea 100644 --- a/components/src/utils/index.ts +++ b/components/src/utils/index.ts @@ -16,6 +16,9 @@ export * from './browser-storage'; export * from './chart-actions'; export * from './combine-sx'; export * from './component-ids'; +export * from './data-field-interpolation'; export * from './format'; -export * from './theme-gen'; export * from './memo'; +export * from './selection-interpolation'; +export * from './theme-gen'; +export * from './variable-interpolation'; diff --git a/components/src/utils/selection-interpolation.ts b/components/src/utils/selection-interpolation.ts new file mode 100644 index 0000000..2952f34 --- /dev/null +++ b/components/src/utils/selection-interpolation.ts @@ -0,0 +1,87 @@ +// 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 { DataItem, InterpolationResult, replaceDataFields, replaceDataFieldsBatch } from './data-field-interpolation'; +import { VariableStateMap, replaceVariables } from './variable-interpolation'; + +export type SelectionItem = DataItem; + +/** + * Interpolate selection data into a template string for individual mode. + * + * Supports: + * - ${__data.fields["fieldName"]} - field value from the item + * - ${__data.index} - current selection index (0-based) + * - ${__data.count} - total number of selections + * + * @param template - The template string with placeholders + * @param item - The current selection item data + * @param index - The current selection index (0-based) + * @param count - Total number of selections + * @param variableState - Optional dashboard variable state for additional interpolation + */ +export function interpolateSelectionIndividual( + template: string, + item: SelectionItem, + index: number, + count: number, + variableState?: VariableStateMap +): InterpolationResult { + // Replace __data patterns using shared utility (includes __data.index and __data.count) + const dataFieldResult = replaceDataFields(template, item, { index, count }); + let result = dataFieldResult.text; + const errors: string[] = []; + if (dataFieldResult.errors) { + errors.push(...dataFieldResult.errors.map((e) => e.replace('in data', 'in selection data'))); + } + + // Apply dashboard variable interpolation if provided + if (variableState) { + result = replaceVariables(result, variableState); + } + + return { text: result, errors: errors.length > 0 ? errors : undefined }; +} + +/** + * Interpolate selection data into a template string for batch mode. + * + * Supports: + * - ${__data[0].fields["fieldName"]} - field value from specific item by index + * - ${__data.fields["fieldName"]:csv} - aggregated field values with format specifier + * - ${__data.count} - total number of selections + * + * @param template - The template string with placeholders + * @param items - Array of all selection items + * @param variableState - Optional dashboard variable state for additional interpolation + */ +export function interpolateSelectionBatch( + template: string, + items: SelectionItem[], + variableState?: VariableStateMap +): InterpolationResult { + // Replace __data patterns using shared utility (includes __data.count) + const dataFieldResult = replaceDataFieldsBatch(template, items); + let result = dataFieldResult.text; + const errors: string[] = []; + if (dataFieldResult.errors) { + errors.push(...dataFieldResult.errors.map((e) => e.replace('in data', 'in selection data'))); + } + + // Apply dashboard variable interpolation if provided + if (variableState) { + result = replaceVariables(result, variableState); + } + + return { text: result, errors: errors.length > 0 ? errors : undefined }; +} diff --git a/plugin-system/src/utils/variables.test.ts b/components/src/utils/variable-interpolation.test.ts similarity index 95% rename from plugin-system/src/utils/variables.test.ts rename to components/src/utils/variable-interpolation.test.ts index 345bdbd..57a8a03 100644 --- a/plugin-system/src/utils/variables.test.ts +++ b/components/src/utils/variable-interpolation.test.ts @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { parseVariables, replaceVariable, replaceVariables } from './variables'; +import { parseVariables, replaceVariable, replaceVariables } from './variable-interpolation'; describe('parseVariables()', () => { const tests = [ @@ -153,6 +153,15 @@ describe('replaceVariables() with custom formats', () => { }, expected: 'hello ["perses","prometheus"] ["world"]', }, + // json stringified object + { + text: 'hello ${var1:json} ${var2:json}', + state: { + var1: { value: ['{"one":"perses","two":"prometheus"}', '{"second":"value"}'], loading: false }, + var2: { value: 'world', loading: false }, + }, + expected: 'hello [{"one":"perses","two":"prometheus"},{"second":"value"}] ["world"]', + }, // lucene { text: 'hello ${var1:lucene} ${var2:lucene}', diff --git a/components/src/utils/variable-interpolation.ts b/components/src/utils/variable-interpolation.ts new file mode 100644 index 0000000..5208149 --- /dev/null +++ b/components/src/utils/variable-interpolation.ts @@ -0,0 +1,225 @@ +// 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 { VariableValue } from '@perses-dev/core'; + +/** + * Option for a variable with label and value + */ +export type VariableOption = { label: string; value: string }; + +/** + * State of a variable including its current value, options, and loading state + */ +export type VariableState = { + value: VariableValue; + options?: VariableOption[]; + loading: boolean; + error?: Error; + /** + * If a local variable is overriding an external variable, local var will have the flag ``overriding=true``. + */ + overriding?: boolean; + /** + * If a local variable is overriding an external variable, external var will have the flag ``overridden=true``. + */ + overridden?: boolean; + defaultValue?: VariableValue; +}; + +/** + * Map of variable names to their states + */ +export type VariableStateMap = Record; + +/** + * Supported interpolation formats for variable values + */ +export enum InterpolationFormat { + CSV = 'csv', + DISTRIBUTED = 'distributed', + DOUBLEQUOTE = 'doublequote', + GLOB = 'glob', + JSON = 'json', + LUCENE = 'lucene', + PERCENTENCODE = 'percentencode', + PIPE = 'pipe', + PROMETHEUS = 'prometheus', + RAW = 'raw', + REGEX = 'regex', + SINGLEQUOTE = 'singlequote', + SQLSTRING = 'sqlstring', + TEXT = 'text', + QUERYPARAM = 'queryparam', +} + +function stringToFormat(val: string | undefined): InterpolationFormat | undefined { + if (!val) return undefined; + + const lowerVal = val.toLowerCase(); + return Object.values(InterpolationFormat).find((format) => format === lowerVal) || undefined; +} + +/** + * Interpolate an array of values with a specific format + */ +export function interpolate(values: string[], name: string, format: InterpolationFormat): string { + switch (format) { + case InterpolationFormat.CSV: + case InterpolationFormat.RAW: + return values.join(','); + case InterpolationFormat.DISTRIBUTED: { + const [first, ...rest] = values; + return `${[first, ...rest.map((v) => `${name}=${v}`)].join(',')}`; + } + case InterpolationFormat.DOUBLEQUOTE: + return values.map((v) => `"${v}"`).join(','); + case InterpolationFormat.GLOB: + return `{${values.join(',')}}`; + case InterpolationFormat.JSON: { + // values might contain stringified JSON objects so we need to parse them first + // and then return a JSON stringified array to return valid JSON + const parsedValues = values.map((v) => { + try { + return JSON.parse(v); + } catch { + return v; + } + }); + + return JSON.stringify(parsedValues); + } + case InterpolationFormat.LUCENE: + return `(${values.map((v) => `"${v}"`).join(' OR ')})`; + case InterpolationFormat.PERCENTENCODE: + return encodeURIComponent(values.join(',')); + case InterpolationFormat.PIPE: + return values.join('|'); + case InterpolationFormat.REGEX: { + const escapedRegex = values.map((v) => v.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')); + return `(${escapedRegex.join('|')})`; + } + case InterpolationFormat.SINGLEQUOTE: + return values.map((v) => `'${v}'`).join(','); + case InterpolationFormat.SQLSTRING: + return values.map((v) => `'${v.replace(/'/g, "''")}'`).join(','); + case InterpolationFormat.TEXT: + return values.join(' + '); + case InterpolationFormat.QUERYPARAM: + return values.map((v) => `${name}=${encodeURIComponent(v)}`).join('&'); + case InterpolationFormat.PROMETHEUS: + default: + return `(${values.join('|')})`; + } +} + +/** + * Replace a single variable in text with its value + */ +export function replaceVariable( + text: string, + varName: string, + variableValue: VariableValue, + varFormat?: InterpolationFormat +): string { + const variableSyntax = '$' + varName; + const alternativeVariableSyntax = '${' + varName + (varFormat ? ':' + varFormat : '') + '}'; + + let replaceString = ''; + if (Array.isArray(variableValue)) { + replaceString = interpolate(variableValue, varName, varFormat || InterpolationFormat.PROMETHEUS); + } + if (typeof variableValue === 'string') { + replaceString = interpolate([variableValue], varName, varFormat || InterpolationFormat.RAW); + } + + text = text.replaceAll(variableSyntax, replaceString); + return text.replaceAll(alternativeVariableSyntax, replaceString); +} + +// This regular expression is designed to identify variable references in a string. +// It supports two formats for referencing variables: +// 1. $variableName - This is a simpler format, and the regular expression captures the variable name (\w+ matches one or more word characters). +// 2. ${variableName} - This is a more complex format and the regular expression captures the variable name (\w+ matches one or more word characters) in the curly braces. +// 3. ${variableName:format} - This is a more complex format that allows specifying a format interpolation. + +const VARIABLE_REGEX = /\$(\w+)|\${(\w+)(?:\.([^:^}]+))?(?::([^}]+))?}/gm; + +/** + * Returns a list of variables + */ +export function parseVariables(text: string): string[] { + const matches = new Set(); + let match; + + while ((match = VARIABLE_REGEX.exec(text)) !== null) { + if (match) { + if (match[1]) { + // \$(\w+)\ + matches.add(match[1]); + } else if (match[2]) { + // \${(\w+)}\ + matches.add(match[2]); + } + } + } + // return unique matches + return Array.from(matches.values()); +} + +/** + * Returns a map of variable names and its format. If no format is specified, it will be undefined. + */ +export function parseVariablesAndFormat(text: string): Map { + const matches = new Map(); + let match; + + while ((match = VARIABLE_REGEX.exec(text)) !== null) { + if (match) { + let format = undefined; + if (match[4]) { + format = match[4]; + } + if (match[1]) { + // \$(\w+)\ + matches.set(match[1], stringToFormat(format)); + } else if (match[2]) { + // \${(\w+)}\ + matches.set(match[2], stringToFormat(format)); + } + } + } + return matches; +} + +/** + * Replace all variables in text with their values from the variable state map + */ +export function replaceVariables(text: string, variableState: VariableStateMap): string { + const variablesMap = parseVariablesAndFormat(text); + const variables = Array.from(variablesMap.keys()); + let finalText = text; + variables + // Sorting variables by their length. + // In order to not have a variable name have contained in another variable name. + // i.e.: $__range replacing $__range_ms => '3600_ms' instead of '3600000' + .sort((a, b) => b.length - a.length) + .forEach((v) => { + const variable = variableState[v]; + if (variable && variable.value !== undefined) { + finalText = replaceVariable(finalText, v, variable?.value, variablesMap.get(v)); + } + }); + + return finalText; +} diff --git a/cue/common/actions.cue b/cue/common/actions.cue new file mode 100644 index 0000000..951e46c --- /dev/null +++ b/cue/common/actions.cue @@ -0,0 +1,45 @@ +// 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 + +#baseAction: { + name: string + icon?: string + confirmMessage?: string + enabled: bool | *true + bodyTemplate?: string + batchMode: "batch" | *"individual" +} +#eventAction: { + #baseAction + type: "event" + eventName: string +} +#webhookAction: { + #baseAction + type: "webhook" + url: string + method: "GET" | *"POST" | "PUT" | "PATCH" | "DELETE" + contentType: *"none" | "json" | "text" + headers?: [string]: string +} + +#itemAction: #eventAction | #webhookAction + +#actions: { + enabled: bool | *true + actionsList: [...#itemAction] + displayInHeader?: bool + displayWithItem?: bool +} \ No newline at end of file diff --git a/cue/common/selection.cue b/cue/common/selection.cue new file mode 100644 index 0000000..e352e72 --- /dev/null +++ b/cue/common/selection.cue @@ -0,0 +1,18 @@ +// 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 + +#selection: { + enabled?: bool | *false +} diff --git a/dashboards/src/components/Panel/Panel.tsx b/dashboards/src/components/Panel/Panel.tsx index ee2e74a..bb0029d 100644 --- a/dashboards/src/components/Panel/Panel.tsx +++ b/dashboards/src/components/Panel/Panel.tsx @@ -12,10 +12,17 @@ // limitations under the License. import { Card, CardContent, CardProps } from '@mui/material'; -import { ErrorAlert, ErrorBoundary, combineSx, useId } from '@perses-dev/components'; +import { + ErrorAlert, + ErrorBoundary, + ItemActionsProvider, + SelectionProvider, + combineSx, + useId, +} from '@perses-dev/components'; import { PanelDefinition, PanelGroupItemId } from '@perses-dev/core'; -import { useDataQueriesContext, usePluginRegistry } from '@perses-dev/plugin-system'; -import { ReactNode, memo, useMemo, useState, useEffect } from 'react'; +import { ActionOptions, useDataQueriesContext, usePluginRegistry } from '@perses-dev/plugin-system'; +import { ReactNode, memo, useEffect, useMemo, useState } from 'react'; import useResizeObserver from 'use-resize-observer'; import { PanelContent } from './PanelContent'; import { PanelHeader, PanelHeaderProps } from './PanelHeader'; @@ -170,70 +177,80 @@ export const Panel = memo(function Panel(props: PanelProps) { // default value for showIcons: if the dashboard is in editing mode or the panel is in fullscreen mode: 'always', otherwise 'hover' const showIcons = panelOptions?.showIcons ?? (editHandlers || readHandlers?.isPanelViewed ? 'always' : 'hover'); + const itemActionsConfig = definition.spec.plugin.spec?.actions + ? (definition.spec.plugin.spec.actions as ActionOptions) + : undefined; + const itemActionsListConfig = + itemActionsConfig?.enabled && itemActionsConfig.displayInHeader ? itemActionsConfig.actionsList : []; return ( - - {!panelOptions?.hideHeader && ( - - )} - - - - - - + + + + {!panelOptions?.hideHeader && ( + + )} + + + + + + + + ); }); diff --git a/dashboards/src/components/Panel/PanelActions.tsx b/dashboards/src/components/Panel/PanelActions.tsx index 544a609..649dca6 100644 --- a/dashboards/src/components/Panel/PanelActions.tsx +++ b/dashboards/src/components/Panel/PanelActions.tsx @@ -12,7 +12,7 @@ // limitations under the License. import { Stack, Box, Popover, CircularProgress, styled, PopoverPosition } from '@mui/material'; -import { isValidElement, PropsWithChildren, ReactNode, useMemo, useState } from 'react'; +import { isValidElement, PropsWithChildren, ReactElement, ReactNode, useMemo, useState } from 'react'; import { InfoTooltip } from '@perses-dev/components'; import { QueryData } from '@perses-dev/plugin-system'; import DatabaseSearch from 'mdi-material-ui/DatabaseSearch'; @@ -26,6 +26,7 @@ import MenuIcon from 'mdi-material-ui/Menu'; import AlertIcon from 'mdi-material-ui/Alert'; import AlertCircleIcon from 'mdi-material-ui/AlertCircle'; import InformationOutlineIcon from 'mdi-material-ui/InformationOutline'; +import LightningBoltIcon from 'mdi-material-ui/LightningBolt'; import { Link, Notice } from '@perses-dev/core'; import { ARIA_LABEL_TEXT, @@ -64,6 +65,8 @@ export interface PanelActionsProps { }; queryResults: QueryData[]; pluginActions?: ReactNode[]; + itemActions?: ReactNode[]; + areItemActionsDisabled?: boolean; showIcons: PanelOptions['showIcons']; } @@ -85,6 +88,7 @@ export const PanelActions: React.FC = ({ links, queryResults, pluginActions = [], + itemActions = [], showIcons, }) => { const descriptionAction = useMemo((): ReactNode | undefined => { @@ -266,7 +270,7 @@ export const PanelActions: React.FC = ({ {descriptionAction} {linksAction} {queryStateIndicator} {noticesIndicator} {extraActions} {viewQueryAction} - {readActions} {pluginActions} + {readActions} {pluginActions} {itemActions} {editActions} {moveAction} @@ -290,7 +294,7 @@ export const PanelActions: React.FC = ({ {extraActions} {readActions} - {editActions} {viewQueryAction} {pluginActions} + {editActions} {viewQueryAction} {pluginActions} {itemActions} {moveAction} @@ -315,6 +319,15 @@ export const PanelActions: React.FC = ({ {readActions} {editActions} {/* Show plugin actions inside a menu if it gets crowded */} {pluginActions.length <= 1 ? pluginActions : {pluginActions}} + {itemActions.length <= 1 ? ( + itemActions + ) : ( + + } direction="column" title={title}> + {itemActions} + + + )} {moveAction} @@ -322,7 +335,9 @@ export const PanelActions: React.FC = ({ ); }; -const OverflowMenu: React.FC> = ({ children, title }) => { +const OverflowMenu: React.FC< + PropsWithChildren<{ title: string; icon?: ReactElement; direction?: 'row' | 'column' }> +> = ({ children, title, icon, direction = 'row' }) => { const [anchorPosition, setAnchorPosition] = useState(); // do not show overflow menu if there is no content (for example, edit actions are hidden) @@ -351,7 +366,7 @@ const OverflowMenu: React.FC> = ({ children aria-label={ARIA_LABEL_TEXT.showPanelActions(title)} size="small" > - + {icon ?? } > = ({ children horizontal: 'left', }} > - + {children} diff --git a/dashboards/src/components/Panel/PanelHeader.tsx b/dashboards/src/components/Panel/PanelHeader.tsx index 8e29ad9..99e6f97 100644 --- a/dashboards/src/components/Panel/PanelHeader.tsx +++ b/dashboards/src/components/Panel/PanelHeader.tsx @@ -14,11 +14,12 @@ import { CardHeader, CardHeaderProps, Stack, Typography, Tooltip } from '@mui/material'; import { combineSx } from '@perses-dev/components'; import { Link } from '@perses-dev/core'; -import { QueryData, useReplaceVariablesInString } from '@perses-dev/plugin-system'; +import { ItemAction, QueryData, useAllVariableValues, useReplaceVariablesInString } from '@perses-dev/plugin-system'; import { ReactElement, ReactNode, useRef } from 'react'; import { HEADER_ACTIONS_CONTAINER_NAME } from '../../constants'; import { PanelActions, PanelActionsProps } from './PanelActions'; import { PanelOptions } from './Panel'; +import { useSelectionItemActions } from './useSelectionItemActions'; type OmittedProps = 'children' | 'action' | 'title' | 'disableTypography'; @@ -32,7 +33,8 @@ export interface PanelHeaderProps extends Omit { viewQueriesHandler?: PanelActionsProps['viewQueriesHandler']; readHandlers?: PanelActionsProps['readHandlers']; editHandlers?: PanelActionsProps['editHandlers']; - pluginActions?: ReactNode[]; // Add pluginActions prop + pluginActions?: ReactNode[]; + itemActionsListConfig?: ItemAction[]; showIcons: PanelOptions['showIcons']; dimension?: { width: number }; } @@ -48,6 +50,7 @@ export function PanelHeader({ sx, extra, pluginActions, + itemActionsListConfig, showIcons, viewQueriesHandler, dimension, @@ -58,67 +61,78 @@ export function PanelHeader({ const title = useReplaceVariablesInString(rawTitle) as string; const description = useReplaceVariablesInString(rawDescription); + const variableState = useAllVariableValues(); const textRef = useRef(null); const isEllipsisActive = textRef.current && dimension?.width ? textRef.current.scrollWidth > textRef.current.clientWidth : false; + const { actionButtons, confirmDialog } = useSelectionItemActions({ + actions: itemActionsListConfig, + variableState, + disabledWithEmptySelection: true, + }); + return ( - - - - {title} - - - - - } - sx={combineSx( - (theme) => ({ - containerType: 'inline-size', - containerName: HEADER_ACTIONS_CONTAINER_NAME, - padding: theme.spacing(1), - borderBottom: `solid 1px ${theme.palette.divider}`, - '.MuiCardHeader-content': { - overflow: 'hidden', - }, - }), - sx - )} - {...rest} - /> + <> + + + + {title} + + + + + } + sx={combineSx( + (theme) => ({ + containerType: 'inline-size', + containerName: HEADER_ACTIONS_CONTAINER_NAME, + padding: theme.spacing(1), + borderBottom: `solid 1px ${theme.palette.divider}`, + '.MuiCardHeader-content': { + overflow: 'hidden', + }, + }), + sx + )} + {...rest} + /> + {confirmDialog} + ); } diff --git a/dashboards/src/components/Panel/index.ts b/dashboards/src/components/Panel/index.ts index ba5f761..610e406 100644 --- a/dashboards/src/components/Panel/index.ts +++ b/dashboards/src/components/Panel/index.ts @@ -14,3 +14,4 @@ export * from './HeaderIconButton'; export * from './Panel'; export * from './PanelPluginLoader'; +export * from './useSelectionItemActions'; diff --git a/dashboards/src/components/Panel/useSelectionItemActions.tsx b/dashboards/src/components/Panel/useSelectionItemActions.tsx new file mode 100644 index 0000000..5f5bbe6 --- /dev/null +++ b/dashboards/src/components/Panel/useSelectionItemActions.tsx @@ -0,0 +1,198 @@ +// 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 } from '@mui/material'; +import { Dialog, InfoTooltip, useItemActions, useSelection } from '@perses-dev/components'; +import { ACTION_ICONS, executeAction, ItemAction, VariableStateMap } from '@perses-dev/plugin-system'; +import { ReactNode, useCallback, useMemo, useState } from 'react'; +import { HeaderIconButton } from './HeaderIconButton'; + +export interface UseItemActionsOptions { + actions?: ItemAction[]; + variableState?: VariableStateMap; + disabledWithEmptySelection?: boolean; +} + +type Item = { id: Id; data: Record }; + +export interface UseItemActionsResult { + actionButtons: ReactNode[]; + confirmDialog: ReactNode; + getItemActionButtons: (item: Item) => ReactNode[]; +} + +/** + * Hook that returns action buttons and confirmation dialog for selection based PanelActions. + */ +export function useSelectionItemActions({ + actions, + variableState, + disabledWithEmptySelection, +}: UseItemActionsOptions): UseItemActionsResult { + const { selectionMap } = useSelection(); + const { actionStatuses, setActionStatus } = useItemActions(); + const [confirmState, setConfirmState] = useState<{ + open: boolean; + action?: ItemAction; + item?: Item; + }>({ open: false }); + + const handleExecuteAction = useCallback( + async (action: ItemAction, item?: Item) => { + if (item) { + // If item is passed, it means we need to use the current item data and not the selection + await executeAction({ + action, + selectionMap: new Map>([[item.id, item.data]]), + variableState, + setActionStatus, + }); + } else { + await executeAction({ + action, + selectionMap: selectionMap as Map>, + variableState, + setActionStatus, + }); + } + }, + [selectionMap, variableState, setActionStatus] + ); + + const handleActionClick = useCallback( + (action: ItemAction, item?: Item) => { + if (action.confirmMessage) { + setConfirmState({ open: true, action, item }); + } else { + handleExecuteAction(action, item); + } + }, + [handleExecuteAction] + ); + + const closeConfirm = useCallback(() => setConfirmState((prev) => ({ ...prev, open: false })), []); + + const handleConfirm = useCallback(async () => { + setConfirmState((prev) => ({ ...prev, open: false })); + if (confirmState.action) { + await handleExecuteAction(confirmState.action, confirmState.item); + } + }, [confirmState.action, handleExecuteAction, confirmState.item]); + + const areButtonsDisabled = disabledWithEmptySelection && selectionMap.size === 0; + + const actionButtons = useMemo((): ReactNode[] => { + if (!actions?.length) return []; + + const buttons: ReactNode[] = []; + + for (const action of actions) { + if (!action.enabled) { + continue; + } + + const isLoading = actionStatuses.get(action.name)?.loading ?? false; + const iconConfig = action.icon ? ACTION_ICONS.find((i) => i.value === action.icon) : undefined; + + buttons.push( + + { + e.stopPropagation(); + handleActionClick(action); + }} + aria-label={action.name} + > + {isLoading ? ( + + ) : iconConfig ? ( + iconConfig.icon + ) : ( + + {action.name} + + )} + + + ); + } + + return buttons; + }, [actions, actionStatuses, handleActionClick, areButtonsDisabled]); + + const getItemActionButtons = useCallback( + (item: Item) => { + if (!actions?.length) return []; + + const buttons: ReactNode[] = []; + + for (const action of actions) { + if (!action.enabled) { + continue; + } + + const isLoading = actionStatuses.get(action.name)?.itemStatuses?.get(item.id)?.loading ?? false; + const iconConfig = action.icon ? ACTION_ICONS.find((i) => i.value === action.icon) : undefined; + + buttons.push( + + { + e.stopPropagation(); + handleActionClick(action, item); + }} + aria-label={action.name} + > + {isLoading ? ( + + ) : iconConfig ? ( + iconConfig.icon + ) : ( + + {action.name} + + )} + + + ); + } + + return buttons; + }, + [actions, actionStatuses, areButtonsDisabled, handleActionClick] + ); + + const confirmDialog = useMemo( + (): ReactNode => ( + + + {confirmState.action?.name ? `Confirm: ${confirmState.action.name}` : 'Confirm Action'} + + + {confirmState.action?.confirmMessage ?? 'Are you sure you want to perform this action?'} + + + Confirm + Cancel + + + ), + [confirmState.open, confirmState.action, closeConfirm, handleConfirm] + ); + + return { actionButtons, confirmDialog, getItemActionButtons }; +} diff --git a/plugin-system/src/components/ItemSelectionActionsOptionsEditor/ItemSelectionActionsOptionsEditor.tsx b/plugin-system/src/components/ItemSelectionActionsOptionsEditor/ItemSelectionActionsOptionsEditor.tsx new file mode 100644 index 0000000..cb6b61b --- /dev/null +++ b/plugin-system/src/components/ItemSelectionActionsOptionsEditor/ItemSelectionActionsOptionsEditor.tsx @@ -0,0 +1,849 @@ +// 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, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + FormControl, + FormControlLabel, + FormLabel, + IconButton, + InputLabel, + MenuItem, + Radio, + RadioGroup, + Select, + Stack, + Switch, + SwitchProps, + TextField, + Typography, +} from '@mui/material'; +import { SelectChangeEvent } from '@mui/material/Select'; +import { + DragAndDropElement, + DragButton, + handleMoveDown, + handleMoveUp, + InfoTooltip, + JSONEditor, + OptionsEditorControl, + OptionsEditorGroup, + useDragAndDropMonitor, +} from '@perses-dev/components'; +import AlertIcon from 'mdi-material-ui/Alert'; +import CheckIcon from 'mdi-material-ui/Check'; +import ChevronDown from 'mdi-material-ui/ChevronDown'; +import ChevronRight from 'mdi-material-ui/ChevronRight'; +import CloseIcon from 'mdi-material-ui/Close'; +import SettingsIcon from 'mdi-material-ui/Cog'; +import DeleteIcon from 'mdi-material-ui/DeleteOutline'; +import DownloadIcon from 'mdi-material-ui/Download'; +import InfoIcon from 'mdi-material-ui/InformationOutline'; +import LinkIcon from 'mdi-material-ui/Link'; +import MagnifyScan from 'mdi-material-ui/MagnifyScan'; +import PauseIcon from 'mdi-material-ui/Pause'; +import PlayIcon from 'mdi-material-ui/Play'; +import PlusIcon from 'mdi-material-ui/Plus'; +import RefreshIcon from 'mdi-material-ui/Refresh'; +import RobotOutline from 'mdi-material-ui/RobotOutline'; +import SendIcon from 'mdi-material-ui/Send'; +import StopIcon from 'mdi-material-ui/Stop'; +import SyncIcon from 'mdi-material-ui/Sync'; +import UploadIcon from 'mdi-material-ui/Upload'; +import { ReactElement, useCallback, useMemo, useState } from 'react'; + +export type ActionIcon = + | 'play' + | 'pause' + | 'stop' + | 'delete' + | 'refresh' + | 'send' + | 'download' + | 'upload' + | 'check' + | 'close' + | 'alert' + | 'info' + | 'settings' + | 'link' + | 'sync' + | 'troubleshoot' + | 'ask-ai'; + +export interface BaseAction { + type: 'event' | 'webhook'; + name: string; + confirmMessage?: string; + icon?: ActionIcon; + enabled?: boolean; + batchMode: BatchMode; + bodyTemplate?: string; +} + +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +export type BatchMode = 'batch' | 'individual'; + +export type ContentType = 'none' | 'json' | 'text'; + +export interface WebhookAction extends BaseAction { + type: 'webhook'; + url: string; + method: HttpMethod; + contentType: ContentType; + headers?: Record; +} + +export interface EventAction extends BaseAction { + type: 'event'; + eventName: string; +} + +export type ItemAction = EventAction | WebhookAction; + +export interface ActionOptions { + enabled?: boolean; + actionsList?: ItemAction[]; + displayInHeader?: boolean; + displayWithItem?: boolean; +} + +export interface SelectionOptions { + enabled?: boolean; +} + +export interface ItemSelectionActionsEditorProps { + actionOptions?: ActionOptions; + selectionOptions?: SelectionOptions; + onChangeActions: (actions?: ActionOptions) => void; + onChangeSelection: (selection?: SelectionOptions) => void; +} + +const HTTP_METHODS: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; +const BATCH_MODES: Array<{ value: BatchMode; label: string }> = [ + { value: 'individual', label: 'Individual (one request per selection)' }, + { value: 'batch', label: 'Batch (single request with all selections)' }, +]; +const CONTENT_TYPES: Array<{ value: ContentType; label: string }> = [ + { value: 'none', label: 'None' }, + { value: 'json', label: 'JSON' }, + { value: 'text', label: 'Text' }, +]; +const BODY_METHODS: HttpMethod[] = ['POST', 'PUT', 'PATCH']; +const BODY_CLEAR_CONFIRM_MESSAGE = 'Changing this option will remove the current body template. Continue?'; + +/** Available action icons with their display components */ +export const ACTION_ICONS: Array<{ value: ActionIcon; label: string; icon: ReactElement }> = [ + { value: 'play', label: 'Play', icon: }, + { value: 'pause', label: 'Pause', icon: }, + { value: 'stop', label: 'Stop', icon: }, + { value: 'delete', label: 'Delete', icon: }, + { value: 'refresh', label: 'Refresh', icon: }, + { value: 'send', label: 'Send', icon: }, + { value: 'download', label: 'Download', icon: }, + { value: 'upload', label: 'Upload', icon: }, + { value: 'check', label: 'Check', icon: }, + { value: 'close', label: 'Close', icon: }, + { value: 'alert', label: 'Alert', icon: }, + { value: 'info', label: 'Info', icon: }, + { value: 'settings', label: 'Settings', icon: }, + { value: 'link', label: 'Link', icon: }, + { value: 'sync', label: 'Sync', icon: }, + { value: 'troubleshoot', label: 'Troubleshoot', icon: }, + { value: 'ask-ai', label: 'Ask AI', icon: }, +]; + +const URL_HELPER_TEXT = 'Supports interpolation: ${__data.fields["fieldName"]}, ${__data.index}, ${__data.count}'; + +function createDefaultEventAction(): EventAction { + return { + type: 'event', + name: 'New Event Action', + eventName: 'selection-action', + batchMode: 'individual', + enabled: true, + }; +} + +function createDefaultWebhookAction(): WebhookAction { + return { + type: 'webhook', + name: 'New Webhook Action', + url: '', + method: 'POST', + contentType: 'none', + batchMode: 'individual', + enabled: true, + }; +} + +interface ItemActionEditorProps { + action: ItemAction; + index: number; + onChange: (index: number, action: ItemAction) => void; + onRemove: (index: number) => void; + onMoveUp: () => void; + onMoveDown: () => void; +} + +interface InterpolationHelperProps { + batchMode: BatchMode; +} + +function InterpolationHelper({ batchMode }: InterpolationHelperProps): ReactElement { + let content: ReactElement = ( +
+ Individual mode patterns: {'${__data.fields["field"]}'}, {'${__data.index}'},{' '} + {'${__data.count}'} +
+ ); + + if (batchMode === 'batch') { + content = ( +
+ Batch mode patterns: {'${__data}'}, {"${__data[0].fields['field']}"},{' '} + {"${__data.fields['field']:csv}"}, {'${__data.count}'} +
+ ); + } + + return ( + + {content} + + ); +} + +function EventActionEditor({ + action, + index, + onChange, + onRemove, + onMoveDown, + onMoveUp, +}: ItemActionEditorProps): ReactElement { + const eventAction = action as EventAction; + + const [isCollapsed, setIsCollapsed] = useState(true); + const hasBodyTemplate = (eventAction.bodyTemplate ?? '').trim().length > 0; + + const handleIncludesTemplateChange = useCallback( + (event: React.ChangeEvent) => { + const nextContentType = event.target.value as 'custom' | 'none'; + + const bodyTemplate = nextContentType === 'custom' ? JSON.stringify({}) : undefined; + + onChange(index, { ...eventAction, bodyTemplate: bodyTemplate }); + }, + [index, onChange, eventAction] + ); + + const handleBodyTemplateChange = useCallback( + (template: string) => { + onChange(index, { ...eventAction, bodyTemplate: template || undefined }); + }, + [index, onChange, eventAction] + ); + + const jsonDataTemplate = useMemo(() => { + if (eventAction.bodyTemplate) { + try { + return JSON.parse(eventAction.bodyTemplate); + } catch { + return {}; + } + } + }, [eventAction.bodyTemplate]); + + return ( + }> + + + setIsCollapsed(!isCollapsed)} + > + {isCollapsed ? : } + + + EVENT ACTION:{' '} + {eventAction.name ? ( + + {eventAction.name} + + ) : ( + {eventAction.name} + )} + + + + + + onRemove(index)} + key="delete-action-button" + > + + + + + theme.palette.background.lighter }, + }} + key="reorder-action-button" + /> + + + + + {!isCollapsed && ( + + onChange(index, { ...eventAction, enabled: e.target.checked })} + /> + } + /> + + + onChange(index, { ...eventAction, name: e.target.value })} + sx={{ flexGrow: 1 }} + /> + + + Icon + + + + + + onChange(index, { ...eventAction, eventName: e.target.value })} + helperText="Name of the CustomEvent to dispatch (e.g., 'selection-action')" + fullWidth + /> + + + Batch Mode + + + + + + Template + + } label="None" /> + } label="JSON template" /> + + + + {hasBodyTemplate && ( + <> + + + + )} + + onChange(index, { ...eventAction, confirmMessage: e.target.value || undefined })} + helperText="If set, shows a confirmation dialog before executing the action" + fullWidth + multiline + rows={2} + /> + + )} + + ); +} + +function WebhookActionEditor({ + action, + index, + onChange, + onRemove, + onMoveUp, + onMoveDown, +}: ItemActionEditorProps): ReactElement { + const webhookAction = action as WebhookAction; + const [pendingChange, setPendingChange] = useState< + { kind: 'contentType'; value: ContentType } | { kind: 'method'; value: HttpMethod } | null + >(null); + const contentTypeValue = webhookAction.contentType ?? 'none'; + const hasBodyTemplate = (webhookAction.bodyTemplate ?? '').trim().length > 0; + const supportsBody = BODY_METHODS.includes(webhookAction.method); + + const [isCollapsed, setIsCollapsed] = useState(true); + + const handleBodyTemplateChange = useCallback( + (template: string) => { + onChange(index, { ...webhookAction, bodyTemplate: template || undefined }); + }, + [index, onChange, webhookAction] + ); + + const handleTextTemplateChange = useCallback( + (event: React.ChangeEvent) => { + onChange(index, { ...webhookAction, bodyTemplate: event.target.value || undefined }); + }, + [index, onChange, webhookAction] + ); + + const handleContentTypeChange = useCallback( + (event: React.ChangeEvent) => { + const nextContentType = event.target.value as ContentType; + if (nextContentType === contentTypeValue) { + return; + } + + if (hasBodyTemplate) { + setPendingChange({ kind: 'contentType', value: nextContentType }); + return; + } + + onChange(index, { ...webhookAction, contentType: nextContentType }); + }, + [contentTypeValue, hasBodyTemplate, index, onChange, webhookAction] + ); + + const handleMethodChange = useCallback( + (event: SelectChangeEvent) => { + const nextMethod = event.target.value as HttpMethod; + if (nextMethod === webhookAction.method) { + return; + } + + const nextSupportsBody = BODY_METHODS.includes(nextMethod); + if (!nextSupportsBody && hasBodyTemplate) { + setPendingChange({ kind: 'method', value: nextMethod }); + return; + } + + onChange(index, { ...webhookAction, method: nextMethod }); + }, + [hasBodyTemplate, index, onChange, webhookAction] + ); + + const handleConfirmClose = useCallback(() => { + setPendingChange(null); + }, []); + + const handleConfirmApply = useCallback(() => { + if (!pendingChange) { + return; + } + + if (pendingChange.kind === 'contentType') { + onChange(index, { ...webhookAction, contentType: pendingChange.value, bodyTemplate: undefined }); + } else { + onChange(index, { ...webhookAction, method: pendingChange.value, bodyTemplate: undefined }); + } + + setPendingChange(null); + }, [index, onChange, pendingChange, webhookAction]); + + const jsonBodyTemplate = useMemo(() => { + if (webhookAction.contentType === 'json' && webhookAction.bodyTemplate) { + try { + return JSON.parse(webhookAction.bodyTemplate); + } catch { + return {}; + } + } + }, [webhookAction.bodyTemplate, webhookAction.contentType]); + + return ( + }> + + + setIsCollapsed(!isCollapsed)} + > + {isCollapsed ? : } + + + WEBHOOK ACTION: {webhookAction.name} + + + + + + onRemove(index)} + key="delete-action-button" + > + + + + + theme.palette.background.lighter }, + }} + key="reorder-action-button" + /> + + + + + {!isCollapsed && ( + + onChange(index, { ...webhookAction, enabled: e.target.checked })} + /> + } + /> + + + onChange(index, { ...webhookAction, name: e.target.value })} + sx={{ flexGrow: 1 }} + /> + + + Icon + + + + + onChange(index, { ...webhookAction, url: e.target.value })} + helperText={URL_HELPER_TEXT} + fullWidth + /> + + + + Method + + + + + Batch Mode + + + + + + Content Type + + {CONTENT_TYPES.map((option) => ( + } + label={option.label} + /> + ))} + + + + {supportsBody && contentTypeValue !== 'none' && ( + + + {contentTypeValue === 'json' ? 'Body Template (JSON)' : 'Body Template (Text)'} + + + {contentTypeValue === 'json' ? ( + + ) : ( + + )} + + )} + + onChange(index, { ...webhookAction, confirmMessage: e.target.value || undefined })} + helperText="If set, shows a confirmation dialog before executing the action" + fullWidth + multiline + rows={2} + /> + + )} + + + Remove Body Template? + + {BODY_CLEAR_CONFIRM_MESSAGE} + + + + + + + + ); +} + +export function ItemSelectionActionsEditor({ + actionOptions, + selectionOptions, + onChangeActions, + onChangeSelection, +}: ItemSelectionActionsEditorProps): ReactElement { + const actions = useMemo( + () => actionOptions || { enabled: true, displayInHeader: true, displayWithItem: false }, + [actionOptions] + ); + + const handleEnableActionsChange: SwitchProps['onChange'] = (_: unknown, checked: boolean) => { + onChangeActions({ ...actions, enabled: checked ? true : undefined }); + }; + + const handleEnableSelectionChange: SwitchProps['onChange'] = (_: unknown, checked: boolean) => { + onChangeSelection({ ...selectionOptions, enabled: checked ? true : undefined }); + }; + + const handleDisplayInHeaderChange: SwitchProps['onChange'] = (_: unknown, checked: boolean) => { + onChangeActions({ ...actions, displayInHeader: checked ? true : undefined }); + }; + + const handleDisplayWithItemChange: SwitchProps['onChange'] = (_: unknown, checked: boolean) => { + onChangeActions({ ...actions, displayWithItem: checked ? true : undefined }); + }; + + const handleAddEventAction = useCallback(() => { + onChangeActions({ ...actions, actionsList: [...(actions.actionsList ?? []), createDefaultEventAction()] }); + }, [actions, onChangeActions]); + + const handleAddWebhookAction = useCallback(() => { + onChangeActions({ ...actions, actionsList: [...(actions.actionsList ?? []), createDefaultWebhookAction()] }); + }, [actions, onChangeActions]); + + const handleActionChange = useCallback( + (index: number, updatedAction: ItemAction) => { + const newActions = actions.actionsList ? [...actions.actionsList] : []; + newActions[index] = updatedAction; + onChangeActions({ ...actions, actionsList: newActions }); + }, + [actions, onChangeActions] + ); + + const handleRemoveAction = useCallback( + (index: number) => { + const newActions = actions.actionsList ? actions.actionsList.filter((_, i) => i !== index) : []; + onChangeActions(newActions.length > 0 ? { ...actions, actionsList: newActions } : undefined); + }, + [actions, onChangeActions] + ); + + useDragAndDropMonitor({ + elements: actions.actionsList as unknown as Array>, + accessKey: 'name', + onChange: (newElements) => { + onChangeActions({ ...actions, actionsList: newElements as unknown as ItemAction[] }); + }, + }); + + return ( + + } + /> + } + /> + } + /> + } + /> + + + {!actions.actionsList || actions.actionsList.length === 0 ? ( + + No actions defined. Add an action to enable triggering events or webhooks on selected data. + + ) : ( + + {actions.actionsList && + actions.actionsList.map((action, index) => ( + theme.palette.divider} pb={1}> + {action.type === 'event' ? ( + + onChangeActions({ ...actions, actionsList: handleMoveDown(action, actions.actionsList!) }) + } + onMoveUp={() => + onChangeActions({ ...actions, actionsList: handleMoveUp(action, actions.actionsList!) }) + } + /> + ) : ( + + onChangeActions({ ...actions, actionsList: handleMoveDown(action, actions.actionsList!) }) + } + onMoveUp={() => + onChangeActions({ ...actions, actionsList: handleMoveUp(action, actions.actionsList!) }) + } + /> + )} + + ))} + + )} + + + + + + + + + ); +} diff --git a/plugin-system/src/components/ItemSelectionActionsOptionsEditor/index.ts b/plugin-system/src/components/ItemSelectionActionsOptionsEditor/index.ts new file mode 100644 index 0000000..eba432d --- /dev/null +++ b/plugin-system/src/components/ItemSelectionActionsOptionsEditor/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 './ItemSelectionActionsOptionsEditor'; diff --git a/plugin-system/src/components/SelectionOptionsEditor/SelectionOptionsEditor.tsx b/plugin-system/src/components/SelectionOptionsEditor/SelectionOptionsEditor.tsx new file mode 100644 index 0000000..657de6b --- /dev/null +++ b/plugin-system/src/components/SelectionOptionsEditor/SelectionOptionsEditor.tsx @@ -0,0 +1,41 @@ +// 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, SwitchProps } from '@mui/material'; +import { OptionsEditorControl, OptionsEditorGroup } from '@perses-dev/components'; +import { ReactElement } from 'react'; + +export interface SelectionOptions { + enabled?: boolean; +} + +export interface SelectionOptionsEditorProps { + value?: SelectionOptions; + onChange: (selection?: SelectionOptions) => void; +} + +export function SelectionOptionsEditor({ value, onChange }: SelectionOptionsEditorProps): ReactElement { + const handleEnabledChange: SwitchProps['onChange'] = (_: unknown, checked: boolean) => { + onChange(checked ? { enabled: true } : undefined); + }; + + return ( + + } + /> + + ); +} diff --git a/plugin-system/src/components/SelectionOptionsEditor/index.ts b/plugin-system/src/components/SelectionOptionsEditor/index.ts new file mode 100644 index 0000000..d05f7d6 --- /dev/null +++ b/plugin-system/src/components/SelectionOptionsEditor/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 './SelectionOptionsEditor'; diff --git a/plugin-system/src/components/index.ts b/plugin-system/src/components/index.ts index e4bb5fd..f7151db 100644 --- a/plugin-system/src/components/index.ts +++ b/plugin-system/src/components/index.ts @@ -15,6 +15,7 @@ export * from './CalculationSelector'; export * from './DatasourceEditorForm'; export * from './DatasourceSelect'; export * from './HTTPSettingsEditor'; +export * from './ItemSelectionActionsOptionsEditor'; export * from './LegendOptionsEditor'; export * from './MultiQueryEditor'; export * from './OptionsEditorRadios'; diff --git a/plugin-system/src/model/variables.ts b/plugin-system/src/model/variables.ts index 17a98cf..b6064c6 100644 --- a/plugin-system/src/model/variables.ts +++ b/plugin-system/src/model/variables.ts @@ -11,11 +11,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { UnknownSpec, AbsoluteTimeRange } from '@perses-dev/core'; -import { VariableStateMap, DatasourceStore } from '../runtime'; +import type { VariableOption } from '@perses-dev/components'; +import { AbsoluteTimeRange, UnknownSpec } from '@perses-dev/core'; +import { DatasourceStore, VariableStateMap } from '../runtime'; import { Plugin } from './plugin-base'; -export type VariableOption = { label: string; value: string }; +// Re-export VariableOption from @perses-dev/components for backwards compatibility +export type { VariableOption } from '@perses-dev/components'; export interface GetVariableOptionsContext { variables: VariableStateMap; diff --git a/plugin-system/src/runtime/index.ts b/plugin-system/src/runtime/index.ts index 700ead9..72c8679 100644 --- a/plugin-system/src/runtime/index.ts +++ b/plugin-system/src/runtime/index.ts @@ -19,6 +19,7 @@ export * from './TimeRangeProvider'; export * from './time-series-queries'; export * from './trace-queries'; export * from './profile-queries'; +export * from './item-actions'; export * from './DataQueriesProvider'; export * from './QueryCountProvider'; export * from './RouterProvider'; diff --git a/plugin-system/src/runtime/item-actions.ts b/plugin-system/src/runtime/item-actions.ts new file mode 100644 index 0000000..d546d08 --- /dev/null +++ b/plugin-system/src/runtime/item-actions.ts @@ -0,0 +1,331 @@ +// 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 { + ActionStatus, + interpolateSelectionBatch, + interpolateSelectionIndividual, + SelectionItem, + VariableStateMap, +} from '@perses-dev/components'; +import { fetch } from '@perses-dev/core'; +import { ItemAction, EventAction, WebhookAction } from '../components/ItemSelectionActionsOptionsEditor'; + +const BODY_METHODS = new Set(['POST', 'PUT', 'PATCH']); + +function buildWebhookHeaders(action: WebhookAction): Record { + const headers: Record = { ...(action.headers ?? {}) }; + const contentType = action.contentType ?? 'none'; + const supportsBody = BODY_METHODS.has(action.method); + + if (supportsBody && contentType === 'json') { + headers['Content-Type'] = 'application/json'; + } else if (supportsBody && contentType === 'text') { + headers['Content-Type'] = 'text/plain; charset=utf-8'; + } + + return headers; +} + +/** + * Parameters for executing a selection action + */ +export interface ExecuteActionParams { + /** The action to execute */ + action: ItemAction; + /** Map of selection IDs to their data */ + selectionMap: Map; + /** Optional dashboard variable state for interpolation */ + variableState?: VariableStateMap; + /** Callback to update action status */ + setActionStatus: (actionName: string, status: Partial, itemId?: Id) => void; +} + +/** + * Result of action execution + */ +export interface ActionExecutionResult { + success: boolean; + error?: Error; + /** For individual batch mode, results per item */ + itemResults?: Map; +} + +/** + * Execute an event action by dispatching a single CustomEvent with batch data + */ +function executeEventBatch( + action: EventAction, + selectionMap: Map, + variableState: VariableStateMap | undefined, + setActionStatus: ExecuteActionParams['setActionStatus'] +): ActionExecutionResult { + try { + setActionStatus(action.name, { loading: true }); + + const items = Array.from(selectionMap.values()); + + // Interpolate body template if provided + let body: string | undefined; + if (action.bodyTemplate) { + const bodyResult = interpolateSelectionBatch(action.bodyTemplate, items, variableState); + body = bodyResult.text; + } else { + body = JSON.stringify({ items }); + } + + const event = new CustomEvent(action.eventName, { + detail: body, + bubbles: true, + cancelable: true, + }); + + window.dispatchEvent(event); + + setActionStatus(action.name, { loading: false, success: true }); + return { success: true }; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + setActionStatus(action.name, { loading: false, error: err }); + return { success: false, error: err }; + } +} + +/** + * Execute events actions by dispatching one CustomEvent per selection + */ +function executeEventIndividual( + action: EventAction, + selectionMap: Map, + variableState: VariableStateMap | undefined, + setActionStatus: ExecuteActionParams['setActionStatus'] +): ActionExecutionResult { + const entries = Array.from(selectionMap.entries()); + const count = entries.length; + const itemResults = new Map(); + + // Initialize all items as loading + setActionStatus(action.name, { loading: true, itemStatuses: new Map() }); + + for (let index = 0; index < entries.length; index++) { + const [id, item] = entries[index]!; + + setActionStatus(action.name, { loading: true }, id); + + try { + // Interpolate body template if provided + let body: string | undefined; + if (action.bodyTemplate) { + const bodyResult = interpolateSelectionIndividual(action.bodyTemplate, item, index, count, variableState); + body = bodyResult.text; + } else { + body = JSON.stringify({ id, data: item }); + } + + const event = new CustomEvent(action.eventName, { + detail: body, + bubbles: true, + cancelable: true, + }); + + window.dispatchEvent(event); + itemResults.set(id, { success: true }); + setActionStatus(action.name, { loading: false, success: true }, id); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + setActionStatus(action.name, { loading: false, error: err }, id); + itemResults.set(id, { success: false, error: err }); + } + } + + setActionStatus(action.name, { loading: false, success: true }); + + return { + success: true, + itemResults, + }; +} + +/** + * Execute a webhook action in individual mode (one request per selection) + */ +async function executeWebhookIndividual( + action: WebhookAction, + selectionMap: Map, + variableState: VariableStateMap | undefined, + setActionStatus: ExecuteActionParams['setActionStatus'] +): Promise { + const entries = Array.from(selectionMap.entries()); + const count = entries.length; + const itemResults = new Map(); + + // Initialize all items as loading + setActionStatus(action.name, { loading: true, itemStatuses: new Map() }); + + // Execute requests sequentially to avoid overwhelming the server + for (let index = 0; index < entries.length; index++) { + const [id, item] = entries[index]!; + + setActionStatus(action.name, { loading: true }, id); + + try { + // Interpolate URL + const urlResult = interpolateSelectionIndividual(action.url, item, index, count, variableState); + + // Interpolate body template if provided + const contentType = action.contentType ?? 'none'; + const supportsBody = BODY_METHODS.has(action.method) && contentType !== 'none'; + let body: string | undefined; + if (supportsBody && action.bodyTemplate) { + const bodyResult = interpolateSelectionIndividual(action.bodyTemplate, item, index, count, variableState); + body = bodyResult.text; + } + + // Make the request + const response = await fetch(urlResult.text, { + method: action.method, + headers: buildWebhookHeaders(action), + body: body, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + setActionStatus(action.name, { loading: false, success: true }, id); + itemResults.set(id, { success: true }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + setActionStatus(action.name, { loading: false, error: err }, id); + itemResults.set(id, { success: false, error: err }); + } + } + + // Update overall action status + const allSucceeded = Array.from(itemResults.values()).every((r) => r.success); + const anyFailed = Array.from(itemResults.values()).some((r) => !r.success); + + if (allSucceeded) { + setActionStatus(action.name, { loading: false, success: true }); + } else if (anyFailed) { + setActionStatus(action.name, { + loading: false, + error: new Error('Some requests failed'), + }); + } + + return { + success: allSucceeded, + itemResults, + }; +} + +/** + * Execute a webhook action in batch mode (single request with all selections) + */ +async function executeWebhookBatch( + action: WebhookAction, + selectionMap: Map, + variableState: VariableStateMap | undefined, + setActionStatus: ExecuteActionParams['setActionStatus'] +): Promise { + const items = Array.from(selectionMap.values()); + + setActionStatus(action.name, { loading: true }); + + try { + // Interpolate URL + const urlResult = interpolateSelectionBatch(action.url, items, variableState); + + // Interpolate body template if provided + const contentType = action.contentType ?? 'none'; + const supportsBody = BODY_METHODS.has(action.method) && contentType !== 'none'; + let body: string | undefined; + if (supportsBody && action.bodyTemplate) { + const bodyResult = interpolateSelectionBatch(action.bodyTemplate, items, variableState); + body = bodyResult.text; + } + + // Make the request + const response = await fetch(urlResult.text, { + method: action.method, + headers: buildWebhookHeaders(action), + body: body, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + setActionStatus(action.name, { loading: false, success: true }); + return { success: true }; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + setActionStatus(action.name, { loading: false, error: err }); + return { success: false, error: err }; + } +} + +/** + * Execute a webhook action + */ +async function executeWebhookAction( + action: WebhookAction, + selectionMap: Map, + variableState: VariableStateMap | undefined, + setActionStatus: ExecuteActionParams['setActionStatus'] +): Promise { + if (action.batchMode === 'batch') { + return executeWebhookBatch(action, selectionMap, variableState, setActionStatus); + } else { + return executeWebhookIndividual(action, selectionMap, variableState, setActionStatus); + } +} + +/** + * Execute an event action + */ +async function executeEventAction( + action: EventAction, + selectionMap: Map, + variableState: VariableStateMap | undefined, + setActionStatus: ExecuteActionParams['setActionStatus'] +): Promise { + if (action.batchMode === 'batch') { + return executeEventBatch(action, selectionMap, variableState, setActionStatus); + } else { + return executeEventIndividual(action, selectionMap, variableState, setActionStatus); + } +} + +/** + * Execute a selection action (event or webhook) + * + * @param params - Execution parameters including action, selections, and callbacks + * @returns Promise resolving to the execution result + */ +export async function executeAction(params: ExecuteActionParams): Promise { + const { action, selectionMap, variableState, setActionStatus } = params; + + if (selectionMap.size === 0) { + return { success: true }; + } + + if (action.type === 'event') { + return executeEventAction(action, selectionMap, variableState, setActionStatus); + } else if (action.type === 'webhook') { + return executeWebhookAction(action, selectionMap, variableState, setActionStatus); + } + + return { success: false, error: new Error(`Unknown action type`) }; +} diff --git a/plugin-system/src/runtime/variables.ts b/plugin-system/src/runtime/variables.ts index 862e2d1..c35e0bb 100644 --- a/plugin-system/src/runtime/variables.ts +++ b/plugin-system/src/runtime/variables.ts @@ -11,30 +11,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { createContext, useContext, useMemo } from 'react'; -import { VariableValue } from '@perses-dev/core'; +import { + VariableOption, + VariableState, + VariableStateMap, + parseVariables, + replaceVariables, +} from '@perses-dev/components'; import { immerable } from 'immer'; -import { VariableOption } from '../model'; -import { parseVariables, replaceVariables } from '../utils'; +import { createContext, useContext, useMemo } from 'react'; import { useBuiltinVariableValues } from './builtin-variables'; -export type VariableState = { - value: VariableValue; - options?: VariableOption[]; - loading: boolean; - error?: Error; - /** - * If a local variable is overriding an external variable, local var will have the flag ``overriding=true``. - */ - overriding?: boolean; - /** - * If a local variable is overriding an external variable, external var will have the flag ``overridden=true``. - */ - overridden?: boolean; - defaultValue?: VariableValue; -}; - -export type VariableStateMap = Record; +// Re-export types from @perses-dev/components for backwards compatibility +export type { VariableOption, VariableState, VariableStateMap }; /** * Structure used as key in the {@link VariableStoreStateMap}. diff --git a/plugin-system/src/utils/variables.ts b/plugin-system/src/utils/variables.ts index c80d643..bac9f1a 100644 --- a/plugin-system/src/utils/variables.ts +++ b/plugin-system/src/utils/variables.ts @@ -11,164 +11,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { VariableValue } from '@perses-dev/core'; -import { VariableStateMap } from '@perses-dev/plugin-system'; - -export function replaceVariables(text: string, variableState: VariableStateMap): string { - const variablesMap = parseVariablesAndFormat(text); - const variables = Array.from(variablesMap.keys()); - let finalText = text; - variables - // Sorting variables by their length. - // In order to not have a variable name have contained in another variable name. - // i.e.: $__range replacing $__range_ms => '3600_ms' instead of '3600000' - .sort((a, b) => b.length - a.length) - .forEach((v) => { - const variable = variableState[v]; - if (variable && variable.value !== undefined) { - finalText = replaceVariable(finalText, v, variable?.value, variablesMap.get(v)); - } - }); - - return finalText; -} - -export enum InterpolationFormat { - CSV = 'csv', - DISTRIBUTED = 'distributed', - DOUBLEQUOTE = 'doublequote', - GLOB = 'glob', - JSON = 'json', - LUCENE = 'lucene', - PERCENTENCODE = 'percentencode', - PIPE = 'pipe', - PROMETHEUS = 'prometheus', - RAW = 'raw', - REGEX = 'regex', - SINGLEQUOTE = 'singlequote', - SQLSTRING = 'sqlstring', - TEXT = 'text', - QUERYPARAM = 'queryparam', -} - -function stringToFormat(val: string | undefined): InterpolationFormat | undefined { - if (!val) return undefined; - - const lowerVal = val.toLowerCase(); - return Object.values(InterpolationFormat).find((format) => format === lowerVal) || undefined; -} - -export function interpolate(values: string[], name: string, format: InterpolationFormat): string { - switch (format) { - case InterpolationFormat.CSV: - case InterpolationFormat.RAW: - return values.join(','); - case InterpolationFormat.DISTRIBUTED: { - const [first, ...rest] = values; - return `${[first, ...rest.map((v) => `${name}=${v}`)].join(',')}`; - } - case InterpolationFormat.DOUBLEQUOTE: - return values.map((v) => `"${v}"`).join(','); - case InterpolationFormat.GLOB: - return `{${values.join(',')}}`; - case InterpolationFormat.JSON: - return JSON.stringify(values); - case InterpolationFormat.LUCENE: - return `(${values.map((v) => `"${v}"`).join(' OR ')})`; - case InterpolationFormat.PERCENTENCODE: - return encodeURIComponent(values.join(',')); - case InterpolationFormat.PIPE: - return values.join('|'); - case InterpolationFormat.REGEX: { - const escapedRegex = values.map((v) => v.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')); - return `(${escapedRegex.join('|')})`; - } - case InterpolationFormat.SINGLEQUOTE: - return values.map((v) => `'${v}'`).join(','); - case InterpolationFormat.SQLSTRING: - return values.map((v) => `'${v.replace(/'/g, "''")}'`).join(','); - case InterpolationFormat.TEXT: - return values.join(' + '); - case InterpolationFormat.QUERYPARAM: - return values.map((v) => `${name}=${encodeURIComponent(v)}`).join('&'); - case InterpolationFormat.PROMETHEUS: - default: - return `(${values.join('|')})`; - } -} - -export function replaceVariable( - text: string, - varName: string, - variableValue: VariableValue, - varFormat?: InterpolationFormat -): string { - const variableSyntax = '$' + varName; - const alternativeVariableSyntax = '${' + varName + (varFormat ? ':' + varFormat : '') + '}'; - - let replaceString = ''; - if (Array.isArray(variableValue)) { - replaceString = interpolate(variableValue, varName, varFormat || InterpolationFormat.PROMETHEUS); - } - if (typeof variableValue === 'string') { - replaceString = interpolate([variableValue], varName, varFormat || InterpolationFormat.RAW); - } - - text = text.replaceAll(variableSyntax, replaceString); - return text.replaceAll(alternativeVariableSyntax, replaceString); -} - -// This regular expression is designed to identify variable references in a string. -// It supports two formats for referencing variables: -// 1. $variableName - This is a simpler format, and the regular expression captures the variable name (\w+ matches one or more word characters). -// 2. ${variableName} - This is a more complex format and the regular expression captures the variable name (\w+ matches one or more word characters) in the curly braces. -// 3. ${variableName:format} - This is a more complex format that allows specifying a format interpolation. - -const VARIABLE_REGEX = /\$(\w+)|\${(\w+)(?:\.([^:^}]+))?(?::([^}]+))?}/gm; - -/** - * Returns a list of variables - */ -export function parseVariables(text: string): string[] { - const matches = new Set(); - let match; - - while ((match = VARIABLE_REGEX.exec(text)) !== null) { - if (match) { - if (match[1]) { - // \$(\w+)\ - matches.add(match[1]); - } else if (match[2]) { - // \${(\w+)}\ - matches.add(match[2]); - } - } - } - // return unique matches - return Array.from(matches.values()); -} - -/** - * Returns a map of variable names and its format. If no format is specified, it will be undefined. - */ -export function parseVariablesAndFormat(text: string): Map { - const matches = new Map(); - let match; - - while ((match = VARIABLE_REGEX.exec(text)) !== null) { - if (match) { - let format = undefined; - if (match[4]) { - format = match[4]; - } - if (match[1]) { - // \$(\w+)\ - matches.set(match[1], stringToFormat(format)); - } else if (match[2]) { - // \${(\w+)}\ - matches.set(match[2], stringToFormat(format)); - } - } - } - return matches; -} +// Re-export all variable interpolation utilities from @perses-dev/components for backwards compatibility +export type { VariableOption, VariableState, VariableStateMap } from '@perses-dev/components'; +export { + InterpolationFormat, + interpolate, + parseVariables, + parseVariablesAndFormat, + replaceVariable, + replaceVariables, +} from '@perses-dev/components';