Skip to content

Commit d8a8219

Browse files
authored
SIMSBIOHUB-869: Improve UX for Team–Policy Assignments (#334)
* paginated server-side data grid * simplify team policy container * dedupe code * cleanup * improve consistency * remove edit * code smells * pr comments * improve team-policy assignment dialog * multiselect policies * search * clean up * remove unnecessary types
1 parent bb8a4a8 commit d8a8219

28 files changed

+961
-936
lines changed

api/src/services/access-policy/team-policy-service.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ describe('TeamPolicyService', () => {
3030
policy_id: '33333333-3333-3333-3333-333333333333'
3131
};
3232

33+
const getExistingStub = sinon.stub(TeamPolicyRepository.prototype, 'getPoliciesByTeamId').resolves([]);
3334
const stub = sinon.stub(TeamPolicyRepository.prototype, 'insertTeamPolicy').resolves(mockTeamPolicy);
3435

3536
const input: CreateTeamPolicy = {
@@ -39,9 +40,44 @@ describe('TeamPolicyService', () => {
3940

4041
const result = await service.createTeamPolicy(input);
4142

43+
expect(getExistingStub).to.have.been.calledWith('22222222-2222-2222-2222-222222222222', {
44+
policyIds: ['33333333-3333-3333-3333-333333333333']
45+
});
4246
expect(stub).to.have.been.calledWith(input);
4347
expect(result).to.eql(mockTeamPolicy);
4448
});
49+
50+
it('should return existing team policy and skip insert when association already exists', async () => {
51+
const existingTeamPolicy: TeamPolicyDetails = {
52+
team_policy_id: '11111111-1111-1111-1111-111111111111',
53+
team_id: '22222222-2222-2222-2222-222222222222',
54+
policy_id: '33333333-3333-3333-3333-333333333333',
55+
team_name: 'Team A',
56+
policy_name: 'Policy A'
57+
};
58+
59+
const getExistingStub = sinon
60+
.stub(TeamPolicyRepository.prototype, 'getPoliciesByTeamId')
61+
.resolves([existingTeamPolicy]);
62+
const insertStub = sinon.stub(TeamPolicyRepository.prototype, 'insertTeamPolicy');
63+
64+
const input: CreateTeamPolicy = {
65+
team_id: '22222222-2222-2222-2222-222222222222',
66+
policy_id: '33333333-3333-3333-3333-333333333333'
67+
};
68+
69+
const result = await service.createTeamPolicy(input);
70+
71+
expect(getExistingStub).to.have.been.calledWith('22222222-2222-2222-2222-222222222222', {
72+
policyIds: ['33333333-3333-3333-3333-333333333333']
73+
});
74+
expect(insertStub).to.not.have.been.called;
75+
expect(result).to.eql({
76+
team_policy_id: '11111111-1111-1111-1111-111111111111',
77+
team_id: '22222222-2222-2222-2222-222222222222',
78+
policy_id: '33333333-3333-3333-3333-333333333333'
79+
});
80+
});
4581
});
4682

4783
describe('getTeamPolicy', () => {

api/src/services/access-policy/team-policy-service.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,20 @@ export class TeamPolicyService extends DBService {
2020
* @return {Promise<TeamPolicy>} - The created team policy record.
2121
* @memberof TeamPolicyService
2222
*/
23-
createTeamPolicy(teamPolicyData: CreateTeamPolicy): Promise<TeamPolicy> {
23+
async createTeamPolicy(teamPolicyData: CreateTeamPolicy): Promise<TeamPolicy> {
24+
const existingPolicies = await this.teamPolicyRepository.getPoliciesByTeamId(teamPolicyData.team_id, {
25+
policyIds: [teamPolicyData.policy_id]
26+
});
27+
28+
if (existingPolicies.length > 0) {
29+
const existingPolicy = existingPolicies[0];
30+
return {
31+
team_policy_id: existingPolicy.team_policy_id,
32+
team_id: existingPolicy.team_id,
33+
policy_id: existingPolicy.policy_id
34+
};
35+
}
36+
2437
return this.teamPolicyRepository.insertTeamPolicy(teamPolicyData);
2538
}
2639

@@ -34,6 +47,7 @@ export class TeamPolicyService extends DBService {
3447
*/
3548
async createTeamPolicies(teamId: string, policyIds: string[]): Promise<TeamPolicy[]> {
3649
const uniquePolicyIds = [...new Set(policyIds)];
50+
3751
if (!uniquePolicyIds.length) {
3852
return [];
3953
}
@@ -46,7 +60,9 @@ export class TeamPolicyService extends DBService {
4660
const policyIdsToCreate = uniquePolicyIds.filter((policyId) => !existingPolicyIds.has(policyId));
4761

4862
return Promise.all(
49-
policyIdsToCreate.map((policyId) => this.createTeamPolicy({ team_id: teamId, policy_id: policyId }))
63+
policyIdsToCreate.map((policyId) =>
64+
this.teamPolicyRepository.insertTeamPolicy({ team_id: teamId, policy_id: policyId })
65+
)
5066
);
5167
}
5268

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import {
2+
GridColDef,
3+
GridPaginationModel,
4+
GridRowId,
5+
GridRowSelectionModel,
6+
GridSortModel,
7+
GridValidRowModel
8+
} from '@mui/x-data-grid';
9+
import CustomDataGrid from './CustomDataGrid';
10+
11+
interface IServerPaginatedDataGridProps<T extends GridValidRowModel> {
12+
rows: T[];
13+
columns: GridColDef<T>[];
14+
getRowId: (row: T) => GridRowId;
15+
dataTestId: string;
16+
noRowsMessage: string;
17+
rowCount: number;
18+
paginationModel: GridPaginationModel;
19+
setPaginationModel: (model: GridPaginationModel) => void;
20+
sortModel: GridSortModel;
21+
setSortModel: (model: GridSortModel) => void;
22+
onRowClick?: (row: T) => void;
23+
rowSelectionModel?: GridRowSelectionModel;
24+
onRowSelectionModelChange?: (model: GridRowSelectionModel) => void;
25+
checkboxSelection?: boolean;
26+
disableMultipleRowSelection?: boolean;
27+
}
28+
29+
/**
30+
* Reusable server-side paginated data grid wrapper.
31+
*
32+
* Encapsulates common server pagination/sorting wiring for `CustomDataGrid`
33+
* and exposes strongly-typed row behavior.
34+
*
35+
* @template T
36+
* @param {IServerPaginatedDataGridProps<T>} props
37+
* @returns {JSX.Element}
38+
*/
39+
export const ServerPaginatedDataGrid = <T extends GridValidRowModel>({
40+
rows,
41+
columns,
42+
getRowId,
43+
dataTestId,
44+
noRowsMessage,
45+
rowCount,
46+
paginationModel,
47+
setPaginationModel,
48+
sortModel,
49+
setSortModel,
50+
onRowClick,
51+
rowSelectionModel,
52+
onRowSelectionModelChange,
53+
checkboxSelection,
54+
disableMultipleRowSelection
55+
}: IServerPaginatedDataGridProps<T>) => {
56+
return (
57+
<CustomDataGrid
58+
data-testid={dataTestId}
59+
rows={rows}
60+
columns={columns}
61+
getRowId={getRowId}
62+
paginationMode="server"
63+
paginationModel={paginationModel}
64+
onPaginationModelChange={setPaginationModel}
65+
pageSizeOptions={[10, 25, 50]}
66+
sortingMode="server"
67+
sortingOrder={['asc', 'desc']}
68+
sortModel={sortModel}
69+
onSortModelChange={setSortModel}
70+
rowCount={rowCount}
71+
noRowsMessage={noRowsMessage}
72+
localeText={{ noRowsLabel: noRowsMessage }}
73+
disableRowSelectionOnClick
74+
disableColumnSelector
75+
checkboxSelection={checkboxSelection}
76+
disableMultipleRowSelection={disableMultipleRowSelection}
77+
rowSelectionModel={rowSelectionModel}
78+
onRowSelectionModelChange={onRowSelectionModelChange}
79+
onRowClick={(params) => {
80+
onRowClick?.(params.row as T);
81+
}}
82+
sx={{
83+
border: 'none',
84+
'& .MuiDataGrid-columnHeaderTitle': {
85+
fontWeight: 700,
86+
textTransform: 'uppercase'
87+
}
88+
}}
89+
/>
90+
);
91+
};

app/src/components/dialog/EditDialog.test.tsx

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { fireEvent, waitFor } from '@testing-library/react';
2-
import EditDialog from 'components/dialog/EditDialog';
2+
import { EditDialog } from 'components/dialog/EditDialog';
33
import CustomTextFieldFormik from 'components/fields/CustomTextFieldFormik';
44
import { useFormikContext } from 'formik';
55
import { render } from 'test-helpers/test-utils';
@@ -31,19 +31,21 @@ const handleOnCancel = vi.fn();
3131
const renderContainer = ({
3232
testFieldValue,
3333
dialogError,
34-
open = true
34+
open,
35+
isLoading
3536
}: {
3637
testFieldValue: string;
3738
dialogError?: string;
3839
open?: boolean;
40+
isLoading?: boolean;
3941
}) => {
4042
return render(
4143
<div id="root">
4244
<EditDialog
43-
isLoading={false}
45+
isLoading={isLoading}
4446
dialogTitle="This is dialog title"
4547
dialogError={dialogError || undefined}
46-
open={open}
48+
open={open ?? false}
4749
component={{
4850
element: <SampleFormikForm />,
4951
initialValues: { testField: testFieldValue },
@@ -58,7 +60,7 @@ const renderContainer = ({
5860

5961
describe('EditDialog', () => {
6062
it('renders component and data values', () => {
61-
const { getByTestId, getByText } = renderContainer({ testFieldValue: 'this is a test' });
63+
const { getByTestId, getByText } = renderContainer({ testFieldValue: 'this is a test', open: true });
6264

6365
expect(getByTestId('testField')).toBeVisible();
6466
expect(getByText('this is a test')).toBeVisible();
@@ -67,21 +69,22 @@ describe('EditDialog', () => {
6769
it('matches snapshot when open, with error message', () => {
6870
const { getByTestId, getByText } = renderContainer({
6971
testFieldValue: 'this is a test',
70-
dialogError: 'This is an error'
72+
dialogError: 'This is an error',
73+
open: true
7174
});
7275

7376
expect(getByTestId('testField')).toBeVisible();
7477
expect(getByText('This is an error')).toBeVisible();
7578
});
7679

7780
it('calls the onSave prop when `Save Changes` button is clicked', async () => {
78-
const { findByText, getByLabelText } = renderContainer({ testFieldValue: 'initial value' });
81+
const { getByTestId, getByLabelText } = renderContainer({ testFieldValue: 'initial value', open: true });
7982

8083
const textField = await getByLabelText('Test Field', { exact: false });
8184

8285
fireEvent.change(textField, { target: { value: 'updated value' } });
8386

84-
const saveChangesButton = await findByText('Save Changes', { exact: false });
87+
const saveChangesButton = getByTestId('edit-dialog-save-button');
8588

8689
fireEvent.click(saveChangesButton);
8790

@@ -93,8 +96,19 @@ describe('EditDialog', () => {
9396
});
9497
});
9598

99+
it('hides save label text while loading', () => {
100+
const { getByTestId, queryByText } = renderContainer({
101+
testFieldValue: 'this is a test',
102+
open: true,
103+
isLoading: true
104+
});
105+
106+
expect(getByTestId('edit-dialog-save-button')).toBeVisible();
107+
expect(queryByText('Save Changes', { exact: false })).toBeNull();
108+
});
109+
96110
it('calls the onCancel prop when `Cancel` button is clicked', async () => {
97-
const { findByText } = renderContainer({ testFieldValue: 'this is a test' });
111+
const { findByText } = renderContainer({ testFieldValue: 'this is a test', open: true });
98112

99113
const cancelButton = await findByText('Cancel', { exact: false });
100114

app/src/components/dialog/EditDialog.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Dialog from '@mui/material/Dialog';
33
import DialogActions from '@mui/material/DialogActions';
44
import DialogContent from '@mui/material/DialogContent';
55
import DialogTitle from '@mui/material/DialogTitle';
6+
import { LoadingGuard } from 'components/loading/LoadingGuard';
67
import { Formik, FormikValues } from 'formik';
78

89
export interface IEditDialogComponentProps<T> {
@@ -64,7 +65,7 @@ export interface IEditDialogProps<T> {
6465
/**
6566
* Prop to track if the dialog should be in a 'loading' state
6667
*/
67-
isLoading: boolean;
68+
isLoading?: boolean;
6869
}
6970

7071
/**
@@ -91,19 +92,21 @@ export const EditDialog = <T extends FormikValues>(props: React.PropsWithChildre
9192
props.onSave(values);
9293
}}>
9394
{(formikProps) => (
94-
<Dialog open={props.open} aria-labelledby="edit-dialog-title" aria-describedby="edit-dialog-description">
95+
<Dialog
96+
open={props.open ?? false}
97+
aria-labelledby="edit-dialog-title"
98+
aria-describedby="edit-dialog-description">
9599
<DialogTitle id="edit-dialog-title">{props.dialogTitle}</DialogTitle>
96100
<DialogContent>{props.component.element}</DialogContent>
97101
<DialogActions>
98102
<Button
99103
loading={props.isLoading}
100-
disabled={!formikProps.isValid}
101104
onClick={formikProps.submitForm}
102105
color="primary"
103106
variant="contained"
104107
autoFocus
105108
data-testid="edit-dialog-save-button">
106-
{props.dialogSaveButtonLabel || 'Save Changes'}
109+
<LoadingGuard isLoading={props.isLoading}>{props.dialogSaveButtonLabel || 'Save Changes'}</LoadingGuard>
107110
</Button>
108111
<Button onClick={props.onCancel} color="primary" variant="outlined" data-testid="edit-dialog-cancel-button">
109112
Cancel
@@ -115,5 +118,3 @@ export const EditDialog = <T extends FormikValues>(props: React.PropsWithChildre
115118
</Formik>
116119
);
117120
};
118-
119-
export default EditDialog;

app/src/components/fields/CustomMultiAutocompleteFormik.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Formik } from 'formik';
22
import { render } from 'test-helpers/test-utils';
33
import { sortAutocompleteOptions } from 'utils/autocomplete';
4-
import CustomMultiAutocompleteFormik from './CustomMultiAutocompleteFormik';
4+
import { CustomMultiAutocompleteFormik } from './CustomMultiAutocompleteFormik';
55
import { ICustomMultiAutocompleteOption } from './CustomMultiAutocomplete';
66

77
describe('CustomMultiAutocompleteFormik', () => {

0 commit comments

Comments
 (0)