Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ REACT_APP_PVWS_HTTP_URL=http://localhost:8081/pvws/
REACT_APP_LIVE_MONITOR_WARN=50
REACT_APP_LIVE_MONITOR_MAX=100

# Set to true to check the "Monitor All" box in the home page results table by default.
# All PVs on the visible page are monitored after each search and each page change.
# Note that a results table page size above REACT_APP_LIVE_MONITOR_WARN will show the warning message after every search
REACT_APP_DEFAULT_MONITOR_ALL=false

# By default, PV Info treats byte arrays as strings. Change this to false to treat them as byte arrays.
# PVWS was updated in 2024 to send "b64byt" instead of using the "text" field for these waveforms of chars
# https://github.com/ornl-epics/pvws/pull/23
Expand Down
66 changes: 50 additions & 16 deletions src/components/home/queryresults/QueryResults.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { Fragment, useCallback, useEffect, useState } from 'react';
import { Typography, Checkbox, Link, IconButton, Tooltip } from '@mui/material';
import { DataGrid } from '@mui/x-data-grid';
import { DataGrid, useGridApiRef, gridPaginatedVisibleSortedGridRowIdsSelector } from '@mui/x-data-grid';
import { useNavigate, Link as RouterLink } from "react-router";
import TimelineIcon from '@mui/icons-material/Timeline';
import ReadMoreIcon from '@mui/icons-material/ReadMore';
Expand All @@ -13,6 +13,7 @@ import csvExportOptions from "../../../csvExportOptions";

function QueryResults(props) {
const navigate = useNavigate();
const apiRef = useGridApiRef();
const pageSizeEnvValue = config.RESULTS_TABLE_SIZE;
const [paginationModel, setPaginationModel] = useState({ page: 0, pageSize: pageSizeEnvValue });
const [pvs, setPVs] = useState([]);
Expand All @@ -31,6 +32,8 @@ function QueryResults(props) {
const [checked, setChecked] = useState([]);
const [currentChecked, setCurrentChecked] = useState(new Set());
const [monitorAllChecked, setMonitorAllChecked] = useState(false);
// set when the visible page changes and monitor all is checked, so the PVs on the new visible page get monitored
const [monitorVisiblePagePending, setMonitorVisiblePagePending] = useState(false);
const liveMonitorMax = config.LIVE_MONITOR_MAX;
const liveMonitorWarn = config.LIVE_MONITOR_WARN;

Expand Down Expand Up @@ -74,23 +77,39 @@ function QueryResults(props) {
setCurrentChecked(newCurrentChecked);
}, [currentChecked, updateCurrentChecked])

const handleMonitorSelectAll = useCallback((firstRow, lastRow) => (event) => {
setMonitorAllChecked(event.target.checked);

if (!firstRow && !lastRow) {
const rowsString = document.getElementsByClassName('MuiTablePagination-displayedRows')[0].innerHTML;
const [start, end] = rowsString.split('\u2013').map(s => s.trim().replace(" of", ""));
[firstRow, lastRow] = [parseInt(start) - 1, parseInt(end) - 1];
}
for (let i = firstRow; i <= lastRow; ++i) {
// check or uncheck every PV on the page the user is looking at. The grid's row ids are used
// so it is the sorted and filtered page and not just the first rows of the search results
const monitorVisiblePage = useCallback((checkedStatus) => {
gridPaginatedVisibleSortedGridRowIdsSelector(apiRef).forEach((id) => {
const row = apiRef.current.getRow(id);
// only check mark active PVs, unless we are ignoring CF PV status
// matters now that we show "Disconnected" for PVs that are not connected to their IOC
if (config.PVWS_IGNORE_CF_PVSTATUS || pvs[i].pvStatus === "Active") {
updateCurrentChecked(i, event.target.checked);
if (row && (config.PVWS_IGNORE_CF_PVSTATUS || row.pvStatus === "Active")) {
updateCurrentChecked(id, checkedStatus);
}
});
}, [apiRef, updateCurrentChecked])

const handleMonitorSelectAll = (event) => {
setMonitorAllChecked(event.target.checked);
monitorVisiblePage(event.target.checked);
}

// the visible page changed (new search, page change, sort, filter), monitor the new page if monitor all should stay checked
const monitorAllOnNewPage = useCallback((keepMonitorAll) => {
if (config.USE_PVWS && keepMonitorAll) {
setMonitorAllChecked(true);
setMonitorVisiblePagePending(true);
}
return
}, [updateCurrentChecked])
}, [])

// runs after the grid has rendered the new page so its sorted and filtered row ids are up to date
useEffect(() => {
if (monitorVisiblePagePending) {
setMonitorVisiblePagePending(false);
monitorVisiblePage(true);
}
}, [monitorVisiblePagePending, monitorVisiblePage])

// Notify user if monitoring over warn or max PVs
useEffect(() => {
Expand Down Expand Up @@ -169,7 +188,7 @@ function QueryResults(props) {
<Typography display="inline" variant="subtitle2">Actions</Typography>
{
config.USE_PVWS ? <Tooltip arrow title="Monitor All PVs">
<Checkbox checked={monitorAllChecked} onChange={handleMonitorSelectAll()} sx={{ ml: ".5rem" }}></Checkbox>
<Checkbox checked={monitorAllChecked} onChange={handleMonitorSelectAll} sx={{ ml: ".5rem" }}></Checkbox>
</Tooltip>
: <div></div>
}
Expand Down Expand Up @@ -261,7 +280,8 @@ function QueryResults(props) {
return pvObject;
}));
setChecked(new Array(props.cfData.length).fill(false));
}, [props.cfData]);
monitorAllOnNewPage(config.DEFAULT_MONITOR_ALL);
}, [props.cfData, monitorAllOnNewPage]);

function roundToBreakpoint(width, breakpoints) {
const smallerBreakpoints = breakpoints.filter((item) => item <= width);
Expand Down Expand Up @@ -357,6 +377,7 @@ function QueryResults(props) {
return (
<Fragment>
<DataGrid
apiRef={apiRef}
rows={pvs}
columns={columns}
autoHeight={true}
Expand All @@ -366,6 +387,19 @@ function QueryResults(props) {
onPaginationModelChange={(newPaginationModel) => {
setPaginationModel(newPaginationModel);
clearMonitoring();
monitorAllOnNewPage(config.DEFAULT_MONITOR_ALL);
}}
onSortModelChange={() => {
if (monitorAllChecked) {
clearMonitoring();
monitorAllOnNewPage(true);
}
}}
onFilterModelChange={() => {
if (monitorAllChecked) {
clearMonitoring();
monitorAllOnNewPage(true);
}
}}
pageSizeOptions={tablePageSizeOptions}
pagination
Expand Down
114 changes: 114 additions & 0 deletions src/components/home/queryresults/QueryResults.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { render, fireEvent, act } from '@testing-library/react';
import { ThemeProvider } from '@mui/material';
import { MemoryRouter } from 'react-router';
import theme from '../../../theme';
import QueryResults from './QueryResults';

// track which PVs are subscribed through the shared PVWS web socket
const subscribed = new Set();
vi.mock('../../../useWebSocket', () => ({
default: () => ({
lastJsonMessage: null,
sendJsonMessage: (message) => {
message.pvs.forEach((pv) => message.type === 'subscribe' ? subscribed.add(pv) : subscribed.delete(pv));
},
}),
}));

// override config settings per test
const configOverrides = vi.hoisted(() => ({}));
vi.mock('../../../config', async (importOriginal) => {
const actual = (await importOriginal()).default;
// config is frozen so proxy a copy of it
return { default: new Proxy({ ...actual }, { get: (target, key) => (key in configOverrides ? configOverrides[key] : target[key]) }) };
});

const pvName = (i) => `PV${String(i).padStart(3, '0')}`;
const cfData = Array.from({ length: 120 }, (_, i) => ({
name: pvName(i),
owner: 'owner',
properties: [{ name: 'pvStatus', value: 'Active' }, { name: 'recordType', value: 'ai' }],
}));

async function renderResults() {
const props = { isLoading: false, cfData, handleErrorMessage: () => {}, handleOpenErrorAlert: () => {}, handleSeverity: () => {} };
const { container } = render(<ThemeProvider theme={theme}><MemoryRouter><QueryResults {...props} /></MemoryRouter></ThemeProvider>);
await act(async () => {});
return container;
}

const visiblePVs = (container) => [...container.querySelectorAll('.MuiDataGrid-row')].map((row) => pvName(row.getAttribute('data-id')));
const monitorAllCheckbox = (container) => container.querySelector('.MuiDataGrid-columnHeader[data-field="button"] input');
const click = async (element) => { await act(async () => { fireEvent.click(element); }); };

Check warning on line 42 in src/components/home/queryresults/QueryResults.test.jsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this redundant `act()` call; the wrapped call already flushes its own updates.

See more on https://sonarcloud.io/project/issues?id=ChannelFinder_pvinfo&issues=AaFbDdlcjVZOZJy3iAuE&open=AaFbDdlcjVZOZJy3iAuE&pullRequest=175
const sortByNameDescending = async (container) => {
const nameHeader = container.querySelector('.MuiDataGrid-columnHeader[data-field="name"] .MuiDataGrid-columnHeaderTitleContainer');
await click(nameHeader);
await click(nameHeader);
};

beforeEach(() => {
subscribed.clear();
Object.keys(configOverrides).forEach((key) => delete configOverrides[key]);
Object.assign(configOverrides, { USE_PVWS: true, PVWS_IGNORE_CF_PVSTATUS: false, RESULTS_TABLE_SIZE: 50, DEFAULT_MONITOR_ALL: false });
});

describe('monitor all', () => {
test('monitors the PVs on the visible page', async () => {
const container = await renderResults();
await click(monitorAllCheckbox(container));
expect([...subscribed].sort()).toEqual(visiblePVs(container).sort());
expect(subscribed.size).toBe(50);
});

test('monitors the visible page after the table is sorted', async () => {
const container = await renderResults();
await sortByNameDescending(container);
expect(visiblePVs(container)[0]).toBe('PV119');
await click(monitorAllCheckbox(container));
expect([...subscribed].sort()).toEqual(visiblePVs(container).sort());
});

test('follows the visible page when the table is sorted while checked', async () => {
const container = await renderResults();
await click(monitorAllCheckbox(container));
await sortByNameDescending(container);
expect(monitorAllCheckbox(container).checked).toBe(true);
expect([...subscribed].sort()).toEqual(visiblePVs(container).sort());
});

test('stops monitoring on page change by default', async () => {
const container = await renderResults();
await click(monitorAllCheckbox(container));
await click(container.querySelector('[aria-label="Go to next page"]'));
expect(subscribed.size).toBe(0);
expect(monitorAllCheckbox(container).checked).toBe(false);
});

test('is checked after a search and follows page changes with DEFAULT_MONITOR_ALL', async () => {
configOverrides.DEFAULT_MONITOR_ALL = true;
const container = await renderResults();
expect(monitorAllCheckbox(container).checked).toBe(true);
expect([...subscribed].sort()).toEqual(visiblePVs(container).sort());

await click(container.querySelector('[aria-label="Go to next page"]'));
expect(visiblePVs(container)[0]).toBe('PV050');
expect(monitorAllCheckbox(container).checked).toBe(true);
expect([...subscribed].sort()).toEqual(visiblePVs(container).sort());
});

test('a PV unchecked by the user stays unchecked with DEFAULT_MONITOR_ALL', async () => {
configOverrides.DEFAULT_MONITOR_ALL = true;
const container = await renderResults();
const firstRowCheckbox = container.querySelector('.MuiDataGrid-row[data-id="0"] [data-field="button"] input');
await click(firstRowCheckbox);
await act(async () => {});
expect(subscribed.has('PV000')).toBe(false);
expect(subscribed.size).toBe(49);
});

test('does nothing with DEFAULT_MONITOR_ALL when PVWS is disabled', async () => {
Object.assign(configOverrides, { DEFAULT_MONITOR_ALL: true, USE_PVWS: false });
await renderResults();
expect(subscribed.size).toBe(0);
});
});
16 changes: 15 additions & 1 deletion src/components/home/queryresults/valuecheckbox/ValueCheckbox.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import useWebSocket from '../../../../useWebSocket';
import { Checkbox, Tooltip } from '@mui/material';
import api from '../../../../api';
Expand All @@ -9,6 +9,7 @@ function ValueCheckbox(props) {
// filter all messages as false since we don't need to read anything in parent component
// This still is in QueryResults so even with USE_PVWS===false it will try to connect, would be better to move to ValueCheckbox?
const [enabled, setEnabled] = useState(true);
const isSubscribed = useRef(false);
const { sendJsonMessage } = useWebSocket(config.PVWS_URL, {
share: true,
filter: (message) => false,
Expand All @@ -35,12 +36,25 @@ function ValueCheckbox(props) {
if (enabled) {
if (props.currentChecked.has(props.id)) {
sendJsonMessage({ "type": "subscribe", "pvs": [props.pvName] });
isSubscribed.current = true;
} else {
sendJsonMessage({ "type": "clear", "pvs": [props.pvName] });
isSubscribed.current = false;
}
}
}, [sendJsonMessage, props.currentChecked, props.checked, props.id, props.pvName, enabled])

// the row is removed from the table when the page, sort, or filter changes, clear the subscription
// since this component is no longer around to see the PV get unchecked
useEffect(() => {
return () => {
if (isSubscribed.current) {
sendJsonMessage({ "type": "clear", "pvs": [props.pvName] });
Comment on lines +49 to +52
isSubscribed.current = false;
}
};
}, [sendJsonMessage, props.pvName])

return (
<Tooltip arrow title={<div>Monitor<br />{props.pvName}</div>}>
<Checkbox
Expand Down
1 change: 1 addition & 0 deletions src/components/services/Services.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ function Services() {
<TableRow><TableCell>Show Disconnected in Live Value Box</TableCell><TableCell>{config.SHOW_DISCONNECTED ? "Yes" : "No"}</TableCell></TableRow>
<TableRow><TableCell>Monitor All Warning Threshold</TableCell><TableCell>{config.LIVE_MONITOR_WARN}</TableCell></TableRow>
<TableRow><TableCell>Monitor All Maximum</TableCell><TableCell>{config.LIVE_MONITOR_MAX}</TableCell></TableRow>
<TableRow><TableCell>Monitor All by Default</TableCell><TableCell>{config.DEFAULT_MONITOR_ALL ? "Yes" : "No"}</TableCell></TableRow>
</TableBody>
</Table>
</Grid>
Expand Down
1 change: 1 addition & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ const config = {
USE_PVWS: parseBoolean(import.meta.env.REACT_APP_USE_PVWS, false),
LIVE_MONITOR_WARN: parseIntOrDefault(import.meta.env.REACT_APP_LIVE_MONITOR_WARN, 50),
LIVE_MONITOR_MAX: parseIntOrDefault(import.meta.env.REACT_APP_LIVE_MONITOR_MAX, 100),
DEFAULT_MONITOR_ALL: parseBoolean(import.meta.env.REACT_APP_DEFAULT_MONITOR_ALL, false),
PVWS_TREAT_BYTE_ARRAY_AS_STRING: parseBoolean(import.meta.env.REACT_APP_PVWS_TREAT_BYTE_ARRAY_AS_STRING, true),
PVWS_ALLOW_WAVEFORMS: parseBoolean(import.meta.env.REACT_APP_PVWS_ALLOW_WAVEFORMS, false),
PVWS_IGNORE_CF_PVSTATUS: parseBoolean(import.meta.env.REACT_APP_PVWS_IGNORE_CF_PVSTATUS, false),
Expand Down
Loading