diff --git a/javascript/grid-ui/src/components/RunningSessions/ColumnSelector.tsx b/javascript/grid-ui/src/components/RunningSessions/ColumnSelector.tsx new file mode 100644 index 0000000000000..2a82639cda2bf --- /dev/null +++ b/javascript/grid-ui/src/components/RunningSessions/ColumnSelector.tsx @@ -0,0 +1,178 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you 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, { useState, useEffect } from 'react' +import { + Box, + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + FormGroup, + IconButton, + Tooltip, + Typography +} from '@mui/material' +import { ViewColumn as ViewColumnIcon } from '@mui/icons-material' + +interface ColumnSelectorProps { + sessions: any[] + selectedColumns: string[] + onColumnSelectionChange: (columns: string[]) => void +} + +const ColumnSelector: React.FC = ({ + sessions, + selectedColumns, + onColumnSelectionChange +}) => { + const [open, setOpen] = useState(false) + const [availableColumns, setAvailableColumns] = useState([]) + const [localSelectedColumns, setLocalSelectedColumns] = useState(selectedColumns) + + useEffect(() => { + setLocalSelectedColumns(selectedColumns) + }, [selectedColumns]) + + useEffect(() => { + let allKeys = new Set() + try { + const savedKeys = localStorage.getItem('selenium-grid-all-capability-keys') + if (savedKeys) { + const parsedKeys = JSON.parse(savedKeys) + parsedKeys.forEach((key: string) => allKeys.add(key)) + } + } catch (e) { + console.error('Error loading saved capability keys:', e) + } + + sessions.forEach(session => { + try { + const capabilities = JSON.parse(session.capabilities) + Object.keys(capabilities).forEach(key => { + if ( + typeof capabilities[key] !== 'object' && + !key.startsWith('goog:') && + !key.startsWith('moz:') && + key !== 'alwaysMatch' && + key !== 'firstMatch' + ) { + allKeys.add(key) + } + }) + } catch (e) { + console.error('Error parsing capabilities:', e) + } + }) + + const keysArray = Array.from(allKeys).sort() + localStorage.setItem('selenium-grid-all-capability-keys', JSON.stringify(keysArray)) + + setAvailableColumns(keysArray) + }, [sessions]) + + const handleToggle = (column: string) => { + setLocalSelectedColumns(prev => { + if (prev.includes(column)) { + return prev.filter(col => col !== column) + } else { + return [...prev, column] + } + }) + } + + const handleClose = () => { + setOpen(false) + } + + const handleSave = () => { + onColumnSelectionChange(localSelectedColumns) + setOpen(false) + } + + const handleSelectAll = (checked: boolean) => { + if (checked) { + setLocalSelectedColumns([...availableColumns]) + } else { + setLocalSelectedColumns([]) + } + } + + return ( + + + setOpen(true)} + > + + + + + + + Select Columns to Display + + + + Select capability fields to display as additional columns: + + + 0} + indeterminate={localSelectedColumns.length > 0 && localSelectedColumns.length < availableColumns.length} + onChange={(e) => handleSelectAll(e.target.checked)} + /> + } + label={Select All / Unselect All} + /> + {availableColumns.map(column => ( + handleToggle(column)} + /> + } + label={column} + /> + ))} + + + + + + + + + ) +} + +export default ColumnSelector diff --git a/javascript/grid-ui/src/components/RunningSessions/RunningSessions.tsx b/javascript/grid-ui/src/components/RunningSessions/RunningSessions.tsx index 5b8cfb8522cf6..211bbe1cb89d0 100644 --- a/javascript/grid-ui/src/components/RunningSessions/RunningSessions.tsx +++ b/javascript/grid-ui/src/components/RunningSessions/RunningSessions.tsx @@ -51,6 +51,7 @@ import { Size } from '../../models/size' import LiveView from '../LiveView/LiveView' import SessionData, { createSessionData } from '../../models/session-data' import { useNavigate } from 'react-router-dom' +import ColumnSelector from './ColumnSelector' function descendingComparator (a: T, b: T, orderBy: keyof T): number { if (orderBy === 'sessionDurationMillis') { @@ -94,7 +95,7 @@ interface HeadCell { numeric: boolean } -const headCells: HeadCell[] = [ +const fixedHeadCells: HeadCell[] = [ { id: 'id', numeric: false, label: 'Session' }, { id: 'capabilities', numeric: false, label: 'Capabilities' }, { id: 'startTime', numeric: false, label: 'Start time' }, @@ -107,10 +108,11 @@ interface EnhancedTableProps { property: keyof SessionData) => void order: Order orderBy: string + headCells: HeadCell[] } function EnhancedTableHead (props: EnhancedTableProps): JSX.Element { - const { order, orderBy, onRequestSort } = props + const { order, orderBy, onRequestSort, headCells } = props const createSortHandler = (property: keyof SessionData) => (event: React.MouseEvent) => { onRequestSort(event, property) } @@ -181,6 +183,16 @@ function RunningSessions (props) { const [rowsPerPage, setRowsPerPage] = useState(10) const [searchFilter, setSearchFilter] = useState('') const [searchBarHelpOpen, setSearchBarHelpOpen] = useState(false) + const [selectedColumns, setSelectedColumns] = useState(() => { + try { + const savedColumns = localStorage.getItem('selenium-grid-selected-columns') + return savedColumns ? JSON.parse(savedColumns) : [] + } catch (e) { + console.error('Error loading saved columns:', e) + return [] + } + }) + const [headCells, setHeadCells] = useState(fixedHeadCells) const liveViewRef = useRef(null) const navigate = useNavigate() @@ -264,8 +276,27 @@ function RunningSessions (props) { const { sessions, origin, sessionId } = props + const getCapabilityValue = (capabilitiesStr: string, key: string): string => { + try { + const capabilities = JSON.parse(capabilitiesStr as string) + const value = capabilities[key] + + if (value === undefined || value === null) { + return '' + } + + if (typeof value === 'object') { + return JSON.stringify(value) + } + + return String(value) + } catch (e) { + return '' + } + } + const rows = sessions.map((session) => { - return createSessionData( + const sessionData = createSessionData( session.id, session.capabilities, session.startTime, @@ -276,6 +307,12 @@ function RunningSessions (props) { session.slot, origin ) + + selectedColumns.forEach(column => { + sessionData[column] = getCapabilityValue(session.capabilities, column) + }) + + return sessionData }) const emptyRows = rowsPerPage - Math.min(rowsPerPage, rows.length - page * rowsPerPage) @@ -291,6 +328,16 @@ function RunningSessions (props) { setRowLiveViewOpen(s) } }, [sessionId, sessions]) + + useEffect(() => { + const dynamicHeadCells = selectedColumns.map(column => ({ + id: column, + numeric: false, + label: column + })) + + setHeadCells([...fixedHeadCells, ...dynamicHeadCells]) + }, [selectedColumns]) return ( @@ -298,12 +345,22 @@ function RunningSessions (props) {
- + + { + setSelectedColumns(columns) + localStorage.setItem('selenium-grid-selected-columns', JSON.stringify(columns)) + }} + /> + + {stableSort(rows, getComparator(order, orderBy)) @@ -494,6 +552,10 @@ function RunningSessions (props) { {row.nodeUri} + {/* Add dynamic columns */} + {selectedColumns.map(column => ( + {row[column]} + ))} ) })} diff --git a/javascript/grid-ui/src/models/session-data.ts b/javascript/grid-ui/src/models/session-data.ts index e232adc9bb23d..bbc576d11c3b4 100644 --- a/javascript/grid-ui/src/models/session-data.ts +++ b/javascript/grid-ui/src/models/session-data.ts @@ -33,6 +33,7 @@ interface SessionData { slot: any vnc: string name: string + [key: string]: any } export function createSessionData ( diff --git a/javascript/grid-ui/src/tests/components/ColumnSelector.test.tsx b/javascript/grid-ui/src/tests/components/ColumnSelector.test.tsx new file mode 100644 index 0000000000000..9a837540077c4 --- /dev/null +++ b/javascript/grid-ui/src/tests/components/ColumnSelector.test.tsx @@ -0,0 +1,273 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you 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 * as React from 'react' +import ColumnSelector from '../../components/RunningSessions/ColumnSelector' +import { act, screen, within } from '@testing-library/react' +import { render } from '../utils/render-utils' +import userEvent from '@testing-library/user-event' +import '@testing-library/jest-dom' + +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: jest.fn((key: string) => store[key] || null), + setItem: jest.fn((key: string, value: string) => { + store[key] = value + }), + clear: jest.fn(() => { + store = {} + }) + } +})() + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock +}) + +const mockSessions = [ + { + id: 'session1', + capabilities: JSON.stringify({ + browserName: 'chrome', + browserVersion: '88.0', + platformName: 'windows', + acceptInsecureCerts: true + }) + }, + { + id: 'session2', + capabilities: JSON.stringify({ + browserName: 'firefox', + browserVersion: '78.0', + platformName: 'linux', + acceptSslCerts: false + }) + } +] + +beforeEach(() => { + localStorageMock.clear() + jest.clearAllMocks() +}) + +it('renders column selector button', () => { + const onColumnSelectionChange = jest.fn() + render( + + ) + + const button = screen.getByRole('button', { name: /select columns/i }) + expect(button).toBeInTheDocument() + + expect(screen.getByTestId('ViewColumnIcon')).toBeInTheDocument() +}) + +it('opens dialog when button is clicked', async () => { + const onColumnSelectionChange = jest.fn() + render( + + ) + + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: /select columns/i })) + + expect(screen.getByText('Select Columns to Display')).toBeInTheDocument() + expect(screen.getByText('Select capability fields to display as additional columns:')).toBeInTheDocument() +}) + +it('displays available columns from session capabilities', async () => { + const onColumnSelectionChange = jest.fn() + render( + + ) + + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: /select columns/i })) + + expect(screen.getByLabelText('browserName')).toBeInTheDocument() + expect(screen.getByLabelText('browserVersion')).toBeInTheDocument() + expect(screen.getByLabelText('platformName')).toBeInTheDocument() + expect(screen.getByLabelText('acceptInsecureCerts')).toBeInTheDocument() + expect(screen.getByLabelText('acceptSslCerts')).toBeInTheDocument() +}) + +it('shows selected columns as checked', async () => { + const onColumnSelectionChange = jest.fn() + const selectedColumns = ['browserName', 'platformName'] + + render( + + ) + + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: /select columns/i })) + + expect(screen.getByLabelText('browserName')).toBeChecked() + expect(screen.getByLabelText('platformName')).toBeChecked() + + expect(screen.getByLabelText('browserVersion')).not.toBeChecked() + expect(screen.getByLabelText('acceptInsecureCerts')).not.toBeChecked() +}) + +it('toggles column selection when checkbox is clicked', async () => { + const onColumnSelectionChange = jest.fn() + const selectedColumns = ['browserName'] + + render( + + ) + + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: /select columns/i })) + + await user.click(screen.getByLabelText('platformName')) + await user.click(screen.getByLabelText('browserName')) + + await user.click(screen.getByRole('button', { name: /apply/i })) + + expect(onColumnSelectionChange).toHaveBeenCalledWith(['platformName']) +}) + +it('selects all columns when "Select All" is clicked', async () => { + const onColumnSelectionChange = jest.fn() + + render( + + ) + + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: /select columns/i })) + + await user.click(screen.getByLabelText(/select all/i)) + + await user.click(screen.getByRole('button', { name: /apply/i })) + + expect(onColumnSelectionChange).toHaveBeenCalled() + const allColumns = ['browserName', 'browserVersion', 'platformName', 'acceptInsecureCerts', 'acceptSslCerts'] + expect(onColumnSelectionChange.mock.calls[0][0].sort()).toEqual(allColumns.sort()) +}) + +it('unselects all columns when "Select All" is unchecked', async () => { + const onColumnSelectionChange = jest.fn() + const allColumns = ['browserName', 'browserVersion', 'platformName', 'acceptInsecureCerts', 'acceptSslCerts'] + + render( + + ) + + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: /select columns/i })) + + await user.click(screen.getByLabelText(/select all/i)) + + await user.click(screen.getByRole('button', { name: /apply/i })) + + expect(onColumnSelectionChange).toHaveBeenCalledWith([]) +}) + +it('closes dialog without changes when Cancel is clicked', async () => { + const onColumnSelectionChange = jest.fn() + const selectedColumns = ['browserName'] + + render( + + ) + + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: /select columns/i })) + + await user.click(screen.getByLabelText('platformName')) + + await user.click(screen.getByRole('button', { name: /cancel/i })) + + expect(onColumnSelectionChange).not.toHaveBeenCalled() +}) + +it('saves capability keys to localStorage', async () => { + render( + + ) + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'selenium-grid-all-capability-keys', + expect.any(String) + ) + + const savedKeys = JSON.parse(localStorageMock.setItem.mock.calls[0][1]) + expect(savedKeys).toContain('browserName') + expect(savedKeys).toContain('browserVersion') + expect(savedKeys).toContain('platformName') + expect(savedKeys).toContain('acceptInsecureCerts') + expect(savedKeys).toContain('acceptSslCerts') +}) + +it('loads capability keys from localStorage', async () => { + const savedKeys = ['browserName', 'customCapability', 'platformName'] + localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(savedKeys)) + + render( + + ) + + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: /select columns/i })) + + expect(screen.getByLabelText('browserName')).toBeInTheDocument() + expect(screen.getByLabelText('customCapability')).toBeInTheDocument() + expect(screen.getByLabelText('platformName')).toBeInTheDocument() +})