diff --git a/cypress/e2e/table.cy.ts b/cypress/e2e/table.cy.ts
index bbcde3e13..83487d27e 100644
--- a/cypress/e2e/table.cy.ts
+++ b/cypress/e2e/table.cy.ts
@@ -144,21 +144,53 @@ describe('Table Component', () => {
cy.get('[role="columnheader"]').should('be.visible');
});
- it('has a sticky time column when scrolling right', () => {
+ it('should be able to have different initial columns and correctly handles sticky columns when scrolling right', () => {
+ // We need no limit set on the records to ensure we don't get warning tooltips for these tests to pass
+ let settings = Object.create(null);
+ cy.request('operationsgateway-settings.json').then((response) => {
+ settings = response.body;
+ });
+ cy.intercept('operationsgateway-settings.json', (req) => {
+ req.reply({
+ statusCode: 200,
+ body: {
+ ...settings,
+ initialChannels: {
+ timestamp: {
+ removable: false,
+ sticky: true,
+ },
+ shotnum: {
+ removable: true,
+ sticky: true,
+ },
+ active_area: {
+ removable: true,
+ sticky: false,
+ },
+ },
+ },
+ });
+ }).as('getSettings');
+ cy.reload();
+
cy.get('[aria-describedby="table-loading-indicator"]').should(
'have.attr',
'aria-busy',
'false'
);
- // // Add enough columns to require horizontal scroll bar
- cy.contains('Data Channels').click();
+ cy.findByRole('columnheader', { name: 'Time' }).should('be.visible');
+ cy.findByRole('columnheader', { name: 'Shot Number' }).should('be.visible');
+ cy.findByRole('columnheader', { name: 'Active Area' }).should('be.visible');
- cy.contains('system').click();
+ // sticky columns should not be re-orderable
+ cy.get(getHandleSelector('timestamp')).should('not.exist');
+ cy.get(getHandleSelector('shotnum')).should('not.exist');
+ cy.get(getHandleSelector('active_area')).should('exist');
- cy.findByRole('checkbox', { name: 'Shot Number' }).check();
- cy.findByRole('checkbox', { name: 'Active Area' }).check();
- cy.findByRole('checkbox', { name: 'Active Experiment' }).check();
+ // Add enough columns to require horizontal scroll bar
+ cy.contains('Data Channels').click();
cy.contains('All Channels').click();
@@ -168,16 +200,47 @@ describe('Table Component', () => {
cy.findByRole('checkbox', { name: 'Channel_ABCDE' }).check();
cy.findByRole('checkbox', { name: 'Channel_BCDEF' }).check();
- cy.findByRole('checkbox', { name: 'Channel_CDEFG' }).check();
cy.contains('Add Channels').click();
cy.get('[data-testid="table-container"]').scrollTo('right');
cy.findByRole('columnheader', { name: 'Time' }).should('be.visible');
+ cy.findByRole('columnheader', { name: 'Shot Number' }).should('be.visible');
// double check that we have scrolled far enough to test sticky column
- cy.findByRole('columnheader', { name: 'Shot Number' }).should(
+ cy.findByRole('columnheader', { name: 'Active Area' }).should(
'not.be.visible'
);
+
+ // should be able to remove initial channels that are configured as removable
+ cy.findByRole('columnheader', { name: 'Shot Number' }).trigger(
+ 'mousedown',
+ {
+ button: 1,
+ }
+ );
+ cy.findByRole('columnheader', { name: 'Shot Number' }).should('not.exist');
+
+ cy.get('[aria-describedby="table-loading-indicator"]').should(
+ 'have.attr',
+ 'aria-busy',
+ 'false'
+ );
+
+ cy.findByRole('button', { name: 'active_area menu' }).click();
+ cy.findByRole('menuitem', { name: 'Close' }).click();
+ cy.findByRole('columnheader', { name: 'Active Area' }).should('not.exist');
+
+ cy.get('[aria-describedby="table-loading-indicator"]').should(
+ 'have.attr',
+ 'aria-busy',
+ 'false'
+ );
+
+ // should not be able to remove initial channels that are configured as non-removable
+ cy.findByRole('columnheader', { name: 'Time' }).trigger('mousedown', {
+ button: 1,
+ });
+ cy.findByRole('columnheader', { name: 'Time' }).should('exist');
});
it('column headers overflow when word wrap is enabled', () => {
diff --git a/cypress/support/util.ts b/cypress/support/util.ts
index b913da3ca..61ad0c1cb 100644
--- a/cypress/support/util.ts
+++ b/cypress/support/util.ts
@@ -35,32 +35,32 @@ export const scrollContainer = {
contextId: `${prefix}-scroll-container-context-id`,
};
-export function getDroppableSelector(droppableId) {
+export function getDroppableSelector(droppableId?: string) {
if (droppableId) {
return `[${droppable.id}="${droppableId}"]`;
}
return `[${droppable.id}]`;
}
-export function getHandleSelector(draggableId) {
+export function getHandleSelector(draggableId?: string) {
if (draggableId) {
return `[${dragHandle.draggableId}="${draggableId}"]`;
}
return `[${dragHandle.draggableId}]`;
}
-export function getDraggableSelector(draggableId) {
+export function getDraggableSelector(draggableId?: string) {
if (draggableId) {
return `[${draggable.id}="${draggableId}"]`;
}
return `[${draggable.id}]`;
}
-export const formatDateTimeForApi = (datetime) => {
+export const formatDateTimeForApi = (datetime: Date) => {
return datetime.toLocaleString('sv-SE').replace(' ', 'T');
};
-export const addInitialSystemChannels = (channels) => {
+export const addInitialSystemChannels = (channels: string[]) => {
cy.contains('Data Channels').click();
cy.contains('system').click();
diff --git a/public/operationsgateway-settings.example.json b/public/operationsgateway-settings.example.json
index 3b6baf3b4..ab9587ce0 100644
--- a/public/operationsgateway-settings.example.json
+++ b/public/operationsgateway-settings.example.json
@@ -6,6 +6,7 @@
{ "value": 1000 },
{ "value": "Unlimited" }
],
+ "initialChannels": { "timestamp": { "removable": false, "sticky": true } },
"workingHours": { "start": 9, "end": 18 },
"dataTypes": [],
"plotAxisSigFigs": ".3~s",
diff --git a/server/e2e-settings-mocked.json b/server/e2e-settings-mocked.json
index 71dc4d37a..52e334153 100644
--- a/server/e2e-settings-mocked.json
+++ b/server/e2e-settings-mocked.json
@@ -6,6 +6,7 @@
{ "value": 1000 },
{ "value": "Unlimited" }
],
+ "initialChannels": { "timestamp": { "removable": false, "sticky": true } },
"workingHours": { "start": 9, "end": 18 },
"plotAxisSigFigs": ".3~s",
"routes": [
diff --git a/server/e2e-settings-real.json b/server/e2e-settings-real.json
index 04e27810a..7a9954242 100644
--- a/server/e2e-settings-real.json
+++ b/server/e2e-settings-real.json
@@ -6,6 +6,7 @@
{ "value": 1000 },
{ "value": "Unlimited" }
],
+ "initialChannels": { "timestamp": { "removable": false, "sticky": true } },
"workingHours": { "start": 9, "end": 18 },
"plotAxisSigFigs": ".3~s",
"routes": [
diff --git a/src/api/records.test.tsx b/src/api/records.test.tsx
index 1f0e2b5d9..de5817194 100644
--- a/src/api/records.test.tsx
+++ b/src/api/records.test.tsx
@@ -13,10 +13,8 @@ import { operators, parseFilter, Token } from '../filtering/filterParser';
import handleOG_APIError from '../handleOG_APIError';
import recordsJson from '../mocks/records.json';
import { server } from '../mocks/server';
-import {
- defaultMaxShotOptions,
- getDefaultMaxShot,
-} from '../state/slices/searchSlice';
+import { defaultMaxShotOptions } from '../state/slices/configSlice';
+import { getDefaultMaxShot } from '../state/slices/searchSlice';
import { RootState } from '../state/store';
import {
createTestQueryClient,
@@ -42,6 +40,7 @@ describe('records api functions', () => {
beforeEach(() => {
state = getInitialState();
+ state.table = { ...state.table, selectedColumnIds: ['timestamp'] };
});
afterEach(() => {
@@ -70,11 +69,11 @@ describe('records api functions', () => {
it('can send date and filter params as part of request', async () => {
state = {
- ...getInitialState(),
+ ...state,
search: {
- ...getInitialState().search,
+ ...state.search,
searchParams: {
- ...getInitialState().search.searchParams,
+ ...state.search.searchParams,
dateRange: {
fromDate: '2022-01-01 00:00:00',
toDate: '2022-01-02 00:00:00',
@@ -84,7 +83,7 @@ describe('records api functions', () => {
},
},
filter: {
- ...getInitialState().filter,
+ ...state.filter,
appliedFilters: [
[
{ type: 'channel', value: 'shotnum', label: 'Shot Number' },
@@ -120,11 +119,11 @@ describe('records api functions', () => {
it('returns cached data from incomingRecordCount request if it is available', async () => {
state = {
- ...getInitialState(),
+ ...state,
search: {
- ...getInitialState().search,
+ ...state.search,
searchParams: {
- ...getInitialState().search.searchParams,
+ ...state.search.searchParams,
dateRange: {},
},
},
@@ -410,15 +409,15 @@ describe('records api functions', () => {
it('can set search and filter params (excludes function from the projection) via the store', async () => {
state = {
- ...getInitialState(),
+ ...state,
table: {
- ...getInitialState().table,
+ ...state.table,
selectedColumnIds: [timeChannelName, 'a'],
},
search: {
- ...getInitialState().search,
+ ...state.search,
searchParams: {
- ...getInitialState().search.searchParams,
+ ...state.search.searchParams,
dateRange: {
fromDate: '2022-01-01 00:00:00',
toDate: '2022-01-02 00:00:00',
@@ -427,7 +426,7 @@ describe('records api functions', () => {
},
},
filter: {
- ...getInitialState().filter,
+ ...state.filter,
appliedFilters: [
[
{ type: 'channel', value: 'shotnum', label: 'Shot Number' },
@@ -488,9 +487,11 @@ describe('records api functions', () => {
const pendingRequest = waitForRequest('GET', '/records');
const { result } = renderHook(() => useRecordsPaginated(), {
- // don't pass in state here as we want the initial state to be generated after
+ // don't pass in full state here as we want the search initial state to be generated after
// we have our fake timers set up
- wrapper: hooksWrapperWithProviders(),
+ wrapper: hooksWrapperWithProviders({
+ table: { ...state.table, selectedColumnIds: ['timestamp'] },
+ }),
});
await waitFor(() => {
@@ -517,16 +518,16 @@ describe('records api functions', () => {
it('can send sort, date range, projection functions and filter parameters as part of request', async () => {
state = {
- ...getInitialState(),
+ ...state,
table: {
- ...getInitialState().table,
+ ...state.table,
sort: { timestamp: 'asc', CHANNEL_1: 'desc' },
selectedColumnIds: [timeChannelName, 'CHANNEL_1', 'a'],
},
search: {
- ...getInitialState().search,
+ ...state.search,
searchParams: {
- ...getInitialState().search.searchParams,
+ ...state.search.searchParams,
dateRange: {
fromDate: '2022-01-01 00:00:00',
toDate: '2022-01-02 00:00:00',
@@ -536,7 +537,7 @@ describe('records api functions', () => {
},
},
filter: {
- ...getInitialState().filter,
+ ...state.filter,
appliedFilters: [
[
{ type: 'channel', value: 'shotnum', label: 'Shot Number' },
@@ -683,9 +684,9 @@ describe('records api functions', () => {
it('can send x-axis, filter, functions and maxShots params as part of request', async () => {
state = {
- ...getInitialState(),
+ ...state,
filter: {
- ...getInitialState().filter,
+ ...state.filter,
appliedFilters: [
[
{ type: 'channel', value: 'shotnum', label: 'Shot Number' },
@@ -706,9 +707,9 @@ describe('records api functions', () => {
],
},
search: {
- ...getInitialState().search,
+ ...state.search,
searchParams: {
- ...getInitialState().search.searchParams,
+ ...state.search.searchParams,
maxShots: 1000,
dateRange: {},
},
@@ -807,9 +808,9 @@ describe('records api functions', () => {
it('does not send the function state if the function name is not included in the projection (or is not dependant)', async () => {
state = {
- ...getInitialState(),
+ ...state,
filter: {
- ...getInitialState().filter,
+ ...state.filter,
appliedFilters: [
[
{ type: 'channel', value: 'shotnum', label: 'Shot Number' },
@@ -830,9 +831,9 @@ describe('records api functions', () => {
],
},
search: {
- ...getInitialState().search,
+ ...state.search,
searchParams: {
- ...getInitialState().search.searchParams,
+ ...state.search.searchParams,
maxShots: 1000,
dateRange: {},
},
@@ -908,11 +909,11 @@ describe('records api functions', () => {
const pendingRequest = waitForRequest('GET', '/records');
state = {
- ...getInitialState(),
+ ...state,
search: {
- ...getInitialState().search,
+ ...state.search,
searchParams: {
- ...getInitialState().search.searchParams,
+ ...state.search.searchParams,
maxShots: Infinity,
dateRange: {},
},
@@ -1002,15 +1003,15 @@ describe('records api functions', () => {
it('can send sort, date range, functions and filter parameters as part of request', async () => {
state = {
- ...getInitialState(),
+ ...state,
table: {
- ...getInitialState().table,
+ ...state.table,
sort: { timestamp: 'asc', CHANNEL_1: 'desc' },
},
search: {
- ...getInitialState().search,
+ ...state.search,
searchParams: {
- ...getInitialState().search.searchParams,
+ ...state.search.searchParams,
dateRange: {
fromDate: '2022-01-01 00:00:00',
toDate: '2022-01-02 00:00:00',
@@ -1019,7 +1020,7 @@ describe('records api functions', () => {
},
},
filter: {
- ...getInitialState().filter,
+ ...state.filter,
appliedFilters: [
[
{ type: 'channel', value: 'shotnum', label: 'Shot Number' },
@@ -1072,15 +1073,15 @@ describe('records api functions', () => {
it('can send sort, date range, functions and filter parameters as part of request', async () => {
state = {
- ...getInitialState(),
+ ...state,
table: {
- ...getInitialState().table,
+ ...state.table,
sort: { timestamp: 'asc', CHANNEL_1: 'desc' },
},
search: {
- ...getInitialState().search,
+ ...state.search,
searchParams: {
- ...getInitialState().search.searchParams,
+ ...state.search.searchParams,
dateRange: {
fromDate: '2022-01-01 00:00:00',
toDate: '2022-01-02 00:00:00',
@@ -1089,7 +1090,7 @@ describe('records api functions', () => {
},
},
filter: {
- ...getInitialState().filter,
+ ...state.filter,
appliedFilters: [
[
{ type: 'channel', value: 'shotnum', label: 'Shot Number' },
diff --git a/src/channels/__snapshots__/channelsDialogue.component.test.tsx.snap b/src/channels/__snapshots__/channelsDialogue.component.test.tsx.snap
index 6fa1fa3c1..b2abb1ac8 100644
--- a/src/channels/__snapshots__/channelsDialogue.component.test.tsx.snap
+++ b/src/channels/__snapshots__/channelsDialogue.component.test.tsx.snap
@@ -195,25 +195,25 @@ exports[`Channels Dialogue > renders channels dialogue when dialogue is open 1`]
>
diff --git a/src/channels/channelTree.component.test.tsx b/src/channels/channelTree.component.test.tsx
index 26152d6b5..4c70c5260 100644
--- a/src/channels/channelTree.component.test.tsx
+++ b/src/channels/channelTree.component.test.tsx
@@ -56,6 +56,7 @@ describe('Channel Tree', () => {
handleChannelChecked={handleChannelChecked}
handleChannelSelected={handleChannelSelected}
tree={tree}
+ nonRemovableChannels={['timestamp']}
/>
);
};
diff --git a/src/channels/channelTree.component.tsx b/src/channels/channelTree.component.tsx
index 0ba84342a..103b18a53 100644
--- a/src/channels/channelTree.component.tsx
+++ b/src/channels/channelTree.component.tsx
@@ -6,7 +6,7 @@ import {
ListItemIcon,
ListItemText,
} from '@mui/material';
-import { FullChannelMetadata, timeChannelName } from '../app.types';
+import { FullChannelMetadata } from '../app.types';
import { TreeNode } from './channelsDialogue.component';
type ChannelTreeProps = {
@@ -16,6 +16,7 @@ type ChannelTreeProps = {
handleChannelChecked: (channel: string, checked: boolean) => void;
handleChannelSelected: (channel: FullChannelMetadata) => void;
displayedChannel?: FullChannelMetadata;
+ nonRemovableChannels: string[];
};
const ChannelTree = (props: ChannelTreeProps) => {
@@ -26,6 +27,7 @@ const ChannelTree = (props: ChannelTreeProps) => {
handleChannelChecked,
handleChannelSelected,
displayedChannel,
+ nonRemovableChannels,
} = props;
const nodes = currNode
@@ -62,7 +64,7 @@ const ChannelTree = (props: ChannelTreeProps) => {
diff --git a/src/channels/channelsDialogue.component.test.tsx b/src/channels/channelsDialogue.component.test.tsx
index 61048d9b4..8f701919f 100644
--- a/src/channels/channelsDialogue.component.test.tsx
+++ b/src/channels/channelsDialogue.component.test.tsx
@@ -170,7 +170,9 @@ describe('Channels Dialogue', () => {
const { store } = createView(state);
await user.click(await screen.findByText('system'));
-
+ // expect timestamp column to be defaulted to non-removable
+ expect(screen.getByRole('checkbox', { name: 'Time' })).toBeChecked();
+ expect(screen.getByRole('checkbox', { name: 'Time' })).toBeDisabled();
expect(screen.getByRole('checkbox', { name: 'Active Area' })).toBeChecked();
expect(
screen.getByRole('checkbox', { name: 'Shot Number' })
diff --git a/src/channels/channelsDialogue.component.tsx b/src/channels/channelsDialogue.component.tsx
index f0f04222f..c05e6d545 100644
--- a/src/channels/channelsDialogue.component.tsx
+++ b/src/channels/channelsDialogue.component.tsx
@@ -12,6 +12,7 @@ import React from 'react';
import { useChannels } from '../api/channels';
import { FullChannelMetadata } from '../app.types';
import { useAppDispatch, useAppSelector } from '../state/hooks';
+import { selectNonRemovableChannels } from '../state/slices/configSlice';
import {
selectSelectedIds,
updateSelectedColumns,
@@ -103,6 +104,8 @@ const ChannelsDialogue = (props: ChannelsDialogueProps) => {
const channelTree = selectChannelTree(channels ?? [], selectedIds);
+ const nonRemovableChannels = useAppSelector(selectNonRemovableChannels);
+
const dispatch = useAppDispatch();
const onChannelSelect = React.useCallback((channel: string): void => {
@@ -175,6 +178,7 @@ const ChannelsDialogue = (props: ChannelsDialogueProps) => {
setCurrNode={onChangeNode}
handleChannelChecked={handleChannelChecked}
handleChannelSelected={setDisplayedChannel}
+ nonRemovableChannels={nonRemovableChannels}
/>
diff --git a/src/search/components/maxShots.component.test.tsx b/src/search/components/maxShots.component.test.tsx
index 3a06f953b..12d2b54b1 100644
--- a/src/search/components/maxShots.component.test.tsx
+++ b/src/search/components/maxShots.component.test.tsx
@@ -5,7 +5,7 @@ import {
type RenderResult,
} from '@testing-library/react';
import userEvent, { UserEvent } from '@testing-library/user-event';
-import { defaultMaxShotOptions } from '../../state/slices/searchSlice';
+import { defaultMaxShotOptions } from '../../state/slices/configSlice';
import MaxShots, { type MaxShotsProps } from './maxShots.component';
describe('maxShots search', () => {
diff --git a/src/search/searchBar.component.test.tsx b/src/search/searchBar.component.test.tsx
index 13b1fb13e..d07c03dd7 100644
--- a/src/search/searchBar.component.test.tsx
+++ b/src/search/searchBar.component.test.tsx
@@ -12,10 +12,8 @@ import React from 'react';
import { formatDateTimeForApi } from '../api/api';
import recordsJson from '../mocks/records.json';
import { server } from '../mocks/server';
-import {
- defaultMaxShotOptions,
- getDefaultMaxShot,
-} from '../state/slices/searchSlice';
+import { defaultMaxShotOptions } from '../state/slices/configSlice';
+import { getDefaultMaxShot } from '../state/slices/searchSlice';
import { RootState } from '../state/store';
import { getInitialState, renderComponentWithProviders } from '../testUtils';
import SearchBar from './searchBar.component';
diff --git a/src/session/sessionSaveButtons.component.test.tsx b/src/session/sessionSaveButtons.component.test.tsx
index 6183d6a06..d548be10d 100644
--- a/src/session/sessionSaveButtons.component.test.tsx
+++ b/src/session/sessionSaveButtons.component.test.tsx
@@ -10,7 +10,7 @@ import { ogApi } from '../api/api';
import { timeChannelName } from '../app.types';
import sessionsJson from '../mocks/sessionsList.json';
import { ImportSessionType } from '../state/store';
-import { renderComponentWithProviders } from '../testUtils';
+import { getInitialState, renderComponentWithProviders } from '../testUtils';
import SessionSaveButtons, {
AUTO_SAVE_INTERVAL_MS,
SessionsSaveButtonsProps,
@@ -21,7 +21,14 @@ describe('session buttons', () => {
const onSaveAsSessionClick = vi.fn();
const onChangeAutoSaveSessionId = vi.fn();
const createView = (): RenderResult => {
- return renderComponentWithProviders();
+ const preloadedState = getInitialState();
+ preloadedState.table = {
+ ...preloadedState.table,
+ selectedColumnIds: ['timestamp'],
+ };
+ return renderComponentWithProviders(, {
+ preloadedState,
+ });
};
let axiosPostSpy: MockInstance;
diff --git a/src/settings.test.ts b/src/settings.test.ts
index 5e511ebe3..e22f057bd 100644
--- a/src/settings.test.ts
+++ b/src/settings.test.ts
@@ -4,7 +4,7 @@ import { MicroFrontendId } from './app.types';
import { server } from './mocks/server';
import { fetchSettings } from './settings';
import { registerRoute } from './state/scigateway.actions';
-import { defaultMaxShotOptions } from './state/slices/searchSlice';
+import { defaultMaxShotOptions } from './state/slices/configSlice';
vi.mock('loglevel');
diff --git a/src/settings.ts b/src/settings.ts
index d470b21bb..0b9a8f912 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -15,10 +15,16 @@ export interface MaxShotType {
default?: boolean;
}
+export type InitialChannelsConfigType = Record<
+ string,
+ { removable: boolean; sticky: boolean }
+>;
+
export interface OperationsGatewaySettings {
apiUrl: string;
recordLimitWarning: number;
maxShots: MaxShotType[];
+ initialChannels?: InitialChannelsConfigType;
routes: PluginRoute[];
helpSteps?: { target: string; content: string }[];
pluginHost?: string;
diff --git a/src/state/slices/configSlice.test.tsx b/src/state/slices/configSlice.test.tsx
index 9f5ae8b86..ee9bc3db6 100644
--- a/src/state/slices/configSlice.test.tsx
+++ b/src/state/slices/configSlice.test.tsx
@@ -3,8 +3,10 @@ import { setSettings } from '../../settings';
import { actions, dispatch, resetActions } from '../../testUtils';
import ConfigReducer, {
configureApp,
+ defaultMaxShotOptions,
initialState,
loadDataTypesSetting,
+ loadInitialChannelsSetting,
loadMaxShotsSetting,
loadPlotAxisSigFigsSetting,
loadPluginHostSetting,
@@ -13,11 +15,6 @@ import ConfigReducer, {
loadWorkingHoursSetting,
settingsLoaded,
} from './configSlice';
-import {
- defaultMaxShotOptions,
- initialiseDataTypes,
- initialiseDefaultMaxShots,
-} from './searchSlice';
vi.mock('loglevel');
@@ -148,12 +145,16 @@ describe('configSlice', () => {
resetActions();
});
- it('settings are loaded and loadUrls, loadRecordLimitWarningSetting, loadPluginHost, loadWorkingHoursSetting, and settingsLoaded actions are sent and data types are configured', async () => {
+ it('settings are loaded and loadUrls, loadRecordLimitWarningSetting, loadInitialChannelsSetting, loadPluginHost, loadWorkingHoursSetting, and settingsLoaded actions are sent and data types are configured', async () => {
setSettings(
Promise.resolve({
apiUrl: 'api',
recordLimitWarning: -1,
maxShots: [{ value: 100, default: true }, { value: 'Unlimited' }],
+ initialChannels: {
+ timestamp: { removable: false, sticky: true },
+ shotnum: { removable: true, sticky: false },
+ },
routes: [
{
section: 'section',
@@ -171,7 +172,7 @@ describe('configSlice', () => {
const asyncAction = configureApp();
await asyncAction(dispatch);
- expect(actions.length).toEqual(10);
+ expect(actions.length).toEqual(9);
expect(actions).toContainEqual(
loadUrls({
apiUrl: 'api',
@@ -185,10 +186,10 @@ describe('configSlice', () => {
])
);
expect(actions).toContainEqual(
- initialiseDefaultMaxShots([
- { value: 100, default: true },
- { value: 'Unlimited' },
- ])
+ loadInitialChannelsSetting({
+ timestamp: { removable: false, sticky: true },
+ shotnum: { removable: true, sticky: false },
+ })
);
expect(actions).toContainEqual(
loadPluginHostSetting('http://localhost:3000/')
@@ -198,7 +199,6 @@ describe('configSlice', () => {
);
expect(actions).toContainEqual(loadPlotAxisSigFigsSetting('.2~s'));
expect(actions).toContainEqual(loadDataTypesSetting(['GS', 'GD']));
- expect(actions).toContainEqual(initialiseDataTypes(['GS', 'GD']));
expect(staticChannels['active_area'].name).toBe('Data Type');
expect(staticChannels['shotnum'].type).toBe('string');
@@ -229,6 +229,7 @@ describe('configSlice', () => {
expect(
actions.every(({ type }) => type !== loadPluginHostSetting.type)
).toBe(true);
+
expect(
actions.every(({ type }) => type !== loadWorkingHoursSetting.type)
).toBe(true);
@@ -238,10 +239,13 @@ describe('configSlice', () => {
expect(
actions.every(({ type }) => type !== loadDataTypesSetting.type)
).toBe(true);
- expect(
- actions.every(({ type }) => type !== initialiseDataTypes.type)
- ).toBe(true);
expect(staticChannels['active_area'].name).toBe('Active Area');
+ // ensure even if we don't define initial channel settings we initialise with the default
+ expect(actions).toContainEqual(
+ loadInitialChannelsSetting({
+ timestamp: { removable: false, sticky: true },
+ })
+ );
expect(actions).toContainEqual(settingsLoaded());
});
@@ -270,9 +274,6 @@ describe('configSlice', () => {
expect(
actions.every(({ type }) => type !== loadDataTypesSetting.type)
).toBe(true);
- expect(
- actions.every(({ type }) => type !== initialiseDataTypes.type)
- ).toBe(true);
expect(staticChannels['active_area'].name).toBe('Active Area');
expect(actions).toContainEqual(settingsLoaded());
diff --git a/src/state/slices/configSlice.tsx b/src/state/slices/configSlice.tsx
index 06afa8a64..72692e589 100644
--- a/src/state/slices/configSlice.tsx
+++ b/src/state/slices/configSlice.tsx
@@ -1,15 +1,21 @@
import Category from '@mui/icons-material/Category';
import type { PayloadAction } from '@reduxjs/toolkit';
-import { createSlice } from '@reduxjs/toolkit';
+import { createSelector, createSlice } from '@reduxjs/toolkit';
import { staticChannels } from '../../api/channels';
import { columnIconMappings } from '../../app.types';
-import { MaxShotType, settings, type WorkingHours } from '../../settings';
-import { AppDispatch, RootState } from '../store';
import {
- defaultMaxShotOptions,
- initialiseDataTypes,
- initialiseDefaultMaxShots,
-} from './searchSlice';
+ InitialChannelsConfigType,
+ MaxShotType,
+ settings,
+ type WorkingHours,
+} from '../../settings';
+import { AppDispatch, RootState } from '../store';
+
+export const defaultMaxShotOptions: MaxShotType[] = [
+ { value: 50, default: true },
+ { value: 1000 },
+ { value: 'Unlimited' },
+];
interface URLs {
apiUrl: string;
@@ -20,6 +26,7 @@ interface ConfigState {
urls: URLs;
recordLimitWarning: number;
maxShots: MaxShotType[];
+ initialChannels: InitialChannelsConfigType;
pluginHost: string;
settingsLoaded: boolean;
workingHours: WorkingHours;
@@ -34,6 +41,7 @@ export const initialState: ConfigState = {
},
recordLimitWarning: -1,
maxShots: defaultMaxShotOptions,
+ initialChannels: { timestamp: { removable: false, sticky: true } },
pluginHost: '',
settingsLoaded: false,
workingHours: { start: 9, end: 18 },
@@ -61,6 +69,12 @@ export const configSlice = createSlice({
loadMaxShotsSetting: (state, action: PayloadAction) => {
state.maxShots = action.payload;
},
+ loadInitialChannelsSetting: (
+ state,
+ action: PayloadAction
+ ) => {
+ state.initialChannels = action.payload;
+ },
loadWorkingHoursSetting: (state, action: PayloadAction) => {
state.workingHours = action.payload;
},
@@ -82,6 +96,7 @@ export const {
loadUrls,
loadRecordLimitWarningSetting,
loadMaxShotsSetting,
+ loadInitialChannelsSetting,
loadWorkingHoursSetting,
loadPlotAxisSigFigsSetting,
loadDataTypesSetting,
@@ -91,6 +106,36 @@ export const selectUrls = (state: RootState) => state.config.urls;
export const selectRecordLimitWarning = (state: RootState) =>
state.config.recordLimitWarning;
export const selectMaxShots = (state: RootState) => state.config.maxShots;
+const selectInitialChannelConfig = (state: RootState) =>
+ state.config.initialChannels;
+export const selectNonRemovableChannels = createSelector(
+ selectInitialChannelConfig,
+ (initialChannels) => {
+ return Object.entries(initialChannels).reduce(
+ (filtered, [channelName, channelOptions]) => {
+ if (!channelOptions.removable) {
+ filtered.push(channelName);
+ }
+ return filtered;
+ },
+ [] as string[]
+ );
+ }
+);
+export const selectStickyChannels = createSelector(
+ selectInitialChannelConfig,
+ (initialChannels) => {
+ return Object.entries(initialChannels).reduce(
+ (filtered, [channelName, channelOptions]) => {
+ if (channelOptions.sticky) {
+ filtered.push(channelName);
+ }
+ return filtered;
+ },
+ [] as string[]
+ );
+ }
+);
export const selectWorkingHours = (state: RootState) =>
state.config.workingHours;
export const selectPlotAxisSigFigs = (state: RootState) =>
@@ -112,7 +157,12 @@ export const configureApp = () => async (dispatch: AppDispatch) => {
);
dispatch(loadMaxShotsSetting(settingsResult['maxShots']));
- dispatch(initialiseDefaultMaxShots(settingsResult['maxShots']));
+
+ dispatch(
+ loadInitialChannelsSetting(
+ settingsResult['initialChannels'] ?? initialState.initialChannels
+ )
+ );
if (settingsResult['pluginHost'] !== undefined) {
dispatch(loadPluginHostSetting(settingsResult['pluginHost']));
@@ -130,8 +180,6 @@ export const configureApp = () => async (dispatch: AppDispatch) => {
// if data types are defined, initialise everything to do with data types properly
if (Array.isArray(dataTypes) && dataTypes.length > 0) {
dispatch(loadDataTypesSetting(dataTypes));
- // initialise selected data types to all of the options
- dispatch(initialiseDataTypes(dataTypes));
// change active_area to read as data type
staticChannels['active_area'].name = 'Data Type';
columnIconMappings.set('active_area', );
diff --git a/src/state/slices/searchSlice.test.tsx b/src/state/slices/searchSlice.test.tsx
index 1d67c8f98..6fe91987d 100644
--- a/src/state/slices/searchSlice.test.tsx
+++ b/src/state/slices/searchSlice.test.tsx
@@ -1,6 +1,6 @@
+import { loadDataTypesSetting, loadMaxShotsSetting } from './configSlice';
import SearchReducer, {
getDefaultMaxShot,
- initialiseDefaultMaxShots,
initialStateFunc,
selectDateRangeInLocalTime,
} from './searchSlice';
@@ -36,12 +36,12 @@ describe('Search slice tests', () => {
});
});
- it('initialiseDefaultMaxShots takes max shot config and extracts the default max shot value', () => {
+ it('loadMaxShotsSetting takes max shot config and extracts the default max shot value', () => {
expect(state.search.searchParams.maxShots).toBe(50);
const updatedState = SearchReducer(
state.search,
- initialiseDefaultMaxShots([
+ loadMaxShotsSetting([
{ value: 100, default: true },
{ value: 'Unlimited' },
])
@@ -49,6 +49,17 @@ describe('Search slice tests', () => {
expect(updatedState.searchParams.maxShots).toBe(100);
});
+
+ it('loadDataTypesSetting takes data types config and updates state', () => {
+ expect(state.search.searchParams.dataTypes).toBeUndefined();
+
+ const updatedState = SearchReducer(
+ state.search,
+ loadDataTypesSetting(['GD'])
+ );
+
+ expect(updatedState.searchParams.dataTypes).toEqual(['GD']);
+ });
});
describe('getDefaultMaxShot', () => {
diff --git a/src/state/slices/searchSlice.tsx b/src/state/slices/searchSlice.tsx
index 2768d31f6..a2bfec69b 100644
--- a/src/state/slices/searchSlice.tsx
+++ b/src/state/slices/searchSlice.tsx
@@ -5,16 +5,15 @@ import { convertApiTimestampToDate, formatDateTimeForApi } from '../../api/api';
import { SearchParams } from '../../app.types';
import { MaxShotType } from '../../settings';
import { RootState } from '../store';
+import {
+ defaultMaxShotOptions,
+ loadDataTypesSetting,
+ loadMaxShotsSetting,
+} from './configSlice';
import { selectQueryFilters } from './filterSlice';
import { selectQueryFunctions } from './functionsSlice';
import { selectPage, selectResultsPerPage, selectSort } from './tableSlice';
-export const defaultMaxShotOptions: MaxShotType[] = [
- { value: 50, default: true },
- { value: 1000 },
- { value: 'Unlimited' },
-];
-
export const getDefaultMaxShot = (maxShots: MaxShotType[]): number => {
const maxShot = maxShots.find((x) => x.default)?.value ?? maxShots[0].value;
return maxShot === 'Unlimited' ? Infinity : maxShot;
@@ -57,26 +56,19 @@ export const searchSlice = createSlice({
changeSearchParams: (state, action: PayloadAction) => {
state.searchParams = { ...action.payload };
},
- initialiseDefaultMaxShots: (
- state,
- action: PayloadAction
- ) => {
- state.searchParams.maxShots = getDefaultMaxShot(action.payload);
- },
- initialiseDataTypes: (
- state,
- action: PayloadAction>
- ) => {
- state.searchParams.dataTypes = action.payload;
- },
+ },
+ extraReducers: (builder) => {
+ builder
+ .addCase(loadMaxShotsSetting, (state, action) => {
+ state.searchParams.maxShots = getDefaultMaxShot(action.payload);
+ })
+ .addCase(loadDataTypesSetting, (state, action) => {
+ state.searchParams.dataTypes = action.payload;
+ });
},
});
-export const {
- changeSearchParams,
- initialiseDataTypes,
- initialiseDefaultMaxShots,
-} = searchSlice.actions;
+export const { changeSearchParams } = searchSlice.actions;
// Other code such as selectors can use the imported `RootState` type
export const selectSearchParams = (state: RootState) =>
diff --git a/src/state/slices/tableSlice.test.tsx b/src/state/slices/tableSlice.test.tsx
index 42a7c88cb..7330eeebf 100644
--- a/src/state/slices/tableSlice.test.tsx
+++ b/src/state/slices/tableSlice.test.tsx
@@ -1,3 +1,4 @@
+import { loadInitialChannelsSetting } from './configSlice';
import ColumnsReducer, {
changeSort,
deselectColumn,
@@ -17,17 +18,13 @@ describe('tableSlice', () => {
it('selectColumn adds new columns in the correct order', () => {
state = ColumnsReducer(state, selectColumn('shotnum'));
- expect(state.selectedColumnIds).toEqual(['timestamp', 'shotnum']);
+ expect(state.selectedColumnIds).toEqual(['shotnum']);
state = ColumnsReducer(state, selectColumn('shotnum'));
- expect(state.selectedColumnIds).toEqual(['timestamp', 'shotnum']);
+ expect(state.selectedColumnIds).toEqual(['shotnum']);
state = ColumnsReducer(state, selectColumn('active_area'));
- expect(state.selectedColumnIds).toEqual([
- 'timestamp',
- 'shotnum',
- 'active_area',
- ]);
+ expect(state.selectedColumnIds).toEqual(['shotnum', 'active_area']);
});
it('deselectColumn removes columns in the correct order', () => {
@@ -46,14 +43,6 @@ describe('tableSlice', () => {
'shotnum',
'active_experiment',
]);
-
- // shouldn't be able to deselect timestamp
- state = ColumnsReducer(state, deselectColumn('timestamp'));
- expect(state.selectedColumnIds).toEqual([
- 'timestamp',
- 'shotnum',
- 'active_experiment',
- ]);
});
it('should reorder columns correctly when reorderColumns action is sent', () => {
@@ -105,6 +94,24 @@ describe('tableSlice', () => {
);
expect(state.sort).toEqual({ shotnum: 'desc' });
});
+
+ it('loadInitialChannelsSetting sets initial selected columns and makes non-removable channels non-removable', () => {
+ state = ColumnsReducer(
+ state,
+ loadInitialChannelsSetting({
+ timestamp: { removable: false, sticky: true },
+ shotnum: { removable: true, sticky: false },
+ })
+ );
+ expect(state.selectedColumnIds).toEqual(['timestamp', 'shotnum']);
+
+ // shouldn't be able to deselect timestamp
+ state = ColumnsReducer(state, deselectColumn('timestamp'));
+ expect(state.selectedColumnIds).toEqual(['timestamp', 'shotnum']);
+
+ state = ColumnsReducer(state, deselectColumn('shotnum'));
+ expect(state.selectedColumnIds).toEqual(['timestamp']);
+ });
});
describe('Selectors', () => {
diff --git a/src/state/slices/tableSlice.tsx b/src/state/slices/tableSlice.tsx
index 4d66e8cff..b018da164 100644
--- a/src/state/slices/tableSlice.tsx
+++ b/src/state/slices/tableSlice.tsx
@@ -7,9 +7,9 @@ import {
FullChannelMetadata,
Order,
RecordRow,
- timeChannelName,
} from '../../app.types';
import { RootState } from '../store';
+import { loadInitialChannelsSetting } from './configSlice';
export const resultsPerPage = 25;
@@ -31,13 +31,14 @@ interface TableState {
// Define the initial state using that type
export const initialState: TableState = {
columnStates: {},
- // Ensure the timestamp column is opened automatically on table load
- selectedColumnIds: [timeChannelName],
+ selectedColumnIds: [],
page: 0,
resultsPerPage: resultsPerPage,
sort: {},
};
+let nonRemovableChannels: string[] = [];
+
export const tableSlice = createSlice({
name: 'table',
// `createSlice` will infer the state type from the `initialState` argument
@@ -54,8 +55,8 @@ export const tableSlice = createSlice({
}
},
deselectColumn: (state, action: PayloadAction) => {
- if (action.payload === timeChannelName) {
- // don't allow time column to be deselected (should be prevented by other
+ if (nonRemovableChannels.includes(action.payload)) {
+ // don't allow non removable channels to be deselected (should be prevented by other
// code as well - just might as well do it here too)
return;
} else {
@@ -108,6 +109,24 @@ export const tableSlice = createSlice({
}
},
},
+ extraReducers: (builder) => {
+ builder.addCase(loadInitialChannelsSetting, (state, action) => {
+ state.selectedColumnIds = Object.keys(action.payload);
+ // we can't access state from another slice, but in practice the config
+ // slice doesn't change so can just store it in a variable to use in the
+ // deselect function. Also the deselect function is just a "backup"
+ nonRemovableChannels = Object.entries(action.payload).reduce(
+ (filtered, [channelName, channelOptions]) => {
+ if (!channelOptions.removable) {
+ filtered.push(channelName);
+ }
+ return filtered;
+ },
+ [] as string[]
+ );
+ return state;
+ });
+ },
});
export const {
diff --git a/src/table/headerRenderers/dataHeader.component.test.tsx b/src/table/headerRenderers/dataHeader.component.test.tsx
index d1d098f3c..ddcb0a2f1 100644
--- a/src/table/headerRenderers/dataHeader.component.test.tsx
+++ b/src/table/headerRenderers/dataHeader.component.test.tsx
@@ -59,6 +59,8 @@ describe('Data Header', () => {
onToggleWordWrap,
isFiltered: false,
openFilters,
+ removable: true,
+ reorderable: true,
};
});
@@ -77,6 +79,14 @@ describe('Data Header', () => {
expect(screen.getByTestId('sort test')).toBeInTheDocument();
});
+ it('renders correctly when not reorderable', () => {
+ props.reorderable = false;
+ createView();
+ expect(
+ screen.queryByTestId('drag', { exact: false })
+ ).not.toBeInTheDocument();
+ });
+
it('renders correctly with filter applied', () => {
props.isFiltered = true;
createView();
@@ -184,6 +194,19 @@ describe('Data Header', () => {
expect(onClose).toHaveBeenCalledWith('test');
});
+ it('does not allow a column to be removed if removable is false', async () => {
+ props.removable = false;
+ createView();
+ const header = screen.getByText('Test');
+ await user.pointer([{ keys: '[MouseMiddle]', target: header }]);
+ expect(onClose).not.toHaveBeenCalled();
+
+ const menuIcon = screen.getByLabelText('test menu');
+ await user.click(menuIcon);
+
+ expect(screen.queryByText('Close')).not.toBeInTheDocument();
+ });
+
describe('calls the onSort method when label is clicked', () => {
it('sets asc order', async () => {
createView();
diff --git a/src/table/headerRenderers/dataHeader.component.tsx b/src/table/headerRenderers/dataHeader.component.tsx
index ce18c2af5..20100eb3c 100644
--- a/src/table/headerRenderers/dataHeader.component.tsx
+++ b/src/table/headerRenderers/dataHeader.component.tsx
@@ -29,7 +29,6 @@ import {
isChannelMetadataVector,
isChannelMetadataWaveform,
Order,
- timeChannelName,
} from '../../app.types';
import ExportChannelColumn from '../../export/exportChannelColumn.component';
@@ -49,6 +48,8 @@ export interface DataHeaderProps {
wordWrap: boolean;
isFiltered: boolean;
openFilters: (headerName: string) => void;
+ removable: boolean;
+ reorderable: boolean;
}
export interface ColumnMenuProps {
@@ -57,10 +58,18 @@ export interface ColumnMenuProps {
onToggleWordWrap: (column: string) => void;
wordWrap: boolean;
channelInfo?: FullChannelMetadata;
+ removable: boolean;
}
const ColumnMenu = (props: ColumnMenuProps): React.ReactElement => {
- const { dataKey, onClose, onToggleWordWrap, wordWrap, channelInfo } = props;
+ const {
+ dataKey,
+ onClose,
+ onToggleWordWrap,
+ wordWrap,
+ channelInfo,
+ removable,
+ } = props;
const [anchorEl, setAnchorEl] = React.useState(null);
const open = Boolean(anchorEl);
@@ -128,7 +137,7 @@ const ColumnMenu = (props: ColumnMenuProps): React.ReactElement => {
Export
)}
- {dataKey !== timeChannelName && (
+ {removable && (