diff --git a/.pnp.cjs b/.pnp.cjs index 9a8a61a2b..fd53611d0 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -43,6 +43,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@reduxjs/toolkit", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:1.9.0"],\ ["@tanstack/react-query", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:4.29.5"],\ ["@tanstack/react-query-devtools", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:4.29.6"],\ + ["@tanstack/react-table", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:8.9.3"],\ ["@tanstack/react-virtual", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:3.0.0-beta.54"],\ ["@testing-library/cypress", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:8.0.7"],\ ["@testing-library/dom", "npm:9.3.1"],\ @@ -55,7 +56,6 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@types/react-beautiful-dnd", "npm:13.1.2"],\ ["@types/react-dom", "npm:18.2.6"],\ ["@types/react-router-dom", "npm:5.3.3"],\ - ["@types/react-table", "npm:7.7.12"],\ ["@types/testing-library__jest-dom", "npm:5.14.3"],\ ["@typescript-eslint/eslint-plugin", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:5.61.0"],\ ["@typescript-eslint/parser", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:5.61.0"],\ @@ -98,7 +98,6 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["react-redux", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:8.1.1"],\ ["react-router-dom", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:6.8.0"],\ ["react-scripts", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:5.0.1"],\ - ["react-table", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:7.8.0"],\ ["serve", "npm:14.2.0"],\ ["serve-static", "npm:1.15.0"],\ ["single-spa-react", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:5.0.1"],\ @@ -5334,6 +5333,33 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@tanstack/react-table", [\ + ["npm:8.9.3", {\ + "packageLocation": "./.yarn/cache/@tanstack-react-table-npm-8.9.3-af8d0ec3fb-a71fbbc608.zip/node_modules/@tanstack/react-table/",\ + "packageDependencies": [\ + ["@tanstack/react-table", "npm:8.9.3"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:8.9.3", {\ + "packageLocation": "./.yarn/__virtual__/@tanstack-react-table-virtual-687e0a52e5/0/cache/@tanstack-react-table-npm-8.9.3-af8d0ec3fb-a71fbbc608.zip/node_modules/@tanstack/react-table/",\ + "packageDependencies": [\ + ["@tanstack/react-table", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:8.9.3"],\ + ["@tanstack/table-core", "npm:8.9.3"],\ + ["@types/react", "npm:18.2.14"],\ + ["@types/react-dom", "npm:18.2.6"],\ + ["react", "npm:18.2.0"],\ + ["react-dom", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:18.2.0"]\ + ],\ + "packagePeers": [\ + "@types/react-dom",\ + "@types/react",\ + "react-dom",\ + "react"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@tanstack/react-virtual", [\ ["npm:3.0.0-beta.54", {\ "packageLocation": "./.yarn/cache/@tanstack-react-virtual-npm-3.0.0-beta.54-58c34420c1-ddeb3cb46d.zip/node_modules/@tanstack/react-virtual/",\ @@ -5357,6 +5383,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@tanstack/table-core", [\ + ["npm:8.9.3", {\ + "packageLocation": "./.yarn/cache/@tanstack-table-core-npm-8.9.3-993026ff01-52c7e57daa.zip/node_modules/@tanstack/table-core/",\ + "packageDependencies": [\ + ["@tanstack/table-core", "npm:8.9.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@tanstack/virtual-core", [\ ["npm:3.0.0-beta.54", {\ "packageLocation": "./.yarn/cache/@tanstack-virtual-core-npm-3.0.0-beta.54-e3efac248b-a58cb30e1b.zip/node_modules/@tanstack/virtual-core/",\ @@ -6060,16 +6095,6 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ - ["@types/react-table", [\ - ["npm:7.7.12", {\ - "packageLocation": "./.yarn/cache/@types-react-table-npm-7.7.12-3528071866-287ea68e75.zip/node_modules/@types/react-table/",\ - "packageDependencies": [\ - ["@types/react-table", "npm:7.7.12"],\ - ["@types/react", "npm:18.2.14"]\ - ],\ - "linkType": "HARD"\ - }]\ - ]],\ ["@types/react-transition-group", [\ ["npm:4.4.6", {\ "packageLocation": "./.yarn/cache/@types-react-transition-group-npm-4.4.6-3b139bdf30-0872143821.zip/node_modules/@types/react-transition-group/",\ @@ -12423,10 +12448,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["graphql", [\ - ["npm:16.6.0", {\ - "packageLocation": "./.yarn/cache/graphql-npm-16.6.0-301c470966-bf1d9e3c19.zip/node_modules/graphql/",\ + ["npm:16.8.1", {\ + "packageLocation": "./.yarn/cache/graphql-npm-16.8.1-c2cd08b4c0-8d304b7b6f.zip/node_modules/graphql/",\ "packageDependencies": [\ - ["graphql", "npm:16.6.0"]\ + ["graphql", "npm:16.8.1"]\ ],\ "linkType": "HARD"\ }]\ @@ -15627,7 +15652,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["chalk", "npm:4.1.1"],\ ["chokidar", "npm:3.5.3"],\ ["cookie", "npm:0.4.2"],\ - ["graphql", "npm:16.6.0"],\ + ["graphql", "npm:16.8.1"],\ ["headers-polyfill", "npm:3.1.2"],\ ["inquirer", "npm:8.2.5"],\ ["is-node-process", "npm:1.2.0"],\ @@ -16108,6 +16133,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@reduxjs/toolkit", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:1.9.0"],\ ["@tanstack/react-query", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:4.29.5"],\ ["@tanstack/react-query-devtools", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:4.29.6"],\ + ["@tanstack/react-table", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:8.9.3"],\ ["@tanstack/react-virtual", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:3.0.0-beta.54"],\ ["@testing-library/cypress", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:8.0.7"],\ ["@testing-library/dom", "npm:9.3.1"],\ @@ -16120,7 +16146,6 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@types/react-beautiful-dnd", "npm:13.1.2"],\ ["@types/react-dom", "npm:18.2.6"],\ ["@types/react-router-dom", "npm:5.3.3"],\ - ["@types/react-table", "npm:7.7.12"],\ ["@types/testing-library__jest-dom", "npm:5.14.3"],\ ["@typescript-eslint/eslint-plugin", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:5.61.0"],\ ["@typescript-eslint/parser", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:5.61.0"],\ @@ -16163,7 +16188,6 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["react-redux", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:8.1.1"],\ ["react-router-dom", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:6.8.0"],\ ["react-scripts", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:5.0.1"],\ - ["react-table", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:7.8.0"],\ ["serve", "npm:14.2.0"],\ ["serve-static", "npm:1.15.0"],\ ["single-spa-react", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:5.0.1"],\ @@ -19144,28 +19168,6 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ - ["react-table", [\ - ["npm:7.8.0", {\ - "packageLocation": "./.yarn/cache/react-table-npm-7.8.0-61488af438-44ca0fb848.zip/node_modules/react-table/",\ - "packageDependencies": [\ - ["react-table", "npm:7.8.0"]\ - ],\ - "linkType": "SOFT"\ - }],\ - ["virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:7.8.0", {\ - "packageLocation": "./.yarn/__virtual__/react-table-virtual-17f8315e27/0/cache/react-table-npm-7.8.0-61488af438-44ca0fb848.zip/node_modules/react-table/",\ - "packageDependencies": [\ - ["react-table", "virtual:c9244accb1038960b8bd2ad679a4254224d40b15df01d8c1ca49bdc22d22637447d555aee5f07b768c5deaa012fb66b3da954479d1ae1181e2bbdad9a15b5496#npm:7.8.0"],\ - ["@types/react", "npm:18.2.14"],\ - ["react", "npm:18.2.0"]\ - ],\ - "packagePeers": [\ - "@types/react",\ - "react"\ - ],\ - "linkType": "HARD"\ - }]\ - ]],\ ["react-transition-group", [\ ["npm:4.4.5", {\ "packageLocation": "./.yarn/cache/react-transition-group-npm-4.4.5-98ea4ef96e-7560284010.zip/node_modules/react-transition-group/",\ diff --git a/.yarn/cache/@tanstack-react-table-npm-8.9.3-af8d0ec3fb-a71fbbc608.zip b/.yarn/cache/@tanstack-react-table-npm-8.9.3-af8d0ec3fb-a71fbbc608.zip new file mode 100644 index 000000000..c3c0929d5 Binary files /dev/null and b/.yarn/cache/@tanstack-react-table-npm-8.9.3-af8d0ec3fb-a71fbbc608.zip differ diff --git a/.yarn/cache/@tanstack-table-core-npm-8.9.3-993026ff01-52c7e57daa.zip b/.yarn/cache/@tanstack-table-core-npm-8.9.3-993026ff01-52c7e57daa.zip new file mode 100644 index 000000000..5943bb0bd Binary files /dev/null and b/.yarn/cache/@tanstack-table-core-npm-8.9.3-993026ff01-52c7e57daa.zip differ diff --git a/.yarn/cache/@types-react-table-npm-7.7.12-3528071866-287ea68e75.zip b/.yarn/cache/@types-react-table-npm-7.7.12-3528071866-287ea68e75.zip deleted file mode 100644 index a3d3299a0..000000000 Binary files a/.yarn/cache/@types-react-table-npm-7.7.12-3528071866-287ea68e75.zip and /dev/null differ diff --git a/.yarn/cache/graphql-npm-16.6.0-301c470966-bf1d9e3c19.zip b/.yarn/cache/graphql-npm-16.8.1-c2cd08b4c0-8d304b7b6f.zip similarity index 85% rename from .yarn/cache/graphql-npm-16.6.0-301c470966-bf1d9e3c19.zip rename to .yarn/cache/graphql-npm-16.8.1-c2cd08b4c0-8d304b7b6f.zip index 626d9145e..18cf7d309 100644 Binary files a/.yarn/cache/graphql-npm-16.6.0-301c470966-bf1d9e3c19.zip and b/.yarn/cache/graphql-npm-16.8.1-c2cd08b4c0-8d304b7b6f.zip differ diff --git a/.yarn/cache/react-table-npm-7.8.0-61488af438-44ca0fb848.zip b/.yarn/cache/react-table-npm-7.8.0-61488af438-44ca0fb848.zip deleted file mode 100644 index ac47840fe..000000000 Binary files a/.yarn/cache/react-table-npm-7.8.0-61488af438-44ca0fb848.zip and /dev/null differ diff --git a/cypress/integration/table/search.spec.ts b/cypress/integration/table/search.spec.ts index bd9b841f9..23c8e407c 100644 --- a/cypress/integration/table/search.spec.ts +++ b/cypress/integration/table/search.spec.ts @@ -39,7 +39,23 @@ describe('Search', () => { }); }).as('getSettings'); - cy.visit('/').wait(['@getSettings']); + cy.visit('/', { + // need these to ensure Date picker media queries pass + // ref: https://mui.com/x/react-date-pickers/getting-started/#testing-caveats + onBeforeLoad: (win) => { + cy.stub(win, 'matchMedia') + .withArgs('(pointer: fine)') + .returns({ + matches: true, + addListener: () => { + // no-op + }, + removeListener: () => { + // no-op + }, + }); + }, + }).wait(['@getSettings']); cy.findByRole('progressbar').should('be.visible'); cy.findByRole('progressbar').should('not.exist'); @@ -1093,7 +1109,23 @@ describe('Search', () => { }); }).as('getSettings'); - cy.visit('/').wait(['@getSettings']); + cy.visit('/', { + // need these to ensure Date picker media queries pass + // ref: https://mui.com/x/react-date-pickers/getting-started/#testing-caveats + onBeforeLoad: (win) => { + cy.stub(win, 'matchMedia') + .withArgs('(pointer: fine)') + .returns({ + matches: true, + addListener: () => { + // no-op + }, + removeListener: () => { + // no-op + }, + }); + }, + }).wait(['@getSettings']); }); it('displays appropriate tooltips', () => { diff --git a/cypress/integration/table/sessions.spec.ts b/cypress/integration/table/sessions.spec.ts index 2706417c3..de256b82e 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 14: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 09:15' + ); }); it('sends a patch request when a user edits a session', () => { @@ -154,6 +164,63 @@ describe('Sessions', () => { }); }); + it('sends a patch request when a user saves their current session', () => { + cy.findByText('Session 2').click(); + + cy.startSnoopingBrowserMockedRequest(); + + cy.findByRole('button', { name: 'Save' }).click(); + + cy.findBrowserMockedRequests({ + method: 'PATCH', + url: '/sessions/:id', + }).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'); + }); + }); + + it('opens the save session dialog if a user session is not selected and user click the save or save as button', () => { + cy.findByRole('button', { name: 'Save' }).click(); + cy.findByLabelText('Save Session').should('exist'); + cy.findByRole('button', { name: 'Close' }).click(); + cy.findByRole('button', { name: 'Save as' }).click(); + cy.findByLabelText('Save Session').should('exist'); + }); + + it('loads in the summary and name (with _copy) when save as is clicked and a user session is selected', () => { + cy.findByText('Session 2').click(); + cy.findByRole('button', { name: 'Save as' }).click(); + cy.findByLabelText('Save Session').should('exist'); + + cy.findByLabelText('Name *').should(($input) => { + const value = $input.val(); + expect(value).to.equal('Session 2_copy'); + }); + + cy.findByLabelText('Summary').should(($input) => { + const value = $input.val(); + expect(value).to.equal('This is the summary for Session 2'); + }); + }); + it('sends a delete request when a user deletes a session', () => { cy.findByRole('button', { name: 'delete Session 1 session' }).click(); cy.findByText('Delete Session'); diff --git a/cypress/integration/table/table.spec.ts b/cypress/integration/table/table.spec.ts index db1098cdf..564dd6648 100644 --- a/cypress/integration/table/table.spec.ts +++ b/cypress/integration/table/table.spec.ts @@ -11,25 +11,34 @@ const verifyColumnOrder = (columns: string[]): void => { describe('Table Component', () => { beforeEach(() => { - cy.visit('/'); + cy.visit('/', { + // need these to ensure Date picker media queries pass + // ref: https://mui.com/x/react-date-pickers/getting-started/#testing-caveats + onBeforeLoad: (win) => { + cy.stub(win, 'matchMedia') + .withArgs('(pointer: fine)') + .returns({ + matches: true, + addListener: () => { + // no-op + }, + removeListener: () => { + // no-op + }, + }); + }, + }); }); - it('initialises with a time column', () => { + it('adds columns in the order they are selected', () => { cy.get('[aria-describedby="table-loading-indicator"]').should( 'have.attr', 'aria-busy', 'false' ); + // check initialised with time column verifyColumnOrder(['Time']); - }); - - it('adds columns in the order they are selected', () => { - cy.get('[aria-describedby="table-loading-indicator"]').should( - 'have.attr', - 'aria-busy', - 'false' - ); addInitialSystemChannels(['Shot Number']); @@ -45,14 +54,14 @@ describe('Table Component', () => { }); it('moves a column left', () => { + addInitialSystemChannels(['Shot Number', 'Active Area']); + cy.get('[aria-describedby="table-loading-indicator"]').should( 'have.attr', 'aria-busy', 'false' ); - addInitialSystemChannels(['Shot Number', 'Active Area']); - cy.get(getHandleSelector()) .first() .as('secondColumn') @@ -71,14 +80,14 @@ describe('Table Component', () => { }); it('moves a column right', () => { + addInitialSystemChannels(['Shot Number', 'Active Area']); + cy.get('[aria-describedby="table-loading-indicator"]').should( 'have.attr', 'aria-busy', 'false' ); - addInitialSystemChannels(['Shot Number', 'Active Area']); - cy.get(getHandleSelector()) .first() .as('secondColumn') @@ -176,12 +185,6 @@ describe('Table Component', () => { }); it('column headers overflow when word wrap is enabled', () => { - cy.get('[aria-describedby="table-loading-indicator"]').should( - 'have.attr', - 'aria-busy', - 'false' - ); - cy.contains('Data Channels').click(); const channelName = 'CHANNEL_ABCDE'; @@ -193,6 +196,12 @@ describe('Table Component', () => { cy.contains('Add Channels').click(); + cy.get('[aria-describedby="table-loading-indicator"]').should( + 'have.attr', + 'aria-busy', + 'false' + ); + cy.get('[data-testid^="sort timestamp"] p') .invoke('css', 'height') .then((height) => { @@ -217,81 +226,6 @@ describe('Table Component', () => { }); }); - describe.skip('should be able to sort by', () => { - it('ascending order', () => { - cy.get('[data-testid="sort timestamp"]').click().wait('@getRecords'); - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(200); - cy.get('[aria-sort="ascending"]').should('exist'); - cy.get('.MuiTableSortLabel-iconDirectionAsc').should('be.visible'); - cy.get('tbody').within(() => { - cy.get('tr') - .first() - .within(() => { - cy.get('td').first().contains('2022-01-01 00:00:00'); - }); - }); - }); - - it('descending order', () => { - cy.get('[data-testid="sort timestamp"]').click().wait('@getRecords'); - cy.get('[data-testid="sort timestamp"]').click().wait('@getRecords'); - cy.get('[aria-sort="descending"]').should('exist'); - cy.get('.MuiTableSortLabel-iconDirectionDesc').should('be.visible'); - cy.get('tbody').within(() => { - cy.get('tr') - .first() - .within(() => { - cy.get('td').first().contains('2022-01-01 00:00:00'); - }); - }); - }); - - it('no order', () => { - cy.get('[data-testid="sort timestamp"]').click().wait('@getRecords'); - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(200); - cy.get('[data-testid="sort timestamp"]').click().wait('@getRecords'); - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(200); - cy.get('[data-testid="sort timestamp"]').click().wait('@getRecords'); - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(200); - cy.get('[aria-sort="ascending"]').should('not.exist'); - cy.get('[aria-sort="descending"]').should('not.exist'); - cy.get('.MuiTableSortLabel-iconDirectionAsc').should( - 'have.css', - 'opacity', - '0' - ); - cy.get('.MuiTableSortLabel-iconDirectionDesc').should('not.exist'); - cy.get('tbody').within(() => { - cy.get('tr') - .first() - .within(() => { - cy.get('td').first().contains('2022-01-01 00:00:00'); - }); - }); - }); - - it('multiple columns', () => { - addInitialSystemChannels(['Shot Number']); - cy.get('[data-testid="sort timestamp"]').click().wait('@getRecords'); - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(200); - cy.get('[data-testid="sort shotnum"]').click().wait('@getRecords'); - - cy.get('tbody').within(() => { - cy.get('tr') - .first() - .within(() => { - cy.get('td').eq(0).contains('2022-01-01 00:00:00'); - cy.get('td').eq(1).contains('1'); - }); - }); - }); - }); - describe('should be able to search by', () => { it('date from', () => { cy.get('input[id="from date-time"]').type('2022-01-01 00:00:00'); diff --git a/e2e/plotting.spec.ts b/e2e/plotting.spec.ts index 4263a69e8..12d720b7f 100644 --- a/e2e/plotting.spec.ts +++ b/e2e/plotting.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { expect, test } from '@playwright/test'; test('plots a time vs shotnum graph and change the plot colour', async ({ page, @@ -527,3 +527,85 @@ test('user can plot channels on the right y axis', async ({ page }) => { }) ).toMatchSnapshot({ maxDiffPixels: 150 }); }); + +test('user can customize left y axis label', async ({ page }) => { + await page.goto('/'); + + await page.locator('text=Plots').click(); + + // open up popup + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + page.locator('text=Create a plot').click(), + ]); + + await popup.locator('label:has-text("Search")').fill('Shot Num'); + await popup.getByRole('option', { name: 'Shot Number', exact: true }).click(); + await popup.getByRole('textbox', { name: 'Label' }).type('left y axis'); + + const chart = await popup.locator('#my-chart'); + + expect( + await chart.screenshot({ + type: 'png', + }) + ).toMatchSnapshot({ maxDiffPixels: 150 }); +}); + +test('user can customize right y axis label', async ({ page }) => { + await page.goto('/'); + + await page.locator('text=Plots').click(); + + // open up popup + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + page.locator('text=Create a plot').click(), + ]); + + await popup.getByRole('button', { name: 'Right' }).click(); + await popup.locator('label:has-text("Search")').fill('Shot Num'); + await popup.getByRole('option', { name: 'Shot Number', exact: true }).click(); + await popup.getByRole('textbox', { name: 'Label' }).type('right y axis'); + + const chart = await popup.locator('#my-chart'); + + expect( + await chart.screenshot({ + type: 'png', + }) + ).toMatchSnapshot({ maxDiffPixels: 150 }); +}); + +test('user can customize both left and right y axis labels', async ({ + page, +}) => { + await page.goto('/'); + + await page.locator('text=Plots').click(); + + // open up popup + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + page.locator('text=Create a plot').click(), + ]); + + await popup.locator('label:has-text("Search")').fill('Shot Num'); + await popup.getByRole('option', { name: 'Shot Number', exact: true }).click(); + await popup.getByRole('textbox', { name: 'Label' }).type('left y axis'); + + await popup.getByRole('button', { name: 'Right' }).click(); + await popup.locator('label:has-text("Search")').fill('DEFGH'); + await popup + .getByRole('option', { name: 'Channel_DEFGH', exact: true }) + .click(); + await popup.getByRole('textbox', { name: 'Label' }).type('right y axis'); + + const chart = await popup.locator('#my-chart'); + + expect( + await chart.screenshot({ + type: 'png', + }) + ).toMatchSnapshot({ maxDiffPixels: 150 }); +}); diff --git a/e2e/plotting.spec.ts-snapshots/user-can-customize-both-left-and-right-y-axis-labels-1-chromium-linux.png b/e2e/plotting.spec.ts-snapshots/user-can-customize-both-left-and-right-y-axis-labels-1-chromium-linux.png new file mode 100644 index 000000000..4697719b7 Binary files /dev/null and b/e2e/plotting.spec.ts-snapshots/user-can-customize-both-left-and-right-y-axis-labels-1-chromium-linux.png differ diff --git a/e2e/plotting.spec.ts-snapshots/user-can-customize-both-left-and-right-y-axis-labels-1-firefox-linux.png b/e2e/plotting.spec.ts-snapshots/user-can-customize-both-left-and-right-y-axis-labels-1-firefox-linux.png new file mode 100644 index 000000000..e69fe5bbd Binary files /dev/null and b/e2e/plotting.spec.ts-snapshots/user-can-customize-both-left-and-right-y-axis-labels-1-firefox-linux.png differ diff --git a/e2e/plotting.spec.ts-snapshots/user-can-customize-both-left-and-right-y-axis-labels-1-webkit-linux.png b/e2e/plotting.spec.ts-snapshots/user-can-customize-both-left-and-right-y-axis-labels-1-webkit-linux.png new file mode 100644 index 000000000..8d7e447e8 Binary files /dev/null and b/e2e/plotting.spec.ts-snapshots/user-can-customize-both-left-and-right-y-axis-labels-1-webkit-linux.png differ diff --git a/e2e/plotting.spec.ts-snapshots/user-can-customize-left-y-axis-label-1-chromium-linux.png b/e2e/plotting.spec.ts-snapshots/user-can-customize-left-y-axis-label-1-chromium-linux.png new file mode 100644 index 000000000..6e5f24636 Binary files /dev/null and b/e2e/plotting.spec.ts-snapshots/user-can-customize-left-y-axis-label-1-chromium-linux.png differ diff --git a/e2e/plotting.spec.ts-snapshots/user-can-customize-left-y-axis-label-1-firefox-linux.png b/e2e/plotting.spec.ts-snapshots/user-can-customize-left-y-axis-label-1-firefox-linux.png new file mode 100644 index 000000000..2a5d9ab7a Binary files /dev/null and b/e2e/plotting.spec.ts-snapshots/user-can-customize-left-y-axis-label-1-firefox-linux.png differ diff --git a/e2e/plotting.spec.ts-snapshots/user-can-customize-left-y-axis-label-1-webkit-linux.png b/e2e/plotting.spec.ts-snapshots/user-can-customize-left-y-axis-label-1-webkit-linux.png new file mode 100644 index 000000000..2389058b4 Binary files /dev/null and b/e2e/plotting.spec.ts-snapshots/user-can-customize-left-y-axis-label-1-webkit-linux.png differ diff --git a/e2e/plotting.spec.ts-snapshots/user-can-customize-right-y-axis-label-1-chromium-linux.png b/e2e/plotting.spec.ts-snapshots/user-can-customize-right-y-axis-label-1-chromium-linux.png new file mode 100644 index 000000000..6d50b0544 Binary files /dev/null and b/e2e/plotting.spec.ts-snapshots/user-can-customize-right-y-axis-label-1-chromium-linux.png differ diff --git a/e2e/plotting.spec.ts-snapshots/user-can-customize-right-y-axis-label-1-firefox-linux.png b/e2e/plotting.spec.ts-snapshots/user-can-customize-right-y-axis-label-1-firefox-linux.png new file mode 100644 index 000000000..7f4b151d4 Binary files /dev/null and b/e2e/plotting.spec.ts-snapshots/user-can-customize-right-y-axis-label-1-firefox-linux.png differ diff --git a/e2e/plotting.spec.ts-snapshots/user-can-customize-right-y-axis-label-1-webkit-linux.png b/e2e/plotting.spec.ts-snapshots/user-can-customize-right-y-axis-label-1-webkit-linux.png new file mode 100644 index 000000000..c12d57897 Binary files /dev/null and b/e2e/plotting.spec.ts-snapshots/user-can-customize-right-y-axis-label-1-webkit-linux.png differ diff --git a/package.json b/package.json index 48ef9a544..db8b7fc3a 100644 --- a/package.json +++ b/package.json @@ -15,13 +15,13 @@ "@reduxjs/toolkit": "1.9.0", "@tanstack/react-query": "4.29.5", "@tanstack/react-query-devtools": "4.29.6", + "@tanstack/react-table": "8.9.3", "@tanstack/react-virtual": "beta", "@types/jest": "29.5.2", "@types/node": "18.16.18", "@types/react": "18.2.14", "@types/react-beautiful-dnd": "13.1.2", "@types/react-dom": "18.2.6", - "@types/react-table": "7.7.12", "axios": "1.4.0", "date-fns": "2.30.0", "hacktimer": "1.1.3", @@ -41,7 +41,6 @@ "react-redux": "8.1.1", "react-router-dom": "6.8.0", "react-scripts": "5.0.1", - "react-table": "7.8.0", "single-spa-react": "5.0.1", "typescript": "5.1.6", "web-vitals": "3.4.0" diff --git a/src/api/channels.test.tsx b/src/api/channels.test.tsx index ae46a5c48..b564a8fc7 100644 --- a/src/api/channels.test.tsx +++ b/src/api/channels.test.tsx @@ -44,32 +44,34 @@ describe('channels api functions', () => { expect(data).not.toBeUndefined(); - const timestampCol = data!.find( - (col) => col.accessor === timeChannelName - ); + const timestampCol = data!.find((col) => col.id === timeChannelName); // assert it converts a static channel correctly expect(timestampCol).toEqual({ - accessor: 'timestamp', - Header: expect.any(Function), - Cell: expect.any(Function), - channelInfo: staticChannels['timestamp'], + id: 'timestamp', + accessorKey: 'timestamp', + header: expect.any(Function), + cell: expect.any(Function), + meta: { channelInfo: staticChannels['timestamp'] }, }); - const channelCol = data!.find((col) => col.accessor === 'CHANNEL_DEFGH'); + const channelCol = data!.find((col) => col.id === 'CHANNEL_DEFGH'); // assert it converts a normal channel correctly expect(channelCol).toEqual({ - accessor: 'CHANNEL_DEFGH', - Header: expect.any(Function), - Cell: expect.any(Function), - channelInfo: { - type: 'scalar', - name: 'Channel_DEFGH', - notation: 'scientific', - precision: 2, - systemName: 'CHANNEL_DEFGH', - path: '/Channels/2', + id: 'CHANNEL_DEFGH', + accessorKey: 'CHANNEL_DEFGH', + header: expect.any(Function), + cell: expect.any(Function), + meta: { + channelInfo: { + type: 'scalar', + name: 'Channel_DEFGH', + notation: 'scientific', + precision: 2, + systemName: 'CHANNEL_DEFGH', + path: '/Channels/2', + }, }, }); }); diff --git a/src/api/channels.tsx b/src/api/channels.tsx index cc851ea6c..a706ffa84 100644 --- a/src/api/channels.tsx +++ b/src/api/channels.tsx @@ -14,7 +14,7 @@ import { UseQueryResult, UseQueryOptions, } from '@tanstack/react-query'; -import { Column } from 'react-table'; +import { ColumnDef, createColumnHelper } from '@tanstack/react-table'; import { roundNumber, TraceOrImageThumbnail, @@ -146,15 +146,17 @@ export const useChannelSummary = ( ); }; -export const constructColumns = ( +export const constructColumnDefs = ( channels: FullChannelMetadata[], dispatch: AppDispatch -): Column[] => { - const myColumns: Column[] = []; +): ColumnDef[] => { + const columnHelper = createColumnHelper(); + const myColumnDefs: ColumnDef[] = []; channels.forEach((channel: FullChannelMetadata) => { - const newColumn: Column = { - Header: () => { + const newColumnDef = columnHelper.accessor(channel.systemName, { + id: channel.systemName, + header: () => { const headerName = channel.name ? channel.name : channel.systemName; // Provide an actual header here when we have it // TODO: do we need to split on things other than underscore? @@ -167,57 +169,65 @@ export const constructColumns = ( ); return {wordWrap.join('')}; }, - accessor: channel.systemName, - // TODO: get these from data channel info - channelInfo: channel, - }; - if (isChannelMetadataScalar(channel)) { - newColumn.Cell = ({ value }) => - typeof value === 'number' && typeof channel.precision === 'number' ? ( - - {roundNumber(value, channel.precision, channel.notation)} - - ) : ( - {String(value ?? '')} - ); - } else if (isChannelMetadataWaveform(channel)) { - newColumn.Cell = ({ row, value }) => ( - - dispatch( - openTraceWindow({ - recordId: (row.original as RecordRow)['_id'], - channelName: channel.systemName, - }) - ) + meta: { channelInfo: channel }, + cell: isChannelMetadataScalar(channel) + ? ({ getValue }) => { + const value = getValue(); + return typeof value === 'number' && + typeof channel.precision === 'number' ? ( + + {roundNumber(value, channel.precision, channel.notation)} + + ) : ( + {String(value ?? '')} + ); } - /> - ); - } else if (isChannelMetadataImage(channel)) { - newColumn.Cell = ({ row, value }) => ( - - dispatch( - openImageWindow({ - recordId: (row.original as RecordRow)['_id'], - channelName: channel.systemName, - }) - ) + : isChannelMetadataWaveform(channel) + ? ({ row, getValue }) => { + const value = getValue(); + return ( + + dispatch( + openTraceWindow({ + recordId: (row.original as RecordRow)['_id'], + channelName: channel.systemName, + }) + ) + } + /> + ); } - /> - ); - } - myColumns.push(newColumn); + : isChannelMetadataImage(channel) + ? ({ row, getValue }) => { + const value = getValue(); + return ( + + dispatch( + openImageWindow({ + recordId: (row.original as RecordRow)['_id'], + channelName: channel.systemName, + }) + ) + } + /> + ); + } + : undefined, + }); + + myColumnDefs.push(newColumnDef); }); - return myColumns; + return myColumnDefs; }; export const getScalarChannels = ( @@ -239,10 +249,13 @@ export const useScalarChannels = (): UseQueryResult< return useChannels(useScalarChannelsOptions); }; -export const useAvailableColumns = (): UseQueryResult => { +export const useAvailableColumns = (): UseQueryResult< + ColumnDef[], + AxiosError +> => { const dispatch = useAppDispatch(); const selectFn = React.useCallback( - (data: FullChannelMetadata[]) => constructColumns(data, dispatch), + (data: FullChannelMetadata[]) => constructColumnDefs(data, dispatch), [dispatch] ); return useChannels({ select: selectFn }); diff --git a/src/api/records.test.tsx b/src/api/records.test.tsx index b0505917a..32b192389 100644 --- a/src/api/records.test.tsx +++ b/src/api/records.test.tsx @@ -5,6 +5,7 @@ import { ScalarChannel, SearchParams, SelectedPlotChannel, + timeChannelName, } from '../app.types'; import { hooksWrapperWithProviders, @@ -193,6 +194,18 @@ describe('records api functions', () => { expect(result.current.data).toEqual(expectedReponse); }); + it('does not send a request to fetch date using ShotnumToDateConverter when query set to disabled', async () => { + const { result } = renderHook( + () => useShotnumToDateConverter(undefined, undefined, false), + { + wrapper: hooksWrapperWithProviders(state), + } + ); + + expect(result.current.data).toEqual(undefined); + expect(result.current.isLoading).toBe(true); + expect(result.current.fetchStatus).toBe('idle'); + }); it.todo( 'sends axios request to fetch records and throws an appropriate error on failure' ); @@ -220,6 +233,18 @@ describe('records api functions', () => { expect(result.current.data).toEqual(expectedReponse); }); + it('does not send a request to fetch date usingDateToShotnumConverter when query set to disabled', async () => { + const { result } = renderHook( + () => useDateToShotnumConverter(undefined, undefined, false), + { + wrapper: hooksWrapperWithProviders(state), + } + ); + + expect(result.current.data).toEqual(undefined); + expect(result.current.isLoading).toBe(true); + expect(result.current.fetchStatus).toBe('idle'); + }); it.todo( 'sends axios request to fetch records and throws an appropriate error on failure' ); @@ -360,18 +385,20 @@ describe('records api functions', () => { params.append('skip', '0'); params.append('limit', '25'); + params.append('projection', `metadata.${timeChannelName}`); expect(request.url.searchParams.toString()).toEqual(params.toString()); expect(result.current.data).toMatchSnapshot(); }); - it('can send sort, date range and filter parameters as part of request', async () => { + it('can send sort, date range, projection and filter parameters as part of request', async () => { state = { ...getInitialState(), table: { ...getInitialState().table, sort: { timestamp: 'asc', CHANNEL_1: 'desc' }, + selectedColumnIds: [timeChannelName, 'CHANNEL_1'], }, search: { ...getInitialState().search, @@ -416,6 +443,8 @@ describe('records api functions', () => { ); params.append('skip', '0'); params.append('limit', '25'); + params.append('projection', `metadata.${timeChannelName}`); + params.append('projection', 'channels.CHANNEL_1'); expect(request.url.searchParams.toString()).toEqual(params.toString()); }); @@ -467,6 +496,12 @@ describe('records api functions', () => { params.append('skip', '0'); params.append('limit', '50'); + // correct projections added + params.append('projection', `metadata.${timeChannelName}`); + testSelectedPlotChannels.forEach((channel) => { + params.append('projection', `channels.${channel.name}`); + }); + expect(request.url.searchParams.toString()).toEqual(params.toString()); const expectedData: PlotDataset[] = [ @@ -536,6 +571,10 @@ describe('records api functions', () => { ); params.append('skip', '0'); params.append('limit', '1000'); + params.append('projection', 'metadata.shotnum'); + testSelectedPlotChannels.forEach((channel) => { + params.append('projection', `channels.${channel.name}`); + }); expect(request.url.searchParams.toString()).toEqual(params.toString()); @@ -590,6 +629,10 @@ describe('records api functions', () => { const request = await pendingRequest; params.append('order', 'metadata.timestamp asc'); + params.append('projection', `metadata.${timeChannelName}`); + testSelectedPlotChannels.forEach((channel) => { + params.append('projection', `channels.${channel.name}`); + }); expect(request.url.searchParams.toString()).toEqual(params.toString()); }); diff --git a/src/api/records.tsx b/src/api/records.tsx index c6f3e331b..6bb4fec6c 100644 --- a/src/api/records.tsx +++ b/src/api/records.tsx @@ -24,6 +24,7 @@ import { selectUrls } from '../state/slices/configSlice'; import { readSciGatewayToken } from '../parseTokens'; import { renderTimestamp } from '../table/cellRenderers/cellContentRenderers'; import { staticChannels } from './channels'; +import { selectSelectedIdsIgnoreOrder } from '../state/slices/tableSlice'; const fetchRecords = async ( apiUrl: string, @@ -197,16 +198,12 @@ export const fetchRangeRecordConverterQuery = ( export const useDateToShotnumConverter = ( fromDate: string | undefined, - toDate: string | undefined + toDate: string | undefined, + enabled?: boolean ): UseQueryResult => { const { apiUrl } = useAppSelector(selectUrls); - return useQuery< - DateRangetoShotnumConverter, - AxiosError, - DateRangetoShotnumConverter, - [string, { fromDate: string | undefined; toDate: string | undefined }] - >( + return useQuery( ['dateToShotnumConverter', { fromDate, toDate }], (params) => { return fetchRangeRecordConverterQuery( @@ -221,22 +218,19 @@ export const useDateToShotnumConverter = ( onError: (error) => { console.log('Got error ' + error.message); }, + enabled, } ); }; export const useShotnumToDateConverter = ( shotnumMin: number | undefined, - shotnumMax: number | undefined + shotnumMax: number | undefined, + enabled?: boolean ): UseQueryResult => { const { apiUrl } = useAppSelector(selectUrls); - return useQuery< - DateRangetoShotnumConverter, - AxiosError, - DateRangetoShotnumConverter, - [string, { shotnumMin: number | undefined; shotnumMax: number | undefined }] - >( + return useQuery( ['shotnumToDateConverter', { shotnumMin, shotnumMax }], (params) => { return fetchRangeRecordConverterQuery( @@ -251,6 +245,7 @@ export const useShotnumToDateConverter = ( onError: (error) => { console.log('Got error ' + error.message); }, + enabled, } ); }; @@ -261,6 +256,7 @@ export const useRecordsPaginated = (): UseQueryResult< const { searchParams, page, resultsPerPage, sort, filters } = useAppSelector(selectQueryParams); const { apiUrl } = useAppSelector(selectUrls); + const projection = useAppSelector(selectSelectedIdsIgnoreOrder); return useQuery< Record[], @@ -274,20 +270,38 @@ export const useRecordsPaginated = (): UseQueryResult< sort: SortType; searchParams: SearchParams; filters: string[]; + projection: string[]; } ] >( - ['records', { page, resultsPerPage, sort, searchParams, filters }], + [ + 'records', + { + page, + resultsPerPage, + sort, + searchParams, + filters, + projection, + }, + ], (params) => { const { page, resultsPerPage, sort, searchParams, filters } = params.queryKey[1]; // React Table pagination is zero-based const startIndex = page * resultsPerPage; const stopIndex = startIndex + resultsPerPage; - return fetchRecords(apiUrl, sort, searchParams, filters, { - startIndex, - stopIndex, - }); + return fetchRecords( + apiUrl, + sort, + searchParams, + filters, + { + startIndex, + stopIndex, + }, + projection + ); }, { onError: (error) => { @@ -305,9 +319,9 @@ export const useRecordsPaginated = (): UseQueryResult< activeExperiment: record.metadata.activeExperiment, }; - const keys = Object.keys(record.channels); + const keys = Object.keys(record.channels ?? {}); keys.forEach((key: string) => { - const channel = record.channels[key]; + const channel = record.channels?.[key]; if (channel) { let channelData; @@ -355,7 +369,7 @@ export const getFormattedAxisData = ( : NaN; break; default: - const channel = record.channels[axisName]; + const channel = record.channels?.[axisName]; if (isChannelScalar(channel)) { formattedData = typeof channel.data === 'number' @@ -375,13 +389,29 @@ export const usePlotRecords = ( const { searchParams, filters } = useAppSelector(selectQueryParams); const parsedXAxis = XAxis ?? timeChannelName; + const projection = [ + parsedXAxis, + ...selectedPlotChannels.map((channel) => channel.name), + ]; + return useQuery< Record[], AxiosError, PlotDataset[], - [string, { sort: SortType; searchParams: SearchParams; filters: string[] }] + [ + string, + { + sort: SortType; + searchParams: SearchParams; + filters: string[]; + projection: string[]; + } + ] >( - ['records', { sort: { [parsedXAxis]: 'asc' }, searchParams, filters }], + [ + 'records', + { sort: { [parsedXAxis]: 'asc' }, searchParams, filters, projection }, + ], (params) => { const { sort, filters, searchParams } = params.queryKey[1]; const { maxShots } = searchParams; @@ -392,7 +422,14 @@ export const usePlotRecords = ( stopIndex: maxShots, }; } - return fetchRecords(apiUrl, sort, searchParams, filters, offsetParams); + return fetchRecords( + apiUrl, + sort, + searchParams, + filters, + offsetParams, + projection + ); }, { onError: (error) => { diff --git a/src/api/sessions.test.tsx b/src/api/sessions.test.tsx index 03ad384e6..7192ea13d 100644 --- a/src/api/sessions.test.tsx +++ b/src/api/sessions.test.tsx @@ -16,7 +16,7 @@ describe('session api functions', () => { mockData = { name: 'test', summary: 'test', - session_data: {}, + session: {}, auto_saved: false, _id: '1', }; @@ -119,6 +119,16 @@ describe('session api functions', () => { expect(result.current.data).toEqual(expected[0]); }); + it('does not send request to fetch session when session is undefined', async () => { + const { result } = renderHook(() => useSession(undefined), { + wrapper: hooksWrapperWithProviders(), + }); + + expect(result.current.data).toEqual(undefined); + expect(result.current.isLoading).toBe(true); + expect(result.current.fetchStatus).toBe('idle'); + }); + it.todo( 'sends axios request to fetch sessions and throws an appropriate error on failure' ); diff --git a/src/api/sessions.tsx b/src/api/sessions.tsx index 7b11b2ca9..980a82d5f 100644 --- a/src/api/sessions.tsx +++ b/src/api/sessions.tsx @@ -4,6 +4,7 @@ import { UseMutationResult, useQuery, UseQueryResult, + useQueryClient, } from '@tanstack/react-query'; import { Session, SessionListItem, SessionResponse } from '../app.types'; import { useAppSelector } from '../state/hooks'; @@ -17,7 +18,7 @@ const saveSession = (apiUrl: string, session: Session): Promise => { queryParams.append('auto_saved', session.auto_saved.toString()); return axios - .post(`${apiUrl}/sessions`, session.session_data, { + .post(`${apiUrl}/sessions`, session.session, { params: queryParams, headers: { Authorization: `Bearer ${readSciGatewayToken()}`, @@ -32,10 +33,14 @@ export const useSaveSession = (): UseMutationResult< Session > => { const { apiUrl } = useAppSelector(selectUrls); + const queryClient = useQueryClient(); return useMutation((session: Session) => saveSession(apiUrl, session), { onError: (error) => { console.log('Got error ' + error.message); }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sessionList'] }); + }, }); }; @@ -44,6 +49,7 @@ const editSession = ( session: SessionResponse ): Promise => { const queryParams = new URLSearchParams(); + queryParams.append('name', session.name); queryParams.append('summary', session.summary); queryParams.append('auto_saved', session.auto_saved.toString()); @@ -64,12 +70,17 @@ export const useEditSession = (): UseMutationResult< SessionResponse > => { const { apiUrl } = useAppSelector(selectUrls); + const queryClient = useQueryClient(); return useMutation( (session: SessionResponse) => editSession(apiUrl, session), { onError: (error) => { console.log('Got error ' + error.message); }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sessionList'] }); + queryClient.invalidateQueries({ queryKey: ['session'] }); + }, } ); }; @@ -93,12 +104,16 @@ export const useDeleteSession = (): UseMutationResult< SessionResponse > => { const { apiUrl } = useAppSelector(selectUrls); + const queryClient = useQueryClient(); return useMutation( (session: SessionResponse) => deleteSession(apiUrl, session), { onError: (error) => { console.log('Got error ' + error.message); }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sessionList'] }); + }, } ); }; @@ -163,6 +178,7 @@ export const useSession = ( onError: (error) => { console.log('Got error ' + error.message); }, + enabled: typeof session_id !== 'undefined', } ); }; diff --git a/src/app.types.tsx b/src/app.types.tsx index 074b1fe32..cac06a662 100644 --- a/src/app.types.tsx +++ b/src/app.types.tsx @@ -9,7 +9,10 @@ export const timeChannelName = 'timestamp'; export interface Record { _id: string; metadata: RecordMetadata; - channels: { [channel: string]: Channel | undefined }; + // channels can be undefined when the user only has metadata channels selected + // as with projection the channels object isn't returned + // whereas we always query for timestamp and so metadata is always defined + channels?: { [channel: string]: Channel | undefined }; } export interface RecordRow { @@ -234,7 +237,7 @@ export interface Session { name: string; summary: string; auto_saved: boolean; - session_data: ImportSessionType; + session: ImportSessionType; } export interface SessionResponse { diff --git a/src/channels/__snapshots__/channelsDialogue.component.test.tsx.snap b/src/channels/__snapshots__/channelsDialogue.component.test.tsx.snap index 4acb1f223..5e6a4543f 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/channelsDialogue.component.test.tsx b/src/channels/channelsDialogue.component.test.tsx index 5839eaecc..4e883af97 100644 --- a/src/channels/channelsDialogue.component.test.tsx +++ b/src/channels/channelsDialogue.component.test.tsx @@ -18,7 +18,7 @@ import { staticChannels } from '../api/channels'; describe('selectChannelTree', () => { it('transforms channel list with selection info into TreeNode', () => { const selectedIds = [...Object.keys(staticChannels), 'CHANNEL_ABCDE']; - const channelTree = selectChannelTree({}, testChannels, selectedIds); + const channelTree = selectChannelTree(testChannels, selectedIds); const expectedTree: TreeNode = { name: '/', diff --git a/src/channels/channelsDialogue.component.tsx b/src/channels/channelsDialogue.component.tsx index bb4b94ed9..67e0e675a 100644 --- a/src/channels/channelsDialogue.component.tsx +++ b/src/channels/channelsDialogue.component.tsx @@ -37,22 +37,15 @@ export interface TreeNode { /** * @returns A selector for a tree representing the channel hierarchy, - * which is used by components in the channels folder - * @params state - redux state - isn't actually used! (means we get nice memoisation from reselect) + * which is used by components in the channels folder (means we get nice memoisation from reselect) * @params availableChannels - array of all the channels the user can select * @params selectedIds - array of all the channels currently selected */ export const selectChannelTree = createSelector( - ( - state: unknown, - availableChannels: FullChannelMetadata[], - selectedIds: string[] - ) => availableChannels, - ( - state: unknown, - availableChannels: FullChannelMetadata[], - selectedIds: string[] - ) => selectedIds, + (availableChannels: FullChannelMetadata[], selectedIds: string[]) => + availableChannels, + (availableChannels: FullChannelMetadata[], selectedIds: string[]) => + selectedIds, (availableChannels, selectedIds) => { const tree: TreeNode = { name: '/', children: {} }; availableChannels.forEach((channel) => { @@ -100,9 +93,7 @@ const ChannelsDialogue = (props: ChannelsDialogueProps) => { const { data: channels } = useChannels(); - const appliedSelectedIds = useAppSelector((state) => - selectSelectedIds(state) - ); + const appliedSelectedIds = useAppSelector(selectSelectedIds); const [selectedIds, setSelectedIds] = React.useState(appliedSelectedIds); @@ -110,9 +101,7 @@ const ChannelsDialogue = (props: ChannelsDialogueProps) => { setSelectedIds(appliedSelectedIds); }, [appliedSelectedIds]); - const channelTree = useAppSelector((state) => - selectChannelTree(state, channels ?? [], selectedIds) - ); + const channelTree = selectChannelTree(channels ?? [], selectedIds); const dispatch = useAppDispatch(); diff --git a/src/index.tsx b/src/index.tsx index 34743c40b..16c85e796 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -46,7 +46,7 @@ function domElementGetter(): HTMLElement { const reactLifecycles = singleSpaReact({ React, ReactDOMClient, - rootComponent: App, + rootComponent: () => document.getElementById(pluginName) ? : null, domElementGetter, errorBoundary: (error) => { log.error(`${pluginName} failed with error: ${error}`); @@ -177,7 +177,16 @@ setSettings(settings); function prepare() { return import('./mocks/browser').then(({ worker }) => { - return worker.start(); + return worker.start({ + onUnhandledRequest(request, print) { + // Ignore unhandled requests to non-localhost things (normally means you're contacting a real server) + if (request.url.hostname !== 'localhost') { + return; + } + + print.warning(); + }, + }); }); } diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index c51af42fb..f91db718f 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -124,9 +124,8 @@ export const handlers = [ return res(ctx.status(200), ctx.json(recordsJson.length)); }), rest.get('/records/range_converter', (req, res, ctx) => { - const searchParams = new URLSearchParams(req.url.search); - const shotnumRange = searchParams.get('shotnum_range'); - const dateRange = searchParams.get('date_range'); + const shotnumRange = req.url.searchParams.get('shotnum_range'); + const dateRange = req.url.searchParams.get('date_range'); if (shotnumRange) { const { min, max } = JSON.parse(decodeURIComponent(shotnumRange)); 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/plotting/__snapshots__/plot.component.test.tsx.snap b/src/plotting/__snapshots__/plot.component.test.tsx.snap index 4cd3cb887..156140e9b 100644 --- a/src/plotting/__snapshots__/plot.component.test.tsx.snap +++ b/src/plotting/__snapshots__/plot.component.test.tsx.snap @@ -7,7 +7,7 @@ exports[`Plot component renders a canvas element with the correct attributes pas > { leftYAxisMaximum, rightYAxisMinimum, rightYAxisMaximum, + leftYAxisLabel, + rightYAxisLabel, viewReset, } = props; @@ -142,6 +146,10 @@ const Plot = (props: PlotProps) => { }, min: leftYAxisMinimum, max: leftYAxisMaximum, + title: { + display: Boolean(leftYAxisLabel), + text: leftYAxisLabel, + }, }, y2: { type: rightYAxisScale, @@ -154,6 +162,10 @@ const Plot = (props: PlotProps) => { }, min: rightYAxisMinimum, max: rightYAxisMaximum, + title: { + display: Boolean(rightYAxisLabel), + text: rightYAxisLabel, + }, }, }, transitions: { @@ -199,6 +211,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?.plugins?.zoom?.limits?.y2 && @@ -214,6 +231,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); }); }, [ @@ -232,6 +254,8 @@ const Plot = (props: PlotProps) => { rightYAxisMaximum, selectedPlotChannels, XAxisDisplayName, + leftYAxisLabel, + rightYAxisLabel, ]); React.useEffect(() => { diff --git a/src/plotting/plotSettings/__snapshots__/plotSettingsController.component.test.tsx.snap b/src/plotting/plotSettings/__snapshots__/plotSettingsController.component.test.tsx.snap index 3aed3f9ae..f9e736a0b 100644 --- a/src/plotting/plotSettings/__snapshots__/plotSettingsController.component.test.tsx.snap +++ b/src/plotting/plotSettings/__snapshots__/plotSettingsController.component.test.tsx.snap @@ -8,13 +8,14 @@ exports[`Plot Settings component snapshots renders plot settings form correctly
- - plotTitle=undefined -changePlotTitle=undefined + label="Title" +value=undefined +onChange=undefined - +
- - plotTitle=undefined -changePlotTitle=undefined + label="Title" +value=undefined +onChange=undefined - +
- - plotTitle=undefined -changePlotTitle=undefined + label="Title" +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/deleteSessionDialogue.component.test.tsx b/src/session/deleteSessionDialogue.component.test.tsx index d1fe7d031..5f6d263e7 100644 --- a/src/session/deleteSessionDialogue.component.test.tsx +++ b/src/session/deleteSessionDialogue.component.test.tsx @@ -10,8 +10,7 @@ describe('delete session dialogue', () => { let props: DeleteSessionDialogueProps; let user; const onClose = jest.fn(); - const refetchSessionsList = jest.fn(); - const onChangeLoadedSessionId = jest.fn(); + const onDeleteLoadedsession = jest.fn(); const createView = (): RenderResult => { return renderComponentWithProviders(); @@ -28,9 +27,8 @@ describe('delete session dialogue', () => { props = { open: true, onClose: onClose, - refetchSessionsList: refetchSessionsList, sessionData: sessionData, - onChangeLoadedSessionId: onChangeLoadedSessionId, + onDeleteLoadedsession: onDeleteLoadedsession, loadedSessionId: undefined, }; user = userEvent; // Assigning userEvent to 'user' @@ -72,24 +70,22 @@ describe('delete session dialogue', () => { it('calls handleDeleteSession when continue button is clicked with a valid session name', async () => { createView(); const continueButton = screen.getByRole('button', { name: 'Continue' }); - user.click(continueButton); + await user.click(continueButton); await waitFor(() => { expect(onClose).toHaveBeenCalled(); }); - expect(refetchSessionsList).toHaveBeenCalled(); }); it('calls handleDeleteSession when continue button is clicked with a valid session name and clears loaded session id', async () => { props = { ...props, loadedSessionId: '1' }; createView(); const continueButton = screen.getByRole('button', { name: 'Continue' }); - user.click(continueButton); + await user.click(continueButton); await waitFor(() => { expect(onClose).toHaveBeenCalled(); }); - expect(refetchSessionsList).toHaveBeenCalled(); - expect(onChangeLoadedSessionId).toHaveBeenCalledWith(undefined); + expect(onDeleteLoadedsession).toHaveBeenCalled(); }); }); diff --git a/src/session/deleteSessionDialogue.component.tsx b/src/session/deleteSessionDialogue.component.tsx index 2273535de..4a4c12956 100644 --- a/src/session/deleteSessionDialogue.component.tsx +++ b/src/session/deleteSessionDialogue.component.tsx @@ -14,20 +14,13 @@ export interface DeleteSessionDialogueProps { open: boolean; onClose: () => void; sessionData: SessionResponse | undefined; - refetchSessionsList: () => void; loadedSessionId: string | undefined; - onChangeLoadedSessionId: (loadedSessionId: string | undefined) => void; + onDeleteLoadedsession: () => void; } const DeleteSessionDialogue = (props: DeleteSessionDialogueProps) => { - const { - open, - onClose, - sessionData, - refetchSessionsList, - loadedSessionId, - onChangeLoadedSessionId, - } = props; + const { open, onClose, sessionData, loadedSessionId, onDeleteLoadedsession } = + props; const [error, setError] = useState(false); const [errorMessage, setErrorMessage] = React.useState( @@ -40,9 +33,8 @@ const DeleteSessionDialogue = (props: DeleteSessionDialogueProps) => { if (sessionData) { deleteSession(sessionData) .then((response) => { - refetchSessionsList(); if (loadedSessionId === sessionData._id) { - onChangeLoadedSessionId(undefined); + onDeleteLoadedsession(); } onClose(); }) @@ -57,9 +49,8 @@ const DeleteSessionDialogue = (props: DeleteSessionDialogueProps) => { }, [ deleteSession, loadedSessionId, - onChangeLoadedSessionId, onClose, - refetchSessionsList, + onDeleteLoadedsession, sessionData, ]); diff --git a/src/session/sessionDialogue.component.test.tsx b/src/session/sessionDialogue.component.test.tsx index ca13988c8..185c09a28 100644 --- a/src/session/sessionDialogue.component.test.tsx +++ b/src/session/sessionDialogue.component.test.tsx @@ -13,7 +13,7 @@ describe('session dialogue', () => { const onChangeSessionName = jest.fn(); const onChangeSessionSummary = jest.fn(); const onChangeLoadedSessionId = jest.fn(); - const refetchSessionsList = jest.fn(); + const onChangeAutoSaveSessionId = jest.fn(); const createView = (): RenderResult => { return renderComponentWithProviders(); @@ -30,7 +30,7 @@ describe('session dialogue', () => { onChangeSessionSummary: onChangeSessionSummary, requestType: 'create', onChangeLoadedSessionId: onChangeLoadedSessionId, - refetchSessionsList: refetchSessionsList, + onChangeAutoSaveSessionId: onChangeAutoSaveSessionId, }; user = userEvent; // Assigning userEvent to 'user' @@ -54,17 +54,17 @@ describe('session dialogue', () => { createView(); const nameInput = screen.getByLabelText('Name *'); - user.type(nameInput, 'Test Session'); + await user.type(nameInput, 'T'); await waitFor(() => { - expect(onChangeSessionName).toHaveBeenCalledWith('Test Session'); + expect(onChangeSessionName).toHaveBeenCalledWith('T'); }); }); it('calls setSessionSummary when input value changes', async () => { createView(); const summaryTextarea = screen.getByLabelText('Summary'); - user.type(summaryTextarea, 'Test Summary'); + await user.type(summaryTextarea, 'Test Summary'); await waitFor(() => { expect(onChangeSessionSummary).toHaveBeenCalled(); }); @@ -73,7 +73,7 @@ describe('session dialogue', () => { it('calls onClose when Close button is clicked', async () => { createView(); const closeButton = screen.getByRole('button', { name: 'Close' }); - user.click(closeButton); + await user.click(closeButton); await waitFor(() => { expect(onClose).toHaveBeenCalled(); @@ -90,13 +90,13 @@ describe('session dialogue', () => { createView(); expect(screen.getByText('Save Session')).toBeInTheDocument(); const saveButton = screen.getByRole('button', { name: 'Save' }); - user.click(saveButton); + await user.click(saveButton); await waitFor(() => { expect(onClose).toHaveBeenCalled(); }); - expect(refetchSessionsList).toHaveBeenCalled(); expect(onChangeLoadedSessionId).toHaveBeenCalledWith('1'); + expect(onChangeAutoSaveSessionId).toHaveBeenCalledWith(undefined); }); }); @@ -119,8 +119,8 @@ describe('session dialogue', () => { onChangeSessionSummary: onChangeSessionSummary, requestType: 'edit', onChangeLoadedSessionId: onChangeLoadedSessionId, - refetchSessionsList: refetchSessionsList, sessionData: sessionData, + onChangeAutoSaveSessionId: onChangeAutoSaveSessionId, }; user = userEvent; // Assigning userEvent to 'user' @@ -152,12 +152,11 @@ describe('session dialogue', () => { it('calls handleExportSession when Save button is clicked with a valid session name', async () => { createView(); const saveButton = screen.getByRole('button', { name: 'Save' }); - user.click(saveButton); + await user.click(saveButton); await waitFor(() => { expect(onClose).toHaveBeenCalled(); }); - expect(refetchSessionsList).toHaveBeenCalled(); }); }); }); diff --git a/src/session/sessionDialogue.component.tsx b/src/session/sessionDialogue.component.tsx index 4c91385ad..573fcb937 100644 --- a/src/session/sessionDialogue.component.tsx +++ b/src/session/sessionDialogue.component.tsx @@ -7,7 +7,7 @@ import { TextField, } from '@mui/material'; import React, { useState } from 'react'; -import { useAppSelector } from '../state/hooks'; +import { sessionSelector, useAppSelector } from '../state/hooks'; import { useEditSession, useSaveSession } from '../api/sessions'; import { SessionResponse } from '../app.types'; @@ -20,7 +20,7 @@ export interface SessionDialogueProps { onChangeSessionSummary: (sessionSummary: string) => void; requestType: 'edit' | 'create'; onChangeLoadedSessionId: (loadedSessionId: string | undefined) => void; - refetchSessionsList: () => void; + onChangeAutoSaveSessionId: (autoSaveSessionId: string | undefined) => void; sessionData?: SessionResponse; } @@ -35,10 +35,10 @@ const SessionDialogue = (props: SessionDialogueProps) => { requestType, sessionData, onChangeLoadedSessionId, - refetchSessionsList, + onChangeAutoSaveSessionId, } = props; - const state = useAppSelector(({ config, ...state }) => state); + const state = useAppSelector(sessionSelector); const { mutateAsync: saveSession } = useSaveSession(); const { mutateAsync: editSession } = useEditSession(); @@ -57,13 +57,13 @@ const SessionDialogue = (props: SessionDialogueProps) => { if (sessionName) { const session = { name: sessionName, - session_data: state, + session: state, summary: sessionSummary, auto_saved: false, }; saveSession(session) .then((response) => { - refetchSessionsList(); + onChangeAutoSaveSessionId(undefined); onChangeLoadedSessionId(response); handleClose(); }) @@ -78,8 +78,8 @@ const SessionDialogue = (props: SessionDialogueProps) => { } }, [ handleClose, + onChangeAutoSaveSessionId, onChangeLoadedSessionId, - refetchSessionsList, saveSession, sessionName, sessionSummary, @@ -99,7 +99,6 @@ const SessionDialogue = (props: SessionDialogueProps) => { editSession(session) .then((response) => { - refetchSessionsList(); handleClose(); }) .catch((error) => { @@ -111,14 +110,7 @@ const SessionDialogue = (props: SessionDialogueProps) => { setError(true); setErrorMessage('Please enter a name'); } - }, [ - sessionName, - sessionData, - sessionSummary, - editSession, - refetchSessionsList, - handleClose, - ]); + }, [sessionName, sessionData, sessionSummary, editSession, handleClose]); return ( @@ -130,7 +122,7 @@ const SessionDialogue = (props: SessionDialogueProps) => { label="Name" required={true} sx={{ width: '100%', margin: '4px' }} - value={sessionName} + value={sessionName ?? ''} error={error} helperText={error && errorMessage} onChange={(event) => { diff --git a/src/session/sessionDrawer.component.test.tsx b/src/session/sessionDrawer.component.test.tsx index 7f7b93d7c..c499c8ff1 100644 --- a/src/session/sessionDrawer.component.test.tsx +++ b/src/session/sessionDrawer.component.test.tsx @@ -10,6 +10,8 @@ describe('session Drawer', () => { const openSessionEdit = jest.fn(); const openSessionDelete = jest.fn(); const onChangeLoadedSessionId = jest.fn(); + const onChangeLoadedSessionTimestamp = jest.fn(); + const onChangeAutoSaveSessionId = jest.fn(); let user; let props: SessionDrawerProps; const createView = (): RenderResult => { @@ -22,8 +24,11 @@ describe('session Drawer', () => { openSessionEdit: openSessionEdit, openSessionDelete: openSessionDelete, loadedSessionId: undefined, + loadedSessionData: undefined, onChangeLoadedSessionId: onChangeLoadedSessionId, sessionsList: SessionsListJSON, + onChangeLoadedSessionTimestamp: onChangeLoadedSessionTimestamp, + onChangeAutoSaveSessionId: onChangeAutoSaveSessionId, }; }); afterEach(() => { @@ -46,8 +51,8 @@ describe('session Drawer', () => { }); it('loads a user session', async () => { + props.loadedSessionId = '1'; createView(); - await waitFor(() => { expect(screen.getByText('Session 1')).toBeInTheDocument(); }); @@ -60,6 +65,13 @@ describe('session Drawer', () => { await waitFor(() => { expect(session1).toHaveStyle('background-color: primary.main'); }); + + expect(onChangeLoadedSessionTimestamp).toHaveBeenCalledWith( + '2023-06-29T10:30:00', + true + ); + + expect(onChangeAutoSaveSessionId).toHaveBeenCalledWith(undefined); }); 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 f23a9ee56..2eb4ddbe0 100644 --- a/src/session/sessionDrawer.component.tsx +++ b/src/session/sessionDrawer.component.tsx @@ -14,8 +14,7 @@ import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import Drawer from '@mui/material/Drawer'; import AddCircleIcon from '@mui/icons-material/AddCircle'; -import { useSession } from '../api/sessions'; -import { SessionListItem } from '../app.types'; +import { SessionListItem, SessionResponse } from '../app.types'; import { importSession } from '../state/store'; import { useAppDispatch } from '../state/hooks'; @@ -29,7 +28,13 @@ export interface SessionDrawerProps { openSessionDelete: (sessionData: SessionListItem) => void; sessionsList: SessionListItem[] | undefined; loadedSessionId: string | undefined; + loadedSessionData: SessionResponse | undefined; onChangeLoadedSessionId: (loadedSessionId: string | undefined) => void; + onChangeLoadedSessionTimestamp: ( + timestamp: string | undefined, + autoSaved: boolean | undefined + ) => void; + onChangeAutoSaveSessionId: (autoSaveSessionId: string | undefined) => void; } interface SessionListElementProps extends SessionListItem { @@ -37,6 +42,20 @@ interface SessionListElementProps extends SessionListItem { selected: boolean; openSessionEdit: (sessionData: SessionListItem) => void; openSessionDelete: (sessionData: SessionListItem) => void; + onChangeLoadedSessionTimestamp: ( + timestamp: string | undefined, + autoSaved: boolean | undefined + ) => void; + onChangeAutoSaveSessionId: (autoSaveSessionId: string | undefined) => void; +} + +function compareSessions(a: SessionListItem, b: SessionListItem): number { + if (a.auto_saved === b.auto_saved) { + // If auto_saved is the same, sort by timestamp (you can adjust the sorting criteria) + return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(); + } + // Sort auto_saved=true sessions above auto_saved=false sessions + return b.auto_saved ? 1 : -1; } const SessionListElement = ( @@ -47,9 +66,28 @@ const SessionListElement = ( openSessionEdit, selected, handleImport, + onChangeLoadedSessionTimestamp, + onChangeAutoSaveSessionId, ...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 + onChangeLoadedSessionTimestamp(session.timestamp, session.auto_saved); + } + } + }, [onChangeLoadedSessionTimestamp, selected, session]); return ( { + onChangeAutoSaveSessionId(undefined); + onChangeLoadedSessionTimestamp(session.timestamp, session.auto_saved); handleImport(session._id); }} > @@ -73,7 +113,7 @@ const SessionListElement = ( > {session.name} - + { @@ -107,9 +147,11 @@ const SessionsDrawer = (props: SessionDrawerProps): React.ReactElement => { sessionsList, loadedSessionId, onChangeLoadedSessionId, + onChangeLoadedSessionTimestamp, + onChangeAutoSaveSessionId, + loadedSessionData, } = props; - const { data: sessionData } = useSession(loadedSessionId); const dispatch = useAppDispatch(); const drawer = ( @@ -130,7 +172,7 @@ const SessionsDrawer = (props: SessionDrawerProps): React.ReactElement => { Workspaces - + @@ -138,10 +180,10 @@ const SessionsDrawer = (props: SessionDrawerProps): React.ReactElement => { ); React.useEffect(() => { - if (sessionData) { - dispatch(importSession(sessionData.session)); + if (loadedSessionData) { + dispatch(importSession(loadedSessionData.session)); } - }, [dispatch, sessionData]); + }, [dispatch, loadedSessionData]); const handleSessionClick = (sessionId: string) => { onChangeLoadedSessionId(undefined); @@ -169,7 +211,7 @@ const SessionsDrawer = (props: SessionDrawerProps): React.ReactElement => { {sessionsList && - sessionsList.map((item, index) => ( + sessionsList.sort(compareSessions).map((item, index) => ( { selected={loadedSessionId === item._id} openSessionDelete={openSessionDelete} openSessionEdit={openSessionEdit} + onChangeLoadedSessionTimestamp={ + onChangeLoadedSessionTimestamp + } + onChangeAutoSaveSessionId={onChangeAutoSaveSessionId} /> ))} diff --git a/src/session/sessionSaveButtons.component.test.tsx b/src/session/sessionSaveButtons.component.test.tsx index dc6586069..badd13d09 100644 --- a/src/session/sessionSaveButtons.component.test.tsx +++ b/src/session/sessionSaveButtons.component.test.tsx @@ -1,13 +1,61 @@ 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, useSaveSession } from '../api/sessions'; +import { timeChannelName } from '../app.types'; + +// Mock the useEditSession hook +jest.mock('../api/sessions', () => ({ + useEditSession: jest.fn(), + useSaveSession: jest.fn(), +})); describe('session buttons', () => { + let props: SessionsSaveButtonsProps; + const onSaveAsSessionClick = jest.fn(); + const onChangeAutoSaveSessionId = jest.fn(); const createView = (): RenderResult => { - return render(); + return renderComponentWithProviders(); }; + beforeEach(() => { + props = { + onSaveAsSessionClick: onSaveAsSessionClick, + loadedSessionData: { + _id: '', + auto_saved: false, + name: 'test', + summary: 'test', + session: {}, + timestamp: '', + }, + loadedSessionTimestamp: { timestamp: undefined, autoSaved: undefined }, + onChangeAutoSaveSessionId: onChangeAutoSaveSessionId, + autoSaveSessionId: undefined, + }; + jest.useFakeTimers(); + + // Mock the return value of useEditSession hook + useEditSession.mockReturnValue({ + mutate: jest.fn().mockResolvedValue({}), + }); + useSaveSession.mockReturnValue({ + mutateAsync: jest.fn().mockResolvedValue({}), + }); + }); + afterEach(() => { + jest.useRealTimers(); jest.clearAllMocks(); }); @@ -15,4 +63,228 @@ describe('session buttons', () => { const { asFragment } = createView(); expect(asFragment()).toMatchSnapshot(); }); + + it('should be able to create an autosaved session from the current state of session', () => { + props = { + ...props, + loadedSessionData: { + name: 'test', + summary: 'test', + auto_saved: false, + session: {}, + _id: '1', + timestamp: '', + }, + }; + const { rerender } = createView(); + + act(() => { + jest.advanceTimersByTime(AUTO_SAVE_INTERVAL_MS); + }); + + expect(useSaveSession().mutateAsync).toHaveBeenCalledTimes(1); + expect(useSaveSession().mutateAsync).toHaveBeenCalledWith({ + auto_saved: true, + name: 'test (autosaved)', + summary: 'test', + session: { + table: { + columnStates: {}, + selectedColumnIds: [timeChannelName], + page: 0, + resultsPerPage: 25, + sort: {}, + }, + search: { + searchParams: { + dateRange: {}, + shotnumRange: {}, + maxShots: 50, + experimentID: null, + }, + }, + plots: {}, + filter: { appliedFilters: [[]] }, + windows: {}, + }, + }); + + props = { + ...props, + loadedSessionData: { + name: 'test', + summary: 'test', + auto_saved: false, + session: {}, + _id: '2', + timestamp: '', + }, + }; + + rerender(); + + expect(useSaveSession().mutateAsync).toHaveBeenCalledTimes(1); + expect(useSaveSession().mutateAsync).toHaveBeenCalledWith({ + auto_saved: true, + name: 'test (autosaved)', + summary: 'test', + session: { + table: { + columnStates: {}, + selectedColumnIds: [timeChannelName], + page: 0, + resultsPerPage: 25, + sort: {}, + }, + search: { + searchParams: { + dateRange: {}, + shotnumRange: {}, + maxShots: 50, + experimentID: null, + }, + }, + plots: {}, + filter: { appliedFilters: [[]] }, + windows: {}, + }, + }); + }); + + it('should update autosave session when autoSavedSessionId exist', () => { + props = { + ...props, + loadedSessionData: { + name: 'test', + summary: 'test', + auto_saved: false, + session: {}, + _id: '1', + timestamp: '', + }, + autoSaveSessionId: '5', + }; + createView(); + + act(() => { + jest.advanceTimersByTime(AUTO_SAVE_INTERVAL_MS); + }); + + expect(useEditSession().mutate).toHaveBeenCalledTimes(1); + expect(useEditSession().mutate).toHaveBeenCalledWith({ + _id: '5', + auto_saved: true, + name: 'test (autosaved)', + summary: 'test', + session: { + table: { + columnStates: {}, + selectedColumnIds: [timeChannelName], + page: 0, + resultsPerPage: 25, + sort: {}, + }, + search: { + searchParams: { + dateRange: {}, + shotnumRange: {}, + maxShots: 50, + experimentID: null, + }, + }, + plots: {}, + filter: { appliedFilters: [[]] }, + windows: {}, + }, + timestamp: '', + }); + }); + + it('should not enable auto save if an user session is not selected', () => { + createView(); + + act(() => { + jest.advanceTimersByTime(AUTO_SAVE_INTERVAL_MS); + }); + + expect(useEditSession().mutate).not.toHaveBeenCalledTimes(1); + }); + + it('save a user session', async () => { + props.loadedSessionData = { + name: 'test', + summary: 'test', + auto_saved: false, + session: {}, + _id: '1', + timestamp: '', + }; + createView(); + const saveButton = screen.getByRole('button', { name: 'Save' }); + expect(saveButton).toBeInTheDocument(); + + await fireEvent.click(saveButton); + + await waitFor(() => { + expect(useEditSession().mutate).toHaveBeenCalledTimes(1); + }); + }); + + it('opens the save dialog when there is not a user session selected', async () => { + props.loadedSessionData = undefined; + 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, + loadedSessionTimestamp: { + timestamp: '2023-06-29T15:45:00', + 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, + loadedSessionTimestamp: { + timestamp: '2023-06-29T15:45:00', + 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..8d921a95b 100644 --- a/src/session/sessionSaveButtons.component.tsx +++ b/src/session/sessionSaveButtons.component.tsx @@ -1,8 +1,112 @@ import React from 'react'; import Box from '@mui/material/Box'; -import { Button } from '@mui/material'; +import { Button, Typography } from '@mui/material'; +import { SessionResponse } from '../app.types'; +import { sessionSelector, useAppSelector } from '../state/hooks'; +import { useEditSession, useSaveSession } from '../api/sessions'; +import { format, parseISO } from 'date-fns'; +import { ImportSessionType } from '../state/store'; + +export interface SessionsSaveButtonsProps { + onSaveAsSessionClick: () => void; + loadedSessionData: SessionResponse | undefined; + loadedSessionTimestamp: { + timestamp: string | undefined; + autoSaved: boolean | undefined; + }; + autoSaveSessionId: string | undefined; + onChangeAutoSaveSessionId: (autoSaveSessionId: string | undefined) => void; +} + +export const AUTO_SAVE_INTERVAL_MS = 5 * 60 * 1000; + +const formatDate = (inputDate: string) => { + const date = parseISO(inputDate); + const formattedDate = format(date, 'dd MMM yyyy HH:mm'); + return formattedDate; +}; + +const SessionSaveButtons = (props: SessionsSaveButtonsProps) => { + const { + onSaveAsSessionClick, + loadedSessionData, + loadedSessionTimestamp, + autoSaveSessionId, + onChangeAutoSaveSessionId, + } = props; + + const { mutate: editSession } = useEditSession(); + const { mutateAsync: saveSession } = useSaveSession(); + + const autoSaveTimeout = React.useRef | null>( + null + ); + + const state = useAppSelector(sessionSelector); + + const prevReduxState = React.useRef(null); // Initialize with null + + // Update the previous state when the state changes + React.useEffect(() => { + prevReduxState.current = state; + }, [state]); + + const handleSaveSession = React.useCallback(() => { + if (loadedSessionData) { + const session = { + session: state, + auto_saved: false, + _id: loadedSessionData._id, + summary: loadedSessionData.summary, + timestamp: loadedSessionData.timestamp, + name: loadedSessionData.name, + }; + editSession(session); + } else { + onSaveAsSessionClick(); + } + }, [loadedSessionData, state, editSession, onSaveAsSessionClick]); + + React.useEffect(() => { + let autoSaveTimer: ReturnType | null; + autoSaveTimer = null; + if (autoSaveTimeout.current) { + clearInterval(autoSaveTimeout.current); + } + + if (loadedSessionData && !loadedSessionData.auto_saved) { + autoSaveTimer = setInterval(() => { + const sessionData = { + name: `${loadedSessionData.name} (autosaved)`, + session: prevReduxState.current ?? state, + summary: loadedSessionData.summary, + auto_saved: true, + }; + if (!autoSaveSessionId) { + saveSession(sessionData).then((repsonse) => { + onChangeAutoSaveSessionId(repsonse); + }); + } else { + editSession({ + _id: autoSaveSessionId, + timestamp: loadedSessionData.timestamp, + ...sessionData, + }); + } + }, AUTO_SAVE_INTERVAL_MS); + } + + // Update the autoSaveTimeout ref after setting the interval + autoSaveTimeout.current = autoSaveTimer; + + return () => { + if (autoSaveTimer) { + clearInterval(autoSaveTimer); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoSaveSessionId, loadedSessionData]); -const SessionsSaveButtons = () => { return ( { paddingbottom: '8px', }} > - - + + + {loadedSessionTimestamp.autoSaved !== undefined + ? loadedSessionTimestamp.autoSaved + ? 'Session last autosaved: ' + : 'Session last saved: ' + : ''} + + {loadedSessionTimestamp.timestamp !== undefined + ? formatDate(loadedSessionTimestamp.timestamp) + : ''} + + + + + ); }; -export default SessionsSaveButtons; +export default SessionSaveButtons; diff --git a/src/state/hooks.tsx b/src/state/hooks.tsx index 92a85eff3..fe8ae6b55 100644 --- a/src/state/hooks.tsx +++ b/src/state/hooks.tsx @@ -1,7 +1,14 @@ import { useDispatch, useSelector } from 'react-redux'; import type { TypedUseSelectorHook } from 'react-redux'; import type { RootState, AppDispatch } from './store'; +import { createSelector } from '@reduxjs/toolkit'; // Use throughout your app instead of plain `useDispatch` and `useSelector` export const useAppDispatch: () => AppDispatch = useDispatch; export const useAppSelector: TypedUseSelectorHook = useSelector; + +const everythingButConfigSelector = ({ config, ...state }: RootState) => state; +export const sessionSelector = createSelector( + everythingButConfigSelector, + (state) => state +); diff --git a/src/state/slices/plotSlice.tsx b/src/state/slices/plotSlice.tsx index 087a45faa..00ec3651b 100644 --- a/src/state/slices/plotSlice.tsx +++ b/src/state/slices/plotSlice.tsx @@ -1,4 +1,4 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { DEFAULT_WINDOW_VARS, PlotType, @@ -24,6 +24,8 @@ export interface PlotConfig extends WindowConfig { leftYAxisMaximum?: number; rightYAxisMinimum?: number; rightYAxisMaximum?: number; + leftYAxisLabel?: string; + rightYAxisLabel?: string; gridVisible: boolean; axesLabelsVisible: boolean; selectedColours: string[]; @@ -96,9 +98,10 @@ export const { createPlot, closePlot, openPlot, savePlot, deletePlot } = // Other code such as selectors can use the imported `RootState` type export const selectPlots = (state: RootState) => state.plots; -export const selectOpenPlots = (state: RootState) => +export const selectOpenPlots = createSelector(selectPlots, (plots) => Object.fromEntries( - Object.entries(state.plots).filter(([plotTitle, plot]) => plot.open) - ); + Object.entries(plots).filter(([plotTitle, plot]) => plot.open) + ) +); export default plotSlice.reducer; diff --git a/src/state/slices/tableSlice.test.tsx b/src/state/slices/tableSlice.test.tsx index a232d268f..0b67a462c 100644 --- a/src/state/slices/tableSlice.test.tsx +++ b/src/state/slices/tableSlice.test.tsx @@ -4,7 +4,7 @@ import ColumnsReducer, { initialState, reorderColumn, selectColumn, - selectHiddenColumns, + selectColumnVisibility, } from './tableSlice'; describe('tableSlice', () => { @@ -17,9 +17,9 @@ describe('tableSlice', () => { it('selectColumn adds new columns in the correct order', () => { state = ColumnsReducer(state, selectColumn('shotnum')); - expect(state.selectedColumnIds).toEqual(['shotnum']); + expect(state.selectedColumnIds).toEqual(['timestamp', 'shotnum']); - state = ColumnsReducer(state, selectColumn('timestamp')); + state = ColumnsReducer(state, selectColumn('shotnum')); expect(state.selectedColumnIds).toEqual(['timestamp', 'shotnum']); state = ColumnsReducer(state, selectColumn('activeArea')); @@ -46,6 +46,14 @@ describe('tableSlice', () => { 'shotnum', 'activeExperiment', ]); + + // shouldn't be able to deselect timestamp + state = ColumnsReducer(state, deselectColumn('timestamp')); + expect(state.selectedColumnIds).toEqual([ + 'timestamp', + 'shotnum', + 'activeExperiment', + ]); }); it('should reorder columns correctly when reorderColumns action is sent', () => { @@ -108,16 +116,16 @@ describe('tableSlice', () => { /** * Test that the memoization of selectSelectedIdsIgnoreOrder works and that - * reordering columns does not cause updates to selectHiddenColumns (which + * reordering columns does not cause updates to selectColumnVisibility (which * saves on rerenders) */ - it('hidden columns selector ignores order of selectedColumnIds', () => { + it('column visibility selector ignores order of selectedColumnIds', () => { const availableColumns = [ - { accessor: '1' }, - { accessor: '2' }, - { accessor: '3' }, - { accessor: '4' }, - { accessor: '5' }, + { id: '1' }, + { id: '2' }, + { id: '3' }, + { id: '4' }, + { id: '5' }, ]; state = { table: { @@ -125,10 +133,13 @@ describe('tableSlice', () => { selectedColumnIds: ['1', '2', '3'], }, }; - expect(selectHiddenColumns(state, availableColumns)).toStrictEqual([ - '4', - '5', - ]); + expect(selectColumnVisibility(state, availableColumns)).toStrictEqual({ + 1: true, + 2: true, + 3: true, + 4: false, + 5: false, + }); // Swap let draggedColumn = { @@ -143,12 +154,15 @@ describe('tableSlice', () => { table: ColumnsReducer(state.table, reorderColumn(draggedColumn)), }; - expect(selectHiddenColumns(state, availableColumns)).toStrictEqual([ - '4', - '5', - ]); + expect(selectColumnVisibility(state, availableColumns)).toStrictEqual({ + 1: true, + 2: true, + 3: true, + 4: false, + 5: false, + }); - expect(selectHiddenColumns.recomputations()).toBe(1); + expect(selectColumnVisibility.recomputations()).toBe(1); // Swap draggedColumn = { @@ -163,12 +177,15 @@ describe('tableSlice', () => { table: ColumnsReducer(state.table, reorderColumn(draggedColumn)), }; - expect(selectHiddenColumns(state, availableColumns)).toStrictEqual([ - '4', - '5', - ]); + expect(selectColumnVisibility(state, availableColumns)).toStrictEqual({ + 1: true, + 2: true, + 3: true, + 4: false, + 5: false, + }); - expect(selectHiddenColumns.recomputations()).toBe(1); + expect(selectColumnVisibility.recomputations()).toBe(1); }); }); }); diff --git a/src/state/slices/tableSlice.tsx b/src/state/slices/tableSlice.tsx index ed58607d9..c1a0d88a3 100644 --- a/src/state/slices/tableSlice.tsx +++ b/src/state/slices/tableSlice.tsx @@ -1,6 +1,6 @@ import { createSelector, createSlice } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit'; -import { Column } from 'react-table'; +import { ColumnDef, VisibilityState } from '@tanstack/react-table'; import { DropResult } from 'react-beautiful-dnd'; import { RootState } from '../store'; import { @@ -8,6 +8,7 @@ import { Order, FullChannelMetadata, timeChannelName, + RecordRow, } from '../../app.types'; export const resultsPerPage = 25; @@ -30,7 +31,8 @@ interface TableState { // Define the initial state using that type export const initialState: TableState = { columnStates: {}, - selectedColumnIds: [], + // Ensure the timestamp column is opened automatically on table load + selectedColumnIds: [timeChannelName], page: 0, resultsPerPage: resultsPerPage, sort: {}, @@ -47,20 +49,23 @@ export const tableSlice = createSlice({ }, // Use the PayloadAction type to declare the contents of `action.payload` selectColumn: (state, action: PayloadAction) => { - // if it's the timestamp column, add to the beginning of the array - if (action.payload === timeChannelName) { - state.selectedColumnIds.unshift(action.payload); - } else { + if (!state.selectedColumnIds.includes(action.payload)) { state.selectedColumnIds.push(action.payload); } }, deselectColumn: (state, action: PayloadAction) => { - delete state.sort[action.payload]; + if (action.payload === timeChannelName) { + // don't allow time column to be deselected (should be prevented by other + // code as well - just might as well do it here too) + return; + } else { + delete state.sort[action.payload]; - const newSelectedColumnsIds = state.selectedColumnIds.filter( - (colId) => colId !== action.payload - ); - state.selectedColumnIds = newSelectedColumnsIds; + const newSelectedColumnsIds = state.selectedColumnIds.filter( + (colId) => colId !== action.payload + ); + state.selectedColumnIds = newSelectedColumnsIds; + } }, reorderColumn: (state, action: PayloadAction) => { const result = action.payload; @@ -121,7 +126,7 @@ export const selectColumnStates = (state: RootState) => state.table.columnStates; export const selectAvailableColumns = ( state: RootState, - availableColumns: Column[] + availableColumns: ColumnDef[] ) => availableColumns; export const selectAvailableChannels = ( state: RootState, @@ -180,20 +185,19 @@ export const selectSelectedChannels = createSelector( ); /** - * @returns A selector for an array of Column objects which are currently ___not___ selected, + * @returns A selector for an {@type VisibilityState} object which details which columns are visible, * which only changes when a column is selected/deselected and not when columns are reordered * @params state - the current redux state * @params availableColumns - array of all the columns the user can select */ -export const selectHiddenColumns = createSelector( +export const selectColumnVisibility = createSelector( selectAvailableColumns, selectSelectedIdsIgnoreOrder, (availableColumns, selectedIds) => { - return availableColumns - .filter((col) => { - return !selectedIds.includes(col.accessor?.toString() ?? ''); - }) - .map((col) => col.accessor?.toString() ?? ''); + return availableColumns.reduce((prev, curr) => { + if (curr.id) prev[curr.id] = selectedIds.includes(curr.id ?? ''); + return prev; + }, {} as VisibilityState); } ); diff --git a/src/table/__snapshots__/table.component.test.tsx.snap b/src/table/__snapshots__/table.component.test.tsx.snap index 3a6d3fa9f..c243b6ec6 100644 --- a/src/table/__snapshots__/table.component.test.tsx.snap +++ b/src/table/__snapshots__/table.component.test.tsx.snap @@ -9,21 +9,17 @@ exports[`Table renders correctly with all columns displayed 1`] = ` >
@@ -108,16 +104,13 @@ exports[`Table renders correctly with all columns displayed 1`] = ` @@ -412,15 +396,12 @@ exports[`Table renders correctly with all columns displayed 1`] = ` aria-busy="false" aria-describedby="table-loading-indicator" class="MuiTableBody-root css-29nbga-MuiTableBody-root" - role="rowgroup" >
@@ -916,9 +889,6 @@ exports[`Table renders correctly, with only timestamp column 1`] = ` @@ -929,15 +899,12 @@ exports[`Table renders correctly, with only timestamp column 1`] = ` aria-busy="false" aria-describedby="table-loading-indicator" class="MuiTableBody-root css-29nbga-MuiTableBody-root" - role="rowgroup" >
{ const onToggleWordWrap = jest.fn(); const handleOnDragEnd = jest.fn(); const openFilters = jest.fn(); + const resizeHandler = jest.fn(); let user; const createView = (): RenderResult => { @@ -46,7 +47,7 @@ describe('Data Header', () => { onSort, onClose, label: 'Test', - resizerProps: {}, + resizeHandler, index: 0, channelInfo: { systemName: 'Test', @@ -186,22 +187,6 @@ describe('Data Header', () => { }); }); - describe('calls the onSort method when default sort is specified', () => { - it('sets asc order', () => { - props.defaultSort = 'asc'; - - createView(); - expect(onSort).toHaveBeenCalledWith('test', 'asc'); - }); - - it('sets desc order', () => { - props.defaultSort = 'desc'; - - createView(); - expect(onSort).toHaveBeenCalledWith('test', 'desc'); - }); - }); - it('displays tooltip when user hovers over column name', async () => { createView(); const header = screen.getByText('Test'); diff --git a/src/table/headerRenderers/dataHeader.component.tsx b/src/table/headerRenderers/dataHeader.component.tsx index 3dfab8356..5c669b82b 100644 --- a/src/table/headerRenderers/dataHeader.component.tsx +++ b/src/table/headerRenderers/dataHeader.component.tsx @@ -28,7 +28,6 @@ import { Order, timeChannelName, } from '../../app.types'; -import { TableResizerProps } from 'react-table'; import { Draggable, DraggableProvided } from 'react-beautiful-dnd'; export interface DataHeaderProps { @@ -37,10 +36,9 @@ export interface DataHeaderProps { sort: { [column: string]: Order }; sx?: SxProps; onSort: (column: string, order: Order | null) => void; - defaultSort?: Order; label?: React.ReactNode; icon?: React.ReactNode; - resizerProps: TableResizerProps; + resizeHandler: (event: unknown) => void; onClose: (column: string) => void; onToggleWordWrap: (column: string) => void; index: number; @@ -130,9 +128,8 @@ const DataHeader = (props: DataHeaderProps): React.ReactElement => { dataKey, sort, onSort, - defaultSort, label, - resizerProps, + resizeHandler, onClose, index, channelInfo, @@ -146,14 +143,6 @@ const DataHeader = (props: DataHeaderProps): React.ReactElement => { // Factor this in by detecting this and applying the MUI asc sort icon on timestamp header const currSortDirection = sort[dataKey]; - //Apply default sort on page load (but only if not already defined in URL params) - //This will apply them in the order of the column definitions given to a table - React.useEffect(() => { - if (defaultSort !== undefined && currSortDirection === undefined) - onSort(dataKey, defaultSort); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - let nextSortDirection: Order | null = null; switch (currSortDirection) { case 'asc': @@ -282,7 +271,8 @@ const DataHeader = (props: DataHeaderProps): React.ReactElement => { onToggleWordWrap={onToggleWordWrap} /> { @@ -19,26 +19,26 @@ describe('Table', () => { const recordRows: RecordRow[] = Array.from(Array(3), (_, i) => generateRow(i + 1) ); - const availableColumns: Column[] = [ + const availableColumns: ColumnDef[] = [ { - Header: 'Timestamp', + header: 'Timestamp', id: 'timestamp', - accessor: 'timestamp', + accessorKey: 'timestamp', }, { - Header: 'Shot Number', + header: 'Shot Number', id: 'shotnum', - accessor: 'shotnum', + accessorKey: 'shotnum', }, { - Header: 'Active Area', + header: 'Active Area', id: 'activeArea', - accessor: 'activeArea', + accessorKey: 'activeArea', }, { - Header: 'Active Experiment', + header: 'Active Experiment', id: 'activeExperiment', - accessor: 'activeExperiment', + accessorKey: 'activeExperiment', }, ]; const onPageChange = jest.fn(); @@ -59,7 +59,11 @@ describe('Table', () => { data: recordRows, availableColumns, columnStates: {}, - hiddenColumns: ['shotnum', 'activeArea', 'activeExperiment'], + columnVisibility: { + shotnum: false, + activeArea: false, + activeExperiment: false, + }, columnOrder: ['timestamp'], totalDataCount: recordRows.length, maxShots: 50, @@ -89,7 +93,7 @@ describe('Table', () => { }); it('renders correctly with all columns displayed', async () => { - props.hiddenColumns = []; + props.columnVisibility = {}; props.columnOrder = [ 'timestamp', 'shotnum', @@ -135,7 +139,7 @@ describe('Table', () => { totalDataCount: 0, loadedData: false, loadedCount: false, - hiddenColumns: [], + columnVisibility: {}, columnOrder: [], }; createView(); diff --git a/src/table/table.component.tsx b/src/table/table.component.tsx index 422044174..4c38b4619 100644 --- a/src/table/table.component.tsx +++ b/src/table/table.component.tsx @@ -8,13 +8,14 @@ import { timeChannelName, } from '../app.types'; import { - useTable, - useFlexLayout, - useResizeColumns, - useColumnOrder, - ColumnInstance, - Column, -} from 'react-table'; + useReactTable, + getCoreRowModel, + getSortedRowModel, + ColumnOrderState, + flexRender, + ColumnDef, + VisibilityState, +} from '@tanstack/react-table'; import { Backdrop, TableContainer as MuiTableContainer, @@ -47,10 +48,10 @@ const stickyColumnStyles: SxProps = { export interface TableProps { tableHeight: string; data: RecordRow[]; - availableColumns: Column[]; + availableColumns: ColumnDef[]; columnStates: { [id: string]: ColumnState }; - hiddenColumns: string[]; - columnOrder: string[]; + columnVisibility: VisibilityState; + columnOrder: ColumnOrderState; totalDataCount: number; maxShots: SearchParams['maxShots']; page: number; @@ -74,7 +75,7 @@ const Table = React.memo((props: TableProps): React.ReactElement => { data, availableColumns, columnStates, - hiddenColumns, + columnVisibility, columnOrder, totalDataCount, maxShots, @@ -92,57 +93,27 @@ const Table = React.memo((props: TableProps): React.ReactElement => { filteredChannelNames, } = props; - /* - ** A note about the columns used in this file: - ** availableColumns - this represent all columns that can currently be added to the - ** display based on data received from the backend - ** columnStates - this represents the user defined column states from redux (e.g. wordWrap state) - ** hiddenColumns - these are the columns the user has *not* selected to appear in the table - ** These are used to tell react-table which columns to show - ** visibleColumns - these are the columns that React Table says are currently in the - ** display. It contains all information about the displayed columns, - ** including column width, columnResizing boolean, etc. selectedColumns - ** does NOT contain this info and is defined by the user, not React Table - */ - - const defaultColumn = React.useMemo( + const defaultColumn: Partial> = React.useMemo( () => ({ - minWidth: 33, - width: 150 + additionalHeaderSpace, + minSize: 33, + size: 150 + additionalHeaderSpace, }), [] ); - const tableInstance = useTable( - { - columns: availableColumns, - data, - defaultColumn, - initialState: { - columnOrder, - hiddenColumns, - }, - autoResetResize: false, - useControlledState: (state) => { - return React.useMemo( - () => ({ - ...state, - columnOrder: columnOrder, - hiddenColumns: hiddenColumns, - }), - // eslint complains that we don't need these deps when we really do - // eslint-disable-next-line react-hooks/exhaustive-deps - [state, columnOrder, hiddenColumns] - ); - }, + const tableInstance = useReactTable({ + columns: availableColumns, + data, + defaultColumn, + state: { + columnOrder, + columnVisibility, }, - useResizeColumns, - useFlexLayout, - useColumnOrder - ); - - const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = - tableInstance; + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + enableColumnResizing: true, + columnResizeMode: 'onChange', + }); return (
@@ -163,7 +134,7 @@ const Table = React.memo((props: TableProps): React.ReactElement => { maxHeight: tableHeight, }} > - + { zIndex: 1, }} > - {headerGroups.map((headerGroup) => { - const { key, ...otherHeaderGroupProps } = - headerGroup.getHeaderGroupProps(); + {tableInstance.getHeaderGroups().map((headerGroup) => { return ( - + {(provided) => { return ( - {headerGroup.headers.map((column, index) => { - const { key, ...otherHeaderProps } = - column.getHeaderProps(); - + {headerGroup.headers.map((header, index) => { + const { column } = header; const dataKey = column.id; const isTimestampColumn = dataKey === timeChannelName; let columnStyles: SxProps = { - minWidth: column.minWidth, - width: column.width, - maxWidth: column.maxWidth, + width: column.getSize(), paddingTop: '0px', paddingBottom: '0px', paddingRight: '0px', @@ -210,18 +175,21 @@ const Table = React.memo((props: TableProps): React.ReactElement => { ...stickyColumnStyles, } : columnStyles; - const { channelInfo } = column as ColumnInstance; + const channelInfo = + column.columnDef.meta?.channelInfo; return ( { })} - {rows.map((row) => { - prepareRow(row); - const { key, ...otherRowProps } = row.getRowProps(); + {tableInstance.getRowModel().rows.map((row) => { return ( - - {row.cells.map((cell) => { - const { key, ...otherCellProps } = cell.getCellProps(); - + + {row.getVisibleCells().map((cell) => { const dataKey = cell.column.id; const isTimestampColumn = dataKey === timeChannelName; let columnStyles: SxProps = { - minWidth: cell.column.minWidth, - width: cell.column.width, - maxWidth: cell.column.maxWidth, + width: cell.column.getSize(), paddingTop: '0px', paddingBottom: '0px', paddingRight: '0px', @@ -285,10 +249,12 @@ const Table = React.memo((props: TableProps): React.ReactElement => { return ( ); })} diff --git a/src/views/__snapshots__/recordTable.component.test.tsx.snap b/src/views/__snapshots__/recordTable.component.test.tsx.snap index 826f985d0..f5dbb2b53 100644 --- a/src/views/__snapshots__/recordTable.component.test.tsx.snap +++ b/src/views/__snapshots__/recordTable.component.test.tsx.snap @@ -9,21 +9,17 @@ exports[`Record Table renders correctly 1`] = ` >
@@ -108,9 +104,6 @@ exports[`Record Table renders correctly 1`] = ` @@ -121,15 +114,12 @@ exports[`Record Table renders correctly 1`] = ` aria-busy="false" aria-describedby="table-loading-indicator" class="MuiTableBody-root css-29nbga-MuiTableBody-root" - role="rowgroup" >

Rows per page:

@@ -915,9 +863,9 @@ exports[`Record Table renders correctly while data count is zero 1`] = `
@@ -845,9 +797,6 @@ exports[`Record Table renders correctly while data count is zero 1`] = ` @@ -858,7 +807,6 @@ exports[`Record Table renders correctly while data count is zero 1`] = ` aria-busy="false" aria-describedby="table-loading-indicator" class="MuiTableBody-root css-29nbga-MuiTableBody-root" - role="rowgroup" >
+ > + +

Rows per page:

@@ -1072,9 +1023,9 @@ exports[`Record Table renders correctly while loading 1`] = ` @@ -745,17 +759,20 @@ exports[`View Tabs renders correctly 1`] = ` >
+ > + + { }); let columns = screen.getAllByRole('columnheader'); - expect(columns.length).toEqual(4); + + await waitFor(() => { + columns = screen.getAllByRole('columnheader'); + expect(columns.length).toEqual(4); + }); expect(columns[0]).toHaveTextContent('Time'); expect(columns[1]).toHaveTextContent('Shot Number'); expect(columns[2]).toHaveTextContent('Active Area'); @@ -183,8 +187,10 @@ describe('Record Table', () => { store.dispatch(deselectColumn('activeArea')); }); - columns = screen.getAllByRole('columnheader'); - expect(columns.length).toEqual(3); + await waitFor(() => { + columns = screen.getAllByRole('columnheader'); + expect(columns.length).toEqual(3); + }); expect(columns[0]).toHaveTextContent('Time'); expect(columns[1]).toHaveTextContent('Shot Number'); expect(columns[2]).toHaveTextContent('Active Experiment'); @@ -194,8 +200,10 @@ describe('Record Table', () => { }); // Should expect the column previously in the middle to now be on the end - columns = screen.getAllByRole('columnheader'); - expect(columns.length).toEqual(4); + await waitFor(() => { + columns = screen.getAllByRole('columnheader'); + expect(columns.length).toEqual(4); + }); expect(columns[0]).toHaveTextContent('Time'); expect(columns[1]).toHaveTextContent('Shot Number'); expect(columns[2]).toHaveTextContent('Active Experiment'); @@ -270,9 +278,11 @@ describe('Record Table', () => { }); await user.click( - screen.getAllByAltText('Channel_CDEFG waveform', { - exact: false, - })[0] + ( + await screen.findAllByAltText('Channel_CDEFG waveform', { + exact: false, + }) + )[0] ); expect(store.getState().windows).toEqual({ diff --git a/src/views/recordTable.component.tsx b/src/views/recordTable.component.tsx index f771ef6ba..1bd0904d7 100644 --- a/src/views/recordTable.component.tsx +++ b/src/views/recordTable.component.tsx @@ -7,18 +7,17 @@ import { changePage, changeResultsPerPage, selectColumnStates, - selectHiddenColumns, + selectColumnVisibility, selectSelectedIds, deselectColumn, reorderColumn, - selectColumn, toggleWordWrap, } from '../state/slices/tableSlice'; import { selectQueryParams } from '../state/slices/searchSlice'; import { selectAppliedFilters } from '../state/slices/filterSlice'; import { useAvailableColumns } from '../api/channels'; import { DropResult } from 'react-beautiful-dnd'; -import { Order, timeChannelName } from '../app.types'; +import { Order } from '../app.types'; import type { Token } from '../filtering/filterParser'; export const extractChannelsFromTokens = ( @@ -60,9 +59,14 @@ const RecordTable = React.memo( const { data: availableColumns, isLoading: columnsLoading } = useAvailableColumns(); + const availableColumnsNullChecked = React.useMemo( + () => availableColumns ?? [], + [availableColumns] + ); + const columnStates = useAppSelector(selectColumnStates); - const hiddenColumns = useAppSelector((state) => - selectHiddenColumns(state, availableColumns ?? []) + const columnVisibility = useAppSelector((state) => + selectColumnVisibility(state, availableColumnsNullChecked) ); const columnOrder = useAppSelector(selectSelectedIds); @@ -113,20 +117,13 @@ const RecordTable = React.memo( return extractChannelsFromTokens(appliedFilters); }, [appliedFilters]); - // Ensure the timestamp column is opened automatically on table load - React.useEffect(() => { - if (!dataLoading && !columnOrder.includes(timeChannelName)) { - dispatch(selectColumn(timeChannelName)); - } - }, [dataLoading, columnOrder, dispatch]); - return (
{ ).toHaveTextContent('Session 1'); const closeButton = screen.getByRole('button', { name: 'Close' }); - user.click(closeButton); + await user.click(closeButton); + await waitFor(() => { + expect(deleteDialog).not.toBeInTheDocument(); + }); + }); + + it('deletes currently loaded user session', async () => { + createView(); + await waitFor(() => { + expect(screen.getByText('Session 1')).toBeInTheDocument(); + }); + + const session1 = screen.getByText('Session 1'); + await user.click(session1); + + const deleteButton = screen.getByRole('button', { + name: 'delete Session 1 session', + }); + + await user.click(deleteButton); + + const deleteDialog = screen.getByRole('dialog'); + + expect(deleteDialog).toBeVisible(); + expect( + within(deleteDialog).getByTestId('delete-session-name') + ).toHaveTextContent('Session 1'); + + const contniueButton = screen.getByRole('button', { name: 'Continue' }); + await user.click(contniueButton); await waitFor(() => { expect(deleteDialog).not.toBeInTheDocument(); }); @@ -137,9 +166,36 @@ describe('View Tabs', () => { ).toBeInTheDocument(); const closeButton = screen.getByRole('button', { name: 'Close' }); - user.click(closeButton); + await user.click(closeButton); await waitFor(() => { 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 10: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 7e8b474d8..49c379dd2 100644 --- a/src/views/viewTabs.component.tsx +++ b/src/views/viewTabs.component.tsx @@ -56,6 +56,10 @@ const ViewTabs = () => { setValue(newValue); }; + const [autoSaveSessionId, setAutoSaveSessionId] = React.useState< + string | undefined + >(undefined); + // This useState manges the selected session id used for deleting and editing a session const [selectedSessionId, setSelectedSessionId] = React.useState< string | undefined @@ -66,9 +70,16 @@ const ViewTabs = () => { string | undefined >(selectedSessionId); - const { data: sessionsList, refetch: refetchSessionsList } = useSessionList(); + const [loadedSessionTimestamp, setLoadedSessionTimestamp] = React.useState<{ + timestamp: string | undefined; + autoSaved: boolean | undefined; + }>({ timestamp: undefined, autoSaved: undefined }); - const { data: sessionData } = useSession(selectedSessionId); + const { data: sessionsList } = useSessionList(); + + const { data: loadedSessionData } = useSession(loadedSessionId); + + const { data: selectedSessionData } = useSession(selectedSessionId); const [sessionSaveOpen, setSessionSaveOpen] = React.useState(false); const [sessionEditOpen, setSessionEditOpen] = React.useState(false); @@ -92,6 +103,30 @@ const ViewTabs = () => { setSelectedSessionId(sessionData._id); }; + const onSaveAsSessionClick = () => { + setSessionSaveOpen(true); + if (loadedSessionData) { + setSessionName(`${loadedSessionData.name}_copy`); + setSessionSummary(loadedSessionData.summary ?? ''); + } + }; + const onChangeLoadedSessionTimestamp = ( + timestamp: string | undefined, + autoSaved: boolean | undefined + ) => { + setLoadedSessionTimestamp({ timestamp, autoSaved }); + }; + + const onDeleteLoadedsession = () => { + setLoadedSessionId(undefined); + setSelectedSessionId(undefined); + setAutoSaveSessionId(undefined); + setLoadedSessionTimestamp({ + timestamp: undefined, + autoSaved: undefined, + }); + }; + return ( { openSessionDelete={onSessionDeleteOpen} sessionsList={sessionsList} loadedSessionId={loadedSessionId} + loadedSessionData={loadedSessionData} onChangeLoadedSessionId={setLoadedSessionId} + onChangeLoadedSessionTimestamp={onChangeLoadedSessionTimestamp} + onChangeAutoSaveSessionId={setAutoSaveSessionId} /> @@ -128,7 +166,13 @@ const ViewTabs = () => { - + @@ -145,9 +189,9 @@ const ViewTabs = () => { onChangeSessionName={setSessionName} onChangeSessionSummary={setSessionSummary} requestType="edit" - sessionData={sessionData} + sessionData={selectedSessionData} onChangeLoadedSessionId={setLoadedSessionId} - refetchSessionsList={refetchSessionsList} + onChangeAutoSaveSessionId={setAutoSaveSessionId} /> { onChangeSessionSummary={setSessionSummary} onChangeLoadedSessionId={setLoadedSessionId} requestType="create" - refetchSessionsList={refetchSessionsList} + onChangeAutoSaveSessionId={setAutoSaveSessionId} /> setSessionDeleteOpen(false)} - sessionData={sessionData} - refetchSessionsList={refetchSessionsList} + sessionData={selectedSessionData} loadedSessionId={loadedSessionId} - onChangeLoadedSessionId={setLoadedSessionId} + onDeleteLoadedsession={onDeleteLoadedsession} /> diff --git a/src/windows/thumbnailSelector.component.tsx b/src/windows/thumbnailSelector.component.tsx index 413592e86..10ce3586d 100644 --- a/src/windows/thumbnailSelector.component.tsx +++ b/src/windows/thumbnailSelector.component.tsx @@ -54,7 +54,7 @@ const ThumbnailSelector = (props: ThumbnailSelectorProps) => { }} > {thumbnails?.map((thumbnailRecord) => { - const channelData = thumbnailRecord.channels[channelName]; + const channelData = thumbnailRecord.channels?.[channelName]; if (typeof channelData === 'undefined') return null; diff --git a/yarn.lock b/yarn.lock index eab315f27..29aff410d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3316,6 +3316,18 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-table@npm:8.9.3": + version: 8.9.3 + resolution: "@tanstack/react-table@npm:8.9.3" + dependencies: + "@tanstack/table-core": 8.9.3 + peerDependencies: + react: ">=16" + react-dom: ">=16" + checksum: a71fbbc608bb11b79ecd2b1a838fad2fc4140f9eb1a25bd44b6b1b4c6acd52741b5745e3e022390c642c284d0d528249360e322c0dbc3d34fea031611c207a7d + languageName: node + linkType: hard + "@tanstack/react-virtual@npm:beta": version: 3.0.0-beta.54 resolution: "@tanstack/react-virtual@npm:3.0.0-beta.54" @@ -3327,6 +3339,13 @@ __metadata: languageName: node linkType: hard +"@tanstack/table-core@npm:8.9.3": + version: 8.9.3 + resolution: "@tanstack/table-core@npm:8.9.3" + checksum: 52c7e57daaa3160450fb0bde702ec0b8aab159e2b19efd1012e03b3e167acc52839f5b39578cc8bf96e91346719f400d0435a5191384c168d9477da82f276c38 + languageName: node + linkType: hard + "@tanstack/virtual-core@npm:3.0.0-beta.54": version: 3.0.0-beta.54 resolution: "@tanstack/virtual-core@npm:3.0.0-beta.54" @@ -9025,9 +9044,9 @@ __metadata: linkType: hard "graphql@npm:^15.0.0 || ^16.0.0": - version: 16.6.0 - resolution: "graphql@npm:16.6.0" - checksum: bf1d9e3c1938ce3c1a81e909bd3ead1ae4707c577f91cff1ca2eca474bfbc7873d5d7b942e1e9777ff5a8304421dba57a4b76d7a29eb19de8711cb70e3c2415e + version: 16.8.1 + resolution: "graphql@npm:16.8.1" + checksum: 8d304b7b6f708c8c5cc164b06e92467dfe36aff6d4f2cf31dd19c4c2905a0e7b89edac4b7e225871131fd24e21460836b369de0c06532644d15b461d55b1ccc0 languageName: node linkType: hard @@ -12259,6 +12278,7 @@ __metadata: "@reduxjs/toolkit": 1.9.0 "@tanstack/react-query": 4.29.5 "@tanstack/react-query-devtools": 4.29.6 + "@tanstack/react-table": 8.9.3 "@tanstack/react-virtual": beta "@testing-library/cypress": 8.0.7 "@testing-library/dom": 9.3.1 @@ -12271,7 +12291,6 @@ __metadata: "@types/react-beautiful-dnd": 13.1.2 "@types/react-dom": 18.2.6 "@types/react-router-dom": 5.3.3 - "@types/react-table": 7.7.12 "@types/testing-library__jest-dom": 5.14.3 "@typescript-eslint/eslint-plugin": 5.61.0 "@typescript-eslint/parser": 5.61.0 @@ -12314,7 +12333,6 @@ __metadata: react-redux: 8.1.1 react-router-dom: 6.8.0 react-scripts: 5.0.1 - react-table: 7.8.0 serve: 14.2.0 serve-static: 1.15.0 single-spa-react: 5.0.1 @@ -14166,15 +14184,6 @@ __metadata: languageName: node linkType: hard -"react-table@npm:7.8.0": - version: 7.8.0 - resolution: "react-table@npm:7.8.0" - peerDependencies: - react: ^16.8.3 || ^17.0.0-0 || ^18.0.0 - checksum: 44ca0fb848c6869cd793cede8dc33072b38ebb8f8d2833565afe7cf3eac5d1fa455ac5fb9d06838b16fab0523d5d03e3e82f7645032f71245096e67b892313b9 - languageName: node - linkType: hard - "react-transition-group@npm:^4.4.5": version: 4.4.5 resolution: "react-transition-group@npm:4.4.5"