diff --git a/apps/backend/src/datasources/InstrumentDataSource.ts b/apps/backend/src/datasources/InstrumentDataSource.ts index afed63a28b..efcf413506 100644 --- a/apps/backend/src/datasources/InstrumentDataSource.ts +++ b/apps/backend/src/datasources/InstrumentDataSource.ts @@ -15,7 +15,8 @@ export interface InstrumentDataSource { getInstrumentsByIds(instrumentIds: number[]): Promise; getInstruments( first?: number, - offset?: number + offset?: number, + agentId?: number ): Promise<{ totalCount: number; instruments: Instrument[] }>; getUserInstruments(userId: number, agentId?: number): Promise; getInstrumentsByCallId( diff --git a/apps/backend/src/datasources/mockups/InstrumentDataSource.ts b/apps/backend/src/datasources/mockups/InstrumentDataSource.ts index 13b9f40276..52ced2b3ac 100644 --- a/apps/backend/src/datasources/mockups/InstrumentDataSource.ts +++ b/apps/backend/src/datasources/mockups/InstrumentDataSource.ts @@ -129,7 +129,8 @@ export class InstrumentDataSourceMock implements InstrumentDataSource { async getInstruments( first?: number, - offset?: number + offset?: number, + agentId?: number ): Promise<{ totalCount: number; instruments: Instrument[] }> { return { totalCount: 1, instruments: [dummyInstrument] }; } diff --git a/apps/backend/src/datasources/postgres/InstrumentDataSource.ts b/apps/backend/src/datasources/postgres/InstrumentDataSource.ts index 514516ab70..8b538b2de1 100644 --- a/apps/backend/src/datasources/postgres/InstrumentDataSource.ts +++ b/apps/backend/src/datasources/postgres/InstrumentDataSource.ts @@ -134,31 +134,54 @@ export default class PostgresInstrumentDataSource async getInstruments( first?: number, - offset?: number + offset?: number, + agentRoleId?: number ): Promise<{ totalCount: number; instruments: Instrument[] }> { - return database - .select(['*', database.raw('count(*) OVER() AS full_count')]) - .from('instruments') - .orderBy('instrument_id', 'desc') - .modify((query) => { - if (first) { - query.limit(first); - } - if (offset) { - query.offset(offset); - } - }) - .then((instruments: InstrumentRecord[]) => { - const result = instruments.map((instrument) => - this.createInstrumentObject(instrument) - ); + let instruments: InstrumentRecord[]; + const tags = agentRoleId + ? (await this.getTagsByRoleId(agentRoleId)) ?? [] + : []; + if (tags.length > 0) { + const tagIds = tags.map((tag) => tag.id); - return { - totalCount: instruments[0] ? instruments[0].full_count : 0, - instruments: result, - }; - }); + instruments = await database + .select(['i.*', database.raw('count(*) OVER() AS full_count')]) + .from('instruments as i') + .join('tag_instrument as ti', 'i.instrument_id', 'ti.instrument_id') + .whereIn('ti.tag_id', tagIds) + .orderBy('i.instrument_id', 'desc') + .modify((query) => { + if (first) { + query.limit(first); + } + if (offset) { + query.offset(offset); + } + }); + } else { + instruments = await database + .select(['*', database.raw('count(*) OVER() AS full_count')]) + .from('instruments') + .orderBy('instrument_id', 'desc') + .modify((query) => { + if (first) { + query.limit(first); + } + if (offset) { + query.offset(offset); + } + }); + } + const result = instruments.map((instrument) => + this.createInstrumentObject(instrument) + ); + + return { + totalCount: instruments[0] ? instruments[0].full_count : 0, + instruments: result, + }; } + async getTagsByRoleId(roleId: number): Promise { try { const rows = await database diff --git a/apps/backend/src/queries/InstrumentQueries.ts b/apps/backend/src/queries/InstrumentQueries.ts index c5261d12a6..bb0f45df24 100644 --- a/apps/backend/src/queries/InstrumentQueries.ts +++ b/apps/backend/src/queries/InstrumentQueries.ts @@ -40,8 +40,15 @@ export default class InstrumentQueries { @Authorized([Roles.USER_OFFICER, Roles.INSTRUMENT_SCIENTIST]) async getAll(agent: UserWithRole | null, callIds: number[]) { + const agentRoleId = this.userAuth.isApiToken(agent) + ? undefined + : agent?.currentRole?.id; if (!callIds || callIds.length === 0) { - return await this.dataSource.getInstruments(); + return await this.dataSource.getInstruments( + undefined, + undefined, + agentRoleId + ); } else { const instrumentsByCallIds = await this.dataSource.getInstrumentsByCallId(callIds); diff --git a/apps/e2e/cypress/e2e/techniqueProposals.cy.ts b/apps/e2e/cypress/e2e/techniqueProposals.cy.ts index 181a1592f9..54752bf1e4 100644 --- a/apps/e2e/cypress/e2e/techniqueProposals.cy.ts +++ b/apps/e2e/cypress/e2e/techniqueProposals.cy.ts @@ -241,6 +241,8 @@ context('Technique Proposal tests', () => { abstract: faker.word.words(5), }; + let derivedUserOfficerRoleId: number; + beforeEach(function () { cy.resetDB(); @@ -576,6 +578,34 @@ context('Technique Proposal tests', () => { }); } }); + + cy.createTag({ name: 'Tag', shortCode: 'tag' }).then((tagResult) => { + cy.assignCallsToTag({ + callIds: initialDBData.call.id, + tagId: tagResult.createTag.id, + }); + cy.addInstrumentToTag({ + instrumentIds: createdInstrumentId1, + tagId: tagResult.createTag.id, + }); + cy.createRole({ + args: { + title: 'Derived User Officer', + shortCode: 'user_officer', + description: '', + }, + }).then((roleResult) => { + derivedUserOfficerRoleId = roleResult.createRole.id; + cy.updateRoleTags({ + roleId: roleResult.createRole.id, + tagIds: tagResult.createTag.id, + }); + cy.updateUserRoles({ + id: initialDBData.users.officer.id, + roles: [roleResult.createRole.id], + }); + }); + }); }); describe('Technique proposal basic tests', () => { @@ -2051,4 +2081,192 @@ context('Technique Proposal tests', () => { }); }); }); + + describe('Technique proposal tags tests', () => { + beforeEach(function () { + cy.getAndStoreFeaturesEnabled().then(() => { + if ( + !featureFlags.getEnabledFeatures().get(FeatureId.TECHNIQUE_PROPOSALS) + ) { + this.skip(); + } + }); + }); + + it('Base User Officer role can see all call filter options', () => { + cy.createCall({ + shortCode: 'untagged call', + cycleComment: 'This is cycle comment', + allocationTimeUnit: AllocationTimeUnits.DAY, + endCall: DateTime.fromJSDate(faker.date.future()), + endCycle: DateTime.fromJSDate(faker.date.future()), + endNotify: DateTime.fromJSDate(faker.date.future()), + endReview: DateTime.fromJSDate(faker.date.future()), + proposalWorkflowId: initialDBData.workflows.defaultWorkflow.id, + startCall: DateTime.fromJSDate(faker.date.past()), + startCycle: DateTime.fromJSDate(faker.date.past()), + startNotify: DateTime.fromJSDate(faker.date.past()), + startReview: DateTime.fromJSDate(faker.date.past()), + templateId: initialDBData.template.id, + }).then((result) => + cy.updateCall({ + id: result.createCall.id, + proposalWorkflowId: callWorkflowId, + }) + ); + + cy.login('officer'); + cy.changeActiveRole(initialDBData.roles.userOfficer); + cy.visit('/'); + cy.finishedLoading(); + + cy.contains('Technique Proposals').click(); + + cy.get('[data-cy="call-filter"]').click(); + cy.get('[role="listbox"]').contains('untagged call'); + cy.get('[role="listbox"]').contains('call 1'); + }); + + it('Base User Officer role can see all technique filter options', () => { + cy.login('officer'); + cy.changeActiveRole(initialDBData.roles.userOfficer); + cy.visit('/'); + cy.finishedLoading(); + + cy.contains('Technique Proposals').click(); + + cy.get('[data-cy="technique-filter"]').click(); + cy.get('[role="listbox"]').contains(technique1.name); + cy.get('[role="listbox"]').contains(technique2.name); + cy.get('[role="listbox"]').contains(technique3.name); + cy.get('[role="listbox"]').contains(technique4.name); + cy.get('[role="listbox"]').contains(technique5.name); + }); + + it('Base User Officer role can see all instrument filter options', () => { + cy.login('officer'); + cy.changeActiveRole(initialDBData.roles.userOfficer); + cy.visit('/'); + cy.finishedLoading(); + + cy.contains('Technique Proposals').click(); + + //the call dropdown will default to a call with no proposals + cy.get('[data-cy="call-filter"]').click(); + cy.get('[role="listbox"]').contains('All').click(); + cy.finishedLoading(); + + cy.get('[data-cy="instrument-filter"]').click(); + cy.get('[role="listbox"]').contains(instrument1.name); + cy.get('[role="listbox"]').contains(instrument2.name); + cy.get('[role="listbox"]').contains(instrument3.name); + cy.get('[role="listbox"]').contains(instrument4.name); + cy.get('[role="listbox"]').contains(instrument5.name); + }); + + it('Base User Officer role can see all proposals', () => { + cy.login('officer'); + cy.changeActiveRole(initialDBData.roles.userOfficer); + cy.visit('/'); + cy.finishedLoading(); + + cy.contains('Technique Proposals').click(); + + //the call dropdown will default to a call with no proposals + cy.get('[data-cy="call-filter"]').click(); + cy.get('[role="listbox"]').contains('All').click(); + cy.finishedLoading(); + + cy.contains(proposal1.title); + cy.contains(proposal2.title); + cy.contains(proposal3.title); + cy.should('not.contain', proposal4.title); + cy.contains(proposal5.title); + }); + + it('Derived User Officer role can only see appropriately tagged call filter options', () => { + cy.createCall({ + shortCode: 'untagged call', + cycleComment: 'This is cycle comment', + allocationTimeUnit: AllocationTimeUnits.DAY, + endCall: DateTime.fromJSDate(faker.date.future()), + endCycle: DateTime.fromJSDate(faker.date.future()), + endNotify: DateTime.fromJSDate(faker.date.future()), + endReview: DateTime.fromJSDate(faker.date.future()), + proposalWorkflowId: initialDBData.workflows.defaultWorkflow.id, + startCall: DateTime.fromJSDate(faker.date.past()), + startCycle: DateTime.fromJSDate(faker.date.past()), + startNotify: DateTime.fromJSDate(faker.date.past()), + startReview: DateTime.fromJSDate(faker.date.past()), + templateId: initialDBData.template.id, + }).then((result) => + cy.updateCall({ + id: result.createCall.id, + proposalWorkflowId: callWorkflowId, + }) + ); + + cy.login('officer'); + cy.changeActiveRole(derivedUserOfficerRoleId); + cy.visit('/'); + cy.finishedLoading(); + + cy.contains('Technique Proposals').click(); + + cy.get('[data-cy="call-filter"]').click(); + cy.get('[role="listbox"]').contains('untagged call').should('not.exist'); + cy.get('[role="listbox"]').contains('call 1'); + }); + + it('Derived User Officer role can only see appropriate technique filter options', () => { + cy.login('officer'); + cy.changeActiveRole(derivedUserOfficerRoleId); + cy.visit('/'); + cy.finishedLoading(); + + cy.contains('Technique Proposals').click(); + + cy.get('[data-cy="technique-filter"]').click(); + cy.get('[role="listbox"]').contains(technique1.name); + cy.get('[role="listbox"]').contains(technique2.name).should('not.exist'); + cy.get('[role="listbox"]').contains(technique3.name).should('not.exist'); + cy.get('[role="listbox"]').contains(technique4.name).should('not.exist'); + cy.get('[role="listbox"]').contains(technique5.name).should('not.exist'); + }); + + it('Derived User Officer role can only see appropriately tagged instrument filter options', () => { + cy.login('officer'); + cy.changeActiveRole(derivedUserOfficerRoleId); + cy.visit('/'); + cy.finishedLoading(); + + cy.contains('Technique Proposals').click(); + + cy.get('[data-cy="call-filter"]').click(); + cy.get('[role="listbox"]').contains('All').click(); + cy.finishedLoading(); + + cy.get('[data-cy="instrument-filter"]').click(); + cy.get('[role="listbox"]').contains(instrument1.name); + cy.get('[role="listbox"]').contains(instrument2.name).should('not.exist'); + cy.get('[role="listbox"]').contains(instrument3.name).should('not.exist'); + cy.get('[role="listbox"]').contains(instrument4.name).should('not.exist'); + cy.get('[role="listbox"]').contains(instrument5.name).should('not.exist'); + }); + + it('Derived User Officer role can only see appropriate tagged proposals', () => { + cy.login('officer'); + cy.changeActiveRole(derivedUserOfficerRoleId); + cy.visit('/'); + cy.finishedLoading(); + + cy.contains('Technique Proposals').click(); + + cy.contains(proposal1.title); + cy.should('not.contain', proposal2.title); + cy.should('not.contain', proposal3.title); + cy.should('not.contain', proposal4.title); + cy.should('not.contain', proposal5.title); + }); + }); }); diff --git a/apps/e2e/cypress/support/tag.ts b/apps/e2e/cypress/support/tag.ts index 8a6c4c17cb..a0bed78e90 100644 --- a/apps/e2e/cypress/support/tag.ts +++ b/apps/e2e/cypress/support/tag.ts @@ -1,12 +1,14 @@ import { AssignCallsToTagMutation, AssignCallsToTagMutationVariables, + CreateRoleMutation, CreateTagMutation, CreateTagMutationVariables, AssignInstrumentsToTagMutationVariables, AssignInstrumentsToTagMutation, UpdateRoleTagsMutation, UpdateRoleTagsMutationVariables, + CreateRoleMutationVariables, } from '@user-office-software-libs/shared-types'; import { getE2EApi } from './utils'; @@ -20,6 +22,15 @@ const createTag = ( return cy.wrap(request); }; +const createRole = ( + createRoleInput: CreateRoleMutationVariables +): Cypress.Chainable => { + const api = getE2EApi(); + const request = api.createRole(createRoleInput); + + return cy.wrap(request); +}; + const addInstrumentToTag = ( addInstrumentsToTag: AssignInstrumentsToTagMutationVariables ): Cypress.Chainable => { @@ -48,6 +59,7 @@ const updateRoleTags = ( }; Cypress.Commands.add('createTag', createTag); +Cypress.Commands.add('createRole', createRole); Cypress.Commands.add('addInstrumentToTag', addInstrumentToTag); Cypress.Commands.add('assignCallsToTag', assignCallsToTag); Cypress.Commands.add('updateRoleTags', updateRoleTags); diff --git a/apps/e2e/cypress/types/role.d.ts b/apps/e2e/cypress/types/role.d.ts new file mode 100644 index 0000000000..bf6245b0f3 --- /dev/null +++ b/apps/e2e/cypress/types/role.d.ts @@ -0,0 +1,22 @@ +import { + CreateRoleMutationVariables, + CreateRoleMutation, +} from '@user-office-software-libs/shared-types'; + +declare global { + namespace Cypress { + interface Chainable { + /** + * Creates new role. + * + * @returns {typeof createRole} + * @memberof Chainable + * @example + * cy.createRole(createRoleInput: CreateRoleMutationVariables) + */ + createRole: ( + createRoleInput: CreateRoleMutationVariables + ) => Cypress.Chainable; + } + } +} diff --git a/apps/frontend/src/components/techniqueProposal/TechniqueProposalTable.tsx b/apps/frontend/src/components/techniqueProposal/TechniqueProposalTable.tsx index 6eeee218a7..90804850f9 100644 --- a/apps/frontend/src/components/techniqueProposal/TechniqueProposalTable.tsx +++ b/apps/frontend/src/components/techniqueProposal/TechniqueProposalTable.tsx @@ -48,6 +48,7 @@ import { import { useFormattedDateTime } from 'hooks/admin/useFormattedDateTime'; import { CallsDataQuantity, useCallsData } from 'hooks/call/useCallsData'; import { useCheckAccess } from 'hooks/common/useCheckAccess'; +import { useInstrumentsMinimalData } from 'hooks/instrument/useInstrumentsMinimalData'; import { useDownloadXLSXProposal } from 'hooks/proposal/useDownloadXLSXProposal'; import { ProposalViewData } from 'hooks/proposal/useProposalsCoreData'; import { useStatusesData } from 'hooks/settings/useStatusesData'; @@ -85,11 +86,36 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { const { techniques, loadingTechniques } = useTechniqueProposalsTechniquesData(); + const { + instruments: instrumentsMinimal, + loadingInstruments: loadingInstrumentsMinimal, + } = useInstrumentsMinimalData(); + const instrumentIds = useMemo(() => { - if (loadingTechniques || !techniques) return []; + if (currentRole === UserRole.USER_OFFICER) { + if (loadingInstrumentsMinimal || !instrumentsMinimal) return []; - return techniques.flatMap((t) => t.instruments.map((i) => i.id)); - }, [loadingTechniques, techniques]); + return instrumentsMinimal.map((i) => i.id); + } else { + if (loadingTechniques || !techniques) return []; + + return techniques.flatMap((t) => t.instruments.map((i) => i.id)); + } + }, [ + loadingTechniques, + techniques, + loadingInstrumentsMinimal, + instrumentsMinimal, + ]); + + const relevantTechniques = + currentRole === UserRole.USER_OFFICER + ? techniques.filter((t) => + t.instruments + .map((ti) => ti.id) + .some((i) => instrumentsMinimal.map((im) => im.id).includes(i)) + ) + : techniques; const { calls, loadingCalls, setCallsFilter } = useCallsData( { @@ -427,6 +453,12 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { instruments = [missingInst, ...instruments]; } + if (currentRole === UserRole.USER_OFFICER) { + instruments = instruments.filter((i) => + techniqueInstruments.map((ti) => ti.id).includes(i.id) + ); + } + // Always show the current instrument at the top of the dropdown instruments.forEach(function (instrument, i) { if (fieldValue && instrument.id === fieldValue) { @@ -724,7 +756,6 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { proposals: ProposalViewData[] | undefined; totalCount: number; } = { proposals: undefined, totalCount: 0 }; - api() .getTechniqueScientistProposals({ filter: { @@ -969,7 +1000,7 @@ const TechniqueProposalTable = ({ confirm }: { confirm: WithConfirmType }) => { isLoading: loadingInstruments, }} techniques={{ - data: techniques, + data: relevantTechniques, isLoading: loadingTechniques, }} proposalStatuses={{