Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7b5facb
adds filtering on tags for user officer derived roles
Scott-James-Hurley Jun 1, 2026
f659a48
technique proposals instrument filter only show options visible to de…
Scott-James-Hurley Jun 10, 2026
01aaa15
only relevant techniques are shown in filter for derived user officer…
Scott-James-Hurley Jun 10, 2026
91c0963
fixes develop merge conflicts
Scott-James-Hurley Jun 11, 2026
a47d107
populate default role config object
Scott-James-Hurley Jun 17, 2026
6fe064c
adds create role type definition for e2e
Scott-James-Hurley Jun 22, 2026
5cbbaf1
adds e2e tests
Scott-James-Hurley Jun 24, 2026
7a3aa93
corrects element name in technique proposal e2e tests
Scott-James-Hurley Jun 24, 2026
d7cda6e
try to fix e2e test
Scott-James-Hurley Jun 24, 2026
fb11253
Merge branch 'develop' into technique_proposals_changes
Scott-James-Hurley Jun 24, 2026
5108c36
technique proposals are not filtered by instrument by default
Scott-James-Hurley Jun 25, 2026
99c22f9
Merge branch 'technique_proposals_changes' of https://github.com/User…
Scott-James-Hurley Jun 25, 2026
5eac23e
corrects technical proposal tag e2e tests
Scott-James-Hurley Jun 26, 2026
c67aadd
Merge branch 'develop' into technique_proposals_changes
Scott-James-Hurley Jun 26, 2026
53945ec
corrects e2e test case
Scott-James-Hurley Jun 26, 2026
695dca2
Merge branch 'technique_proposals_changes' of https://github.com/User…
Scott-James-Hurley Jun 26, 2026
3a39dfa
derived user officers can only assign technical proposals to instrume…
Scott-James-Hurley Jul 1, 2026
f61c765
Merge branch 'develop' into technique_proposals_changes
Scott-James-Hurley Jul 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/backend/src/datasources/InstrumentDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export interface InstrumentDataSource {
getInstrumentsByIds(instrumentIds: number[]): Promise<Instrument[]>;
getInstruments(
first?: number,
offset?: number
offset?: number,
agentId?: number
): Promise<{ totalCount: number; instruments: Instrument[] }>;
getUserInstruments(userId: number, agentId?: number): Promise<Instrument[]>;
getInstrumentsByCallId(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] };
}
Expand Down
67 changes: 45 additions & 22 deletions apps/backend/src/datasources/postgres/InstrumentDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Tag[]> {
try {
const rows = await database
Expand Down
9 changes: 8 additions & 1 deletion apps/backend/src/queries/InstrumentQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a stupid question, but surely if .getInstrumentsByCallId( is called instead of .getInstruments( then your new filter on tag won't apply. Wouldn't this contradict the idea of the PR to limit what these users can see?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I can see, getInstrumentsByCallId is not called when populating the instrument filter on the technique proposals page. The call to fetch instruments doesn't contain a call id so it will take the path starting line 47, not line 54. Happy to corrected if I'm mistaken, though.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see you're right, but what about

.getInstrumentsMinimal({ callIds }) in useInstrumentsMinimalData.ts,

should that one also be using your filter?

Expand Down
218 changes: 218 additions & 0 deletions apps/e2e/cypress/e2e/techniqueProposals.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ context('Technique Proposal tests', () => {
abstract: faker.word.words(5),
};

let derivedUserOfficerRoleId: number;

beforeEach(function () {
cy.resetDB();

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});
12 changes: 12 additions & 0 deletions apps/e2e/cypress/support/tag.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,6 +22,15 @@ const createTag = (
return cy.wrap(request);
};

const createRole = (
createRoleInput: CreateRoleMutationVariables
): Cypress.Chainable<CreateRoleMutation> => {
const api = getE2EApi();
const request = api.createRole(createRoleInput);

return cy.wrap(request);
};

const addInstrumentToTag = (
addInstrumentsToTag: AssignInstrumentsToTagMutationVariables
): Cypress.Chainable<AssignInstrumentsToTagMutation> => {
Expand Down Expand Up @@ -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);
Loading
Loading