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 && ( { onClose(dataKey); @@ -170,6 +179,8 @@ const DataHeader = (props: DataHeaderProps): React.ReactElement => { onToggleWordWrap, isFiltered, openFilters, + removable, + reorderable, } = props; // TODO currently, when sort is empty, API returns sort by timestamp ASC @@ -239,7 +250,7 @@ const DataHeader = (props: DataHeaderProps): React.ReactElement => { }} onMouseDown={(event) => { // Middle mouse button can also fire onClose - if (dataKey !== timeChannelName && event.button === 1) { + if (removable && event.button === 1) { event.preventDefault(); onClose(dataKey); } @@ -313,6 +324,7 @@ const DataHeader = (props: DataHeaderProps): React.ReactElement => { wordWrap={wordWrap} onToggleWordWrap={onToggleWordWrap} channelInfo={channelInfo} + removable={removable} /> { ); }; - // Timestamp column must not be reordered - return dataKey !== timeChannelName ? ( + // sticky channels can't be re-ordered + return reorderable ? ( {(provided) => } diff --git a/src/table/table.component.test.tsx b/src/table/table.component.test.tsx index 88e791e5d..dd3491fc5 100644 --- a/src/table/table.component.test.tsx +++ b/src/table/table.component.test.tsx @@ -83,6 +83,8 @@ describe('Table', () => { onColumnWordWrapToggle, openFilters, filteredChannelNames: [], + nonRemovableChannels: ['timestamp'], + stickyChannels: ['timestamp'], }; }); diff --git a/src/table/table.component.tsx b/src/table/table.component.tsx index 0a513000f..5d4841340 100644 --- a/src/table/table.component.tsx +++ b/src/table/table.component.tsx @@ -36,7 +36,6 @@ import { Order, RecordRow, SearchParams, - timeChannelName, } from '../app.types'; import DataCell from './cellRenderers/dataCell.component'; import DataHeader from './headerRenderers/dataHeader.component'; @@ -53,8 +52,6 @@ const stickyColumnStyles: SxProps = { const CHECKBOX_COLUMN_ID = 'CHECKBOX_COLUMN'; -const columnPinning = { left: [CHECKBOX_COLUMN_ID, timeChannelName] }; - export interface TableProps { tableHeight: string; data: RecordRow[]; @@ -79,6 +76,8 @@ export interface TableProps { onColumnClose: (column: string) => void; openFilters: (headerName: string) => void; filteredChannelNames: string[]; + nonRemovableChannels: string[]; + stickyChannels: string[]; } const Table = React.memo((props: TableProps): React.ReactElement => { @@ -106,6 +105,8 @@ const Table = React.memo((props: TableProps): React.ReactElement => { onColumnClose, openFilters, filteredChannelNames, + nonRemovableChannels, + stickyChannels, } = props; const count = maxShots > totalDataCount ? totalDataCount : maxShots; @@ -216,6 +217,11 @@ const Table = React.memo((props: TableProps): React.ReactElement => { [columnVisibility] ); + const columnPinning = React.useMemo( + () => ({ left: [CHECKBOX_COLUMN_ID, ...stickyChannels] }), + [stickyChannels] + ); + const tableInstance = useReactTable({ columns, data, @@ -290,8 +296,8 @@ const Table = React.memo((props: TableProps): React.ReactElement => { ); } - const isTimestampColumn = - dataKey === timeChannelName; + const isStickyColumn = + stickyChannels.includes(dataKey); let columnStyles: SxProps = { width: column.getSize(), paddingTop: '0px', @@ -303,7 +309,7 @@ const Table = React.memo((props: TableProps): React.ReactElement => { alignItems: 'center', }; - columnStyles = isTimestampColumn + columnStyles = isStickyColumn ? { ...columnStyles, ...stickyColumnStyles, @@ -348,6 +354,10 @@ const Table = React.memo((props: TableProps): React.ReactElement => { dataKey )} openFilters={openFilters} + removable={ + !nonRemovableChannels.includes(dataKey) + } + reorderable={!isStickyColumn} /> ); })} @@ -401,7 +411,7 @@ const Table = React.memo((props: TableProps): React.ReactElement => { key: CHECKBOX_COLUMN_ID, }); } - const isTimestampColumn = dataKey === timeChannelName; + const isStickyColumn = stickyChannels.includes(dataKey); let columnStyles: SxProps = { width: cell.column.getSize(), @@ -412,7 +422,7 @@ const Table = React.memo((props: TableProps): React.ReactElement => { flexDirection: 'row', }; - columnStyles = isTimestampColumn + columnStyles = isStickyColumn ? { ...columnStyles, ...stickyColumnStyles, diff --git a/src/testUtils.tsx b/src/testUtils.tsx index 566a3158e..576c16876 100644 --- a/src/testUtils.tsx +++ b/src/testUtils.tsx @@ -162,7 +162,7 @@ export const createTestQueryClient = (): QueryClient => }); export const hooksWrapperWithProviders = ( - state = {}, + state: Partial = {}, queryClient?: QueryClient ) => { const testQueryClient = queryClient ?? createTestQueryClient(); diff --git a/src/views/recordTable.component.test.tsx b/src/views/recordTable.component.test.tsx index 14ebe0de2..a304ee691 100644 --- a/src/views/recordTable.component.test.tsx +++ b/src/views/recordTable.component.test.tsx @@ -36,6 +36,7 @@ describe('Record Table', () => { beforeEach(() => { state = getInitialState(); + state.table = { ...state.table, selectedColumnIds: ['timestamp'] }; vi.spyOn(global.crypto, 'randomUUID').mockImplementation( // @ts-expect-error Format is intentionally different to uuid v4 diff --git a/src/views/recordTable.component.tsx b/src/views/recordTable.component.tsx index 33437d511..62f6cb1a3 100644 --- a/src/views/recordTable.component.tsx +++ b/src/views/recordTable.component.tsx @@ -6,6 +6,10 @@ import { useRecordCount, useRecordsPaginated } from '../api/records'; import { Order } from '../app.types'; import type { Token } from '../filtering/filterParser'; import { useAppDispatch, useAppSelector } from '../state/hooks'; +import { + selectNonRemovableChannels, + selectStickyChannels, +} from '../state/slices/configSlice'; import { selectAppliedFilters } from '../state/slices/filterSlice'; import { selectQueryParams } from '../state/slices/searchSlice'; import { @@ -76,6 +80,9 @@ const RecordTable = React.memo( const columnOrder = useAppSelector(selectSelectedIds); + const stickyChannels = useAppSelector(selectStickyChannels); + const nonRemovableChannels = useAppSelector(selectNonRemovableChannels); + const onPageChange = React.useCallback( (page: number) => { dispatch(changePage(page)); @@ -159,6 +166,8 @@ const RecordTable = React.memo( onColumnClose={handleColumnClose} openFilters={openFilters} filteredChannelNames={filteredChannelNames} + stickyChannels={stickyChannels} + nonRemovableChannels={nonRemovableChannels} /> ); }