From d4bdaa9632f38c7a3ac164c4f03ef527ef77fcaf Mon Sep 17 00:00:00 2001 From: Kenneth Date: Wed, 22 Mar 2023 17:15:04 +0000 Subject: [PATCH 01/37] Add ability to customize y axis labels --- src/plotting/plot.component.tsx | 24 ++++++++++++ .../plotSettingsController.component.tsx | 12 ++++++ .../plotSettings/yAxisTab.component.tsx | 38 +++++++++++++++++++ src/plotting/plotWindow.component.tsx | 9 +++++ src/state/slices/plotSlice.tsx | 2 + 5 files changed, 85 insertions(+) diff --git a/src/plotting/plot.component.tsx b/src/plotting/plot.component.tsx index 43f32373c..47d7b2d7f 100644 --- a/src/plotting/plot.component.tsx +++ b/src/plotting/plot.component.tsx @@ -31,6 +31,8 @@ export interface PlotProps { leftYAxisMaximum?: number; rightYAxisMinimum?: number; rightYAxisMaximum?: number; + leftYAxisLabel?: string; + rightYAxisLabel?: string; viewReset: boolean; } @@ -54,6 +56,8 @@ const Plot = (props: PlotProps) => { leftYAxisMaximum, rightYAxisMinimum, rightYAxisMaximum, + leftYAxisLabel, + rightYAxisLabel, viewReset, } = props; @@ -126,6 +130,10 @@ const Plot = (props: PlotProps) => { }, min: leftYAxisMinimum, max: leftYAxisMaximum, + title: { + display: Boolean(leftYAxisLabel), + text: leftYAxisLabel, + }, }, y2: { type: rightYAxisScale, @@ -138,6 +146,10 @@ const Plot = (props: PlotProps) => { }, min: rightYAxisMinimum, max: rightYAxisMaximum, + title: { + display: Boolean(rightYAxisLabel), + text: rightYAxisLabel, + }, }, }, } as ChartOptions) @@ -166,6 +178,11 @@ const Plot = (props: PlotProps) => { (channel) => channel.options.yAxis === 'left' && channel.options.visible )); + options?.scales?.y && + (options.scales.y.title = { + display: Boolean(leftYAxisLabel), + text: leftYAxisLabel, + }); options?.scales?.y2 && (options.scales.y2.min = rightYAxisMinimum); options?.scales?.y2 && (options.scales.y2.max = rightYAxisMaximum); options?.scales?.y2 && (options.scales.y2.type = rightYAxisScale); @@ -176,6 +193,11 @@ const Plot = (props: PlotProps) => { )); options?.scales?.y2?.grid && (options.scales.y2.grid.display = gridVisible); + options?.scales?.y2 && + (options.scales.y2.title = { + display: Boolean(rightYAxisLabel), + text: rightYAxisLabel, + }); return JSON.stringify(options); }); }, [ @@ -194,6 +216,8 @@ const Plot = (props: PlotProps) => { rightYAxisMaximum, selectedPlotChannels, XAxisDisplayName, + leftYAxisLabel, + rightYAxisLabel, ]); React.useEffect(() => { diff --git a/src/plotting/plotSettings/plotSettingsController.component.tsx b/src/plotting/plotSettings/plotSettingsController.component.tsx index dcfc6937d..95c83bd13 100644 --- a/src/plotting/plotSettings/plotSettingsController.component.tsx +++ b/src/plotting/plotSettings/plotSettingsController.component.tsx @@ -76,12 +76,16 @@ export interface PlotSettingsControllerProps { leftYAxisMaximum?: number; rightYAxisMinimum?: number; rightYAxisMaximum?: number; + leftYAxisLabel?: string; + rightYAxisLabel?: string; changeXMinimum: (value: number | undefined) => void; changeXMaximum: (value: number | undefined) => void; changeLeftYAxisMinimum: (value: number | undefined) => void; changeLeftYAxisMaximum: (value: number | undefined) => void; changeRightYAxisMinimum: (value: number | undefined) => void; changeRightYAxisMaximum: (value: number | undefined) => void; + changeLeftYAxisLabel: (newLabel: string) => void; + changeRightYAxisLabel: (newLabel: string) => void; selectedColours: string[]; remainingColours: string[]; changeSelectedColours: (selected: string[]) => void; @@ -112,12 +116,16 @@ const PlotSettingsController = (props: PlotSettingsControllerProps) => { leftYAxisMaximum, rightYAxisMinimum, rightYAxisMaximum, + leftYAxisLabel, + rightYAxisLabel, changeXMinimum, changeXMaximum, changeLeftYAxisMinimum, changeLeftYAxisMaximum, changeRightYAxisMinimum, changeRightYAxisMaximum, + changeLeftYAxisLabel, + changeRightYAxisLabel, selectedColours, remainingColours, changeSelectedColours, @@ -165,6 +173,10 @@ const PlotSettingsController = (props: PlotSettingsControllerProps) => { changeLeftYAxisScale={changeLeftYAxisScale} rightYAxisScale={rightYAxisScale} changeRightYAxisScale={changeRightYAxisScale} + leftYAxisLabel={leftYAxisLabel} + changeLeftYAxisLabel={changeLeftYAxisLabel} + rightYAxisLabel={rightYAxisLabel} + changeRightYAxisLabel={changeRightYAxisLabel} initialSelectedColours={selectedColours} initialRemainingColours={remainingColours} changeSelectedColours={changeSelectedColours} diff --git a/src/plotting/plotSettings/yAxisTab.component.tsx b/src/plotting/plotSettings/yAxisTab.component.tsx index b80215fd6..8af9e5b1b 100644 --- a/src/plotting/plotSettings/yAxisTab.component.tsx +++ b/src/plotting/plotSettings/yAxisTab.component.tsx @@ -60,6 +60,10 @@ export interface YAxisTabProps { initialRemainingColours: string[]; changeSelectedColours: (selected: string[]) => void; changeRemainingColours: (remaining: string[]) => void; + leftYAxisLabel?: string; + changeLeftYAxisLabel: (newLabel: string) => void; + rightYAxisLabel?: string; + changeRightYAxisLabel: (newLabel: string) => void; } const YAxisTab = (props: YAxisTabProps) => { @@ -80,6 +84,10 @@ const YAxisTab = (props: YAxisTabProps) => { changeLeftYAxisScale, rightYAxisScale, changeRightYAxisScale, + leftYAxisLabel, + changeLeftYAxisLabel, + rightYAxisLabel, + changeRightYAxisLabel, initialSelectedColours, initialRemainingColours, changeSelectedColours, @@ -228,6 +236,24 @@ const YAxisTab = (props: YAxisTabProps) => { ] ); + const changeAxisLabel = React.useCallback( + (event: React.ChangeEvent) => { + const newLabel = event.currentTarget.value; + switch (axis) { + case 'left': + changeLeftYAxisLabel(newLabel); + break; + + case 'right': + changeRightYAxisLabel(newLabel); + break; + } + }, + [axis, changeLeftYAxisLabel, changeRightYAxisLabel] + ); + + const currentAxisLabel = axis === 'left' ? leftYAxisLabel : rightYAxisLabel; + return ( @@ -257,6 +283,18 @@ const YAxisTab = (props: YAxisTabProps) => { + + + void; plotConfig: PlotConfig; } + const drawerWidth = 300; const PlotWindow = (props: PlotWindowProps) => { @@ -71,6 +72,8 @@ const PlotWindow = (props: PlotWindowProps) => { const [rightYAxisScale, setRightYAxisScale] = React.useState( plotConfig.rightYAxisScale ); + const [leftYAxisLabel, setLeftYAxisLabel] = React.useState(''); + const [rightYAxisLabel, setRightYAxisLabel] = React.useState(''); const [XAxis, setXAxis] = React.useState( plotConfig.XAxis @@ -283,12 +286,16 @@ const PlotWindow = (props: PlotWindowProps) => { leftYAxisMaximum={leftYAxisMaximum} rightYAxisMinimum={rightYAxisMinimum} rightYAxisMaximum={rightYAxisMaximum} + leftYAxisLabel={leftYAxisLabel} + rightYAxisLabel={rightYAxisLabel} changeXMinimum={setXMinimum} changeXMaximum={setXMaximum} changeLeftYAxisMinimum={setLeftYAxisMinimum} changeLeftYAxisMaximum={setLeftYAxisMaximum} changeRightYAxisMinimum={setRightYAxisMinimum} changeRightYAxisMaximum={setRightYAxisMaximum} + changeLeftYAxisLabel={setLeftYAxisLabel} + changeRightYAxisLabel={setRightYAxisLabel} selectedColours={selectedColours} remainingColours={remainingColours} changeSelectedColours={setSelectedColours} @@ -372,6 +379,8 @@ const PlotWindow = (props: PlotWindowProps) => { leftYAxisMaximum={leftYAxisMaximum} rightYAxisMinimum={rightYAxisMinimum} rightYAxisMaximum={rightYAxisMaximum} + leftYAxisLabel={leftYAxisLabel} + rightYAxisLabel={rightYAxisLabel} viewReset={viewFlag} /> diff --git a/src/state/slices/plotSlice.tsx b/src/state/slices/plotSlice.tsx index a7707bb02..16b81fd8d 100644 --- a/src/state/slices/plotSlice.tsx +++ b/src/state/slices/plotSlice.tsx @@ -31,6 +31,8 @@ export interface PlotConfig { leftYAxisMaximum?: number; rightYAxisMinimum?: number; rightYAxisMaximum?: number; + leftYAxisLabel?: string; + rightYAxisLabel?: string; gridVisible: boolean; axesLabelsVisible: boolean; selectedColours: string[]; From 9bcc1a44d38e98504b7ca9e782d129bdf826c283 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Mon, 27 Mar 2023 17:39:41 +0100 Subject: [PATCH 02/37] Write unit tests for the new changes --- ...SettingsController.component.test.tsx.snap | 38 +++++++------ .../plotTitleField.component.test.tsx.snap | 43 --------------- .../yAxisTab.component.test.tsx.snap | 53 ++++++++++++++++--- .../plotSettingsController.component.test.tsx | 14 +++-- .../plotSettingsController.component.tsx | 7 +-- .../plotSettingsTextField.component.test.tsx | 51 ++++++++++++++++++ .../plotSettingsTextField.component.tsx | 33 ++++++++++++ .../plotTitleField.component.test.tsx | 40 -------------- .../plotSettings/plotTitleField.component.tsx | 31 ----------- .../plotSettings/xAxisTab.component.tsx | 12 ++--- .../plotSettings/yAxisTab.component.tsx | 33 +++--------- 11 files changed, 176 insertions(+), 179 deletions(-) delete mode 100644 src/plotting/plotSettings/__snapshots__/plotTitleField.component.test.tsx.snap create mode 100644 src/plotting/plotSettings/plotSettingsTextField.component.test.tsx create mode 100644 src/plotting/plotSettings/plotSettingsTextField.component.tsx delete mode 100644 src/plotting/plotSettings/plotTitleField.component.test.tsx delete mode 100644 src/plotting/plotSettings/plotTitleField.component.tsx diff --git a/src/plotting/plotSettings/__snapshots__/plotSettingsController.component.test.tsx.snap b/src/plotting/plotSettings/__snapshots__/plotSettingsController.component.test.tsx.snap index 3aed3f9ae..b33acc121 100644 --- a/src/plotting/plotSettings/__snapshots__/plotSettingsController.component.test.tsx.snap +++ b/src/plotting/plotSettings/__snapshots__/plotSettingsController.component.test.tsx.snap @@ -8,13 +8,13 @@ exports[`Plot Settings component snapshots renders plot settings form correctly
- - plotTitle=undefined -changePlotTitle=undefined + value=undefined +onChange=undefined - +
- - plotTitle=undefined -changePlotTitle=undefined + value=undefined +onChange=undefined - +
- - plotTitle=undefined -changePlotTitle=undefined + value=undefined +onChange=undefined - +
-
- -
- - -
-
- -`; diff --git a/src/plotting/plotSettings/__snapshots__/yAxisTab.component.test.tsx.snap b/src/plotting/plotSettings/__snapshots__/yAxisTab.component.test.tsx.snap index 3c0b6bb8a..11e0c5d27 100644 --- a/src/plotting/plotSettings/__snapshots__/yAxisTab.component.test.tsx.snap +++ b/src/plotting/plotSettings/__snapshots__/yAxisTab.component.test.tsx.snap @@ -39,6 +39,47 @@ exports[`y-axis tab renders correctly 1`] = `
+
+
+ +
+ + +
+
+
@@ -51,8 +92,8 @@ exports[`y-axis tab renders correctly 1`] = `
`; diff --git a/src/session/sessionDrawer.component.test.tsx b/src/session/sessionDrawer.component.test.tsx index 006249789..6d36ae588 100644 --- a/src/session/sessionDrawer.component.test.tsx +++ b/src/session/sessionDrawer.component.test.tsx @@ -9,7 +9,8 @@ describe('session Drawer', () => { const openSessionSave = jest.fn(); const openSessionEdit = jest.fn(); const openSessionDelete = jest.fn(); - const onChangeSessionId = jest.fn(); + const onChangeSelectedSessionId = jest.fn(); + const onChangeSelectedSessionTimestamp = jest.fn(); let user; let props: SessionDrawerProps; const createView = (): RenderResult => { @@ -22,8 +23,9 @@ describe('session Drawer', () => { openSessionEdit: openSessionEdit, openSessionDelete: openSessionDelete, selectedSessionId: undefined, - onChangeSelectedSessionId: onChangeSessionId, + onChangeSelectedSessionId: onChangeSelectedSessionId, sessionsList: SessionsListJSON, + onChangeSelectedSessionTimestamp: onChangeSelectedSessionTimestamp, }; }); afterEach(() => { @@ -46,8 +48,8 @@ describe('session Drawer', () => { }); it('loads a user session', async () => { + props.selectedSessionId = '1'; createView(); - await waitFor(() => { expect(screen.getByText('Session 1')).toBeInTheDocument(); }); @@ -55,10 +57,16 @@ describe('session Drawer', () => { expect(screen.getByText('Session 3')).toBeInTheDocument(); const session1 = screen.getByText('Session 1'); await user.click(session1); - expect(onChangeSessionId).toHaveBeenCalledWith('1'); + expect(onChangeSelectedSessionId).toHaveBeenCalledWith('1'); + + await waitFor(() => { + expect(session1).toHaveStyle('background-color: primary.main'); + }); - expect(session1).toHaveStyle('background-color: background.paper'); - expect(session1).toHaveStyle('color: inherit'); + expect(onChangeSelectedSessionTimestamp).toHaveBeenCalledWith( + '2023-06-29T10:30:00Z', + true + ); }); it('a user can open the edit session dialogue', async () => { diff --git a/src/session/sessionDrawer.component.tsx b/src/session/sessionDrawer.component.tsx index 3ec0c42c4..c1dbe7226 100644 --- a/src/session/sessionDrawer.component.tsx +++ b/src/session/sessionDrawer.component.tsx @@ -28,6 +28,10 @@ export interface SessionDrawerProps { sessionsList: SessionList[] | undefined; selectedSessionId: string | undefined; onChangeSelectedSessionId: (selectedSessionId: string | undefined) => void; + onChangeSelectedSessionTimestamp: ( + timestamp: string | undefined, + autoSaved: boolean | undefined + ) => void; } interface SessionListElementProps extends SessionList { @@ -35,6 +39,10 @@ interface SessionListElementProps extends SessionList { selected: boolean; openSessionEdit: (sessionData: SessionList) => void; openSessionDelete: (sessionData: SessionList) => void; + onChangeSelectedSessionTimestamp: ( + timestamp: string | undefined, + autoSaved: boolean | undefined + ) => void; } const SessionListElement = ( @@ -45,9 +53,27 @@ const SessionListElement = ( openSessionEdit, selected, handleImport, + onChangeSelectedSessionTimestamp, ...session } = props; + const prevTimestampRef = React.useRef(undefined); + const prevAutoSavedRef = React.useRef(undefined); + React.useEffect(() => { + if (selected) { + // Check if the timestamp and auto_saved values have changed + if ( + session.timestamp !== prevTimestampRef.current || + session.auto_saved !== prevAutoSavedRef.current + ) { + // Update the previous values with the current ones + prevTimestampRef.current = session.timestamp; + prevAutoSavedRef.current = session.auto_saved; + // Call the onChangeSelectedSessionTimestamp function + onChangeSelectedSessionTimestamp(session.timestamp, session.auto_saved); + } + } + }, [onChangeSelectedSessionTimestamp, selected, session]); return ( { sessionsList, selectedSessionId, onChangeSelectedSessionId, + onChangeSelectedSessionTimestamp, } = props; const { data: sessionData } = useSession(selectedSessionId); @@ -182,6 +209,9 @@ const SessionsDrawer = (props: SessionDrawerProps): React.ReactElement => { selected={selectedSessionId === item._id} openSessionDelete={openSessionDelete} openSessionEdit={openSessionEdit} + onChangeSelectedSessionTimestamp={ + onChangeSelectedSessionTimestamp + } /> ))} diff --git a/src/session/sessionSaveButtons.component.test.tsx b/src/session/sessionSaveButtons.component.test.tsx index dc6586069..07c61661b 100644 --- a/src/session/sessionSaveButtons.component.test.tsx +++ b/src/session/sessionSaveButtons.component.test.tsx @@ -1,13 +1,54 @@ import React from 'react'; -import { render, type RenderResult } from '@testing-library/react'; -import SessionsButtons from './sessionSaveButtons.component'; +import { + type RenderResult, + act, + screen, + waitFor, + fireEvent, +} from '@testing-library/react'; +import SessionSaveButtons, { + SessionsSaveButtonsProps, + AUTO_SAVE_INTERVAL_MS, +} from './sessionSaveButtons.component'; +import { renderComponentWithProviders } from '../setupTests'; +import { useEditSession } from '../api/sessions'; + +// Mock the useEditSession hook +jest.mock('../api/sessions', () => ({ + useEditSession: jest.fn(), +})); + +// jest.setTimeout(100000); describe('session buttons', () => { + let props: SessionsSaveButtonsProps; + // let user: ReturnType; + const onSaveAsSessionClick = jest.fn(); + const refetchSessionsList = jest.fn(); const createView = (): RenderResult => { - return render(); + return renderComponentWithProviders(); }; + beforeEach(() => { + props = { + sessionId: undefined, + onSaveAsSessionClick: onSaveAsSessionClick, + selectedSessionData: undefined, + selectedSessionTimestamp: { timestamp: undefined, autoSaved: undefined }, + refetchSessionsList: refetchSessionsList, + }; + jest.useFakeTimers(); + + // user = userEvent.setup(); + + // Mock the return value of useEditSession hook + useEditSession.mockReturnValue({ + mutateAsync: jest.fn().mockResolvedValue({}), + }); + }); + afterEach(() => { + jest.useRealTimers(); jest.clearAllMocks(); }); @@ -15,4 +56,148 @@ describe('session buttons', () => { const { asFragment } = createView(); expect(asFragment()).toMatchSnapshot(); }); + + it('should enable auto save if an user session is selected', () => { + props.selectedSessionData = { + name: 'test', + summary: 'test', + auto_saved: false, + session_data: '{}', + _id: '1', + }; + props.sessionId = '1'; + const { rerender } = createView(); + + act(() => { + jest.advanceTimersByTime(AUTO_SAVE_INTERVAL_MS); + }); + + expect(useEditSession().mutateAsync).toHaveBeenCalledTimes(1); + expect(useEditSession().mutateAsync).toHaveBeenCalledWith({ + _id: '1', + auto_saved: true, + session_data: + '{"table":{"columnStates":{},"selectedColumnIds":[],"page":0,"resultsPerPage":25,"sort":{}},"search":{"searchParams":{"dateRange":{},"shotnumRange":{},"maxShots":50,"experimentID":null}},"plots":{},"filter":{"appliedFilters":[[]]},"windows":{}}', + }); + + props.selectedSessionData = { + name: 'test 2', + summary: 'test 2', + auto_saved: false, + session_data: '{}', + _id: '2', + }; + props.sessionId = '2'; + rerender(); + + act(() => { + jest.advanceTimersByTime(AUTO_SAVE_INTERVAL_MS); + }); + + expect(useEditSession().mutateAsync).toHaveBeenCalledTimes(2); + expect(useEditSession().mutateAsync).toHaveBeenCalledWith({ + _id: '2', + auto_saved: true, + session_data: + '{"table":{"columnStates":{},"selectedColumnIds":[],"page":0,"resultsPerPage":25,"sort":{}},"search":{"searchParams":{"dateRange":{},"shotnumRange":{},"maxShots":50,"experimentID":null}},"plots":{},"filter":{"appliedFilters":[[]]},"windows":{}}', + }); + + act(() => { + jest.advanceTimersByTime(AUTO_SAVE_INTERVAL_MS); + }); + + expect(useEditSession().mutateAsync).toHaveBeenCalledTimes(3); + expect(useEditSession().mutateAsync).toHaveBeenCalledWith({ + _id: '2', + auto_saved: true, + session_data: + '{"table":{"columnStates":{},"selectedColumnIds":[],"page":0,"resultsPerPage":25,"sort":{}},"search":{"searchParams":{"dateRange":{},"shotnumRange":{},"maxShots":50,"experimentID":null}},"plots":{},"filter":{"appliedFilters":[[]]},"windows":{}}', + }); + }); + + it('should not enable auto save if an user session is not selected', () => { + createView(); + + act(() => { + jest.advanceTimersByTime(AUTO_SAVE_INTERVAL_MS); + }); + + expect(useEditSession().mutateAsync).not.toHaveBeenCalledTimes(1); + }); + + it('save a user session', async () => { + props.selectedSessionData = { + name: 'test', + summary: 'test', + auto_saved: false, + session_data: '{}', + _id: '1', + }; + createView(); + const saveButton = screen.getByRole('button', { name: 'Save' }); + expect(saveButton).toBeInTheDocument(); + + await fireEvent.click(saveButton); + + await waitFor(() => { + expect(useEditSession().mutateAsync).toHaveBeenCalledTimes(1); + }); + }); + + it('opens the save dialog when there is not a user session selected', async () => { + createView(); + const saveAsButton = screen.getByRole('button', { name: 'Save' }); + expect(saveAsButton).toBeInTheDocument(); + + await fireEvent.click(saveAsButton); + + await waitFor(() => { + expect(onSaveAsSessionClick).toHaveBeenCalledTimes(1); + }); + }); + it('opens the save dialog when save as button is clicked', async () => { + createView(); + const saveAsButton = screen.getByRole('button', { name: 'Save as' }); + expect(saveAsButton).toBeInTheDocument(); + + await fireEvent.click(saveAsButton); + + await waitFor(() => { + expect(onSaveAsSessionClick).toHaveBeenCalledTimes(1); + }); + }); + + it('shows the last time a selected user session was saved', async () => { + props = { + ...props, + selectedSessionTimestamp: { + timestamp: '2023-06-29T14:45:00Z', + autoSaved: false, + }, + }; + createView(); + + const timestamp = screen.getByTestId('session-save-buttons-timestamp'); + + expect(timestamp).toHaveTextContent( + 'Session last saved: 29 Jun 2023 15:45' + ); + }); + + it('shows the last time a selected user session was auto saved', async () => { + props = { + ...props, + selectedSessionTimestamp: { + timestamp: '2023-06-29T14:45:00Z', + autoSaved: true, + }, + }; + createView(); + + const element = screen.getByTestId('session-save-buttons-timestamp'); + + expect(element).toHaveTextContent( + 'Session last autosaved: 29 Jun 2023 15:45' + ); + }); }); diff --git a/src/session/sessionSaveButtons.component.tsx b/src/session/sessionSaveButtons.component.tsx index 274427851..ccc790960 100644 --- a/src/session/sessionSaveButtons.component.tsx +++ b/src/session/sessionSaveButtons.component.tsx @@ -1,8 +1,110 @@ import React from 'react'; import Box from '@mui/material/Box'; -import { Button } from '@mui/material'; +import { Button, Typography } from '@mui/material'; +import { Session } from '../app.types'; +import { useAppSelector } from '../state/hooks'; +import { useEditSession } from '../api/sessions'; -const SessionsSaveButtons = () => { +export interface SessionsSaveButtonsProps { + sessionId: string | undefined; + onSaveAsSessionClick: () => void; + selectedSessionData: Session | undefined; + selectedSessionTimestamp: { + timestamp: string | undefined; + autoSaved: boolean | undefined; + }; + refetchSessionsList: () => void; +} + +export const AUTO_SAVE_INTERVAL_MS = 5 * 60 * 1000; + +const SessionSaveButtons = (props: SessionsSaveButtonsProps) => { + const { + onSaveAsSessionClick, + selectedSessionData, + selectedSessionTimestamp, + refetchSessionsList, + sessionId, + } = props; + + const { mutateAsync: editSession } = useEditSession(); + + const autoSaveTimeout = React.useRef | null>( + null + ); + + const state = useAppSelector(({ config, ...state }) => state); + const handleSaveSession = React.useCallback(() => { + if (selectedSessionData) { + const session = { + session_data: JSON.stringify(state), + auto_saved: false, + _id: selectedSessionData._id, + }; + editSession(session).then((response) => { + refetchSessionsList(); + }); + } else { + onSaveAsSessionClick(); + } + }, [ + editSession, + onSaveAsSessionClick, + refetchSessionsList, + selectedSessionData, + state, + ]); + + React.useEffect(() => { + let autoSaveTimer: ReturnType | null; + autoSaveTimer = null; + if (autoSaveTimeout.current) { + clearInterval(autoSaveTimeout.current); + } + + if (selectedSessionData) { + autoSaveTimer = setInterval(() => { + const session = { + session_data: JSON.stringify(state), + auto_saved: true, + _id: selectedSessionData._id, + }; + editSession(session).then((response) => { + refetchSessionsList(); + }); + }, AUTO_SAVE_INTERVAL_MS); + } + + // Update the autoSaveTimeout ref after setting the interval + autoSaveTimeout.current = autoSaveTimer; + + return () => { + if (autoSaveTimer) { + clearInterval(autoSaveTimer); + } + }; + }, [ + editSession, + handleSaveSession, + refetchSessionsList, + selectedSessionData, + sessionId, + state, + ]); + + let timestamp; + timestamp = undefined; + + if (selectedSessionTimestamp.timestamp) { + const date = new Date(selectedSessionTimestamp.timestamp); + const day = date.getDate(); + const month = date.toLocaleString('default', { month: 'short' }); + const year = date.getFullYear(); + const hour = date.getHours(); + const minute = date.getMinutes(); + + timestamp = `${day} ${month} ${year} ${hour}:${minute}`; + } return ( { paddingbottom: '8px', }} > - - + + + {selectedSessionTimestamp.autoSaved !== undefined + ? selectedSessionTimestamp.autoSaved + ? 'Session last autosaved: ' + : 'Session last saved: ' + : ''} + + {timestamp !== undefined ? timestamp : ''} + + + + + ); }; -export default SessionsSaveButtons; +export default SessionSaveButtons; diff --git a/src/views/__snapshots__/viewTabs.component.test.tsx.snap b/src/views/__snapshots__/viewTabs.component.test.tsx.snap index 9f55a72d5..3a0b195a4 100644 --- a/src/views/__snapshots__/viewTabs.component.test.tsx.snap +++ b/src/views/__snapshots__/viewTabs.component.test.tsx.snap @@ -114,26 +114,39 @@ exports[`View Tabs renders correctly 1`] = `
- - +

+ +

+ + +
diff --git a/src/views/viewTabs.component.test.tsx b/src/views/viewTabs.component.test.tsx index 5272d0e5e..136b0e88c 100644 --- a/src/views/viewTabs.component.test.tsx +++ b/src/views/viewTabs.component.test.tsx @@ -138,4 +138,31 @@ describe('View Tabs', () => { expect(editDialog).not.toBeInTheDocument(); }); }); + + it('selects a user session and opens the save as session dialog', async () => { + createView(); + await waitFor(() => { + expect(screen.getByText('Session 1')).toBeInTheDocument(); + }); + const session1 = screen.getByRole('button', { name: 'Session 1' }); + await user.click(session1); + const element = screen.getByTestId('session-save-buttons-timestamp'); + + expect(element).toHaveTextContent( + 'Session last autosaved: 29 Jun 2023 11:30' + ); + + const saveAsButton = screen.getByRole('button', { name: 'Save as' }); + await user.click(saveAsButton); + + const dialog = screen.getByRole('dialog'); + + const summaryTextarea = within(dialog).getByLabelText('Summary'); + const nameInput = within(dialog).getByLabelText('Name*'); + + expect(summaryTextarea).toHaveTextContent( + 'This is the summary for Session 1' + ); + expect(nameInput.value).toBe('Session 1_copy'); + }); }); diff --git a/src/views/viewTabs.component.tsx b/src/views/viewTabs.component.tsx index 5e7a07b6b..863794ea5 100644 --- a/src/views/viewTabs.component.tsx +++ b/src/views/viewTabs.component.tsx @@ -64,9 +64,16 @@ const ViewTabs = () => { string | undefined >(sessionId); + const [selectedSessionTimestamp, setSelectedSessionTimestamp] = + React.useState<{ + timestamp: string | undefined; + autoSaved: boolean | undefined; + }>({ timestamp: undefined, autoSaved: undefined }); + const { data: sessionsList, refetch: refetchSessionsList } = useSessionList(); const { data: sessionData } = useSession(sessionId); + const { data: selectedSessionData } = useSession(selectedSessionId); const [sessionSaveOpen, setSessionSaveOpen] = React.useState(false); const [sessionEditOpen, setSessionEditOpen] = React.useState(false); @@ -89,12 +96,28 @@ const ViewTabs = () => { setSessionDeleteOpen(true); setSessionId(sessionData._id); }; + + const onSaveAsSessionClick = () => { + setSessionSaveOpen(true); + if (selectedSessionData) { + setSessionName(`${selectedSessionData.name}_copy`); + setSessionSummary(selectedSessionData.summary ?? ''); + } + }; + const onChangeSelectedSessionTimestamp = ( + timestamp: string | undefined, + autoSaved: boolean | undefined + ) => { + setSelectedSessionTimestamp({ timestamp, autoSaved }); + }; + React.useEffect(() => { if (!sessionEditOpen) { setSessionName(undefined); setSessionSummary(''); } }, [sessionEditOpen]); + return ( { sessionsList={sessionsList} selectedSessionId={selectedSessionId} onChangeSelectedSessionId={setSelectedSessionId} + onChangeSelectedSessionTimestamp={onChangeSelectedSessionTimestamp} /> @@ -131,7 +155,13 @@ const ViewTabs = () => { - + From 9f69739c8e19375b7c8e6f47304831304aee18f2 Mon Sep 17 00:00:00 2001 From: Joshua Kitenge <83226114+joshuadkitenge@users.noreply.github.com> Date: Mon, 24 Jul 2023 13:15:34 +0100 Subject: [PATCH 09/37] DSEGOG-129 fix unit test --- src/session/sessionSaveButtons.component.test.tsx | 4 ++-- src/session/sessionSaveButtons.component.tsx | 6 +++++- src/views/viewTabs.component.test.tsx | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/session/sessionSaveButtons.component.test.tsx b/src/session/sessionSaveButtons.component.test.tsx index 07c61661b..55788a928 100644 --- a/src/session/sessionSaveButtons.component.test.tsx +++ b/src/session/sessionSaveButtons.component.test.tsx @@ -171,7 +171,7 @@ describe('session buttons', () => { props = { ...props, selectedSessionTimestamp: { - timestamp: '2023-06-29T14:45:00Z', + timestamp: '2023-06-29T15:45:00Z', autoSaved: false, }, }; @@ -188,7 +188,7 @@ describe('session buttons', () => { props = { ...props, selectedSessionTimestamp: { - timestamp: '2023-06-29T14:45:00Z', + timestamp: '2023-06-29T15:45:00Z', autoSaved: true, }, }; diff --git a/src/session/sessionSaveButtons.component.tsx b/src/session/sessionSaveButtons.component.tsx index ccc790960..b1465c829 100644 --- a/src/session/sessionSaveButtons.component.tsx +++ b/src/session/sessionSaveButtons.component.tsx @@ -100,10 +100,14 @@ const SessionSaveButtons = (props: SessionsSaveButtonsProps) => { const day = date.getDate(); const month = date.toLocaleString('default', { month: 'short' }); const year = date.getFullYear(); - const hour = date.getHours(); + const hour = date.getUTCHours(); const minute = date.getMinutes(); timestamp = `${day} ${month} ${year} ${hour}:${minute}`; + console.log(day); + console.log(hour); + console.log(timestamp); + console.log(selectedSessionTimestamp.timestamp); } return ( { const element = screen.getByTestId('session-save-buttons-timestamp'); expect(element).toHaveTextContent( - 'Session last autosaved: 29 Jun 2023 11:30' + 'Session last autosaved: 29 Jun 2023 10:30' ); const saveAsButton = screen.getByRole('button', { name: 'Save as' }); From b85bfec5792fa66c040f0c53acb1df27a078a4dd Mon Sep 17 00:00:00 2001 From: Joshua Kitenge <83226114+joshuadkitenge@users.noreply.github.com> Date: Thu, 27 Jul 2023 09:49:00 +0100 Subject: [PATCH 10/37] update to pr to match deployed api --- cypress/integration/table/sessions.spec.ts | 20 ++++- package.json | 2 +- src/session/sessionDrawer.component.test.tsx | 5 +- src/session/sessionDrawer.component.tsx | 6 -- .../sessionSaveButtons.component.test.tsx | 90 +++++++++++++++---- src/session/sessionSaveButtons.component.tsx | 2 + src/views/viewTabs.component.tsx | 1 - yarn.lock | 4 +- 8 files changed, 103 insertions(+), 27 deletions(-) diff --git a/cypress/integration/table/sessions.spec.ts b/cypress/integration/table/sessions.spec.ts index 26123238b..3900496ea 100644 --- a/cypress/integration/table/sessions.spec.ts +++ b/cypress/integration/table/sessions.spec.ts @@ -93,6 +93,11 @@ describe('Sessions', () => { expect(value).to.equal('2022-01-09 12:00'); }); + cy.findByTestId('session-save-buttons-timestamp').should( + 'have.text', + 'Session last saved: 29 Jun 2023 15:45' + ); + cy.findByText('Session 3').click(); // wait for search to initiate and finish cy.findByRole('progressbar').should('exist'); @@ -109,6 +114,11 @@ describe('Sessions', () => { const value = $input.val(); expect(value).to.equal(''); }); + + cy.findByTestId('session-save-buttons-timestamp').should( + 'have.text', + 'Session last autosaved: 30 Jun 2023 10:15' + ); }); it('sends a patch request when a user edits a session', () => { @@ -167,14 +177,22 @@ describe('Sessions', () => { }).should((patchRequests) => { expect(patchRequests.length).equal(1); const request = patchRequests[0]; - + expect(JSON.stringify(request.body)).equal( + '{"table":{"columnStates":{},"selectedColumnIds":["timestamp","CHANNEL_EFGHI","CHANNEL_FGHIJ","shotnum"],"page":0,"resultsPerPage":25,"sort":{}},"search":{"searchParams":{"dateRange":{"fromDate":"2022-01-06T13:00:00","toDate":"2022-01-09T12:00:59"},"shotnumRange":{"min":7,"max":9},"maxShots":50,"experimentID":{"_id":"19210012-1","end_date":"2022-01-09T12:00:00","experiment_id":"19210012","part":1,"start_date":"2022-01-06T13:00:00"}}},"plots":{},"filter":{"appliedFilters":[[{"type":"channel","value":"shotnum","label":"Shot Number"},{"type":"compop","value":">","label":">"},{"type":"number","value":"7","label":"7"}]]},"windows":{}}' + ); expect(request.url.toString()).to.contain('2'); + expect(request.url.toString()).to.contain('name='); + expect(request.url.toString()).to.contain('summary='); expect(request.url.toString()).to.contain('auto_saved='); const paramMap: Map = getParamsFromUrl( request.url.toString() ); + expect(paramMap.get('name')).equal('Session+2'); + expect(paramMap.get('summary')).equal( + 'This+is+the+summary+for+Session+2' + ); expect(paramMap.get('auto_saved')).equal('false'); }); }); diff --git a/package.json b/package.json index fe488334a..fb8b40c23 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@types/react-dom": "18.0.4", "@types/react-table": "7.7.12", "axios": "1.4.0", - "date-fns": "^2.30.0", + "date-fns": "2.30.0", "hacktimer": "1.1.3", "history": "5.3.0", "i18next": "22.4.3", diff --git a/src/session/sessionDrawer.component.test.tsx b/src/session/sessionDrawer.component.test.tsx index 6d36ae588..7f8eabb1d 100644 --- a/src/session/sessionDrawer.component.test.tsx +++ b/src/session/sessionDrawer.component.test.tsx @@ -11,6 +11,7 @@ describe('session Drawer', () => { const openSessionDelete = jest.fn(); const onChangeSelectedSessionId = jest.fn(); const onChangeSelectedSessionTimestamp = jest.fn(); + const refetchSessionsData = jest.fn(); let user; let props: SessionDrawerProps; const createView = (): RenderResult => { @@ -26,6 +27,7 @@ describe('session Drawer', () => { onChangeSelectedSessionId: onChangeSelectedSessionId, sessionsList: SessionsListJSON, onChangeSelectedSessionTimestamp: onChangeSelectedSessionTimestamp, + refetchSessionsData: refetchSessionsData, }; }); afterEach(() => { @@ -64,9 +66,10 @@ describe('session Drawer', () => { }); expect(onChangeSelectedSessionTimestamp).toHaveBeenCalledWith( - '2023-06-29T10:30:00Z', + '2023-06-29T10:30:00:00', true ); + expect(refetchSessionsData).toHaveBeenCalledWith('1'); }); it('a user can open the edit session dialogue', async () => { diff --git a/src/session/sessionDrawer.component.tsx b/src/session/sessionDrawer.component.tsx index 5773cae89..5ff034367 100644 --- a/src/session/sessionDrawer.component.tsx +++ b/src/session/sessionDrawer.component.tsx @@ -33,7 +33,6 @@ export interface SessionDrawerProps { autoSaved: boolean | undefined ) => void; refetchSessionsData: (sessionId: string) => void; - refetchSessionsList: () => void; } interface SessionListElementProps extends SessionListItem { @@ -46,7 +45,6 @@ interface SessionListElementProps extends SessionListItem { autoSaved: boolean | undefined ) => void; refetchSessionsData: (sessionId: string) => void; - refetchSessionsList: () => void; } const SessionListElement = ( @@ -59,7 +57,6 @@ const SessionListElement = ( handleImport, onChangeSelectedSessionTimestamp, refetchSessionsData, - refetchSessionsList, ...session } = props; const prevTimestampRef = React.useRef(undefined); @@ -100,7 +97,6 @@ const SessionListElement = ( }} onClick={() => { refetchSessionsData(session._id); - refetchSessionsList(); onChangeSelectedSessionTimestamp( session.timestamp, session.auto_saved @@ -152,7 +148,6 @@ const SessionsDrawer = (props: SessionDrawerProps): React.ReactElement => { onChangeSelectedSessionId, onChangeSelectedSessionTimestamp, refetchSessionsData, - refetchSessionsList, } = props; const { data: sessionData } = useSession(selectedSessionId); @@ -227,7 +222,6 @@ const SessionsDrawer = (props: SessionDrawerProps): React.ReactElement => { onChangeSelectedSessionTimestamp } refetchSessionsData={refetchSessionsData} - refetchSessionsList={refetchSessionsList} /> ))} diff --git a/src/session/sessionSaveButtons.component.test.tsx b/src/session/sessionSaveButtons.component.test.tsx index 55788a928..c3948f81b 100644 --- a/src/session/sessionSaveButtons.component.test.tsx +++ b/src/session/sessionSaveButtons.component.test.tsx @@ -18,11 +18,8 @@ jest.mock('../api/sessions', () => ({ useEditSession: jest.fn(), })); -// jest.setTimeout(100000); - describe('session buttons', () => { let props: SessionsSaveButtonsProps; - // let user: ReturnType; const onSaveAsSessionClick = jest.fn(); const refetchSessionsList = jest.fn(); const createView = (): RenderResult => { @@ -39,8 +36,6 @@ describe('session buttons', () => { }; jest.useFakeTimers(); - // user = userEvent.setup(); - // Mock the return value of useEditSession hook useEditSession.mockReturnValue({ mutateAsync: jest.fn().mockResolvedValue({}), @@ -62,8 +57,9 @@ describe('session buttons', () => { name: 'test', summary: 'test', auto_saved: false, - session_data: '{}', + session: {}, _id: '1', + timestamp: '', }; props.sessionId = '1'; const { rerender } = createView(); @@ -76,16 +72,38 @@ describe('session buttons', () => { expect(useEditSession().mutateAsync).toHaveBeenCalledWith({ _id: '1', auto_saved: true, - session_data: - '{"table":{"columnStates":{},"selectedColumnIds":[],"page":0,"resultsPerPage":25,"sort":{}},"search":{"searchParams":{"dateRange":{},"shotnumRange":{},"maxShots":50,"experimentID":null}},"plots":{},"filter":{"appliedFilters":[[]]},"windows":{}}', + name: 'test', + summary: 'test', + timestamp: '', + session: { + table: { + columnStates: {}, + selectedColumnIds: [], + page: 0, + resultsPerPage: 25, + sort: {}, + }, + search: { + searchParams: { + dateRange: {}, + shotnumRange: {}, + maxShots: 50, + experimentID: null, + }, + }, + plots: {}, + filter: { appliedFilters: [[]] }, + windows: {}, + }, }); props.selectedSessionData = { name: 'test 2', summary: 'test 2', auto_saved: false, - session_data: '{}', + session: {}, _id: '2', + timestamp: '', }; props.sessionId = '2'; rerender(); @@ -98,8 +116,29 @@ describe('session buttons', () => { expect(useEditSession().mutateAsync).toHaveBeenCalledWith({ _id: '2', auto_saved: true, - session_data: - '{"table":{"columnStates":{},"selectedColumnIds":[],"page":0,"resultsPerPage":25,"sort":{}},"search":{"searchParams":{"dateRange":{},"shotnumRange":{},"maxShots":50,"experimentID":null}},"plots":{},"filter":{"appliedFilters":[[]]},"windows":{}}', + name: 'test 2', + summary: 'test 2', + timestamp: '', + session: { + table: { + columnStates: {}, + selectedColumnIds: [], + page: 0, + resultsPerPage: 25, + sort: {}, + }, + search: { + searchParams: { + dateRange: {}, + shotnumRange: {}, + maxShots: 50, + experimentID: null, + }, + }, + plots: {}, + filter: { appliedFilters: [[]] }, + windows: {}, + }, }); act(() => { @@ -110,8 +149,29 @@ describe('session buttons', () => { expect(useEditSession().mutateAsync).toHaveBeenCalledWith({ _id: '2', auto_saved: true, - session_data: - '{"table":{"columnStates":{},"selectedColumnIds":[],"page":0,"resultsPerPage":25,"sort":{}},"search":{"searchParams":{"dateRange":{},"shotnumRange":{},"maxShots":50,"experimentID":null}},"plots":{},"filter":{"appliedFilters":[[]]},"windows":{}}', + name: 'test 2', + summary: 'test 2', + timestamp: '', + session: { + table: { + columnStates: {}, + selectedColumnIds: [], + page: 0, + resultsPerPage: 25, + sort: {}, + }, + search: { + searchParams: { + dateRange: {}, + shotnumRange: {}, + maxShots: 50, + experimentID: null, + }, + }, + plots: {}, + filter: { appliedFilters: [[]] }, + windows: {}, + }, }); }); @@ -171,7 +231,7 @@ describe('session buttons', () => { props = { ...props, selectedSessionTimestamp: { - timestamp: '2023-06-29T15:45:00Z', + timestamp: '2023-06-29T15:45:00', autoSaved: false, }, }; @@ -188,7 +248,7 @@ describe('session buttons', () => { props = { ...props, selectedSessionTimestamp: { - timestamp: '2023-06-29T15:45:00Z', + timestamp: '2023-06-29T15:45:00', autoSaved: true, }, }; diff --git a/src/session/sessionSaveButtons.component.tsx b/src/session/sessionSaveButtons.component.tsx index 7d59e65b5..a16bff11a 100644 --- a/src/session/sessionSaveButtons.component.tsx +++ b/src/session/sessionSaveButtons.component.tsx @@ -103,7 +103,9 @@ const SessionSaveButtons = (props: SessionsSaveButtonsProps) => { timestamp = undefined; const formatDate = (inputDate: string) => { + console.log(inputDate); const date = parseISO(inputDate); + console.log(date); const formattedDate = format(date, 'dd MMM yyyy HH:mm'); return formattedDate; }; diff --git a/src/views/viewTabs.component.tsx b/src/views/viewTabs.component.tsx index 216be84d0..4f63692d3 100644 --- a/src/views/viewTabs.component.tsx +++ b/src/views/viewTabs.component.tsx @@ -147,7 +147,6 @@ const ViewTabs = () => { onChangeSelectedSessionId={setSelectedSessionId} onChangeSelectedSessionTimestamp={onChangeSelectedSessionTimestamp} refetchSessionsData={onChangeRefetchSessionData} - refetchSessionsList={refetchSessionsList} /> diff --git a/yarn.lock b/yarn.lock index 5237acd6e..a9a068055 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6855,7 +6855,7 @@ __metadata: languageName: node linkType: hard -"date-fns@npm:^2.30.0": +"date-fns@npm:2.30.0": version: 2.30.0 resolution: "date-fns@npm:2.30.0" dependencies: @@ -12246,7 +12246,7 @@ __metadata: cross-env: 7.0.3 cypress: 9.7.0 cypress-failed-log: 2.10.0 - date-fns: ^2.30.0 + date-fns: 2.30.0 eslint: 8.39.0 eslint-config-prettier: 8.8.0 eslint-config-react-app: 7.0.1 From e829dfdad5393d75a7c0c50a988a08c99563848c Mon Sep 17 00:00:00 2001 From: Joshua Kitenge <83226114+joshuadkitenge@users.noreply.github.com> Date: Thu, 27 Jul 2023 13:45:18 +0100 Subject: [PATCH 11/37] fix unit test --- src/session/sessionDrawer.component.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/session/sessionDrawer.component.test.tsx b/src/session/sessionDrawer.component.test.tsx index 7f8eabb1d..aafe6dc4a 100644 --- a/src/session/sessionDrawer.component.test.tsx +++ b/src/session/sessionDrawer.component.test.tsx @@ -66,7 +66,7 @@ describe('session Drawer', () => { }); expect(onChangeSelectedSessionTimestamp).toHaveBeenCalledWith( - '2023-06-29T10:30:00:00', + '2023-06-29T10:30:00Z', true ); expect(refetchSessionsData).toHaveBeenCalledWith('1'); From 50f6742c28c4231bf49aa456f2d2cdfe69bb6dea Mon Sep 17 00:00:00 2001 From: Joshua Kitenge <83226114+joshuadkitenge@users.noreply.github.com> Date: Mon, 31 Jul 2023 09:38:04 +0100 Subject: [PATCH 12/37] fix e2e --- cypress/integration/table/sessions.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/integration/table/sessions.spec.ts b/cypress/integration/table/sessions.spec.ts index 3900496ea..e389d4628 100644 --- a/cypress/integration/table/sessions.spec.ts +++ b/cypress/integration/table/sessions.spec.ts @@ -95,7 +95,7 @@ describe('Sessions', () => { cy.findByTestId('session-save-buttons-timestamp').should( 'have.text', - 'Session last saved: 29 Jun 2023 15:45' + 'Session last saved: 29 Jun 2023 14:45' ); cy.findByText('Session 3').click(); From 8f289d5e680159eb15d6d84d17d47d6c1e1e5a24 Mon Sep 17 00:00:00 2001 From: Joshua Kitenge <83226114+joshuadkitenge@users.noreply.github.com> Date: Mon, 31 Jul 2023 10:17:47 +0100 Subject: [PATCH 13/37] fix e2e test --- cypress/integration/table/sessions.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/integration/table/sessions.spec.ts b/cypress/integration/table/sessions.spec.ts index e389d4628..0d742d05b 100644 --- a/cypress/integration/table/sessions.spec.ts +++ b/cypress/integration/table/sessions.spec.ts @@ -117,7 +117,7 @@ describe('Sessions', () => { cy.findByTestId('session-save-buttons-timestamp').should( 'have.text', - 'Session last autosaved: 30 Jun 2023 10:15' + 'Session last autosaved: 30 Jun 2023 09:15' ); }); From bf16fbae588a6d764536bc24ad2696d0f6a0d923 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Fri, 4 Aug 2023 17:17:37 +0100 Subject: [PATCH 14/37] Update playwright webkit test snapshot --- ...customize-y-axis-labels-1-webkit-linux.png | Bin 28414 -> 28429 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/e2e/plotting.spec.ts-snapshots/user-can-customize-y-axis-labels-1-webkit-linux.png b/e2e/plotting.spec.ts-snapshots/user-can-customize-y-axis-labels-1-webkit-linux.png index 5f5b4d6de0e7bdf9e4dc729ae03a30ca491bf739..2389058b4329e33ffb1458d36ddd75bbdf5147ad 100644 GIT binary patch literal 28429 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 literal 28414 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 Date: Wed, 9 Aug 2023 10:38:02 +0100 Subject: [PATCH 15/37] fix failing tests --- cypress/integration/table/sessions.spec.ts | 2 +- src/mocks/sessionsList.json | 8 ++++---- src/session/sessionSaveButtons.component.test.tsx | 3 ++- src/session/sessionSaveButtons.component.tsx | 2 -- src/views/viewTabs.component.test.tsx | 2 +- 5 files changed, 8 insertions(+), 9 deletions(-) diff --git a/cypress/integration/table/sessions.spec.ts b/cypress/integration/table/sessions.spec.ts index 7b2e3940c..a874e0eec 100644 --- a/cypress/integration/table/sessions.spec.ts +++ b/cypress/integration/table/sessions.spec.ts @@ -210,7 +210,7 @@ describe('Sessions', () => { cy.findByRole('button', { name: 'Save as' }).click(); cy.findByLabelText('Save Session').should('exist'); - cy.findByLabelText('Name*').should(($input) => { + cy.findByLabelText('Name *').should(($input) => { const value = $input.val(); expect(value).to.equal('Session 2_copy'); }); diff --git a/src/mocks/sessionsList.json b/src/mocks/sessionsList.json index 51b218962..1c5acb2ef 100644 --- a/src/mocks/sessionsList.json +++ b/src/mocks/sessionsList.json @@ -4,7 +4,7 @@ "name": "Session 1", "summary": "This is the summary for Session 1", "auto_saved": true, - "timestamp": "2023-06-29T10:30:00Z", + "timestamp": "2023-06-29T10:30:00", "session": { "table": { "columnStates": {}, @@ -87,7 +87,7 @@ "name": "Session 2", "summary": "This is the summary for Session 2", "auto_saved": false, - "timestamp": "2023-06-29T14:45:00Z", + "timestamp": "2023-06-29T14:45:00", "session": { "table": { "columnStates": {}, @@ -136,7 +136,7 @@ "name": "Session 3", "summary": "This is the summary for Session 3", "auto_saved": true, - "timestamp": "2023-06-30T09:15:00Z", + "timestamp": "2023-06-30T09:15:00", "session": { "table": { "columnStates": {}, @@ -167,7 +167,7 @@ "summary": "This is the summary for Session 4", "auto_saved": false, "name": "Session 4", - "timestamp": "2023-06-30T09:15:00Z", + "timestamp": "2023-06-30T09:15:00", "session": { "table": { "columnStates": {}, diff --git a/src/session/sessionSaveButtons.component.test.tsx b/src/session/sessionSaveButtons.component.test.tsx index c3948f81b..8e93af0f2 100644 --- a/src/session/sessionSaveButtons.component.test.tsx +++ b/src/session/sessionSaveButtons.component.test.tsx @@ -190,8 +190,9 @@ describe('session buttons', () => { name: 'test', summary: 'test', auto_saved: false, - session_data: '{}', + session: {}, _id: '1', + timestamp: '', }; createView(); const saveButton = screen.getByRole('button', { name: 'Save' }); diff --git a/src/session/sessionSaveButtons.component.tsx b/src/session/sessionSaveButtons.component.tsx index a16bff11a..7d59e65b5 100644 --- a/src/session/sessionSaveButtons.component.tsx +++ b/src/session/sessionSaveButtons.component.tsx @@ -103,9 +103,7 @@ const SessionSaveButtons = (props: SessionsSaveButtonsProps) => { timestamp = undefined; const formatDate = (inputDate: string) => { - console.log(inputDate); const date = parseISO(inputDate); - console.log(date); const formattedDate = format(date, 'dd MMM yyyy HH:mm'); return formattedDate; }; diff --git a/src/views/viewTabs.component.test.tsx b/src/views/viewTabs.component.test.tsx index 3cc181418..949458dc3 100644 --- a/src/views/viewTabs.component.test.tsx +++ b/src/views/viewTabs.component.test.tsx @@ -158,7 +158,7 @@ describe('View Tabs', () => { const dialog = screen.getByRole('dialog'); const summaryTextarea = within(dialog).getByLabelText('Summary'); - const nameInput = within(dialog).getByLabelText('Name*'); + const nameInput = within(dialog).getByLabelText('Name *'); expect(summaryTextarea).toHaveTextContent( 'This is the summary for Session 1' From fecafa04ff93b24de133100293b3a133343dc4be Mon Sep 17 00:00:00 2001 From: Joshua Kitenge <83226114+joshuadkitenge@users.noreply.github.com> Date: Wed, 9 Aug 2023 10:52:17 +0100 Subject: [PATCH 16/37] fix unit tests --- src/session/sessionDrawer.component.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/session/sessionDrawer.component.test.tsx b/src/session/sessionDrawer.component.test.tsx index 1bf16000a..92c5bfe99 100644 --- a/src/session/sessionDrawer.component.test.tsx +++ b/src/session/sessionDrawer.component.test.tsx @@ -66,7 +66,7 @@ describe('session Drawer', () => { }); expect(onChangeSelectedSessionTimestamp).toHaveBeenCalledWith( - '2023-06-29T10:30:00Z', + '2023-06-29T10:30:00', true ); expect(refetchSessionsData).toHaveBeenCalledWith('1'); From dd13ca47cf32ee320dc80c602c7c98e781e37a17 Mon Sep 17 00:00:00 2001 From: Joshua Kitenge <83226114+joshuadkitenge@users.noreply.github.com> Date: Thu, 10 Aug 2023 14:06:53 +0100 Subject: [PATCH 17/37] add aria-label to Iconbutton --- src/session/__snapshots__/sessionDrawer.component.test.tsx.snap | 1 + src/session/sessionDrawer.component.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/session/__snapshots__/sessionDrawer.component.test.tsx.snap b/src/session/__snapshots__/sessionDrawer.component.test.tsx.snap index df4407871..793a14612 100644 --- a/src/session/__snapshots__/sessionDrawer.component.test.tsx.snap +++ b/src/session/__snapshots__/sessionDrawer.component.test.tsx.snap @@ -24,6 +24,7 @@ exports[`session Drawer renders correctly 1`] = ` class="MuiBox-root css-zdpt2t" > - From d04de2cbeeb26596f496a99f4e5c1ed658c47526 Mon Sep 17 00:00:00 2001 From: Joshua Kitenge <83226114+joshuadkitenge@users.noreply.github.com> Date: Mon, 14 Aug 2023 09:03:07 +0100 Subject: [PATCH 23/37] update snapshot --- src/views/__snapshots__/viewTabs.component.test.tsx.snap | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/__snapshots__/viewTabs.component.test.tsx.snap b/src/views/__snapshots__/viewTabs.component.test.tsx.snap index 8df466a5a..65cf1565f 100644 --- a/src/views/__snapshots__/viewTabs.component.test.tsx.snap +++ b/src/views/__snapshots__/viewTabs.component.test.tsx.snap @@ -128,7 +128,7 @@ exports[`View Tabs renders correctly 1`] = ` />