From b0935ebf2eff7b605f0c7e9292cd2d415726eea6 Mon Sep 17 00:00:00 2001 From: Gabriel Bernal Date: Wed, 24 Jun 2026 13:42:23 +0200 Subject: [PATCH] [FEATURE] add column settings to logs table Signed-off-by: Gabriel Bernal --- logstable/package.json | 3 +- logstable/schemas/logstable.cue | 16 +- logstable/src/LogsTable.ts | 3 +- logstable/src/LogsTableColumnsEditor.test.tsx | 151 + logstable/src/LogsTableColumnsEditor.tsx | 153 + logstable/src/LogsTableComponent.tsx | 7 +- logstable/src/LogsTablePanel.test.tsx | 6 +- .../src/components/ColumnsEditor.test.tsx | 183 + logstable/src/components/ColumnsEditor.tsx | 192 + .../components/LogRow/LogLabelCell.test.tsx | 45 + .../src/components/LogRow/LogLabelCell.tsx | 61 + .../src/components/LogRow/LogRow.test.tsx | 132 +- logstable/src/components/LogRow/LogRow.tsx | 166 +- .../src/components/LogRow/LogTimestamp.tsx | 4 +- .../src/components/LogRow/LogsStyles.tsx | 13 +- .../src/components/LogsTableHeader.test.tsx | 143 + logstable/src/components/LogsTableHeader.tsx | 92 + .../src/components/VirtualizedLogsList.tsx | 187 +- .../src/components/column-resolution.test.ts | 275 ++ logstable/src/components/column-resolution.ts | 109 + .../src/components/logs-table-sorting.test.ts | 117 + .../src/components/logs-table-sorting.ts | 75 + logstable/src/model.ts | 16 + logstable/src/setup-tests.ts | 7 + package-lock.json | 3298 +++++------------ 25 files changed, 2958 insertions(+), 2496 deletions(-) create mode 100644 logstable/src/LogsTableColumnsEditor.test.tsx create mode 100644 logstable/src/LogsTableColumnsEditor.tsx create mode 100644 logstable/src/components/ColumnsEditor.test.tsx create mode 100644 logstable/src/components/ColumnsEditor.tsx create mode 100644 logstable/src/components/LogRow/LogLabelCell.test.tsx create mode 100644 logstable/src/components/LogRow/LogLabelCell.tsx create mode 100644 logstable/src/components/LogsTableHeader.test.tsx create mode 100644 logstable/src/components/LogsTableHeader.tsx create mode 100644 logstable/src/components/column-resolution.test.ts create mode 100644 logstable/src/components/column-resolution.ts create mode 100644 logstable/src/components/logs-table-sorting.test.ts create mode 100644 logstable/src/components/logs-table-sorting.ts diff --git a/logstable/package.json b/logstable/package.json index 7879ee555..9e467d879 100644 --- a/logstable/package.json +++ b/logstable/package.json @@ -26,7 +26,8 @@ "types": "lib/index.d.ts", "dependencies": { "ansi_up": "^6.0.0", - "dompurify": "^3.4.11" + "dompurify": "^3.4.11", + "immer": "^10.1.1" }, "peerDependencies": { "@emotion/react": "^11.7.1", diff --git a/logstable/schemas/logstable.cue b/logstable/schemas/logstable.cue index b683fc1e1..e5ba2562c 100644 --- a/logstable/schemas/logstable.cue +++ b/logstable/schemas/logstable.cue @@ -21,7 +21,17 @@ kind: "LogsTable" spec: close({ allowWrap?: bool enableDetails?: bool - showTime?: bool - selection?: common.#selection - actions?: common.#actions + // Deprecated: use columns instead. Only effective when columns is not set. + showTime?: bool + columns?: [...close({ + name: string + header?: string + enableSorting?: bool + sort?: "asc" | "desc" + sortMode?: "alphabetical" | "numeric" | "timestamp" + allowWrap?: bool + width?: number + })] + selection?: common.#selection + actions?: common.#actions }) diff --git a/logstable/src/LogsTable.ts b/logstable/src/LogsTable.ts index ed63e65d6..13ec23ccf 100644 --- a/logstable/src/LogsTable.ts +++ b/logstable/src/LogsTable.ts @@ -12,6 +12,7 @@ // limitations under the License. import { PanelPlugin } from '@perses-dev/plugin-system'; +import { LogsTableColumnsEditor } from './LogsTableColumnsEditor'; import { LogsTableComponent } from './LogsTableComponent'; import { LogsTableItemSelectionActionsEditor } from './LogsTableItemSelectionActionsEditor'; import { LogsTableSettingsEditor } from './LogsTableSettingsEditor'; @@ -22,11 +23,11 @@ export const LogsTable: PanelPlugin = { PanelComponent: LogsTableComponent, panelOptionsEditorComponents: [ { label: 'Settings', content: LogsTableSettingsEditor }, + { label: 'Columns', content: LogsTableColumnsEditor }, { label: 'Item Actions', content: LogsTableItemSelectionActionsEditor }, ], supportedQueryTypes: ['LogQuery'], createInitialOptions: () => ({ - showTime: true, allowWrap: true, enableDetails: true, }), diff --git a/logstable/src/LogsTableColumnsEditor.test.tsx b/logstable/src/LogsTableColumnsEditor.test.tsx new file mode 100644 index 000000000..826f47094 --- /dev/null +++ b/logstable/src/LogsTableColumnsEditor.test.tsx @@ -0,0 +1,151 @@ +// 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, fireEvent } from '@testing-library/react'; +import { LogsTableColumnsEditor } from './LogsTableColumnsEditor'; +import { LogsTableOptions, LogsColumnDefinition } from './model'; + +const createProps = (columns?: LogsColumnDefinition[]): { value: LogsTableOptions; onChange: jest.Mock } => { + const value: LogsTableOptions = { + allowWrap: true, + enableDetails: true, + columns, + }; + const onChange = jest.fn(); + return { value, onChange }; +}; + +describe('LogsTableColumnsEditor', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the description text about default columns', () => { + const props = createProps(); + render(); + expect(screen.getByText(/Timestamp and Log line are shown by default/i)).toBeInTheDocument(); + }); + + it('should render columns when provided', () => { + const props = createProps([{ name: 'service', header: 'Service' }, { name: 'level' }]); + render(); + expect(screen.getByText('Service')).toBeInTheDocument(); + expect(screen.getByText('level')).toBeInTheDocument(); + }); + + it('should pre-populate with timestamp and line columns on first add', () => { + const props = createProps(undefined); + render(); + fireEvent.click(screen.getByRole('button', { name: /add column/i })); + expect(props.onChange).toHaveBeenCalledTimes(1); + const newValue = props.onChange.mock.calls[0][0]; + expect(newValue.columns).toHaveLength(2); + expect(newValue.columns[0]).toEqual({ + name: 'timestamp', + header: 'Timestamp', + sortMode: 'timestamp', + sort: 'desc', + }); + expect(newValue.columns[1]).toEqual({ name: 'line', header: 'Log line', allowWrap: true, enableSorting: false }); + }); + + it('should call onChange with new column appended when add is clicked', () => { + const props = createProps([{ name: 'existing' }]); + render(); + fireEvent.click(screen.getByRole('button', { name: /add column/i })); + expect(props.onChange).toHaveBeenCalledTimes(1); + const newValue = props.onChange.mock.calls[0][0]; + expect(newValue.columns).toHaveLength(2); + expect(newValue.columns[1]).toEqual({ name: '' }); + }); + + it('should call onChange with column removed when delete is clicked', () => { + const props = createProps([{ name: 'first' }, { name: 'second' }]); + render(); + const deleteButtons = screen.getAllByLabelText('Delete column'); + fireEvent.click(deleteButtons[0]!); + expect(props.onChange).toHaveBeenCalledTimes(1); + const newValue = props.onChange.mock.calls[0][0]; + expect(newValue.columns).toHaveLength(1); + expect(newValue.columns[0].name).toBe('second'); + }); + + it('should call onChange with columns reordered when move up is clicked', () => { + const props = createProps([{ name: 'first' }, { name: 'second' }]); + render(); + const moveUpButtons = screen.getAllByLabelText('Move column up'); + fireEvent.click(moveUpButtons[1]!); // move second column up + expect(props.onChange).toHaveBeenCalledTimes(1); + const newValue = props.onChange.mock.calls[0][0]; + expect(newValue.columns[0].name).toBe('second'); + expect(newValue.columns[1].name).toBe('first'); + }); + + it('should call onChange with columns reordered when move down is clicked', () => { + const props = createProps([{ name: 'first' }, { name: 'second' }]); + render(); + const moveDownButtons = screen.getAllByLabelText('Move column down'); + fireEvent.click(moveDownButtons[0]!); // move first column down + expect(props.onChange).toHaveBeenCalledTimes(1); + const newValue = props.onChange.mock.calls[0][0]; + expect(newValue.columns[0].name).toBe('second'); + expect(newValue.columns[1].name).toBe('first'); + }); + + it('should render wrap content checkbox for each column', () => { + const props = createProps([{ name: 'col1' }, { name: 'col2' }]); + render(); + const wrapCheckboxes = screen.getAllByLabelText('Wrap content'); + expect(wrapCheckboxes).toHaveLength(2); + }); + + it('should have wrap content unchecked by default', () => { + const props = createProps([{ name: 'col1' }]); + render(); + const wrapCheckbox = screen.getByLabelText('Wrap content'); + expect(wrapCheckbox).not.toBeChecked(); + }); + + it('should have wrap content checked when allowWrap is true', () => { + const props = createProps([{ name: 'col1', allowWrap: true }]); + render(); + const wrapCheckbox = screen.getByLabelText('Wrap content'); + expect(wrapCheckbox).toBeChecked(); + }); + + it('should render column name field with helper text', () => { + const props = createProps([{ name: 'service' }]); + render(); + expect(screen.getByText(/Use 'timestamp', 'line', or a label key/)).toBeInTheDocument(); + }); + + it('should display "New column" as display name for empty column', () => { + const props = createProps([{ name: '' }]); + render(); + expect(screen.getByText('New column')).toBeInTheDocument(); + }); + + it('should render with empty columns array when columns is undefined', () => { + const props = createProps(undefined); + render(); + expect(screen.getByRole('button', { name: /add column/i })).toBeInTheDocument(); + expect(screen.queryByLabelText('Enable sorting')).not.toBeInTheDocument(); + }); + + it('should render sort mode options including Alphabetical, Numeric, and Timestamp', () => { + const props = createProps([{ name: 'col1' }]); + render(); + // The sort mode select should be present + expect(screen.getByLabelText('Sort mode')).toBeInTheDocument(); + }); +}); diff --git a/logstable/src/LogsTableColumnsEditor.tsx b/logstable/src/LogsTableColumnsEditor.tsx new file mode 100644 index 000000000..c91e3912a --- /dev/null +++ b/logstable/src/LogsTableColumnsEditor.tsx @@ -0,0 +1,153 @@ +// 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 { Checkbox, FormControlLabel, Stack, TextField } from '@mui/material'; +import { OptionsEditorProps } from '@perses-dev/plugin-system'; +import { produce } from 'immer'; +import { ReactElement, useCallback } from 'react'; +import { ColumnsEditor } from './components/ColumnsEditor'; +import { LogsTableOptions, LogsColumnDefinition, LogsColumnSortMode } from './model'; + +const SORT_MODE_LABELS: Record = { + alphabetical: 'Alphabetical', + numeric: 'Numeric', + timestamp: 'Timestamp', +}; + +export function LogsTableColumnsEditor(props: OptionsEditorProps): ReactElement { + const { value, onChange } = props; + const columns = value.columns ?? []; + + const updateColumns = useCallback( + (recipe: (draft: LogsColumnDefinition[]) => void) => { + onChange( + produce(value, (draft) => { + if (!draft.columns) draft.columns = []; + recipe(draft.columns); + }) + ); + }, + [value, onChange] + ); + + const handleAddColumn = useCallback(() => { + updateColumns((cols) => { + if (cols.length === 0) { + cols.push( + { name: 'timestamp', header: 'Timestamp', sortMode: 'timestamp', sort: 'desc' }, + { name: 'line', header: 'Log line', allowWrap: true, enableSorting: false } + ); + } else { + cols.push({ name: '' }); + } + }); + }, [updateColumns]); + + const handleRemoveColumn = useCallback( + (index: number) => updateColumns((cols) => cols.splice(index, 1)), + [updateColumns] + ); + + const handleUpdateColumn = useCallback( + (index: number, updater: (draft: LogsColumnDefinition) => void) => { + updateColumns((cols) => { + if (cols[index]) updater(cols[index]); + }); + }, + [updateColumns] + ); + + const handleMoveUp = useCallback( + (index: number) => { + if (index <= 0) return; + updateColumns((cols) => { + const [item] = cols.splice(index, 1); + if (item) cols.splice(index - 1, 0, item); + }); + }, + [updateColumns] + ); + + const handleMoveDown = useCallback( + (index: number) => { + if (index >= columns.length - 1) return; + updateColumns((cols) => { + const [item] = cols.splice(index, 1); + if (item) cols.splice(index + 1, 0, item); + }); + }, + [updateColumns, columns.length] + ); + + return ( + + columns={columns} + description="Timestamp and Log line are shown by default. Add columns below to customize which columns are visible and their order." + sortModeLabels={SORT_MODE_LABELS} + defaultSortMode="alphabetical" + getDisplayName={(col) => col.header || col.name || 'New column'} + getHeaderPlaceholder={(col) => col.name || 'Column header'} + onAdd={handleAddColumn} + onRemove={handleRemoveColumn} + onUpdate={handleUpdateColumn} + onMoveUp={handleMoveUp} + onMoveDown={handleMoveDown} + renderNameField={(col, index, onUpdate) => ( + + onUpdate(index, (draft) => { + draft.name = e.target.value; + }) + } + size="small" + fullWidth + helperText="Use 'timestamp', 'line', or a label key" + /> + )} + renderExtraFields={(col, index, onUpdate) => ( + + + onUpdate(index, (draft) => { + const val = e.target.value ? parseInt(e.target.value, 10) : undefined; + draft.width = val && val > 0 ? val : undefined; + }) + } + size="small" + sx={{ width: 120 }} + placeholder="auto" + /> + + onUpdate(index, (draft) => { + draft.allowWrap = e.target.checked || undefined; + }) + } + size="small" + /> + } + label="Wrap content" + /> + + )} + /> + ); +} diff --git a/logstable/src/LogsTableComponent.tsx b/logstable/src/LogsTableComponent.tsx index fdcb99ef9..54188ec66 100644 --- a/logstable/src/LogsTableComponent.tsx +++ b/logstable/src/LogsTableComponent.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ReactElement } from 'react'; +import { ReactElement, useMemo } from 'react'; import { Box, Typography } from '@mui/material'; import { LogsTableProps } from './model'; import { LogsList } from './components/LogsList'; @@ -19,10 +19,7 @@ import { LogsList } from './components/LogsList'; export function LogsTableComponent(props: LogsTableProps): ReactElement | null { const { queryResults, spec } = props; - // all queries results must be included - const logs = queryResults - .flatMap((result) => result?.data.logs?.entries ?? []) - .sort((a, b) => b.timestamp - a.timestamp); + const logs = useMemo(() => queryResults.flatMap((result) => result?.data.logs?.entries ?? []), [queryResults]); if (!logs.length) { return ( diff --git a/logstable/src/LogsTablePanel.test.tsx b/logstable/src/LogsTablePanel.test.tsx index 8a02797c7..96620f1c9 100644 --- a/logstable/src/LogsTablePanel.test.tsx +++ b/logstable/src/LogsTablePanel.test.tsx @@ -125,12 +125,12 @@ describe('LogsTablePanel', () => { fireEvent.mouseDown(firstRow, { metaKey: true }); fireEvent.mouseDown(secondRow, { metaKey: true }); - // Copy with onCopy event - const virtuosoScroller = screen.getByTestId('virtuoso-scroller'); + // Copy with onCopy event — fire on the outer container which has the onCopy handler + const container = items.closest('[class*="MuiBox-root"]')!; const mockClipboardData = { setData: jest.fn(), }; - fireEvent.copy(virtuosoScroller, { clipboardData: mockClipboardData }); + fireEvent.copy(container, { clipboardData: mockClipboardData }); // Should have copied both logs expect(mockClipboardData.setData).toHaveBeenCalledWith('text/plain', expect.stringMatching(/foo.*bar/s)); diff --git a/logstable/src/components/ColumnsEditor.test.tsx b/logstable/src/components/ColumnsEditor.test.tsx new file mode 100644 index 000000000..035d5d913 --- /dev/null +++ b/logstable/src/components/ColumnsEditor.test.tsx @@ -0,0 +1,183 @@ +// 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, fireEvent } from '@testing-library/react'; +import { ReactElement } from 'react'; +import { ColumnsEditor, ColumnsEditorProps, BaseColumnDefinition } from './ColumnsEditor'; + +interface TestColumn extends BaseColumnDefinition { + customField?: string; +} + +const defaultProps = (overrides: Partial> = {}): ColumnsEditorProps => ({ + columns: [], + description: 'Test description text', + sortModeLabels: { alpha: 'Alphabetical', num: 'Numeric' }, + defaultSortMode: 'alpha', + getDisplayName: (col) => col.header || col.name || 'Unnamed', + getHeaderPlaceholder: (col) => col.name || 'Column header', + onAdd: jest.fn(), + onRemove: jest.fn(), + onUpdate: jest.fn(), + onMoveUp: jest.fn(), + onMoveDown: jest.fn(), + renderNameField: (col, index, _onUpdate) => ( + {}} /> + ), + ...overrides, +}); + +const sampleColumns: TestColumn[] = [ + { name: 'col1', header: 'Column One', enableSorting: true, sortMode: 'alpha' }, + { name: 'col2', header: 'Column Two' }, +]; + +describe('ColumnsEditor', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render description text', () => { + render(); + expect(screen.getByText('Test description text')).toBeInTheDocument(); + }); + + it('should render "Columns" group title', () => { + render(); + expect(screen.getByText('Columns')).toBeInTheDocument(); + }); + + it('should render add column button', () => { + render(); + expect(screen.getByRole('button', { name: /add column/i })).toBeInTheDocument(); + }); + + it('should call onAdd when add column button is clicked', () => { + const onAdd = jest.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /add column/i })); + expect(onAdd).toHaveBeenCalledTimes(1); + }); + + it('should render column display names', () => { + render(); + expect(screen.getByText('Column One')).toBeInTheDocument(); + expect(screen.getByText('Column Two')).toBeInTheDocument(); + }); + + it('should render name field for each column via renderNameField', () => { + render(); + expect(screen.getByTestId('name-field-0')).toBeInTheDocument(); + expect(screen.getByTestId('name-field-1')).toBeInTheDocument(); + }); + + it('should render header text field for each column', () => { + render(); + const headerInputs = screen.getAllByLabelText('Header'); + expect(headerInputs).toHaveLength(2); + }); + + it('should render enable sorting checkbox for each column', () => { + render(); + const checkboxes = screen.getAllByLabelText('Enable sorting'); + expect(checkboxes).toHaveLength(2); + }); + + it('should have enable sorting checked by default (when enableSorting is undefined)', () => { + const cols: TestColumn[] = [{ name: 'test' }]; // enableSorting is undefined => default true + render(); + const checkbox = screen.getByLabelText('Enable sorting'); + expect(checkbox).toBeChecked(); + }); + + it('should have enable sorting unchecked when enableSorting is false', () => { + const cols: TestColumn[] = [{ name: 'test', enableSorting: false }]; + render(); + const checkbox = screen.getByLabelText('Enable sorting'); + expect(checkbox).not.toBeChecked(); + }); + + it('should render sort mode select for each column', () => { + render(); + const sortModeSelects = screen.getAllByLabelText('Sort mode'); + expect(sortModeSelects).toHaveLength(2); + }); + + it('should render default sort select for each column', () => { + render(); + const defaultSortSelects = screen.getAllByLabelText('Default sort'); + expect(defaultSortSelects).toHaveLength(2); + }); + + it('should call onRemove with correct index when delete button is clicked', () => { + const onRemove = jest.fn(); + render(); + const deleteButtons = screen.getAllByLabelText('Delete column'); + fireEvent.click(deleteButtons[1]!); + expect(onRemove).toHaveBeenCalledWith(1); + }); + + it('should call onMoveUp with correct index when move up button is clicked', () => { + const onMoveUp = jest.fn(); + render(); + const moveUpButtons = screen.getAllByLabelText('Move column up'); + fireEvent.click(moveUpButtons[1]!); + expect(onMoveUp).toHaveBeenCalledWith(1); + }); + + it('should call onMoveDown with correct index when move down button is clicked', () => { + const onMoveDown = jest.fn(); + render(); + const moveDownButtons = screen.getAllByLabelText('Move column down'); + fireEvent.click(moveDownButtons[0]!); + expect(onMoveDown).toHaveBeenCalledWith(0); + }); + + it('should call onUpdate when header text field changes', () => { + const onUpdate = jest.fn(); + render(); + const headerInputs = screen.getAllByLabelText('Header'); + fireEvent.change(headerInputs[0]!, { target: { value: 'New Header' } }); + expect(onUpdate).toHaveBeenCalledWith(0, expect.any(Function)); + }); + + it('should call onUpdate when enable sorting checkbox changes', () => { + const onUpdate = jest.fn(); + render(); + const checkboxes = screen.getAllByLabelText('Enable sorting'); + fireEvent.click(checkboxes[0]!); + expect(onUpdate).toHaveBeenCalledWith(0, expect.any(Function)); + }); + + it('should render extra fields when renderExtraFields is provided', () => { + const renderExtraFields = (col: TestColumn, index: number): ReactElement => ( +
Extra for {col.name}
+ ); + render(); + expect(screen.getByTestId('extra-field-0')).toBeInTheDocument(); + expect(screen.getByTestId('extra-field-1')).toBeInTheDocument(); + expect(screen.getByText('Extra for col1')).toBeInTheDocument(); + }); + + it('should not render extra fields section when renderExtraFields is not provided', () => { + render(); + expect(screen.queryByTestId('extra-field-0')).not.toBeInTheDocument(); + }); + + it('should render empty state with only description and add button when no columns', () => { + render(); + expect(screen.getByText('Test description text')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /add column/i })).toBeInTheDocument(); + expect(screen.queryByLabelText('Enable sorting')).not.toBeInTheDocument(); + }); +}); diff --git a/logstable/src/components/ColumnsEditor.tsx b/logstable/src/components/ColumnsEditor.tsx new file mode 100644 index 000000000..1c232dda9 --- /dev/null +++ b/logstable/src/components/ColumnsEditor.tsx @@ -0,0 +1,192 @@ +// 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, + Checkbox, + Divider, + FormControlLabel, + IconButton, + MenuItem, + Stack, + TextField, + Typography, +} from '@mui/material'; +import ArrowUp from 'mdi-material-ui/ArrowUp'; +import ArrowDown from 'mdi-material-ui/ArrowDown'; +import DeleteIcon from 'mdi-material-ui/Delete'; +import PlusIcon from 'mdi-material-ui/Plus'; +import { OptionsEditorGroup } from '@perses-dev/components'; +import { ReactElement } from 'react'; + +export interface BaseColumnDefinition { + name: string; + header?: string; + enableSorting?: boolean; + sort?: 'asc' | 'desc'; + sortMode?: string; +} + +export type ColumnUpdater = (index: number, updater: (draft: C) => void) => void; + +export interface ColumnsEditorProps { + columns: C[]; + description: string; + sortModeLabels: Record; + defaultSortMode: string; + getDisplayName: (column: C) => string; + getHeaderPlaceholder: (column: C) => string; + onAdd: () => void; + onRemove: (index: number) => void; + onUpdate: ColumnUpdater; + onMoveUp: (index: number) => void; + onMoveDown: (index: number) => void; + renderNameField: (column: C, index: number, onUpdate: ColumnUpdater) => ReactElement; + renderExtraFields?: (column: C, index: number, onUpdate: ColumnUpdater) => ReactElement; +} + +export function ColumnsEditor(props: ColumnsEditorProps): ReactElement { + const { + columns, + description, + sortModeLabels, + defaultSortMode, + getDisplayName, + getHeaderPlaceholder, + onAdd, + onRemove, + onUpdate, + onMoveUp, + onMoveDown, + renderNameField, + renderExtraFields, + } = props; + + return ( + + + {description} + + + {columns.map((column, index) => { + return ( + + {index > 0 && } + + {/* Header row: display name + action buttons */} + + {getDisplayName(column)} + + onMoveUp(index)} aria-label="Move column up"> + + + onMoveDown(index)} aria-label="Move column down"> + + + onRemove(index)} aria-label="Delete column"> + + + + + + {/* Name + Header row */} + + {renderNameField(column, index, onUpdate)} + + { + onUpdate(index, (draft) => { + draft.header = e.target.value || undefined; + }); + }} + placeholder={getHeaderPlaceholder(column)} + size="small" + fullWidth + /> + + + + {/* Enable sorting checkbox */} + { + onUpdate(index, (draft) => { + draft.enableSorting = e.target.checked ? undefined : false; + }); + }} + size="small" + /> + } + label="Enable sorting" + /> + + {/* Sort mode + Default sort row */} + + + { + onUpdate(index, (draft) => { + draft.sortMode = e.target.value; + }); + }} + size="small" + fullWidth + > + {Object.entries(sortModeLabels).map(([value, label]) => ( + + {label} + + ))} + + + + { + onUpdate(index, (draft) => { + draft.sort = (e.target.value as 'asc' | 'desc') || undefined; + }); + }} + size="small" + fullWidth + > + None + Ascending + Descending + + + + + {/* Extra fields */} + {renderExtraFields && {renderExtraFields(column, index, onUpdate)}} + + + ); + })} + + + + ); +} diff --git a/logstable/src/components/LogRow/LogLabelCell.test.tsx b/logstable/src/components/LogRow/LogLabelCell.test.tsx new file mode 100644 index 000000000..59e9059f3 --- /dev/null +++ b/logstable/src/components/LogRow/LogLabelCell.test.tsx @@ -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. + +import { render, screen } from '@testing-library/react'; +import { LogLabelCell } from './LogLabelCell'; + +describe('LogLabelCell', () => { + it('should render the value text', () => { + render(); + expect(screen.getByText('my-service')).toBeInTheDocument(); + }); + + it('should render em-dash when value is undefined', () => { + render(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('should apply nowrap styles when allowWrap is false', () => { + render(); + const el = screen.getByText('some-value'); + expect(el).toHaveStyle({ whiteSpace: 'nowrap' }); + }); + + it('should apply wrap styles when allowWrap is true', () => { + render(); + const el = screen.getByText('some-value'); + expect(el).toHaveStyle({ whiteSpace: 'pre-wrap' }); + }); + + it('should render with monospace font', () => { + render(); + const el = screen.getByText('test'); + expect(el).toHaveStyle({ fontSize: '12px' }); + }); +}); diff --git a/logstable/src/components/LogRow/LogLabelCell.tsx b/logstable/src/components/LogRow/LogLabelCell.tsx new file mode 100644 index 000000000..c826b5464 --- /dev/null +++ b/logstable/src/components/LogRow/LogLabelCell.tsx @@ -0,0 +1,61 @@ +// 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 React from 'react'; +import { Box, Tooltip } from '@mui/material'; + +interface LogLabelCellProps { + value: string | undefined; + allowWrap: boolean; + minWidth?: number; +} + +export const LogLabelCell: React.FC = ({ value, allowWrap, minWidth = 80 }) => { + if (value === undefined) { + return ( + + — + + ); + } + + const content = ( + + {value} + + ); + + if (!allowWrap) { + return {content}; + } + + return content; +}; diff --git a/logstable/src/components/LogRow/LogRow.test.tsx b/logstable/src/components/LogRow/LogRow.test.tsx index 8b12498b8..5bc98bc57 100644 --- a/logstable/src/components/LogRow/LogRow.test.tsx +++ b/logstable/src/components/LogRow/LogRow.test.tsx @@ -13,6 +13,7 @@ import { render, screen, waitFor, fireEvent, RenderResult } from '@testing-library/react'; import { LogEntry } from '@perses-dev/spec'; +import { ResolvedColumn } from '../column-resolution'; import { LogRow } from './LogRow'; // Mock clipboard API @@ -22,6 +23,20 @@ Object.assign(navigator, { }, }); +const defaultResolvedColumns: ResolvedColumn[] = [ + { + name: 'timestamp', + header: 'Timestamp', + type: 'timestamp', + enableSorting: true, + sortMode: 'timestamp', + allowWrap: false, + }, + { name: 'line', header: 'Log line', type: 'line', enableSorting: false, sortMode: 'alphabetical', allowWrap: false }, +]; + +const defaultGridTemplate = '16px 190px 1fr min-content'; + describe('LogRow', () => { const mockLog: LogEntry = { timestamp: 1767225600, @@ -38,6 +53,8 @@ describe('LogRow', () => { onToggle={jest.fn()} isSelected={isSelected} onSelect={onSelect} + resolvedColumns={defaultResolvedColumns} + gridTemplateColumns={defaultGridTemplate} /> ); }; @@ -135,7 +152,16 @@ describe('LogRow', () => { line: '\x1b[31mERROR\x1b[0m connection refused', labels: { level: 'error' }, }; - render(); + render( + + ); const errorSpan = document.querySelector('.ansi-red-fg'); expect(errorSpan).toBeInTheDocument(); expect(errorSpan).toHaveTextContent('ERROR'); @@ -147,4 +173,108 @@ describe('LogRow', () => { expect(document.querySelector('[class*="ansi-"]')).toBeNull(); }); }); + + describe('dynamic columns', () => { + it('should render label column cells', () => { + const columnsWithLabel: ResolvedColumn[] = [ + { + name: 'region', + header: 'Region', + type: 'label', + enableSorting: true, + sortMode: 'alphabetical', + allowWrap: false, + }, + { + name: 'line', + header: 'Log line', + type: 'line', + enableSorting: false, + sortMode: 'alphabetical', + allowWrap: false, + }, + ]; + render( + + ); + // 'bar' is the value of the region label, and also appears in the log line. + // Use getAllByText to confirm at least one element renders the label value. + const elements = screen.getAllByText((content, element) => { + return element?.tagName === 'SPAN' && content === 'bar'; + }); + expect(elements.length).toBeGreaterThanOrEqual(1); + }); + + it('should render em-dash for missing label values', () => { + const columnsWithMissingLabel: ResolvedColumn[] = [ + { + name: 'nonexistent', + header: 'Missing', + type: 'label', + enableSorting: true, + sortMode: 'alphabetical', + allowWrap: false, + }, + { + name: 'line', + header: 'Log line', + type: 'line', + enableSorting: false, + sortMode: 'alphabetical', + allowWrap: false, + }, + ]; + render( + + ); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('should render timestamp column', () => { + render( + + ); + // LogTimestamp renders a