diff --git a/apps/backend/src/datasources/ExperimentDataSource.ts b/apps/backend/src/datasources/ExperimentDataSource.ts index 5adfb4ab14..13ba54f477 100644 --- a/apps/backend/src/datasources/ExperimentDataSource.ts +++ b/apps/backend/src/datasources/ExperimentDataSource.ts @@ -2,6 +2,7 @@ import { Experiment, ExperimentHasSample, ExperimentSafety, + ExperimentTableSortField, InstrumentScientistDecisionEnum, ExperimentSafetyReviewerDecisionEnum, } from '../models/Experiment'; @@ -87,7 +88,7 @@ export interface ExperimentDataSource { filter?: ExperimentsFilter, first?: number, offset?: number, - sortField?: string, + sortField?: ExperimentTableSortField, sortDirection?: PaginationSortDirection, searchText?: string ): Promise<{ diff --git a/apps/backend/src/datasources/mockups/ExperimentDataSource.ts b/apps/backend/src/datasources/mockups/ExperimentDataSource.ts index 98cc9ae155..bea88280e6 100644 --- a/apps/backend/src/datasources/mockups/ExperimentDataSource.ts +++ b/apps/backend/src/datasources/mockups/ExperimentDataSource.ts @@ -3,6 +3,7 @@ import { ExperimentSafety, ExperimentHasSample, ExperimentStatus, + ExperimentTableSortField, InstrumentScientistDecisionEnum, ExperimentSafetyReviewerDecisionEnum, } from '../../models/Experiment'; @@ -121,7 +122,7 @@ export class ExperimentDataSourceMock implements ExperimentDataSource { filter?: ExperimentsFilter, first?: number, offset?: number, - sortField?: string, + sortField?: ExperimentTableSortField, sortDirection?: PaginationSortDirection, searchText?: string ): Promise<{ totalCount: number; experiments: Experiment[] }> { diff --git a/apps/backend/src/datasources/postgres/ExperimentDataSource.ts b/apps/backend/src/datasources/postgres/ExperimentDataSource.ts index 0832d4af67..8347d5c940 100644 --- a/apps/backend/src/datasources/postgres/ExperimentDataSource.ts +++ b/apps/backend/src/datasources/postgres/ExperimentDataSource.ts @@ -7,6 +7,7 @@ import { ExperimentHasSample, ExperimentSafety, ExperimentSafetyReviewerDecisionEnum, + ExperimentTableSortField, InstrumentScientistDecisionEnum, } from '../../models/Experiment'; import { Rejection } from '../../models/Rejection'; @@ -83,8 +84,23 @@ function generateExperimentId( return `${proposalNumber}-${sequence ?? 0}`; } -const fieldMap: { [key: string]: string } = { - experimentId: 'experiment_id', +type SortableExperimentTableColumnName = + | 'experiment_id' + | 'proposal_id' + | 'starts_at' + | 'ends_at'; + +// Maps a sortable column from the API to its database column. +// Instrument and experiment safety status are intentionally omitted +// (not sortable at the DB layer; create a view later if needed). +const fieldMap: Record< + ExperimentTableSortField, + SortableExperimentTableColumnName +> = { + [ExperimentTableSortField.experimentId]: 'experiment_id', + [ExperimentTableSortField.proposalId]: 'proposal_id', + [ExperimentTableSortField.startsAt]: 'starts_at', + [ExperimentTableSortField.endsAt]: 'ends_at', }; @injectable() @@ -570,7 +586,7 @@ export default class PostgresExperimentDataSource filter?: ExperimentsFilter, first?: number, offset?: number, - sortField?: string, + sortField?: ExperimentTableSortField, sortDirection?: PaginationSortDirection, searchText?: string ): Promise<{ totalCount: number; experiments: Experiment[] }> { @@ -689,8 +705,8 @@ export default class PostgresExperimentDataSource if (!fieldMap.hasOwnProperty(sortField)) { throw new GraphQLError(`Bad sort field given: ${sortField}`); } - sortField = fieldMap[sortField]; - query.orderBy(sortField, sortDirection); + const databaseSortField = fieldMap[sortField]; + query.orderBy(databaseSortField, sortDirection); } if (first) { diff --git a/apps/backend/src/models/Experiment.ts b/apps/backend/src/models/Experiment.ts index c5a9fef515..74af03df60 100644 --- a/apps/backend/src/models/Experiment.ts +++ b/apps/backend/src/models/Experiment.ts @@ -4,6 +4,15 @@ export enum ExperimentStatus { COMPLETED = 'COMPLETED', } +// Experiment table columns that support sorting. Lives in the model so it can +// be reused across queries/datasources without importing from the resolver layer. +export enum ExperimentTableSortField { + experimentId = 'experimentId', + proposalId = 'proposalId', + startsAt = 'startsAt', + endsAt = 'endsAt', +} + export class Experiment { constructor( public experimentPk: number, diff --git a/apps/backend/src/queries/ExperimentQueries.ts b/apps/backend/src/queries/ExperimentQueries.ts index 1953451bbf..f822080d2f 100644 --- a/apps/backend/src/queries/ExperimentQueries.ts +++ b/apps/backend/src/queries/ExperimentQueries.ts @@ -4,6 +4,7 @@ import { UserAuthorization } from '../auth/UserAuthorization'; import { Tokens } from '../config/Tokens'; import { ExperimentDataSource } from '../datasources/ExperimentDataSource'; import { Authorized } from '../decorators'; +import { ExperimentTableSortField } from '../models/Experiment'; import { Roles } from '../models/Role'; import { UserWithRole } from '../models/User'; import { ExperimentSampleArgs } from '../resolvers/queries/ExperimentSampleQuery'; @@ -97,7 +98,7 @@ export default class ExperimentQueries { filter: ExperimentsFilter = {}, first?: number, offset?: number, - sortField?: string, + sortField?: ExperimentTableSortField, sortDirection?: PaginationSortDirection, searchText?: string ) { diff --git a/apps/backend/src/resolvers/queries/ExperimentsQuery.ts b/apps/backend/src/resolvers/queries/ExperimentsQuery.ts index 71b41e673d..657efdf514 100644 --- a/apps/backend/src/resolvers/queries/ExperimentsQuery.ts +++ b/apps/backend/src/resolvers/queries/ExperimentsQuery.ts @@ -11,7 +11,10 @@ import { } from 'type-graphql'; import { ResolverContext } from '../../context'; -import { ExperimentStatus } from '../../models/Experiment'; +import { + ExperimentStatus, + ExperimentTableSortField, +} from '../../models/Experiment'; import { PaginationSortDirection } from '../../utils/pagination'; import { Experiment } from '../types/Experiment'; @@ -59,8 +62,8 @@ export class ExperimentsArgs { @Field(() => Int, { nullable: true }) public offset?: number; - @Field({ nullable: true }) - public sortField?: string; + @Field(() => ExperimentTableSortField, { nullable: true }) + public sortField?: ExperimentTableSortField; @Field(() => PaginationSortDirection, { nullable: true }) public sortDirection?: PaginationSortDirection; diff --git a/apps/backend/src/resolvers/registerEnums.ts b/apps/backend/src/resolvers/registerEnums.ts index da52202294..0d5e076b97 100644 --- a/apps/backend/src/resolvers/registerEnums.ts +++ b/apps/backend/src/resolvers/registerEnums.ts @@ -9,6 +9,7 @@ import { import { ExperimentSafetyReviewerDecisionEnum, ExperimentStatus, + ExperimentTableSortField, InstrumentScientistDecisionEnum, } from '../models/Experiment'; import { FapReviewVisibility } from '../models/Fap'; @@ -70,6 +71,10 @@ export const registerEnums = () => { registerEnumType(ExperimentStatus, { name: 'ExperimentStatus', }); + registerEnumType(ExperimentTableSortField, { + name: 'ExperimentTableSortField', + description: 'Experiment table columns that support sorting', + }); registerEnumType(FeedbackStatus, { name: 'FeedbackStatus', }); diff --git a/apps/e2e/cypress/e2e/experiments.cy.ts b/apps/e2e/cypress/e2e/experiments.cy.ts index b86e8bb9da..6689563116 100644 --- a/apps/e2e/cypress/e2e/experiments.cy.ts +++ b/apps/e2e/cypress/e2e/experiments.cy.ts @@ -1,580 +1,637 @@ -import { - FeatureId, - ProposalEndStatus, -} from '@user-office-software-libs/shared-types'; - -import featureFlags from '../support/featureFlags'; -import initialDBData from '../support/initialDBData'; - -context('Experiments tests', () => { - const instrumentScientist1 = initialDBData.users.instrumentScientist1; - - beforeEach(function () { - cy.resetDB(true); - cy.getAndStoreFeaturesEnabled().then(() => { - // NOTE: We can check features after they are stored to the local storage - if ( - !featureFlags.getEnabledFeatures().get(FeatureId.SCHEDULER) || - !featureFlags - .getEnabledFeatures() - .get(FeatureId.EXPERIMENT_SAFETY_REVIEW) - ) { - this.skip(); - } - }); - - cy.viewport(1920, 1080); - - cy.updateProposalManagementDecision({ - proposalPk: initialDBData.proposal.id, - finalStatus: ProposalEndStatus.ACCEPTED, - managementTimeAllocations: [ - { instrumentId: initialDBData.instrument1.id, value: 5 }, - ], - managementDecisionSubmitted: true, - }); - cy.createExperimentSafety({ - experimentPk: initialDBData.experiments.upcoming.experimentPk, - }); - cy.createVisit({ - experimentPk: initialDBData.experiments.upcoming.experimentPk, - team: [ - initialDBData.users.user1.id, - initialDBData.users.user2.id, - initialDBData.users.user3.id, - ], - teamLeadUserId: initialDBData.users.user1.id, - }); - }); - - describe('Experiments tests', () => { - it('Can filter by call and instrument', () => { - cy.login('officer'); - cy.visit('/'); - cy.get('[data-cy=officer-menu-items]').contains('Experiments').click(); - cy.finishedLoading(); - cy.get('button[value=NONE]').click(); - - cy.get('[data-cy=call-filter]').click(); - cy.get('[role=presentation]').contains('call 1').click(); - cy.contains('1-4 of 4'); - - cy.get('[data-cy=instrument-filter]').click(); - cy.get('[role=presentation]').contains('Instrument 3').click(); - cy.contains('0-0 of 0'); - - cy.get('[data-cy=instrument-filter]').click(); - cy.get('[role=presentation]').contains('Instrument 2').click(); - cy.contains('1-2 of 2'); - - cy.get('[data-cy=instrument-filter]').click(); - cy.get('[role=presentation]').contains('Instrument 1').click(); - cy.contains('1-2 of 2'); - }); - - it('Can filter by date', () => { - cy.login('officer'); - cy.visit('/'); - cy.get('[data-cy=officer-menu-items]').contains('Experiments').click(); - - cy.get('[value=TODAY]').click(); - cy.contains('0-0 of 0'); - - cy.get('[value=NONE]').click(); - cy.contains('1-4 of 4'); - }); - - it('Can view visits', () => { - cy.login('officer'); - cy.visit('/'); - cy.get('[data-cy=officer-menu-items]').contains('Experiments').click(); - cy.get('[value=NONE]').click(); - - cy.finishedLoading(); - - cy.get('[data-cy=officer-scheduled-events-table] Table button') - .first() - .click(); - cy.get('button[role="tab"]').contains('Visit').click({ force: true }); - cy.contains(initialDBData.users.user1.lastName); - }); - - it('All the columns in visit table are sortable', () => { - cy.login('officer'); - cy.visit('/'); - cy.get('[data-cy=officer-menu-items]').contains('Experiments').click(); - cy.get('[value=NONE]').click(); - - cy.finishedLoading(); - - cy.get('[data-cy=officer-scheduled-events-table] Table button') - .first() - .click(); - cy.get('button[role="tab"]').contains('Visit').click({ force: true }); - let tableValue: string[] = []; - cy.get('[data-cy=visit-registrations-table] tbody td') - .each(($el) => { - tableValue = [...tableValue, $el.text().toString()]; - }) - .then(() => { - // Explanation: The table has 7 columns. We will sort each column in ascending and descending order and check if the table is sorted correctly. - // tableColumns: Array of objects. Each object contains the title of the column and the data of the column in original, ascending and descending order. - const tableColumns = [ - { - title: 'Actions', - data: { - original: tableValue.filter((d, i) => i % 6 === 0), - asc: tableValue.filter((d, i) => i % 6 === 0), - desc: tableValue.filter((d, i) => i % 6 === 0), - }, - }, - { - title: 'Status', - data: { - original: tableValue.filter((d, i) => i % 6 === 1), - asc: tableValue.filter((d, i) => i % 6 === 1).sort(), - desc: tableValue - .filter((d, i) => i % 6 === 1) - .sort() - .reverse(), - }, - }, - { - title: 'Visitor name', - data: { - original: tableValue.filter((d, i) => i % 6 === 2), - asc: tableValue.filter((d, i) => i % 6 === 2).sort(), - desc: tableValue - .filter((d, i) => i % 6 === 2) - .sort() - .reverse(), - }, - }, - { - title: 'Teamleader', - data: { - original: tableValue.filter((d, i) => i % 6 === 3), - asc: tableValue.filter((d, i) => i % 6 === 3).sort(), - desc: tableValue - .filter((d, i) => i % 6 === 3) - .sort() - .reverse(), - }, - }, - { - title: 'Visit start', - data: { - original: tableValue.filter((d, i) => i % 6 === 4), - asc: tableValue.filter((d, i) => i % 6 === 4).sort(), - desc: tableValue - .filter((d, i) => i % 6 === 4) - .sort() - .reverse(), - }, - }, - { - title: 'Visit end', - data: { - original: tableValue.filter((d, i) => i % 6 === 5), - asc: tableValue.filter((d, i) => i % 6 === 5).sort(), - desc: tableValue - .filter((d, i) => i % 6 === 5) - .sort() - .reverse(), - }, - }, - ]; - - // Sort each column in ascending and descending order and check if the table is sorted correctly. - for (let i = 0; i < tableColumns.length; i++) { - cy.get('[data-cy=visit-registrations-table] thead th') - .contains(tableColumns[i].title) - .click(); - - // Check if the table is sorted in ascending order - cy.get('[data-cy=visit-registrations-table] tbody td').each( - ($el, index) => { - if (index % 6 === i) { - expect($el.text()).to.eq( - tableColumns[i].data.asc[Math.floor(index / 6)] - ); - } - } - ); - - // Check if the table is sorted in descending order - cy.get('[data-cy=visit-registrations-table] thead th') - .contains(tableColumns[i].title) - .click(); - - cy.get('[data-cy=visit-registrations-table] tbody td').each( - ($el, index) => { - if (index % 6 === i) { - expect($el.text()).to.eq( - tableColumns[i].data.desc[Math.floor(index / 6)] - ); - } - } - ); - - // Check if the table is sorted in original order - cy.get('[data-cy=visit-registrations-table] thead th') - .contains(tableColumns[i].title) - .click(); - - cy.get('[data-cy=visit-registrations-table] tbody td').each( - ($el, index) => { - if (index % 6 === i) { - expect($el.text()).to.eq( - tableColumns[i].data.original[Math.floor(index / 6)] - ); - } - } - ); - - // Reset the table to original order. How? Click on Hide button first and Expand button then. - cy.reload(); - } - }); - }); - - it('Instrument Scientists should be able to see Experiments only associated with their instruments', () => { - cy.login(instrumentScientist1); - cy.visit('/experiments'); - cy.finishedLoading(); - - // There should be a div with data-cy experiments-table, which will contain table inside it - cy.get('[data-cy=experiments-table]').should('exist'); - - // Wait for the table to load with data - cy.get('[data-cy=experiments-table] table tbody tr').should( - 'have.length.at.least', - 1 - ); - - // Inside the table, there should be a column with title "Instrument" - cy.get('[data-cy=experiments-table] table thead th') - .contains('Instrument') - .should('exist'); - - // This column should only contains with value Instrument 1. Make sure you check only against that column and not others - // Make sure the table is fully loaded by checking that data exists - cy.get('[data-cy=experiments-table] table tbody tr td').should('exist'); - - // Get the header row and find the Instrument column index - cy.get('[data-cy=experiments-table] table thead tr th').then( - ($headers) => { - const headers = Array.from($headers).map((header) => - Cypress.$(header).text().trim() - ); - const instrumentColumnIndex = headers.findIndex( - (header) => header === 'Instrument' - ); - - cy.log(`Found headers: ${JSON.stringify(headers)}`); - cy.log(`Instrument column index: ${instrumentColumnIndex}`); - - // Verify we found the column - expect(instrumentColumnIndex).to.be.greaterThan(-1); - - // Now check all rows in the table body for this specific column - // Use a more robust approach that re-queries the DOM each time - cy.get('[data-cy=experiments-table] table tbody tr').then(($rows) => { - const rowCount = $rows.length; - for (let i = 0; i < rowCount; i++) { - cy.get('[data-cy=experiments-table] table tbody tr') - .eq(i) - .find('td') - .eq(instrumentColumnIndex) - .should('contain.text', 'Instrument 1'); - } - }); - } - ); - }); - - it('Should display error when trying to change status of experiment without experimentSafety', () => { - // NOTE: The beforeEach creates experimentSafety only for the "upcoming" experiment - // This test should find an experiment without experimentSafety and try to change its status - - cy.login('officer'); - cy.visit('/'); - cy.get('[data-cy=officer-menu-items]').contains('Experiments').click(); - // Remove date filter to show all experiments - cy.get('[value=NONE]').click(); - cy.finishedLoading(); - - // Find the table and count rows - should have multiple experiments - cy.get('[data-cy=experiments-table] table tbody tr').should( - 'have.length.at.least', - 1 - ); - - // Get all experiment rows and find one that shows "ESF Not Started" status - // These are experiments without experimentSafety - cy.get('[data-cy=experiments-table] table tbody tr').then(($rows) => { - // Look for a row with "ESF Not Started" status - let selectedRowIndex = -1; - $rows.each((index, row) => { - const cells = Cypress.$(row).find('td'); - const statusCell = cells.eq(5); // Experiment Safety Status column - if (statusCell.text().includes('ESF Not Started')) { - selectedRowIndex = index; - - return false; // break - } - }); - - // If we found a row with ESF Not Started, select it - if (selectedRowIndex !== -1) { - cy.get('[data-cy=experiments-table] input[type="checkbox"]') - .eq(selectedRowIndex + 1) // +1 because first row is header - .check({ force: true }); - cy.finishedLoading(); - - // Click on the change status button - cy.get('[data-cy=change-experiment-safety-status]').click(); - cy.finishedLoading(); - - // Check for the error notification - cy.notification({ - variant: 'error', - text: /Cannot change status\. Some or all selected experiments do not have required safety information\./, - }); - } else { - // If no experiment without ESF was found, skip this test - cy.log('No experiment without experimentSafety found'); - } - }); - }); - - it('User officer should see experiment status as a clickable link', () => { - cy.login('officer'); - cy.visit('/'); - cy.get('[data-cy=officer-menu-items]').contains('Experiments').click(); - cy.finishedLoading(); - - // Find the status column cell that contains ESF text and verify it's clickable - cy.get('[data-cy=experiments-table] tbody tr') - .first() - .contains('ESF') - .closest('td') - .find('a, button') - .should('exist') - .and('include.text', 'ESF'); - }); - - it('Instrument scientist should see experiment status as plain text, not clickable', () => { - cy.login('instrumentScientist1'); - cy.visit('/experiments'); - cy.finishedLoading(); - - // Find the status column cell and verify it has no clickable elements - cy.get('[data-cy=experiments-table] tbody tr') - .first() - .contains('ESF') - .closest('td') - .then(($cell) => { - // Should contain ESF text - cy.wrap($cell).should('include.text', 'ESF'); - // Should NOT have any clickable links or buttons - cy.wrap($cell).find('a, button').should('not.exist'); - }); - }); - - it('User officer should be able to open and change experiment safety status', () => { - cy.login('officer'); - cy.visit('/'); - cy.get('[data-cy=officer-menu-items]').contains('Experiments').click(); - cy.finishedLoading(); - - // Click on the status cell (which should be clickable for officer) - cy.get('[data-cy=experiments-table] tbody tr') - .first() - .contains('ESF') - .closest('td') - .find('a, button') - .click(); - cy.finishedLoading(); - - // Verify the modal opened with the workflow tree - cy.get('[role="presentation"]').should( - 'contain', - 'Change experiment(s) safety status' - ); - cy.get('.MuiDialogContent-root').should('exist'); - - // Verify the status selection dropdown exists and is enabled - cy.get('#selectedWorkflowStatusId-input').should( - 'not.have.class', - 'Mui-disabled' - ); - }); - - it('Instrument scientist should not be able to click on status to open change status modal', () => { - cy.login('instrumentScientist1'); - cy.visit('/experiments'); - cy.finishedLoading(); - - // The status cell should be plain text (no clickable elements) - cy.get('[data-cy=experiments-table] tbody tr') - .first() - .contains('ESF') - .closest('td') - .then(($cell) => { - // Verify it's not a button or link - cy.wrap($cell).find('a, button').should('not.exist'); - }); - - // Verify the change status modal is NOT present (check for specific modal title) - cy.contains('Change experiment(s) safety status').should('not.exist'); - }); - - it('Instrument scientist should not see change status button in toolbar', () => { - cy.login('instrumentScientist1'); - cy.visit('/experiments'); - cy.finishedLoading(); - - // Non-officers don't have checkboxes, so the change status button should not exist - cy.get('[data-cy=change-experiment-safety-status]').should('not.exist'); - }); - - it('Experiment safety reviewer should see experiment status as plain text, not clickable', () => { - cy.login('experimentSafetyReviewer1'); - cy.visit('/experiments'); - cy.finishedLoading(); - - // Find the status column cell and verify it has no clickable elements - cy.get('[data-cy=experiments-table] tbody tr') - .first() - .contains('ESF') - .closest('td') - .then(($cell) => { - // Should contain ESF text - cy.wrap($cell).should('include.text', 'ESF'); - // Should NOT have any clickable links or buttons - cy.wrap($cell).find('a, button').should('not.exist'); - }); - }); - - it('Experiment safety reviewer should not be able to click on status to open change status modal', () => { - cy.login('experimentSafetyReviewer1'); - cy.visit('/experiments'); - cy.finishedLoading(); - - // The status cell should be plain text (no clickable elements) - cy.get('[data-cy=experiments-table] tbody tr') - .first() - .contains('ESF') - .closest('td') - .then(($cell) => { - // Verify it's not a button or link - cy.wrap($cell).find('a, button').should('not.exist'); - }); - - // Verify the change status modal is NOT present (check for specific modal title) - cy.contains('Change experiment(s) safety status').should('not.exist'); - }); - - it('Experiment safety reviewer should not see change status button in toolbar', () => { - cy.login('experimentSafetyReviewer1'); - cy.visit('/experiments'); - cy.finishedLoading(); - - // Non-officers don't have checkboxes, so the change status button should not exist - cy.get('[data-cy=change-experiment-safety-status]').should('not.exist'); - }); - - it('User officer should see status action logs after changing experiment status', () => { - cy.login('officer'); - cy.visit('/experiments'); - cy.finishedLoading(); - - // Click the status link to open the change status modal - cy.get('[data-cy=experiments-table] tbody tr') - .first() - .contains('ESF') - .closest('td') - .find('a, button') - .click({ force: true }); - cy.finishedLoading(); - - // Modal for changing status should appear - cy.contains('Change experiment(s) safety status').should('exist'); - - // Click the status selection dropdown and select the second status (which has status actions) - cy.get('[data-cy=status-selection]').should('exist'); - - // Find the input field within the autocomplete component and click it - cy.get('[data-cy=status-selection] input').click(); - - // Wait for the dropdown to appear and select the second option - cy.get('[role="listbox"]').should('be.visible'); - cy.get('[role="option"]').eq(1).click(); - - // Check the "Run status actions" checkbox so the email and RabbitMQ - // status actions are executed (otherwise no status action logs are created). - // The data-cy is on the MUI wrapper span, so target the inner input. - cy.get('[data-cy=run-status-actions-checkbox] input').check({ - force: true, - }); - - // When more than one incoming connection has actions, a transition must be selected. - cy.get('body').then(($body) => { - if ($body.find('[data-cy=connection-selection]').length) { - cy.get('[data-cy=connection-selection]').click(); - cy.get('[role="option"]').first().click(); - } - }); - - // Click submit button to change status - cy.get('[data-cy=submit-experiment-status-change]').click(); - cy.finishedLoading(); - - // Verify success notification - cy.notification({ - variant: 'success', - text: 'status changed successfully', - }); - - // Wait for modal to close - cy.contains('Change experiment(s) safety status').should('not.exist'); - cy.finishedLoading(); - - // Now open experiment details to check the logs - cy.get('[data-cy=experiments-table] tbody tr') - .first() - .find('[data-cy=view-experiment]') - .click(); - cy.finishedLoading(); - - // Experiment details modal should be open - cy.get('[role="presentation"]').should('exist'); - - // Click on the Logs tab - cy.get('[role="tab"]').contains(/Logs/i).should('exist').click(); - cy.finishedLoading(); - - // Verify that event logs table is displayed - cy.get('[data-cy="event-logs-table"]').should('exist'); - - // Verify the logs are not empty and contain status action entries - cy.get('[data-cy="event-logs-table"] tbody tr').should( - 'have.length.at.least', - 1 - ); - - // Verify the logs contain the expected status action messages - cy.get('[data-cy="event-logs-table"]') - .invoke('text') - .then((tableText) => { - // Should contain email status action log entries - // The logs show: Email successfully sent template: [template-name] to: [email] recipient: [recipient-type] - expect(tableText).to.contain('Email successfully sent template'); - // Should also contain RabbitMQ action log entries - // The logs show: RabbitMQ message successfully sent - expect(tableText).to.contain('Experiment event successfully sent'); - }); - }); - }); -}); +import { + FeatureId, + ProposalEndStatus, +} from '@user-office-software-libs/shared-types'; + +import featureFlags from '../support/featureFlags'; +import initialDBData from '../support/initialDBData'; + +// Clicks through each sortable column of the Experiments table and asserts that +// the sort state lands in the URL. Those `sortField`/`sortDirection` params are +// what get forwarded as the getExperiments query variables, so this catches a +// regression in the column -> sort-field wiring, not just a header that stops +// toggling visually. +const assertExperimentsTableColumnsAreSortable = () => { + const sortableColumns = [ + { draggableId: '1', sortField: 'experimentId' }, + { draggableId: '2', sortField: 'proposal.proposalId' }, + { draggableId: '3', sortField: 'startsAt' }, + { draggableId: '4', sortField: 'endsAt' }, + ]; + + sortableColumns.forEach(({ draggableId, sortField }) => { + cy.get( + `div[data-rfd-draggable-id="${draggableId}"] [data-testid="mtableheader-sortlabel"]` + ).click(); + cy.url().should('include', `sortField=${sortField}`); + cy.url().should('include', 'sortDirection=asc'); + + cy.get('span[aria-sort="ascending"]').click(); + cy.url().should('include', 'sortDirection=desc'); + + cy.get('span[aria-sort="descending"]').click(); + }); +}; + +context('Experiments tests', () => { + const instrumentScientist1 = initialDBData.users.instrumentScientist1; + + beforeEach(function () { + cy.resetDB(true); + cy.getAndStoreFeaturesEnabled().then(() => { + // NOTE: We can check features after they are stored to the local storage + if ( + !featureFlags.getEnabledFeatures().get(FeatureId.SCHEDULER) || + !featureFlags + .getEnabledFeatures() + .get(FeatureId.EXPERIMENT_SAFETY_REVIEW) + ) { + this.skip(); + } + }); + + cy.viewport(1920, 1080); + + cy.updateProposalManagementDecision({ + proposalPk: initialDBData.proposal.id, + finalStatus: ProposalEndStatus.ACCEPTED, + managementTimeAllocations: [ + { instrumentId: initialDBData.instrument1.id, value: 5 }, + ], + managementDecisionSubmitted: true, + }); + cy.createExperimentSafety({ + experimentPk: initialDBData.experiments.upcoming.experimentPk, + }); + cy.createVisit({ + experimentPk: initialDBData.experiments.upcoming.experimentPk, + team: [ + initialDBData.users.user1.id, + initialDBData.users.user2.id, + initialDBData.users.user3.id, + ], + teamLeadUserId: initialDBData.users.user1.id, + }); + }); + + describe('Experiments tests', () => { + it('Can filter by call and instrument', () => { + cy.login('officer'); + cy.visit('/'); + cy.get('[data-cy=officer-menu-items]').contains('Experiments').click(); + cy.finishedLoading(); + cy.get('button[value=NONE]').click(); + + cy.get('[data-cy=call-filter]').click(); + cy.get('[role=presentation]').contains('call 1').click(); + cy.contains('1-4 of 4'); + + cy.get('[data-cy=instrument-filter]').click(); + cy.get('[role=presentation]').contains('Instrument 3').click(); + cy.contains('0-0 of 0'); + + cy.get('[data-cy=instrument-filter]').click(); + cy.get('[role=presentation]').contains('Instrument 2').click(); + cy.contains('1-2 of 2'); + + cy.get('[data-cy=instrument-filter]').click(); + cy.get('[role=presentation]').contains('Instrument 1').click(); + cy.contains('1-2 of 2'); + }); + + it('Can filter by date', () => { + cy.login('officer'); + cy.visit('/'); + cy.get('[data-cy=officer-menu-items]').contains('Experiments').click(); + + cy.get('[value=TODAY]').click(); + cy.contains('0-0 of 0'); + + cy.get('[value=NONE]').click(); + cy.contains('1-4 of 4'); + }); + + it('Columns in Experiments Table are sortable - Officer', () => { + cy.login('officer'); + cy.visit('/'); + + cy.get('[data-cy=officer-menu-items]').contains('Experiments').click(); + cy.finishedLoading(); + + assertExperimentsTableColumnsAreSortable(); + }); + + it('Columns in Experiments Table are sortable - Instrument Scientist', () => { + cy.login(instrumentScientist1); + cy.visit('/'); + + cy.contains('Experiments').click(); + cy.finishedLoading(); + + assertExperimentsTableColumnsAreSortable(); + }); + + it('Columns in Experiments Table are sortable - Experiment Safety Reviewer', () => { + cy.login('experimentSafetyReviewer1'); + cy.visit('/'); + + cy.contains('Experiments').click(); + cy.finishedLoading(); + + assertExperimentsTableColumnsAreSortable(); + }); + + it('Can view visits', () => { + cy.login('officer'); + cy.visit('/'); + cy.get('[data-cy=officer-menu-items]').contains('Experiments').click(); + cy.get('[value=NONE]').click(); + + cy.finishedLoading(); + + cy.get('[data-cy=officer-scheduled-events-table] Table button') + .first() + .click(); + cy.get('button[role="tab"]').contains('Visit').click({ force: true }); + cy.contains(initialDBData.users.user1.lastName); + }); + + it('All the columns in visit table are sortable', () => { + cy.login('officer'); + cy.visit('/'); + cy.get('[data-cy=officer-menu-items]').contains('Experiments').click(); + cy.get('[value=NONE]').click(); + + cy.finishedLoading(); + + cy.get('[data-cy=officer-scheduled-events-table] Table button') + .first() + .click(); + cy.get('button[role="tab"]').contains('Visit').click({ force: true }); + let tableValue: string[] = []; + cy.get('[data-cy=visit-registrations-table] tbody td') + .each(($el) => { + tableValue = [...tableValue, $el.text().toString()]; + }) + .then(() => { + // Explanation: The table has 6 columns. We will sort each column in ascending and descending order and check if the table is sorted correctly. + // tableColumns: Array of objects. Each object contains the title of the column and the data of the column in original, ascending and descending order. + const tableColumns = [ + { + title: 'Actions', + data: { + original: tableValue.filter((d, i) => i % 6 === 0), + asc: tableValue.filter((d, i) => i % 6 === 0), + desc: tableValue.filter((d, i) => i % 6 === 0), + }, + }, + { + title: 'Status', + data: { + original: tableValue.filter((d, i) => i % 6 === 1), + asc: tableValue.filter((d, i) => i % 6 === 1).sort(), + desc: tableValue + .filter((d, i) => i % 6 === 1) + .sort() + .reverse(), + }, + }, + { + title: 'Visitor name', + data: { + original: tableValue.filter((d, i) => i % 6 === 2), + asc: tableValue.filter((d, i) => i % 6 === 2).sort(), + desc: tableValue + .filter((d, i) => i % 6 === 2) + .sort() + .reverse(), + }, + }, + { + title: 'Teamleader', + data: { + original: tableValue.filter((d, i) => i % 6 === 3), + asc: tableValue.filter((d, i) => i % 6 === 3).sort(), + desc: tableValue + .filter((d, i) => i % 6 === 3) + .sort() + .reverse(), + }, + }, + { + title: 'Visit start', + data: { + original: tableValue.filter((d, i) => i % 6 === 4), + asc: tableValue.filter((d, i) => i % 6 === 4).sort(), + desc: tableValue + .filter((d, i) => i % 6 === 4) + .sort() + .reverse(), + }, + }, + { + title: 'Visit end', + data: { + original: tableValue.filter((d, i) => i % 6 === 5), + asc: tableValue.filter((d, i) => i % 6 === 5).sort(), + desc: tableValue + .filter((d, i) => i % 6 === 5) + .sort() + .reverse(), + }, + }, + ]; + + // Sort each column in ascending and descending order and check if the table is sorted correctly. + for (let i = 0; i < tableColumns.length; i++) { + cy.get('[data-cy=visit-registrations-table] thead th') + .contains(tableColumns[i].title) + .click(); + + // Check if the table is sorted in ascending order + cy.get('[data-cy=visit-registrations-table] tbody td').each( + ($el, index) => { + if (index % 6 === i) { + expect($el.text()).to.eq( + tableColumns[i].data.asc[Math.floor(index / 6)] + ); + } + } + ); + + // Check if the table is sorted in descending order + cy.get('[data-cy=visit-registrations-table] thead th') + .contains(tableColumns[i].title) + .click(); + + cy.get('[data-cy=visit-registrations-table] tbody td').each( + ($el, index) => { + if (index % 6 === i) { + expect($el.text()).to.eq( + tableColumns[i].data.desc[Math.floor(index / 6)] + ); + } + } + ); + + // Check if the table is sorted in original order + cy.get('[data-cy=visit-registrations-table] thead th') + .contains(tableColumns[i].title) + .click(); + + cy.get('[data-cy=visit-registrations-table] tbody td').each( + ($el, index) => { + if (index % 6 === i) { + expect($el.text()).to.eq( + tableColumns[i].data.original[Math.floor(index / 6)] + ); + } + } + ); + + // Reset the table to original order. How? Click on Hide button first and Expand button then. + cy.reload(); + } + }); + }); + + it('Instrument Scientists should be able to see Experiments only associated with their instruments', () => { + cy.login(instrumentScientist1); + cy.visit('/experiments'); + cy.finishedLoading(); + + // There should be a div with data-cy experiments-table, which will contain table inside it + cy.get('[data-cy=experiments-table]').should('exist'); + + // Wait for the table to load with data + cy.get('[data-cy=experiments-table] table tbody tr').should( + 'have.length.at.least', + 1 + ); + + // Inside the table, there should be a column with title "Instrument" + cy.get('[data-cy=experiments-table] table thead th') + .contains('Instrument') + .should('exist'); + + // This column should only contains with value Instrument 1. Make sure you check only against that column and not others + // Make sure the table is fully loaded by checking that data exists + cy.get('[data-cy=experiments-table] table tbody tr td').should('exist'); + + // Get the header row and find the Instrument column index + cy.get('[data-cy=experiments-table] table thead tr th').then( + ($headers) => { + const headers = Array.from($headers).map((header) => + Cypress.$(header).text().trim() + ); + const instrumentColumnIndex = headers.findIndex( + (header) => header === 'Instrument' + ); + + cy.log(`Found headers: ${JSON.stringify(headers)}`); + cy.log(`Instrument column index: ${instrumentColumnIndex}`); + + // Verify we found the column + expect(instrumentColumnIndex).to.be.greaterThan(-1); + + // Now check all rows in the table body for this specific column + // Use a more robust approach that re-queries the DOM each time + cy.get('[data-cy=experiments-table] table tbody tr').then(($rows) => { + const rowCount = $rows.length; + for (let i = 0; i < rowCount; i++) { + cy.get('[data-cy=experiments-table] table tbody tr') + .eq(i) + .find('td') + .eq(instrumentColumnIndex) + .should('contain.text', 'Instrument 1'); + } + }); + } + ); + }); + + it('Should display error when trying to change status of experiment without experimentSafety', () => { + // NOTE: The beforeEach creates experimentSafety only for the "upcoming" experiment + // This test should find an experiment without experimentSafety and try to change its status + + cy.login('officer'); + cy.visit('/'); + cy.get('[data-cy=officer-menu-items]').contains('Experiments').click(); + // Remove date filter to show all experiments + cy.get('[value=NONE]').click(); + cy.finishedLoading(); + + // Find the table and count rows - should have multiple experiments + cy.get('[data-cy=experiments-table] table tbody tr').should( + 'have.length.at.least', + 1 + ); + + // Get all experiment rows and find one that shows "ESF Not Started" status + // These are experiments without experimentSafety + cy.get('[data-cy=experiments-table] table tbody tr').then(($rows) => { + // Look for a row with "ESF Not Started" status + let selectedRowIndex = -1; + $rows.each((index, row) => { + const cells = Cypress.$(row).find('td'); + const statusCell = cells.eq(5); // Experiment Safety Status column + if (statusCell.text().includes('ESF Not Started')) { + selectedRowIndex = index; + + return false; // break + } + }); + + // If we found a row with ESF Not Started, select it + if (selectedRowIndex !== -1) { + cy.get('[data-cy=experiments-table] input[type="checkbox"]') + .eq(selectedRowIndex + 1) // +1 because first row is header + .check({ force: true }); + cy.finishedLoading(); + + // Click on the change status button + cy.get('[data-cy=change-experiment-safety-status]').click(); + cy.finishedLoading(); + + // Check for the error notification + cy.notification({ + variant: 'error', + text: /Cannot change status\. Some or all selected experiments do not have required safety information\./, + }); + } else { + // If no experiment without ESF was found, skip this test + cy.log('No experiment without experimentSafety found'); + } + }); + }); + + it('User officer should see experiment status as a clickable link', () => { + cy.login('officer'); + cy.visit('/'); + cy.get('[data-cy=officer-menu-items]').contains('Experiments').click(); + cy.finishedLoading(); + + // Find the status column cell that contains ESF text and verify it's clickable + cy.get('[data-cy=experiments-table] tbody tr') + .first() + .contains('ESF') + .closest('td') + .find('a, button') + .should('exist') + .and('include.text', 'ESF'); + }); + + it('Instrument scientist should see experiment status as plain text, not clickable', () => { + cy.login('instrumentScientist1'); + cy.visit('/experiments'); + cy.finishedLoading(); + + // Find the status column cell and verify it has no clickable elements + cy.get('[data-cy=experiments-table] tbody tr') + .first() + .contains('ESF') + .closest('td') + .then(($cell) => { + // Should contain ESF text + cy.wrap($cell).should('include.text', 'ESF'); + // Should NOT have any clickable links or buttons + cy.wrap($cell).find('a, button').should('not.exist'); + }); + }); + + it('User officer should be able to open and change experiment safety status', () => { + cy.login('officer'); + cy.visit('/'); + cy.get('[data-cy=officer-menu-items]').contains('Experiments').click(); + cy.finishedLoading(); + + // Click on the status cell (which should be clickable for officer) + cy.get('[data-cy=experiments-table] tbody tr') + .first() + .contains('ESF') + .closest('td') + .find('a, button') + .click(); + cy.finishedLoading(); + + // Verify the modal opened with the workflow tree + cy.get('[role="presentation"]').should( + 'contain', + 'Change experiment(s) safety status' + ); + cy.get('.MuiDialogContent-root').should('exist'); + + // Verify the status selection dropdown exists and is enabled + cy.get('#selectedWorkflowStatusId-input').should( + 'not.have.class', + 'Mui-disabled' + ); + }); + + it('Instrument scientist should not be able to click on status to open change status modal', () => { + cy.login('instrumentScientist1'); + cy.visit('/experiments'); + cy.finishedLoading(); + + // The status cell should be plain text (no clickable elements) + cy.get('[data-cy=experiments-table] tbody tr') + .first() + .contains('ESF') + .closest('td') + .then(($cell) => { + // Verify it's not a button or link + cy.wrap($cell).find('a, button').should('not.exist'); + }); + + // Verify the change status modal is NOT present (check for specific modal title) + cy.contains('Change experiment(s) safety status').should('not.exist'); + }); + + it('Instrument scientist should not see change status button in toolbar', () => { + cy.login('instrumentScientist1'); + cy.visit('/experiments'); + cy.finishedLoading(); + + // Non-officers don't have checkboxes, so the change status button should not exist + cy.get('[data-cy=change-experiment-safety-status]').should('not.exist'); + }); + + it('Experiment safety reviewer should see experiment status as plain text, not clickable', () => { + cy.login('experimentSafetyReviewer1'); + cy.visit('/experiments'); + cy.finishedLoading(); + + // Find the status column cell and verify it has no clickable elements + cy.get('[data-cy=experiments-table] tbody tr') + .first() + .contains('ESF') + .closest('td') + .then(($cell) => { + // Should contain ESF text + cy.wrap($cell).should('include.text', 'ESF'); + // Should NOT have any clickable links or buttons + cy.wrap($cell).find('a, button').should('not.exist'); + }); + }); + + it('Experiment safety reviewer should not be able to click on status to open change status modal', () => { + cy.login('experimentSafetyReviewer1'); + cy.visit('/experiments'); + cy.finishedLoading(); + + // The status cell should be plain text (no clickable elements) + cy.get('[data-cy=experiments-table] tbody tr') + .first() + .contains('ESF') + .closest('td') + .then(($cell) => { + // Verify it's not a button or link + cy.wrap($cell).find('a, button').should('not.exist'); + }); + + // Verify the change status modal is NOT present (check for specific modal title) + cy.contains('Change experiment(s) safety status').should('not.exist'); + }); + + it('Experiment safety reviewer should not see change status button in toolbar', () => { + cy.login('experimentSafetyReviewer1'); + cy.visit('/experiments'); + cy.finishedLoading(); + + // Non-officers don't have checkboxes, so the change status button should not exist + cy.get('[data-cy=change-experiment-safety-status]').should('not.exist'); + }); + + it('User officer should see status action logs after changing experiment status', () => { + cy.login('officer'); + cy.visit('/experiments'); + cy.finishedLoading(); + + // Click the status link to open the change status modal + cy.get('[data-cy=experiments-table] tbody tr') + .first() + .contains('ESF') + .closest('td') + .find('a, button') + .click({ force: true }); + cy.finishedLoading(); + + // Modal for changing status should appear + cy.contains('Change experiment(s) safety status').should('exist'); + + // Click the status selection dropdown and select the second status (which has status actions) + cy.get('[data-cy=status-selection]').should('exist'); + + // Find the input field within the autocomplete component and click it + cy.get('[data-cy=status-selection] input').click(); + + // Wait for the dropdown to appear and select the second option + cy.get('[role="listbox"]').should('be.visible'); + cy.get('[role="option"]').eq(1).click(); + + // Check the "Run status actions" checkbox so the email and RabbitMQ + // status actions are executed (otherwise no status action logs are created). + // The data-cy is on the MUI wrapper span, so target the inner input. + cy.get('[data-cy=run-status-actions-checkbox] input').check({ + force: true, + }); + + // When more than one incoming connection has actions, a transition must be selected. + cy.get('body').then(($body) => { + if ($body.find('[data-cy=connection-selection]').length) { + cy.get('[data-cy=connection-selection]').click(); + cy.get('[role="option"]').first().click(); + } + }); + + // Click submit button to change status + cy.get('[data-cy=submit-experiment-status-change]').click(); + cy.finishedLoading(); + + // Verify success notification + cy.notification({ + variant: 'success', + text: 'status changed successfully', + }); + + // Wait for modal to close + cy.contains('Change experiment(s) safety status').should('not.exist'); + cy.finishedLoading(); + + // Now open experiment details to check the logs + cy.get('[data-cy=experiments-table] tbody tr') + .first() + .find('[data-cy=view-experiment]') + .click(); + cy.finishedLoading(); + + // Experiment details modal should be open + cy.get('[role="presentation"]').should('exist'); + + // Click on the Logs tab + cy.get('[role="tab"]').contains(/Logs/i).should('exist').click(); + cy.finishedLoading(); + + // Verify that event logs table is displayed + cy.get('[data-cy="event-logs-table"]').should('exist'); + + // Verify the logs are not empty and contain status action entries + cy.get('[data-cy="event-logs-table"] tbody tr').should( + 'have.length.at.least', + 1 + ); + + // Verify the logs contain the expected status action messages + cy.get('[data-cy="event-logs-table"]') + .invoke('text') + .then((tableText) => { + // Should contain email status action log entries + // The logs show: Email successfully sent template: [template-name] to: [email] recipient: [recipient-type] + expect(tableText).to.contain('Email successfully sent template'); + // Should also contain RabbitMQ action log entries + // The logs show: RabbitMQ message successfully sent + expect(tableText).to.contain('Experiment event successfully sent'); + }); + }); + }); +}); diff --git a/apps/frontend/src/components/experiment/ExperimentsTable.tsx b/apps/frontend/src/components/experiment/ExperimentsTable.tsx index 5972dbdacb..508a53325a 100644 --- a/apps/frontend/src/components/experiment/ExperimentsTable.tsx +++ b/apps/frontend/src/components/experiment/ExperimentsTable.tsx @@ -21,6 +21,7 @@ import ListStatusIcon from 'components/common/icons/ListStatusIcon'; import RoleBasedLink from 'components/common/RoleBasedLink'; import { Experiment, + ExperimentTableSortField, PaginationSortDirection, SettingsId, UserRole, @@ -43,6 +44,14 @@ type ExperimentsTableProps = { setExperimentsFilter?: (filter: ExperimentsFilter) => void; }; +// Maps a material-table column `field` to the GraphQL sort enum. +const COLUMN_FIELD_TO_SORT_FIELD: Record = { + experimentId: ExperimentTableSortField.EXPERIMENTID, + 'proposal.proposalId': ExperimentTableSortField.PROPOSALID, + startsAt: ExperimentTableSortField.STARTSAT, + endsAt: ExperimentTableSortField.ENDSAT, +}; + const RowActionButtons = (rowData: Experiment) => { const [, setSearchParams] = useSearchParams(); @@ -151,7 +160,9 @@ export default function ExperimentsTable({ ...(experimentStartDate ? { experimentStartDate } : {}), ...(experimentEndDate ? { experimentEndDate } : {}), }, - sortField: orderBy?.orderByField, + sortField: orderBy + ? COLUMN_FIELD_TO_SORT_FIELD[orderBy.orderByField] + : undefined, sortDirection: orderBy?.orderDirection == PaginationSortDirection.ASC ? PaginationSortDirection.ASC @@ -243,10 +254,12 @@ export default function ExperimentsTable({ { title: 'Instrument', field: 'instrument.name', + sorting: false, }, { title: 'Experiment Safety Status', field: 'experimentSafety.status.name', + sorting: false, render: (rowData: Experiment) => rowData.experimentSafety?.status?.name ?? 'ESF Not Started', }, diff --git a/apps/frontend/src/graphql/experiment/getAllExperiments.graphql b/apps/frontend/src/graphql/experiment/getAllExperiments.graphql index e9385964ca..7b21e8b0ab 100644 --- a/apps/frontend/src/graphql/experiment/getAllExperiments.graphql +++ b/apps/frontend/src/graphql/experiment/getAllExperiments.graphql @@ -2,7 +2,7 @@ query getAllExperiments( $filter: ExperimentsFilter $first: Int $offset: Int - $sortField: String + $sortField: ExperimentTableSortField $sortDirection: PaginationSortDirection $searchText: String ) {