diff --git a/api/src/services/access-policy/team-policy-service.test.ts b/api/src/services/access-policy/team-policy-service.test.ts index 3d49b65ed..4623d6663 100644 --- a/api/src/services/access-policy/team-policy-service.test.ts +++ b/api/src/services/access-policy/team-policy-service.test.ts @@ -30,6 +30,7 @@ describe('TeamPolicyService', () => { policy_id: '33333333-3333-3333-3333-333333333333' }; + const getExistingStub = sinon.stub(TeamPolicyRepository.prototype, 'getPoliciesByTeamId').resolves([]); const stub = sinon.stub(TeamPolicyRepository.prototype, 'insertTeamPolicy').resolves(mockTeamPolicy); const input: CreateTeamPolicy = { @@ -39,9 +40,44 @@ describe('TeamPolicyService', () => { const result = await service.createTeamPolicy(input); + expect(getExistingStub).to.have.been.calledWith('22222222-2222-2222-2222-222222222222', { + policyIds: ['33333333-3333-3333-3333-333333333333'] + }); expect(stub).to.have.been.calledWith(input); expect(result).to.eql(mockTeamPolicy); }); + + it('should return existing team policy and skip insert when association already exists', async () => { + const existingTeamPolicy: TeamPolicyDetails = { + team_policy_id: '11111111-1111-1111-1111-111111111111', + team_id: '22222222-2222-2222-2222-222222222222', + policy_id: '33333333-3333-3333-3333-333333333333', + team_name: 'Team A', + policy_name: 'Policy A' + }; + + const getExistingStub = sinon + .stub(TeamPolicyRepository.prototype, 'getPoliciesByTeamId') + .resolves([existingTeamPolicy]); + const insertStub = sinon.stub(TeamPolicyRepository.prototype, 'insertTeamPolicy'); + + const input: CreateTeamPolicy = { + team_id: '22222222-2222-2222-2222-222222222222', + policy_id: '33333333-3333-3333-3333-333333333333' + }; + + const result = await service.createTeamPolicy(input); + + expect(getExistingStub).to.have.been.calledWith('22222222-2222-2222-2222-222222222222', { + policyIds: ['33333333-3333-3333-3333-333333333333'] + }); + expect(insertStub).to.not.have.been.called; + expect(result).to.eql({ + team_policy_id: '11111111-1111-1111-1111-111111111111', + team_id: '22222222-2222-2222-2222-222222222222', + policy_id: '33333333-3333-3333-3333-333333333333' + }); + }); }); describe('getTeamPolicy', () => { diff --git a/api/src/services/access-policy/team-policy-service.ts b/api/src/services/access-policy/team-policy-service.ts index c4f4aa2cf..d3ad94517 100644 --- a/api/src/services/access-policy/team-policy-service.ts +++ b/api/src/services/access-policy/team-policy-service.ts @@ -20,7 +20,20 @@ export class TeamPolicyService extends DBService { * @return {Promise} - The created team policy record. * @memberof TeamPolicyService */ - createTeamPolicy(teamPolicyData: CreateTeamPolicy): Promise { + async createTeamPolicy(teamPolicyData: CreateTeamPolicy): Promise { + const existingPolicies = await this.teamPolicyRepository.getPoliciesByTeamId(teamPolicyData.team_id, { + policyIds: [teamPolicyData.policy_id] + }); + + if (existingPolicies.length > 0) { + const existingPolicy = existingPolicies[0]; + return { + team_policy_id: existingPolicy.team_policy_id, + team_id: existingPolicy.team_id, + policy_id: existingPolicy.policy_id + }; + } + return this.teamPolicyRepository.insertTeamPolicy(teamPolicyData); } @@ -34,6 +47,7 @@ export class TeamPolicyService extends DBService { */ async createTeamPolicies(teamId: string, policyIds: string[]): Promise { const uniquePolicyIds = [...new Set(policyIds)]; + if (!uniquePolicyIds.length) { return []; } @@ -46,7 +60,9 @@ export class TeamPolicyService extends DBService { const policyIdsToCreate = uniquePolicyIds.filter((policyId) => !existingPolicyIds.has(policyId)); return Promise.all( - policyIdsToCreate.map((policyId) => this.createTeamPolicy({ team_id: teamId, policy_id: policyId })) + policyIdsToCreate.map((policyId) => + this.teamPolicyRepository.insertTeamPolicy({ team_id: teamId, policy_id: policyId }) + ) ); } diff --git a/app/src/components/data-grid/ServerPaginatedDataGrid.tsx b/app/src/components/data-grid/ServerPaginatedDataGrid.tsx new file mode 100644 index 000000000..552db331d --- /dev/null +++ b/app/src/components/data-grid/ServerPaginatedDataGrid.tsx @@ -0,0 +1,91 @@ +import { + GridColDef, + GridPaginationModel, + GridRowId, + GridRowSelectionModel, + GridSortModel, + GridValidRowModel +} from '@mui/x-data-grid'; +import CustomDataGrid from './CustomDataGrid'; + +interface IServerPaginatedDataGridProps { + rows: T[]; + columns: GridColDef[]; + getRowId: (row: T) => GridRowId; + dataTestId: string; + noRowsMessage: string; + rowCount: number; + paginationModel: GridPaginationModel; + setPaginationModel: (model: GridPaginationModel) => void; + sortModel: GridSortModel; + setSortModel: (model: GridSortModel) => void; + onRowClick?: (row: T) => void; + rowSelectionModel?: GridRowSelectionModel; + onRowSelectionModelChange?: (model: GridRowSelectionModel) => void; + checkboxSelection?: boolean; + disableMultipleRowSelection?: boolean; +} + +/** + * Reusable server-side paginated data grid wrapper. + * + * Encapsulates common server pagination/sorting wiring for `CustomDataGrid` + * and exposes strongly-typed row behavior. + * + * @template T + * @param {IServerPaginatedDataGridProps} props + * @returns {JSX.Element} + */ +export const ServerPaginatedDataGrid = ({ + rows, + columns, + getRowId, + dataTestId, + noRowsMessage, + rowCount, + paginationModel, + setPaginationModel, + sortModel, + setSortModel, + onRowClick, + rowSelectionModel, + onRowSelectionModelChange, + checkboxSelection, + disableMultipleRowSelection +}: IServerPaginatedDataGridProps) => { + return ( + { + onRowClick?.(params.row as T); + }} + sx={{ + border: 'none', + '& .MuiDataGrid-columnHeaderTitle': { + fontWeight: 700, + textTransform: 'uppercase' + } + }} + /> + ); +}; diff --git a/app/src/components/dialog/EditDialog.test.tsx b/app/src/components/dialog/EditDialog.test.tsx index 9e625707b..9e238247c 100644 --- a/app/src/components/dialog/EditDialog.test.tsx +++ b/app/src/components/dialog/EditDialog.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, waitFor } from '@testing-library/react'; -import EditDialog from 'components/dialog/EditDialog'; +import { EditDialog } from 'components/dialog/EditDialog'; import CustomTextFieldFormik from 'components/fields/CustomTextFieldFormik'; import { useFormikContext } from 'formik'; import { render } from 'test-helpers/test-utils'; @@ -31,19 +31,21 @@ const handleOnCancel = vi.fn(); const renderContainer = ({ testFieldValue, dialogError, - open = true + open, + isLoading }: { testFieldValue: string; dialogError?: string; open?: boolean; + isLoading?: boolean; }) => { return render(
, initialValues: { testField: testFieldValue }, @@ -58,7 +60,7 @@ const renderContainer = ({ describe('EditDialog', () => { it('renders component and data values', () => { - const { getByTestId, getByText } = renderContainer({ testFieldValue: 'this is a test' }); + const { getByTestId, getByText } = renderContainer({ testFieldValue: 'this is a test', open: true }); expect(getByTestId('testField')).toBeVisible(); expect(getByText('this is a test')).toBeVisible(); @@ -67,7 +69,8 @@ describe('EditDialog', () => { it('matches snapshot when open, with error message', () => { const { getByTestId, getByText } = renderContainer({ testFieldValue: 'this is a test', - dialogError: 'This is an error' + dialogError: 'This is an error', + open: true }); expect(getByTestId('testField')).toBeVisible(); @@ -75,13 +78,13 @@ describe('EditDialog', () => { }); it('calls the onSave prop when `Save Changes` button is clicked', async () => { - const { findByText, getByLabelText } = renderContainer({ testFieldValue: 'initial value' }); + const { getByTestId, getByLabelText } = renderContainer({ testFieldValue: 'initial value', open: true }); const textField = await getByLabelText('Test Field', { exact: false }); fireEvent.change(textField, { target: { value: 'updated value' } }); - const saveChangesButton = await findByText('Save Changes', { exact: false }); + const saveChangesButton = getByTestId('edit-dialog-save-button'); fireEvent.click(saveChangesButton); @@ -93,8 +96,19 @@ describe('EditDialog', () => { }); }); + it('hides save label text while loading', () => { + const { getByTestId, queryByText } = renderContainer({ + testFieldValue: 'this is a test', + open: true, + isLoading: true + }); + + expect(getByTestId('edit-dialog-save-button')).toBeVisible(); + expect(queryByText('Save Changes', { exact: false })).toBeNull(); + }); + it('calls the onCancel prop when `Cancel` button is clicked', async () => { - const { findByText } = renderContainer({ testFieldValue: 'this is a test' }); + const { findByText } = renderContainer({ testFieldValue: 'this is a test', open: true }); const cancelButton = await findByText('Cancel', { exact: false }); diff --git a/app/src/components/dialog/EditDialog.tsx b/app/src/components/dialog/EditDialog.tsx index 884a53c75..61d44ed09 100644 --- a/app/src/components/dialog/EditDialog.tsx +++ b/app/src/components/dialog/EditDialog.tsx @@ -3,6 +3,7 @@ import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; import { Formik, FormikValues } from 'formik'; export interface IEditDialogComponentProps { @@ -64,7 +65,7 @@ export interface IEditDialogProps { /** * Prop to track if the dialog should be in a 'loading' state */ - isLoading: boolean; + isLoading?: boolean; } /** @@ -91,19 +92,21 @@ export const EditDialog = (props: React.PropsWithChildre props.onSave(values); }}> {(formikProps) => ( - + {props.dialogTitle} {props.component.element} + )} + + + + + + {children} + + ); +}; diff --git a/app/src/components/security/SecuritiesDialog.tsx b/app/src/components/security/SecuritiesDialog.tsx index 2e7f7d548..70c16510f 100644 --- a/app/src/components/security/SecuritiesDialog.tsx +++ b/app/src/components/security/SecuritiesDialog.tsx @@ -1,6 +1,6 @@ import { Typography } from '@mui/material'; import { GridRowSelectionModel } from '@mui/x-data-grid'; -import EditDialog from 'components/dialog/EditDialog'; +import { EditDialog } from 'components/dialog/EditDialog'; import { ApplySecurityRulesI18N } from 'constants/i18n'; import { ISecurityRuleAndCategory } from 'hooks/api/useSecurityApi'; import { useApi } from 'hooks/useApi'; diff --git a/app/src/features/admin/policies/ManagePoliciesPage.test.tsx b/app/src/features/admin/policies/ManagePoliciesPage.test.tsx index 21a68480f..e1d6fbd86 100644 --- a/app/src/features/admin/policies/ManagePoliciesPage.test.tsx +++ b/app/src/features/admin/policies/ManagePoliciesPage.test.tsx @@ -8,35 +8,23 @@ import { render } from 'test-helpers/test-utils'; import { Mock } from 'vitest'; import { ManagePoliciesPage } from './ManagePoliciesPage'; -// Types for mock component props -interface MockActivePoliciesListProps { +interface MockPoliciesContainerProps { policies: IPolicy[]; - onSelectPolicy: (id: string | null) => void; - selectedPolicyId: string | null; } interface MockTeamsContainerProps { teams: ITeam[]; - onSelectTeam: (id: string | null) => void; - selectedTeamId: string | null; } interface MockTeamPoliciesContainerProps { teamPolicies: ITeamPolicyDetails[]; - selectedTeam: ITeam | null; - selectedPolicy: IPolicy | null; } -// Mock child components - we test them separately, here we test page logic -vi.mock('./components/ActivePoliciesList', () => ({ - ActivePoliciesList: ({ policies, onSelectPolicy, selectedPolicyId }: MockActivePoliciesListProps) => ( +vi.mock('./components/PoliciesContainer', () => ({ + PoliciesContainer: ({ policies }: MockPoliciesContainerProps) => (
{policies.map((p) => ( -
onSelectPolicy(selectedPolicyId === p.policy_id ? null : p.policy_id)}> +
{p.name}
))} @@ -45,14 +33,10 @@ vi.mock('./components/ActivePoliciesList', () => ({ })); vi.mock('./components/TeamsContainer', () => ({ - TeamsContainer: ({ teams, onSelectTeam, selectedTeamId }: MockTeamsContainerProps) => ( + TeamsContainer: ({ teams }: MockTeamsContainerProps) => (
{teams.map((t) => ( -
onSelectTeam(selectedTeamId === t.team_id ? null : t.team_id)}> +
{t.name}
))} @@ -61,17 +45,9 @@ vi.mock('./components/TeamsContainer', () => ({ })); vi.mock('./components/TeamPoliciesContainer', () => ({ - TeamPoliciesContainer: ({ teamPolicies, selectedTeam, selectedPolicy }: MockTeamPoliciesContainerProps) => ( + TeamPoliciesContainer: ({ teamPolicies }: MockTeamPoliciesContainerProps) => (
-
- {selectedTeam && selectedPolicy - ? `Assignment: ${selectedTeam.name} + ${selectedPolicy.name}` - : selectedTeam - ? `Policies for "${selectedTeam.name}"` - : selectedPolicy - ? `Teams with "${selectedPolicy.name}"` - : 'Team-Policy Assignments'} -
+
Team-Policy Assignments
{teamPolicies.map((tp) => (
{tp.team_name} - {tp.policy_name} @@ -84,7 +60,6 @@ vi.mock('./components/TeamPoliciesContainer', () => ({ vi.mock('../../../hooks/useApi'); const mockBiohubApi = useApi as Mock; -// Mock data const mockPolicies = [ { policy_id: 'p1', name: 'Policy One', description: 'First policy', statements: [] }, { policy_id: 'p2', name: 'Policy Two', description: 'Second policy', statements: [] } @@ -150,15 +125,11 @@ describe('ManagePoliciesPage', () => { cleanup(); }); - describe('Client-Side Filtering (Team-Policy Assignments)', () => { - it('shows all assignments when nothing selected', async () => { - // Step 1: Setup API mocks to return test data + describe('Assignments List', () => { + it('shows all assignments when loaded', async () => { setupMocksWithData(); - - // Step 2: Render page const { getByTestId } = renderPage(); - // Step 3: Verify all 3 assignments are visible (no filtering) await waitFor(() => { expect(getByTestId('tp-tp1')).toBeVisible(); expect(getByTestId('tp-tp2')).toBeVisible(); @@ -166,151 +137,35 @@ describe('ManagePoliciesPage', () => { }); }); - it('filters assignments when policy is selected', async () => { - // Step 1: Setup API mocks + it('does not change assignments when clicking team or policy rows', async () => { setupMocksWithData(); + const { getByTestId } = renderPage(); - // Step 2: Render page - const { getByTestId, queryByTestId } = renderPage(); - - // Step 3: Wait for policies to load await waitFor(() => { expect(getByTestId('policy-p1')).toBeVisible(); - }); - - // Step 4: Click on Policy One (p1) to select it - fireEvent.click(getByTestId('policy-p1')); - - // Step 5: Verify filtering - only tp1 has policy_id='p1' - await waitFor(() => { - expect(getByTestId('tp-tp1')).toBeVisible(); - expect(queryByTestId('tp-tp2')).toBeNull(); - expect(queryByTestId('tp-tp3')).toBeNull(); - }); - }); - - it('filters assignments when team is selected', async () => { - // Step 1: Setup API mocks - setupMocksWithData(); - - // Step 2: Render page - const { getByTestId, queryByTestId } = renderPage(); - - // Step 3: Wait for teams to load - await waitFor(() => { expect(getByTestId('team-t1')).toBeVisible(); }); - // Step 4: Click on Team Alpha (t1) to select it + fireEvent.click(getByTestId('policy-p1')); fireEvent.click(getByTestId('team-t1')); - // Step 5: Verify filtering - tp1 and tp3 have team_id='t1' await waitFor(() => { expect(getByTestId('tp-tp1')).toBeVisible(); - expect(queryByTestId('tp-tp2')).toBeNull(); - expect(getByTestId('tp-tp3')).toBeVisible(); - }); - }); - - it('filters assignments when both team and policy selected', async () => { - // Step 1: Setup API mocks - setupMocksWithData(); - - // Step 2: Render page - const { getByTestId, queryByTestId } = renderPage(); - - // Step 3: Wait for data to load - await waitFor(() => { - expect(getByTestId('policy-p2')).toBeVisible(); - expect(getByTestId('team-t1')).toBeVisible(); - }); - - // Step 4: Select Policy Two AND Team Alpha - fireEvent.click(getByTestId('policy-p2')); - fireEvent.click(getByTestId('team-t1')); - - // Step 5: Verify intersection - only tp3 has both team_id='t1' AND policy_id='p2' - await waitFor(() => { - expect(queryByTestId('tp-tp1')).toBeNull(); - expect(queryByTestId('tp-tp2')).toBeNull(); + expect(getByTestId('tp-tp2')).toBeVisible(); expect(getByTestId('tp-tp3')).toBeVisible(); - }); - }); - }); - - describe('Dynamic Header Updates', () => { - it('updates header when team selected', async () => { - // Step 1: Setup and render - setupMocksWithData(); - const { getByTestId } = renderPage(); - - // Step 2: Wait for data to load - await waitFor(() => { - expect(getByTestId('team-t1')).toBeVisible(); - }); - - // Step 3: Select a team - fireEvent.click(getByTestId('team-t1')); - - // Step 4: Verify header shows team-specific text - await waitFor(() => { - expect(getByTestId('header')).toHaveTextContent('Policies for "Team Alpha"'); - }); - }); - - it('updates header when policy selected', async () => { - // Step 1: Setup and render - setupMocksWithData(); - const { getByTestId } = renderPage(); - - // Step 2: Wait for data to load - await waitFor(() => { - expect(getByTestId('policy-p1')).toBeVisible(); - }); - - // Step 3: Select a policy - fireEvent.click(getByTestId('policy-p1')); - - // Step 4: Verify header shows policy-specific text - await waitFor(() => { - expect(getByTestId('header')).toHaveTextContent('Teams with "Policy One"'); - }); - }); - - it('updates header when both selected', async () => { - // Step 1: Setup and render - setupMocksWithData(); - const { getByTestId } = renderPage(); - - // Step 2: Wait for data to load - await waitFor(() => { - expect(getByTestId('team-t1')).toBeVisible(); - expect(getByTestId('policy-p1')).toBeVisible(); - }); - - // Step 3: Select both team and policy - fireEvent.click(getByTestId('team-t1')); - fireEvent.click(getByTestId('policy-p1')); - - // Step 4: Verify header shows combined assignment text - await waitFor(() => { - expect(getByTestId('header')).toHaveTextContent('Assignment: Team Alpha + Policy One'); + expect(getByTestId('header')).toHaveTextContent('Team-Policy Assignments'); }); }); }); describe('API Integration', () => { it('calls getPolicies with correct pagination params on mount', async () => { - // Step 1: Setup mocks setupMocksWithData(); - - // Step 2: Render page (triggers API calls) renderPage(); - // Step 3: Verify getPolicies was called with correct params await waitFor(() => { expect(mockGetPolicies).toHaveBeenCalledWith( - { search: undefined }, + { search: '' }, expect.objectContaining({ page: 1, limit: 10, @@ -322,16 +177,12 @@ describe('ManagePoliciesPage', () => { }); it('calls getTeams with correct pagination params on mount', async () => { - // Step 1: Setup mocks setupMocksWithData(); - - // Step 2: Render page (triggers API calls) renderPage(); - // Step 3: Verify getTeams was called with correct params await waitFor(() => { expect(mockGetTeams).toHaveBeenCalledWith( - { search: undefined }, + { search: '' }, expect.objectContaining({ page: 1, limit: 10, @@ -343,15 +194,12 @@ describe('ManagePoliciesPage', () => { }); it('calls getTeamPolicies with correct pagination params on mount', async () => { - // Step 1: Setup mocks setupMocksWithData(); - - // Step 2: Render page (triggers API calls) renderPage(); - // Step 3: Verify getTeamPolicies was called with correct params await waitFor(() => { expect(mockGetTeamPolicies).toHaveBeenCalledWith( + { search: '' }, expect.objectContaining({ page: 1, limit: 10, @@ -362,56 +210,4 @@ describe('ManagePoliciesPage', () => { }); }); }); - - describe('Selection State', () => { - it('deselects policy when clicking selected policy', async () => { - // Step 1: Setup and render - setupMocksWithData(); - const { getByTestId } = renderPage(); - - // Step 2: Wait for data to load - await waitFor(() => { - expect(getByTestId('policy-p1')).toBeVisible(); - }); - - // Step 3: Click to SELECT policy - fireEvent.click(getByTestId('policy-p1')); - await waitFor(() => { - expect(getByTestId('header')).toHaveTextContent('Teams with "Policy One"'); - }); - - // Step 4: Click again to DESELECT (toggle behavior) - fireEvent.click(getByTestId('policy-p1')); - - // Step 5: Verify header returns to default state - await waitFor(() => { - expect(getByTestId('header')).toHaveTextContent('Team-Policy Assignments'); - }); - }); - - it('deselects team when clicking selected team', async () => { - // Step 1: Setup and render - setupMocksWithData(); - const { getByTestId } = renderPage(); - - // Step 2: Wait for data to load - await waitFor(() => { - expect(getByTestId('team-t1')).toBeVisible(); - }); - - // Step 3: Click to SELECT team - fireEvent.click(getByTestId('team-t1')); - await waitFor(() => { - expect(getByTestId('header')).toHaveTextContent('Policies for "Team Alpha"'); - }); - - // Step 4: Click again to DESELECT (toggle behavior) - fireEvent.click(getByTestId('team-t1')); - - // Step 5: Verify header returns to default state - await waitFor(() => { - expect(getByTestId('header')).toHaveTextContent('Team-Policy Assignments'); - }); - }); - }); }); diff --git a/app/src/features/admin/policies/ManagePoliciesPage.tsx b/app/src/features/admin/policies/ManagePoliciesPage.tsx index 559436e36..3eb0522c8 100644 --- a/app/src/features/admin/policies/ManagePoliciesPage.tsx +++ b/app/src/features/admin/policies/ManagePoliciesPage.tsx @@ -1,31 +1,26 @@ import Box from '@mui/material/Box'; import Container from '@mui/material/Container'; -import Paper from '@mui/material/Paper'; import { GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; import { PageHeader } from 'components/header/PageHeader'; import { useApi } from 'hooks/useApi'; import useDataLoader from 'hooks/useDataLoader'; -import { toApiPagination, useServerPaginatedDataGrid } from 'hooks/useServerPaginatedDataGrid'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import useDebounce from 'hooks/useDebounce'; +import { useServerPaginatedDataGrid } from 'hooks/useServerPaginatedDataGrid'; +import { useCallback, useEffect, useState } from 'react'; import { ApiPaginationRequestOptions } from 'types/pagination'; -import { ActivePoliciesList } from './components/ActivePoliciesList'; +import { toApiPagination } from 'utils/pagination'; +import { PoliciesContainer } from './components/PoliciesContainer'; import { TeamPoliciesContainer } from './components/TeamPoliciesContainer'; import { TeamsContainer } from './components/TeamsContainer'; /** * Admin page for managing policies, teams, and team-policy assignments. * - * Features selection-based workflow: - * - Select a policy to filter assignments by that policy - * - Select a team to filter assignments by that team - * - Select both to see/create specific assignment + * @returns {*} */ export const ManagePoliciesPage = () => { const biohubApi = useApi(); - const [selectedPolicyId, setSelectedPolicyId] = useState(null); - const [selectedTeamId, setSelectedTeamId] = useState(null); - const policies = useServerPaginatedDataGrid({ fetcher: (search, pagination) => biohubApi.policies.getPolicies({ search }, pagination), extractData: (response) => response.policies, @@ -47,69 +42,65 @@ export const ManagePoliciesPage = () => { const [teamPoliciesSortModel, setTeamPoliciesSortModel] = useState([ { field: 'team_name', sort: 'asc' } ]); + const [teamPoliciesSearchTerm, setTeamPoliciesSearchTerm] = useState(''); + const [debouncedTeamPoliciesSearchTerm, setDebouncedTeamPoliciesSearchTerm] = useState(''); - const teamPoliciesDataLoader = useDataLoader((pagination: ApiPaginationRequestOptions) => - biohubApi.teamPolicies.getTeamPolicies(pagination) + const teamPoliciesDataLoader = useDataLoader((search: string, pagination: ApiPaginationRequestOptions) => + biohubApi.teamPolicies.getTeamPolicies({ search }, pagination) ); useEffect(() => { - teamPoliciesDataLoader.load(toApiPagination(teamPoliciesPaginationModel, teamPoliciesSortModel)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const apiPagination = toApiPagination(teamPoliciesPaginationModel, teamPoliciesSortModel); + teamPoliciesDataLoader.load(debouncedTeamPoliciesSearchTerm, apiPagination); + }, [debouncedTeamPoliciesSearchTerm, teamPoliciesDataLoader, teamPoliciesPaginationModel, teamPoliciesSortModel]); + + const debouncedTeamPoliciesRefresh = useDebounce((searchTerm: string) => { + setDebouncedTeamPoliciesSearchTerm(searchTerm); + const resetPaginationModel = { ...teamPoliciesPaginationModel, page: 0 }; + setTeamPoliciesPaginationModel(resetPaginationModel); + const apiPagination = toApiPagination(resetPaginationModel, teamPoliciesSortModel); + teamPoliciesDataLoader.refresh(searchTerm, apiPagination); + }, 300); const handleTeamPoliciesPaginationChange = useCallback( (model: GridPaginationModel) => { setTeamPoliciesPaginationModel(model); - teamPoliciesDataLoader.refresh(toApiPagination(model, teamPoliciesSortModel)); + const apiPagination = toApiPagination(model, teamPoliciesSortModel); + teamPoliciesDataLoader.refresh(debouncedTeamPoliciesSearchTerm, apiPagination); }, // eslint-disable-next-line react-hooks/exhaustive-deps - [teamPoliciesSortModel] + [teamPoliciesSortModel, debouncedTeamPoliciesSearchTerm] ); const handleTeamPoliciesSortChange = useCallback( (model: GridSortModel) => { setTeamPoliciesSortModel(model); - teamPoliciesDataLoader.refresh(toApiPagination(teamPoliciesPaginationModel, model)); + const apiPagination = toApiPagination(teamPoliciesPaginationModel, model); + teamPoliciesDataLoader.refresh(debouncedTeamPoliciesSearchTerm, apiPagination); }, // eslint-disable-next-line react-hooks/exhaustive-deps - [teamPoliciesPaginationModel] + [teamPoliciesPaginationModel, debouncedTeamPoliciesSearchTerm] ); const refreshTeamPolicies = useCallback(() => { - teamPoliciesDataLoader.refresh(toApiPagination(teamPoliciesPaginationModel, teamPoliciesSortModel)); + const apiPagination = toApiPagination(teamPoliciesPaginationModel, teamPoliciesSortModel); + teamPoliciesDataLoader.refresh(debouncedTeamPoliciesSearchTerm, apiPagination); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [teamPoliciesPaginationModel, teamPoliciesSortModel]); - - const filteredTeamPolicies = useMemo(() => { - const teamPolicies = teamPoliciesDataLoader.data?.team_policies ?? []; - let result = teamPolicies; - - if (selectedTeamId) { - result = result.filter((tp) => tp.team_id === selectedTeamId); - } - if (selectedPolicyId) { - result = result.filter((tp) => tp.policy_id === selectedPolicyId); - } - - return result; - }, [teamPoliciesDataLoader.data?.team_policies, selectedTeamId, selectedPolicyId]); + }, [teamPoliciesPaginationModel, teamPoliciesSortModel, debouncedTeamPoliciesSearchTerm]); - const selectedTeam = teams.data.find((t) => t.team_id === selectedTeamId) ?? null; - const selectedPolicy = policies.data.find((p) => p.policy_id === selectedPolicyId) ?? null; - - const handleSelectPolicy = useCallback((policyId: string | null) => { - setSelectedPolicyId(policyId); - }, []); - - const handleSelectTeam = useCallback((teamId: string | null) => { - setSelectedTeamId(teamId); - }, []); + const handleTeamPoliciesSearch = useCallback( + (searchTerm: string) => { + setTeamPoliciesSearchTerm(searchTerm); + debouncedTeamPoliciesRefresh(searchTerm); + }, + [debouncedTeamPoliciesRefresh] + ); return ( <> - { refresh={policies.refresh} searchTerm={policies.searchTerm} onSearch={policies.handleSearch} - selectedPolicyId={selectedPolicyId} - onSelectPolicy={handleSelectPolicy} /> - - - + - - - + diff --git a/app/src/features/admin/policies/components/CreateTeamPolicyDialog.tsx b/app/src/features/admin/policies/components/CreateTeamPolicyDialog.tsx new file mode 100644 index 000000000..49044caf6 --- /dev/null +++ b/app/src/features/admin/policies/components/CreateTeamPolicyDialog.tsx @@ -0,0 +1,105 @@ +import { EditDialog } from 'components/dialog/EditDialog'; +import useDebounce from 'hooks/useDebounce'; +import useDataLoader from 'hooks/useDataLoader'; +import { useApi } from 'hooks/useApi'; +import { useCallback, useEffect, useMemo } from 'react'; +import { ApiPaginationRequestOptions } from 'types/pagination'; +import { + ITeamPolicyFormValues, + TeamPolicyForm, + TeamPolicyFormInitialValues, + TeamPolicyFormYupSchema +} from './TeamPolicyForm'; + +export interface ICreateTeamPolicyDialogProps { + open: boolean; + isLoading: boolean; + onLoadError: (title: string, text: string, error: unknown) => void; + onCancel: () => void; + onSave: (values: ITeamPolicyFormValues) => void; +} + +const ASSIGNMENT_OPTIONS_PAGINATION: ApiPaginationRequestOptions = { + page: 1, + limit: 25, + sort: 'name', + order: 'asc' +}; + +/** + * Dialog for creating a team-policy assignment. + * + * @param {ICreateTeamPolicyDialogProps} props + * @returns {JSX.Element} + */ +export const CreateTeamPolicyDialog = (props: ICreateTeamPolicyDialogProps) => { + const { open, isLoading, onLoadError, onCancel, onSave } = props; + const biohubApi = useApi(); + + const teamsDataLoader = useDataLoader( + (search?: string) => biohubApi.teams.getTeams({ search }, ASSIGNMENT_OPTIONS_PAGINATION), + (error) => onLoadError('Failed to Load Assignment Options', 'An error occurred while loading teams.', error) + ); + + const policiesDataLoader = useDataLoader( + (search?: string) => biohubApi.policies.getPolicies({ search }, ASSIGNMENT_OPTIONS_PAGINATION), + (error) => onLoadError('Failed to Load Assignment Options', 'An error occurred while loading policies.', error) + ); + + useEffect(() => { + if (!open) { + return; + } + teamsDataLoader.refresh(); + policiesDataLoader.refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const teams = useMemo(() => teamsDataLoader.data?.teams ?? [], [teamsDataLoader.data?.teams]); + const policies = useMemo(() => policiesDataLoader.data?.policies ?? [], [policiesDataLoader.data?.policies]); + + const debouncedTeamRefresh = useDebounce((search: string) => { + teamsDataLoader.refresh(search || undefined); + }, 300); + + const debouncedPolicyRefresh = useDebounce((search: string) => { + policiesDataLoader.refresh(search || undefined); + }, 300); + + const handleTeamSearch = useCallback( + (search: string) => { + debouncedTeamRefresh(search); + }, + [debouncedTeamRefresh] + ); + + const handlePolicySearch = useCallback( + (search: string) => { + debouncedPolicyRefresh(search); + }, + [debouncedPolicyRefresh] + ); + + return ( + + open={open} + isLoading={isLoading} + dialogTitle="Add Assignment" + dialogSaveButtonLabel="Add" + component={{ + element: ( + + ), + initialValues: TeamPolicyFormInitialValues, + validationSchema: TeamPolicyFormYupSchema + }} + onCancel={onCancel} + onSave={onSave} + /> + ); +}; diff --git a/app/src/features/admin/policies/components/ActivePoliciesList.test.tsx b/app/src/features/admin/policies/components/PoliciesContainer.test.tsx similarity index 92% rename from app/src/features/admin/policies/components/ActivePoliciesList.test.tsx rename to app/src/features/admin/policies/components/PoliciesContainer.test.tsx index a31f62662..005cad690 100644 --- a/app/src/features/admin/policies/components/ActivePoliciesList.test.tsx +++ b/app/src/features/admin/policies/components/PoliciesContainer.test.tsx @@ -4,7 +4,7 @@ import { IPolicy } from 'interfaces/usePoliciesApi.interface'; import { MemoryRouter } from 'react-router'; import { cleanup, render, waitFor } from 'test-helpers/test-utils'; import { Mock } from 'vitest'; -import { ActivePoliciesList, IActivePoliciesListProps } from './ActivePoliciesList'; +import { PoliciesContainer, IPoliciesContainerProps } from './PoliciesContainer'; // Types for DataGrid mock interface MockDataGridProps { @@ -55,7 +55,7 @@ const mockUseApi = { } }; -const defaultProps: IActivePoliciesListProps = { +const defaultProps: IPoliciesContainerProps = { policies: [], rowCount: 0, paginationModel: { page: 0, pageSize: 10 }, @@ -64,20 +64,18 @@ const defaultProps: IActivePoliciesListProps = { setSortModel: vi.fn(), refresh: vi.fn(), searchTerm: '', - onSearch: vi.fn(), - selectedPolicyId: null, - onSelectPolicy: vi.fn() + onSearch: vi.fn() }; -const renderComponent = (props: Partial = {}) => { +const renderComponent = (props: Partial = {}) => { return render( - + ); }; -describe('ActivePoliciesList', () => { +describe('PoliciesContainer', () => { beforeEach(() => { mockBiohubApi.mockImplementation(() => mockUseApi); }); @@ -132,7 +130,7 @@ describe('ActivePoliciesList', () => { const { getByTestId, getByText } = renderComponent(); // Step 2: Click Add button - fireEvent.click(getByTestId('add-policy-button')); + fireEvent.click(getByTestId('policies-add-button')); // Step 3: Verify dialog opens await waitFor(() => { @@ -153,7 +151,7 @@ describe('ActivePoliciesList', () => { const { getByTestId, getByLabelText, getByRole } = renderComponent({ refresh: mockRefresh }); // Step 4: Click "Add" button to open dialog - fireEvent.click(getByTestId('add-policy-button')); + fireEvent.click(getByTestId('policies-add-button')); // Step 5: Wait for dialog to appear (async rendering) await waitFor(() => { diff --git a/app/src/features/admin/policies/components/ActivePoliciesList.tsx b/app/src/features/admin/policies/components/PoliciesContainer.tsx similarity index 65% rename from app/src/features/admin/policies/components/ActivePoliciesList.tsx rename to app/src/features/admin/policies/components/PoliciesContainer.tsx index c8f3543c8..a5c737a56 100644 --- a/app/src/features/admin/policies/components/ActivePoliciesList.tsx +++ b/app/src/features/admin/policies/components/PoliciesContainer.tsx @@ -1,19 +1,16 @@ -import { mdiDotsVertical, mdiMagnify, mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import { mdiDotsVertical, mdiMagnify, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; import Chip from '@mui/material/Chip'; import Container from '@mui/material/Container'; -import Divider from '@mui/material/Divider'; import InputAdornment from '@mui/material/InputAdornment'; -import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; import TextField from '@mui/material/TextField'; -import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; -import { GridColDef, GridRowSelectionModel } from '@mui/x-data-grid'; -import CustomDataGrid from 'components/data-grid/CustomDataGrid'; -import EditDialog from 'components/dialog/EditDialog'; +import { GridColDef } from '@mui/x-data-grid'; +import { ServerPaginatedDataGrid } from 'components/data-grid/ServerPaginatedDataGrid'; +import { EditDialog } from 'components/dialog/EditDialog'; +import { PageSection } from 'components/section/PageSection'; import { CustomMenuIconButton } from 'components/toolbar/ActionToolbars'; import { ISnackbarProps } from 'contexts/dialogContext'; import { APIError } from 'hooks/api/useAxios'; @@ -31,9 +28,9 @@ import { } from './AddPolicyForm'; /** - * Props for the ActivePoliciesList component. + * Props for the PoliciesContainer component. */ -export interface IActivePoliciesListProps extends IServerPaginationProps { +export interface IPoliciesContainerProps extends IServerPaginationProps { /** Array of policies to display in the table */ policies: IPolicy[]; /** Callback to refresh the policies list after create/update/delete */ @@ -42,10 +39,6 @@ export interface IActivePoliciesListProps extends IServerPaginationProps { searchTerm: string; /** Callback when search term changes */ onSearch: (term: string) => void; - /** Currently selected policy ID for filtering team-policy assignments */ - selectedPolicyId: string | null; - /** Callback when a policy row is selected/deselected */ - onSelectPolicy: (policyId: string | null) => void; } /** @@ -58,38 +51,12 @@ export interface IActivePoliciesListProps extends IServerPaginationProps { * - Edit existing policies via dialog * - Delete policies with confirmation * - * @param {IActivePoliciesListProps} props - Component props + * @param {IPoliciesContainerProps} props - Component props * @returns {React.ReactElement} The policies list component */ -export const ActivePoliciesList: React.FC> = (props) => { +export const PoliciesContainer = (props: IPoliciesContainerProps) => { const biohubApi = useApi(); - const { - policies, - rowCount, - paginationModel, - setPaginationModel, - sortModel, - setSortModel, - selectedPolicyId, - onSelectPolicy - } = props; - - /** - * Handle row selection changes in the DataGrid. - * Extracts the selected policy ID and calls the parent callback. - * - * @param {GridRowSelectionModel} model - The new selection model from DataGrid - */ - const handleRowSelectionChange = (model: GridRowSelectionModel) => { - const ids = model && 'ids' in model ? Array.from(model.ids) : []; - const newSelectedId = (ids[0] as string) || null; - onSelectPolicy(newSelectedId); - }; - - const rowSelectionModel: GridRowSelectionModel = { - type: 'include', - ids: selectedPolicyId ? new Set([selectedPolicyId]) : new Set() - }; + const { policies, rowCount, paginationModel, setPaginationModel, sortModel, setSortModel } = props; const dialogContext = useDialogContext(); @@ -107,12 +74,38 @@ export const ActivePoliciesList: React.FC { + const apiError = error as APIError; + dialogContext.setErrorDialog({ + open: true, + dialogTitle: title, + dialogText: text, + dialogError: apiError.message, + dialogErrorDetails: apiError.errors, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + } + }); + }; + + const closeDeletePolicyDialog = () => { + dialogContext.setYesNoDialog({ open: false }); + }; + /** * Open a confirmation dialog to delete a policy. * * @param {IPolicy} row - The policy to delete */ const handleDeletePolicyClick = (row: IPolicy) => { + const handleConfirmDelete = () => { + deletePolicy(row); + closeDeletePolicyDialog(); + }; + dialogContext.setYesNoDialog({ dialogTitle: 'Delete policy?', dialogContent: ( @@ -123,18 +116,10 @@ export const ActivePoliciesList: React.FC { - dialogContext.setYesNoDialog({ open: false }); - }, - onNo: () => { - dialogContext.setYesNoDialog({ open: false }); - }, + onClose: closeDeletePolicyDialog, + onNo: closeDeletePolicyDialog, open: true, - onYes: () => { - deletePolicy(row).then(() => { - dialogContext.setYesNoDialog({ open: false }); - }); - } + onYes: handleConfirmDelete }); }; @@ -152,31 +137,12 @@ export const ActivePoliciesList: React.FC - Policy {policy.name} deleted. - - ), - open: true + snackbarMessage: 'Deleted policy' }); props.refresh(); } catch (error) { - const apiError = error as APIError; - - dialogContext.setErrorDialog({ - open: true, - dialogTitle: 'Error Deleting Policy', - dialogText: 'An error occurred while deleting the policy.', - dialogError: apiError.message, - dialogErrorDetails: apiError.errors, - onClose: () => { - dialogContext.setErrorDialog({ open: false }); - }, - onOk: () => { - dialogContext.setErrorDialog({ open: false }); - } - }); + showApiErrorDialog('Error Deleting Policy', 'An error occurred while deleting the policy.', error); } }; @@ -215,28 +181,10 @@ export const ActivePoliciesList: React.FC - Policy {values.name} created. - - ) + snackbarMessage: 'Created policy' }); } catch (error) { - const apiError = error as APIError; - - dialogContext.setErrorDialog({ - open: true, - dialogTitle: 'Failed to Create Policy', - dialogText: 'An error occurred while creating the policy.', - dialogError: apiError.message, - dialogErrorDetails: apiError.errors, - onClose: () => { - dialogContext.setErrorDialog({ open: false }); - }, - onOk: () => { - dialogContext.setErrorDialog({ open: false }); - } - }); + showApiErrorDialog('Failed to Create Policy', 'An error occurred while creating the policy.', error); } finally { setIsLoading(false); } @@ -272,28 +220,10 @@ export const ActivePoliciesList: React.FC - Policy {values.name} updated. - - ) + snackbarMessage: 'Updated policy' }); } catch (error) { - const apiError = error as APIError; - - dialogContext.setErrorDialog({ - open: true, - dialogTitle: 'Failed to Update Policy', - dialogText: 'An error occurred while updating the policy.', - dialogError: apiError.message, - dialogErrorDetails: apiError.errors, - onClose: () => { - dialogContext.setErrorDialog({ open: false }); - }, - onOk: () => { - dialogContext.setErrorDialog({ open: false }); - } - }); + showApiErrorDialog('Failed to Update Policy', 'An error occurred while updating the policy.', error); } finally { setIsLoading(false); } @@ -383,14 +313,18 @@ export const ActivePoliciesList: React.FC - - - + Active Policies{' '} ({rowCount}) - + + } + onAdd={() => setOpenAddPolicyDialog(true)} + headerContent={ - - - - - - + + dataTestId="active-policies-table" rows={policies} columns={columns} getRowId={(row) => row.policy_id} - paginationMode="server" + noRowsMessage="No Policies" + rowCount={rowCount} paginationModel={paginationModel} - onPaginationModelChange={setPaginationModel} - pageSizeOptions={[10, 25, 50]} - sortingMode="server" - sortingOrder={['asc', 'desc']} + setPaginationModel={setPaginationModel} sortModel={sortModel} - onSortModelChange={setSortModel} - rowCount={rowCount} - rowSelectionModel={rowSelectionModel} - onRowSelectionModelChange={handleRowSelectionChange} - checkboxSelection - disableMultipleRowSelection - disableColumnSelector - disableColumnMenu - localeText={{ noRowsLabel: 'No Policies' }} + setSortModel={setSortModel} /> - + ({ DataGrid: ({ rows, columns, localeText }: MockDataGridProps) => (
@@ -27,7 +25,6 @@ vi.mock('@mui/x-data-grid', () => ({
{row.team_name} {row.policy_name} - {/* Render actions column */} {columns.find((c) => c.field === 'actions')?.renderCell?.({ row } as never)}
)) @@ -68,13 +65,21 @@ const mockPolicies: IPolicy[] = [ { policy_id: 'policy-3', name: 'Admin Policy', description: 'Admin policy', statements: [] } ]; -const mockCreateTeamPolicy = vi.fn(); +const mockCreateTeamPolicies = vi.fn(); const mockDeleteTeamPolicy = vi.fn(); +const mockGetTeams = vi.fn(); +const mockGetPolicies = vi.fn(); const mockUseApi = { teamPolicies: { - createTeamPolicy: mockCreateTeamPolicy, - deleteTeamPolicy: mockDeleteTeamPolicy + deleteTeamPolicy: mockDeleteTeamPolicy, + createTeamPolicies: mockCreateTeamPolicies + }, + teams: { + getTeams: mockGetTeams + }, + policies: { + getPolicies: mockGetPolicies } }; @@ -85,9 +90,9 @@ const defaultProps: ITeamPoliciesContainerProps = { setPaginationModel: vi.fn(), sortModel: [{ field: 'team_name', sort: 'asc' }], setSortModel: vi.fn(), - selectedTeam: null, - selectedPolicy: null, - refresh: vi.fn() + refresh: vi.fn(), + searchTerm: '', + onSearch: vi.fn() }; const renderComponent = (props: Partial = {}) => { @@ -100,6 +105,8 @@ const renderComponent = (props: Partial = {}) => { describe('TeamPoliciesContainer', () => { beforeEach(() => { + mockGetTeams.mockResolvedValue({ teams: mockTeams }); + mockGetPolicies.mockResolvedValue({ policies: mockPolicies }); mockBiohubApi.mockImplementation(() => mockUseApi); }); @@ -108,154 +115,83 @@ describe('TeamPoliciesContainer', () => { vi.clearAllMocks(); }); - describe('Header', () => { - it('displays rowCount in header', async () => { - // Step 1: Render with default props (rowCount: 2) - const { getByText } = renderComponent(); + it('displays rowCount in header', async () => { + const { getByText } = renderComponent(); - // Step 2: Verify dynamic rowCount appears in header - await waitFor(() => { - expect(getByText('(2)')).toBeVisible(); - }); + await waitFor(() => { + expect(getByText('(2)')).toBeVisible(); }); + }); - it('shows team-specific header when team is selected', async () => { - // Step 1: Render with selectedTeam prop - const { getByText } = renderComponent({ selectedTeam: mockTeams[0] }); + it('opens Add Assignment dialog when Add is clicked', async () => { + const { getByRole, getByText } = renderComponent(); - // Step 2: Verify header shows team-specific text - await waitFor(() => { - expect(getByText('Policies for "Alpha Team"')).toBeVisible(); - }); + await waitFor(() => { + expect(getByRole('button', { name: /add/i })).toBeEnabled(); }); - it('shows policy-specific header when policy is selected', async () => { - // Step 1: Render with selectedPolicy prop - const { getByText } = renderComponent({ selectedPolicy: mockPolicies[0] }); + fireEvent.click(getByRole('button', { name: /add/i })); - // Step 2: Verify header shows policy-specific text - await waitFor(() => { - expect(getByText('Teams with "Data Access Policy"')).toBeVisible(); - }); + await waitFor(() => { + expect(getByText('Add Assignment')).toBeVisible(); }); + }); + + it('displays controlled search term value', async () => { + const { getByPlaceholderText } = renderComponent({ searchTerm: 'Alpha' }); - it('shows combined header when both team and policy are selected', async () => { - // Step 1: Render with both selectedTeam and selectedPolicy props - const { getByText } = renderComponent({ - selectedTeam: mockTeams[0], - selectedPolicy: mockPolicies[0] - }); - - // Step 2: Verify header shows combined assignment text - await waitFor(() => { - expect(getByText('Assignment: Alpha Team + Data Access Policy')).toBeVisible(); - }); + await waitFor(() => { + expect(getByPlaceholderText('Search by team or policy')).toHaveValue('Alpha'); }); }); - describe('Assign Button', () => { - it('does not show Assign button when no selection', async () => { - // Step 1: Render with no selection (default props) - const { queryByRole } = renderComponent(); + it('calls onSearch when input changes', () => { + const mockOnSearch = vi.fn(); + const { getByPlaceholderText } = renderComponent({ onSearch: mockOnSearch }); - // Step 2: Verify Assign button is NOT visible - await waitFor(() => { - expect(queryByRole('button', { name: /assign/i })).toBeNull(); - }); - }); + fireEvent.change(getByPlaceholderText('Search by team or policy'), { target: { value: 'Security' } }); - it('does not show Assign button when only team is selected', async () => { - // Step 1: Render with only selectedTeam (no policy) - const { queryByRole } = renderComponent({ selectedTeam: mockTeams[0] }); + expect(mockOnSearch).toHaveBeenCalledWith('Security'); + }); - // Step 2: Verify Assign button is NOT visible (needs both) - await waitFor(() => { - expect(queryByRole('button', { name: /assign/i })).toBeNull(); - }); - }); + it('calls createTeamPolicies API when Add dialog is saved', async () => { + mockCreateTeamPolicies.mockResolvedValueOnce({}); + const mockRefresh = vi.fn(); - it('does not show Assign button when only policy is selected', async () => { - // Step 1: Render with only selectedPolicy (no team) - const { queryByRole } = renderComponent({ selectedPolicy: mockPolicies[0] }); + const { getByRole, getByTestId } = renderComponent({ refresh: mockRefresh }); + + await waitFor(() => { + expect(getByRole('button', { name: /add/i })).toBeEnabled(); + }); - // Step 2: Verify Assign button is NOT visible (needs both) - await waitFor(() => { - expect(queryByRole('button', { name: /assign/i })).toBeNull(); - }); + fireEvent.click(getByRole('button', { name: /add/i })); + fireEvent.keyDown(getByRole('combobox', { name: 'Team' }), { key: 'ArrowDown' }); + await waitFor(() => { + expect(getByRole('option', { name: 'Gamma Team' })).toBeVisible(); }); + fireEvent.click(getByRole('option', { name: 'Gamma Team' })); - it('shows Assign button when both selected and assignment does not exist', async () => { - // Step 1: Render with team + policy that are NOT already assigned - const { getByRole } = renderComponent({ - selectedTeam: mockTeams[2], // Gamma Team - not in mockTeamPolicies - selectedPolicy: mockPolicies[2] // Admin Policy - not in mockTeamPolicies - }); - - // Step 2: Verify Assign button IS visible (can create new assignment) - await waitFor(() => { - expect(getByRole('button', { name: /assign/i })).toBeVisible(); - }); + fireEvent.keyDown(getByRole('combobox', { name: 'Policy' }), { key: 'ArrowDown' }); + await waitFor(() => { + expect(getByRole('option', { name: 'Admin Policy' })).toBeVisible(); }); + fireEvent.click(getByRole('option', { name: 'Admin Policy' })); + fireEvent.click(getByTestId('edit-dialog-save-button')); - it('does not show Assign button when assignment already exists', async () => { - // Step 1: Render with team + policy that ARE already assigned - const { queryByRole } = renderComponent({ - selectedTeam: mockTeams[0], // Alpha Team - selectedPolicy: mockPolicies[0] // Data Access Policy - already assigned - }); - - // Step 2: Verify Assign button is NOT visible (duplicate prevention) - await waitFor(() => { - expect(queryByRole('button', { name: /assign/i })).toBeNull(); - }); + await waitFor(() => { + expect(mockCreateTeamPolicies).toHaveBeenCalledWith('team-3', { policies: ['policy-3'] }); }); - it('calls createTeamPolicy API when Assign is clicked', async () => { - // Step 1: Setup - make createTeamPolicy return {} (simulates successful API response) - mockCreateTeamPolicy.mockResolvedValueOnce({}); - - // Step 2: Create mock refresh function to verify it's called after submit - const mockRefresh = vi.fn(); - - // Step 3: Render component with selected team + policy (enables Assign button) - const { getByRole } = renderComponent({ - selectedTeam: mockTeams[2], - selectedPolicy: mockPolicies[2], - refresh: mockRefresh - }); - - // Step 4: Wait for Assign button to appear - await waitFor(() => { - expect(getByRole('button', { name: /assign/i })).toBeVisible(); - }); - - // Step 5: Click Assign button - fireEvent.click(getByRole('button', { name: /assign/i })); - - // Step 6: Verify API was called with correct params - await waitFor(() => { - expect(mockCreateTeamPolicy).toHaveBeenCalledWith({ - team_id: 'team-3', - policy_id: 'policy-3' - }); - }); - - // Step 7: Verify refresh was called after success - await waitFor(() => { - expect(mockRefresh).toHaveBeenCalled(); - }); + await waitFor(() => { + expect(mockRefresh).toHaveBeenCalled(); }); }); - describe('Empty State', () => { - it('shows empty state message', async () => { - // Step 1: Render with empty teamPolicies array - const { getByText } = renderComponent({ teamPolicies: [], rowCount: 0 }); + it('shows empty state message', async () => { + const { getByText } = renderComponent({ teamPolicies: [], rowCount: 0 }); - // Step 2: Verify empty state message appears (from DataGrid localeText) - await waitFor(() => { - expect(getByText('No Team-Policy Assignments')).toBeVisible(); - }); + await waitFor(() => { + expect(getByText('No Team-Policy Assignments')).toBeVisible(); }); }); }); diff --git a/app/src/features/admin/policies/components/TeamPoliciesContainer.tsx b/app/src/features/admin/policies/components/TeamPoliciesContainer.tsx index ad0452d0e..8bb525982 100644 --- a/app/src/features/admin/policies/components/TeamPoliciesContainer.tsx +++ b/app/src/features/admin/policies/components/TeamPoliciesContainer.tsx @@ -1,56 +1,47 @@ -import { mdiDotsVertical, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import { mdiDotsVertical, mdiMagnify, mdiTrashCanOutline } from '@mdi/js'; import Icon from '@mdi/react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Divider from '@mui/material/Divider'; -import Toolbar from '@mui/material/Toolbar'; +import InputAdornment from '@mui/material/InputAdornment'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; -import { GridColDef, GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; -import CustomDataGrid from 'components/data-grid/CustomDataGrid'; +import { GridColDef } from '@mui/x-data-grid'; +import { ServerPaginatedDataGrid } from 'components/data-grid/ServerPaginatedDataGrid'; +import { PageSection } from 'components/section/PageSection'; import { CustomMenuIconButton } from 'components/toolbar/ActionToolbars'; import { ISnackbarProps } from 'contexts/dialogContext'; import { APIError } from 'hooks/api/useAxios'; import { useApi } from 'hooks/useApi'; import { useDialogContext } from 'hooks/useContext'; -import { IPolicy } from 'interfaces/usePoliciesApi.interface'; import { ITeamPolicyDetails } from 'interfaces/useTeamPoliciesApi.interface'; -import { ITeam } from 'interfaces/useTeamsApi.interface'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; +import { IServerPaginationProps } from 'types/pagination'; +import { CreateTeamPolicyDialog } from './CreateTeamPolicyDialog'; +import { ITeamPolicyFormValues } from './TeamPolicyForm'; /** * Props for the TeamPoliciesContainer component. */ -export interface ITeamPoliciesContainerProps { +export interface ITeamPoliciesContainerProps extends IServerPaginationProps { /** Array of team-policy associations to display (pre-filtered by parent) */ teamPolicies: ITeamPolicyDetails[]; - /** Total number of team-policy associations (for server-side pagination) */ - rowCount: number; - /** Current pagination model from parent */ - paginationModel: GridPaginationModel; - /** Callback when pagination changes */ - setPaginationModel: (model: GridPaginationModel) => void; - /** Current sort model from parent */ - sortModel: GridSortModel; - /** Callback when sort changes */ - setSortModel: (model: GridSortModel) => void; - /** Currently selected team from TeamsContainer (null if none selected) */ - selectedTeam: ITeam | null; - /** Currently selected policy from ActivePoliciesList (null if none selected) */ - selectedPolicy: IPolicy | null; - /** Callback to refresh the team-policies list after create/delete */ + /** Callback to refresh the team-policies list after create/update/delete */ refresh: () => void; + /** Current search term for filtering assignments */ + searchTerm: string; + /** Callback when search term changes */ + onSearch: (term: string) => void; } /** * Container component for managing team-policy associations. * * Displays filtered team-policy assignments based on selection in parent containers. - * When both a team and policy are selected, shows an "Assign" button to create the association. + * Supports adding and removing assignments. * * @param {ITeamPoliciesContainerProps} props - Component props * @returns {React.ReactElement} The team-policies container component */ -export const TeamPoliciesContainer: React.FC = (props) => { +export const TeamPoliciesContainer = (props: ITeamPoliciesContainerProps) => { const { teamPolicies, rowCount, @@ -58,95 +49,66 @@ export const TeamPoliciesContainer: React.FC = (pro setPaginationModel, sortModel, setSortModel, - selectedTeam, - selectedPolicy, - refresh + refresh, + searchTerm, + onSearch } = props; const biohubApi = useApi(); const dialogContext = useDialogContext(); - const [isAssigning, setIsAssigning] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [openCreateDialog, setOpenCreateDialog] = useState(false); - // Check if the selected team-policy combination already exists - const assignmentExists = - selectedTeam && - selectedPolicy && - teamPolicies.some((tp) => tp.team_id === selectedTeam.team_id && tp.policy_id === selectedPolicy.policy_id); - - // Can assign when both are selected and assignment doesn't exist - const canAssign = selectedTeam && selectedPolicy && !assignmentExists; + const showApiErrorDialog = useCallback( + (title: string, text: string, error: unknown) => { + const apiError = error as APIError; + dialogContext.setErrorDialog({ + open: true, + dialogTitle: title, + dialogText: text, + dialogError: apiError.message, + dialogErrorDetails: apiError.errors, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + } + }); + }, + [dialogContext] + ); /** * Display a snackbar notification. * - * @param {Partial} [textDialogProps] - Optional snackbar configuration + * @param {Partial} [textDialogProps] */ const showSnackBar = (textDialogProps?: Partial) => { dialogContext.setSnackbar({ ...textDialogProps, open: true }); }; - /** - * Get dynamic header text based on selection state. - * - * @returns {string} Header text describing what's being shown - */ - const getHeaderText = (): string => { - if (selectedTeam && selectedPolicy) { - return `Assignment: ${selectedTeam.name} + ${selectedPolicy.name}`; - } - if (selectedTeam) { - return `Policies for "${selectedTeam.name}"`; - } - if (selectedPolicy) { - return `Teams with "${selectedPolicy.name}"`; - } - return 'Team-Policy Assignments'; - }; - - /** - * Handle creating a new team-policy association. - */ - const handleAssign = async () => { - if (!selectedTeam || !selectedPolicy) { - return; - } - - setIsAssigning(true); + const handleCreate = async (values: ITeamPolicyFormValues) => { + setIsSaving(true); try { - await biohubApi.teamPolicies.createTeamPolicy({ - team_id: selectedTeam.team_id, - policy_id: selectedPolicy.policy_id - }); + await biohubApi.teamPolicies.createTeamPolicies(values.team_id, { policies: values.policies }); + setOpenCreateDialog(false); refresh(); showSnackBar({ - snackbarMessage: ( - - Assigned {selectedPolicy.name} to {selectedTeam.name}. - - ) + snackbarMessage: 'Created assignments' }); } catch (error) { - const apiError = error as APIError; - - dialogContext.setErrorDialog({ - open: true, - dialogTitle: 'Failed to Assign Policy', - dialogText: 'An error occurred while assigning the policy to the team.', - dialogError: apiError.message, - dialogErrorDetails: apiError.errors, - onClose: () => { - dialogContext.setErrorDialog({ open: false }); - }, - onOk: () => { - dialogContext.setErrorDialog({ open: false }); - } - }); + showApiErrorDialog( + 'Failed to Create Assignment', + 'An error occurred while creating the team-policy assignment.', + error + ); } finally { - setIsAssigning(false); + setIsSaving(false); } }; @@ -156,10 +118,15 @@ export const TeamPoliciesContainer: React.FC = (pro * @param {ITeamPolicyDetails} teamPolicy - The association to delete */ const handleDeleteClick = (teamPolicy: ITeamPolicyDetails) => { + const handleConfirmDelete = () => { + handleDelete(teamPolicy); + dialogContext.setYesNoDialog({ open: false }); + }; + dialogContext.setYesNoDialog({ dialogTitle: 'Remove assignment?', dialogContent: ( - + Remove policy {teamPolicy.policy_name} from team {teamPolicy.team_name}? ), @@ -173,11 +140,7 @@ export const TeamPoliciesContainer: React.FC = (pro dialogContext.setYesNoDialog({ open: false }); }, open: true, - onYes: () => { - deleteTeamPolicy(teamPolicy).then(() => { - dialogContext.setYesNoDialog({ open: false }); - }); - } + onYes: handleConfirmDelete }); }; @@ -186,39 +149,20 @@ export const TeamPoliciesContainer: React.FC = (pro * * @param {ITeamPolicyDetails} teamPolicy - The association to delete */ - const deleteTeamPolicy = async (teamPolicy: ITeamPolicyDetails) => { + const handleDelete = async (teamPolicy: ITeamPolicyDetails) => { try { await biohubApi.teamPolicies.deleteTeamPolicy(teamPolicy.team_policy_id); showSnackBar({ - snackbarMessage: ( - - Removed {teamPolicy.policy_name} from {teamPolicy.team_name}. - - ) + snackbarMessage: 'Removed assignment' }); refresh(); } catch (error) { - const apiError = error as APIError; - - dialogContext.setErrorDialog({ - open: true, - dialogTitle: 'Error Removing Assignment', - dialogText: 'An error occurred while removing the policy assignment.', - dialogError: apiError.message, - dialogErrorDetails: apiError.errors, - onClose: () => { - dialogContext.setErrorDialog({ open: false }); - }, - onOk: () => { - dialogContext.setErrorDialog({ open: false }); - } - }); + showApiErrorDialog('Error Removing Assignment', 'An error occurred while removing the policy assignment.', error); } }; - // DataGrid columns const columns: GridColDef[] = [ { field: 'team_name', @@ -256,54 +200,59 @@ export const TeamPoliciesContainer: React.FC = (pro ]; return ( - - - - {getHeaderText()}{' '} - - ({rowCount}) - - - {canAssign && ( - - )} - - - + <> + + Assignments{' '} + + ({rowCount}) + + + } + onAdd={() => setOpenCreateDialog(true)} + headerContent={ + + onSearch(e.target.value)} + slotProps={{ + input: { + startAdornment: ( + + + + ) + } + }} + sx={{ width: 250 }} + /> + + }> + + dataTestId="team-policies-table" + rows={teamPolicies} + columns={columns} + getRowId={(row) => row.team_policy_id} + noRowsMessage="No Team-Policy Assignments" + rowCount={rowCount} + paginationModel={paginationModel} + setPaginationModel={setPaginationModel} + sortModel={sortModel} + setSortModel={setSortModel} + /> + - row.team_policy_id} - paginationMode="server" - paginationModel={paginationModel} - onPaginationModelChange={setPaginationModel} - pageSizeOptions={[10, 25, 50]} - sortingMode="server" - sortingOrder={['asc', 'desc']} - sortModel={sortModel} - onSortModelChange={setSortModel} - rowCount={rowCount} - disableRowSelectionOnClick - disableColumnSelector - disableColumnMenu - localeText={{ noRowsLabel: 'No Team-Policy Assignments' }} - sx={{ - border: 'none', - '& .MuiDataGrid-columnHeaderTitle': { - fontWeight: 700, - textTransform: 'uppercase' - } - }} + setOpenCreateDialog(false)} + onSave={handleCreate} /> - + ); }; diff --git a/app/src/features/admin/policies/components/TeamPolicyForm.tsx b/app/src/features/admin/policies/components/TeamPolicyForm.tsx new file mode 100644 index 000000000..3436cc4f1 --- /dev/null +++ b/app/src/features/admin/policies/components/TeamPolicyForm.tsx @@ -0,0 +1,81 @@ +import Box from '@mui/material/Box'; +import { ICustomMultiAutocompleteOption } from 'components/fields/CustomMultiAutocomplete'; +import { CustomMultiAutocompleteFormik } from 'components/fields/CustomMultiAutocompleteFormik'; +import { SearchAutocomplete } from 'features/search/result/sidebar/search/components/section/autocomplete/SearchAutocomplete'; +import { SidebarOption } from 'features/search/result/sidebar/search/components/section/option/SearchSidebarOption'; +import { useFormikContext } from 'formik'; +import { IPolicy } from 'interfaces/usePoliciesApi.interface'; +import { ITeam } from 'interfaces/useTeamsApi.interface'; +import yup from 'utils/YupSchema'; + +export interface ITeamPolicyFormValues { + team_id: string; + policies: string[]; +} + +export interface ITeamPolicyFormProps { + teams: ITeam[]; + policies: IPolicy[]; + onTeamSearch: (search: string) => void; + onPolicySearch: (search: string) => void; +} + +export const TeamPolicyFormInitialValues: ITeamPolicyFormValues = { + team_id: '', + policies: [] +}; + +export const TeamPolicyFormYupSchema = yup.object().shape({ + team_id: yup.string().required('Team is required'), + policies: yup.array().of(yup.string().required()).min(1, 'At least one policy is required').required() +}); + +/** + * Form fields for selecting team and policy when creating/editing assignments. + * + * @param {ITeamPolicyFormProps} props + * @returns {JSX.Element} + */ +export const TeamPolicyForm = (props: ITeamPolicyFormProps) => { + const { teams, policies, onTeamSearch, onPolicySearch } = props; + const { values, setFieldValue } = useFormikContext(); + + const teamOptions: SidebarOption[] = teams.map((team) => ({ + label: team.name, + value: team.team_id + })); + + const policyOptions: ICustomMultiAutocompleteOption[] = policies.map((policy) => ({ + label: policy.name, + value: policy.policy_id + })); + + const selectedTeamOption = teamOptions.find((team) => team.value === values.team_id) ?? null; + return ( + + + { + setFieldValue('team_id', option?.value ?? ''); + }} + /> + + + + + + + ); +}; diff --git a/app/src/features/admin/policies/components/TeamsContainer.test.tsx b/app/src/features/admin/policies/components/TeamsContainer.test.tsx index d2b58da21..1649f9314 100644 --- a/app/src/features/admin/policies/components/TeamsContainer.test.tsx +++ b/app/src/features/admin/policies/components/TeamsContainer.test.tsx @@ -80,9 +80,7 @@ const defaultProps: ITeamsContainerProps = { setSortModel: vi.fn(), refresh: vi.fn(), searchTerm: '', - onSearch: vi.fn(), - selectedTeamId: null, - onSelectTeam: vi.fn() + onSearch: vi.fn() }; const renderComponent = (props: Partial = {}) => { diff --git a/app/src/features/admin/policies/components/TeamsContainer.tsx b/app/src/features/admin/policies/components/TeamsContainer.tsx index 0a92d27d2..4b6d580c8 100644 --- a/app/src/features/admin/policies/components/TeamsContainer.tsx +++ b/app/src/features/admin/policies/components/TeamsContainer.tsx @@ -1,16 +1,13 @@ -import { mdiDotsVertical, mdiMagnify, mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import { mdiDotsVertical, mdiMagnify, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; import Icon from '@mdi/react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Divider from '@mui/material/Divider'; import InputAdornment from '@mui/material/InputAdornment'; import Stack from '@mui/material/Stack'; import TextField from '@mui/material/TextField'; -import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; -import { GridColDef, GridRowSelectionModel } from '@mui/x-data-grid'; -import CustomDataGrid from 'components/data-grid/CustomDataGrid'; -import EditDialog from 'components/dialog/EditDialog'; +import { GridColDef } from '@mui/x-data-grid'; +import { ServerPaginatedDataGrid } from 'components/data-grid/ServerPaginatedDataGrid'; +import { EditDialog } from 'components/dialog/EditDialog'; +import { PageSection } from 'components/section/PageSection'; import { CustomMenuIconButton } from 'components/toolbar/ActionToolbars'; import { ISnackbarProps } from 'contexts/dialogContext'; import { APIError } from 'hooks/api/useAxios'; @@ -33,10 +30,6 @@ export interface ITeamsContainerProps extends IServerPaginationProps { searchTerm: string; /** Callback when search term changes */ onSearch: (term: string) => void; - /** Currently selected team ID for filtering team-policy assignments */ - selectedTeamId: string | null; - /** Callback when a team row is selected/deselected */ - onSelectTeam: (teamId: string | null) => void; } /** @@ -44,7 +37,6 @@ export interface ITeamsContainerProps extends IServerPaginationProps { * * Provides functionality to: * - View teams in a searchable, paginated table - * - Select a team to filter team-policy assignments * - Create new teams via dialog * - Edit existing teams via dialog * - Delete teams with confirmation @@ -52,7 +44,7 @@ export interface ITeamsContainerProps extends IServerPaginationProps { * @param {ITeamsContainerProps} props - Component props * @returns {React.ReactElement} The teams container component */ -export const TeamsContainer: React.FC = (props) => { +export const TeamsContainer = (props: ITeamsContainerProps) => { const { teams, rowCount, @@ -62,9 +54,7 @@ export const TeamsContainer: React.FC = (props) => { setSortModel, refresh, searchTerm, - onSearch, - selectedTeamId, - onSelectTeam + onSearch } = props; const biohubApi = useApi(); @@ -76,24 +66,6 @@ export const TeamsContainer: React.FC = (props) => { const [editingTeam, setEditingTeam] = useState(null); const [isLoading, setIsLoading] = useState(false); - /** - * Handle row selection changes in the DataGrid. - * Extracts the selected team ID and calls the parent callback. - * - * @param {GridRowSelectionModel} model - The new selection model from DataGrid - */ - const handleRowSelectionChange = (model: GridRowSelectionModel) => { - const ids = model && 'ids' in model ? Array.from(model.ids) : []; - const newSelectedId = (ids[0] as string) || null; - onSelectTeam(newSelectedId); - }; - - // Convert selectedTeamId to DataGrid selection model format - const rowSelectionModel: GridRowSelectionModel = { - type: 'include', - ids: selectedTeamId ? new Set([selectedTeamId]) : new Set() - }; - /** * Display a snackbar notification. * @@ -109,6 +81,11 @@ export const TeamsContainer: React.FC = (props) => { * @param {ITeam} team - The team to delete */ const handleDeleteTeamClick = (team: ITeam) => { + const handleConfirmDelete = () => { + deleteTeam(team); + dialogContext.setYesNoDialog({ open: false }); + }; + dialogContext.setYesNoDialog({ dialogTitle: 'Delete team?', dialogContent: ( @@ -126,11 +103,7 @@ export const TeamsContainer: React.FC = (props) => { dialogContext.setYesNoDialog({ open: false }); }, open: true, - onYes: () => { - deleteTeam(team).then(() => { - dialogContext.setYesNoDialog({ open: false }); - }); - } + onYes: handleConfirmDelete }); }; @@ -148,19 +121,9 @@ export const TeamsContainer: React.FC = (props) => { await biohubApi.teams.deleteTeam(team.team_id); showSnackBar({ - snackbarMessage: ( - - Team {team.name} deleted. - - ), + snackbarMessage: 'Deleted team', open: true }); - - // Clear selection if deleted team was selected - if (selectedTeamId === team.team_id) { - onSelectTeam(null); - } - refresh(); } catch (error) { const apiError = error as APIError; @@ -211,11 +174,7 @@ export const TeamsContainer: React.FC = (props) => { refresh(); showSnackBar({ - snackbarMessage: ( - - Team {values.name} created. - - ) + snackbarMessage: 'Created team' }); } catch (error) { const apiError = error as APIError; @@ -263,11 +222,7 @@ export const TeamsContainer: React.FC = (props) => { refresh(); showSnackBar({ - snackbarMessage: ( - - Team {values.name} updated. - - ) + snackbarMessage: 'Updated team' }); } catch (error) { const apiError = error as APIError; @@ -357,14 +312,18 @@ export const TeamsContainer: React.FC = (props) => { return ( <> - - - + Teams{' '} ({rowCount}) - + + } + onAdd={() => setOpenAddTeamDialog(true)} + headerContent={ = (props) => { }} sx={{ width: 250 }} /> - - - - - - + + dataTestId="teams-table" rows={teams} columns={columns} getRowId={(row) => row.team_id} - paginationMode="server" + noRowsMessage="No Teams" + rowCount={rowCount} paginationModel={paginationModel} - onPaginationModelChange={setPaginationModel} - pageSizeOptions={[10, 25, 50]} - sortingMode="server" - sortingOrder={['asc', 'desc']} + setPaginationModel={setPaginationModel} sortModel={sortModel} - onSortModelChange={setSortModel} - rowCount={rowCount} - rowSelectionModel={rowSelectionModel} - onRowSelectionModelChange={handleRowSelectionChange} - checkboxSelection - disableMultipleRowSelection - disableColumnSelector - disableColumnMenu - localeText={{ noRowsLabel: 'No Teams' }} + setSortModel={setSortModel} /> - + { { options: SidebarOption[]; value: SidebarOption | null; + label?: string; + showStartAdornment?: boolean; placeholder?: string; onChange: (option: SidebarOption | null) => void; onInputChange?: (value: string) => void; @@ -21,6 +24,8 @@ interface SearchAutocompleteProps extends Omit< export const SearchAutocomplete = ({ options, value, + label, + showStartAdornment = true, placeholder = 'Search...', onChange, onInputChange, @@ -30,10 +35,9 @@ export const SearchAutocomplete = ({ const [inputValue, setInputValue] = useState(''); return ( - {...autocompleteProps} fullWidth - size="small" options={options} value={value} filterOptions={(x) => x} @@ -56,21 +60,15 @@ export const SearchAutocomplete = ({ renderInput={(params) => ( - ), - endAdornment: value && ( - - onChange(null)}> - - - - ) + ) : undefined }} /> )} diff --git a/app/src/hooks/api/useTeamPoliciesApi.ts b/app/src/hooks/api/useTeamPoliciesApi.ts index b60e68709..17fc78e93 100644 --- a/app/src/hooks/api/useTeamPoliciesApi.ts +++ b/app/src/hooks/api/useTeamPoliciesApi.ts @@ -1,6 +1,12 @@ import { AxiosInstance } from 'axios'; -import { ICreateTeamPolicyRequest, ITeamPoliciesResponse, ITeamPolicy } from 'interfaces/useTeamPoliciesApi.interface'; -import { ApiPaginationRequestOptions } from 'types/pagination'; +import { + ICreateTeamPoliciesRequest, + ICreateTeamPoliciesResponse, + ICreateTeamPolicyRequest, + ITeamPoliciesResponse, + ITeamPolicy +} from 'interfaces/useTeamPoliciesApi.interface'; +import { ApiPaginationRequestOptions, ApiSearchParams } from 'types/pagination'; /** * Returns a set of supported api methods for working with team-policy associations. @@ -12,11 +18,16 @@ export const useTeamPoliciesApi = (axios: AxiosInstance) => { /** * Get all team-policy associations with team and policy names. * + * @param {ApiSearchParams} [searchParams] - Optional search parameters. * @param {ApiPaginationRequestOptions} [pagination] - Optional pagination parameters. * @return {*} {Promise} */ - const getTeamPolicies = async (pagination?: ApiPaginationRequestOptions): Promise => { - const { data } = await axios.get('/api/administrative/team-policies', { params: pagination }); + const getTeamPolicies = async ( + searchParams?: ApiSearchParams, + pagination?: ApiPaginationRequestOptions + ): Promise => { + const params = { ...searchParams, ...pagination }; + const { data } = await axios.get('/api/administrative/team-policies', { params }); return data; }; @@ -33,6 +44,22 @@ export const useTeamPoliciesApi = (axios: AxiosInstance) => { return data; }; + /** + * Create team-policy associations in bulk for a single team. + * + * @param {string} teamId + * @param {ICreateTeamPoliciesRequest} request + * @return {*} {Promise} + */ + const createTeamPolicies = async ( + teamId: string, + request: ICreateTeamPoliciesRequest + ): Promise => { + const { data } = await axios.post(`/api/administrative/teams/${teamId}/policy`, request); + + return data; + }; + /** * Delete a team-policy association. * @@ -46,6 +73,7 @@ export const useTeamPoliciesApi = (axios: AxiosInstance) => { return { getTeamPolicies, createTeamPolicy, + createTeamPolicies, deleteTeamPolicy }; }; diff --git a/app/src/hooks/useServerPaginatedDataGrid.test.ts b/app/src/hooks/useServerPaginatedDataGrid.test.ts index b2b695f4e..e7b8d909d 100644 --- a/app/src/hooks/useServerPaginatedDataGrid.test.ts +++ b/app/src/hooks/useServerPaginatedDataGrid.test.ts @@ -1,5 +1,6 @@ import { act, renderHook } from '@testing-library/react'; -import { useServerPaginatedDataGrid, toApiPagination } from './useServerPaginatedDataGrid'; +import { toApiPagination } from 'utils/pagination'; +import { useServerPaginatedDataGrid } from './useServerPaginatedDataGrid'; // Mock useDataLoader const mockLoad = vi.fn(); @@ -30,7 +31,7 @@ interface ITestResponse { // Default hook options for tests const createDefaultOptions = (overrides = {}) => ({ - fetcher: vi.fn<(search: string | undefined, pagination: any) => Promise>().mockResolvedValue({ + fetcher: vi.fn<(search: string, pagination: any) => Promise>().mockResolvedValue({ items: [{ id: '1', name: 'Test' }], pagination: { total: 1 } }), @@ -131,7 +132,7 @@ describe('useServerPaginatedDataGrid', () => { renderHook(() => useServerPaginatedDataGrid(createDefaultOptions())); expect(mockLoad).toHaveBeenCalledWith( - undefined, // no search term + '', // no search term expect.objectContaining({ page: 1, limit: 10, @@ -144,7 +145,7 @@ describe('useServerPaginatedDataGrid', () => { it('uses custom defaultPageSize in initial load', () => { renderHook(() => useServerPaginatedDataGrid(createDefaultOptions({ defaultPageSize: 50 }))); - expect(mockLoad).toHaveBeenCalledWith(undefined, expect.objectContaining({ limit: 50 })); + expect(mockLoad).toHaveBeenCalledWith('', expect.objectContaining({ limit: 50 })); }); }); @@ -298,7 +299,7 @@ describe('useServerPaginatedDataGrid', () => { // Should be called immediately, not debounced expect(mockRefresh).toHaveBeenCalledWith( - undefined, + '', expect.objectContaining({ page: 3, // 0-indexed page 2 = API page 3 limit: 10 @@ -354,7 +355,7 @@ describe('useServerPaginatedDataGrid', () => { }); expect(mockRefresh).toHaveBeenCalledWith( - undefined, + '', expect.objectContaining({ sort: 'updated_at', order: 'asc' @@ -380,7 +381,7 @@ describe('useServerPaginatedDataGrid', () => { // Page should be preserved expect(mockRefresh).toHaveBeenCalledWith( - undefined, + '', expect.objectContaining({ page: 3 // page 2 (0-indexed) = API page 3 }) diff --git a/app/src/hooks/useServerPaginatedDataGrid.ts b/app/src/hooks/useServerPaginatedDataGrid.ts index c8c913b6d..e792ae925 100644 --- a/app/src/hooks/useServerPaginatedDataGrid.ts +++ b/app/src/hooks/useServerPaginatedDataGrid.ts @@ -1,15 +1,16 @@ import { GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; import { useCallback, useEffect, useState } from 'react'; +import { ApiPaginationRequestOptions } from 'types/pagination'; import useDataLoader from './useDataLoader'; import useDebounce from './useDebounce'; -import { ApiPaginationRequestOptions } from 'types/pagination'; +import { toApiPagination } from 'utils/pagination'; /** * Configuration options for the useServerPaginatedDataGrid hook. */ export interface IUseServerPaginatedDataGridOptions { /** Function to fetch data from the API */ - fetcher: (search: string | undefined, pagination: ApiPaginationRequestOptions) => Promise; + fetcher: (search: string, pagination: ApiPaginationRequestOptions) => Promise; /** Function to extract the data array from the API response */ extractData: (response: TResponse) => TData[]; /** Function to extract the total count from the API response */ @@ -48,22 +49,6 @@ export interface IUseServerPaginatedDataGridReturn { refresh: () => void; } -/** - * Helper to convert GridPaginationModel and GridSortModel to API pagination options. - */ -export const toApiPagination = ( - paginationModel: GridPaginationModel, - sortModel: GridSortModel -): ApiPaginationRequestOptions => { - const sort = sortModel[0]; - return { - page: paginationModel.page + 1, // API uses 1-indexed pages - limit: paginationModel.pageSize, - sort: sort?.field, - order: sort?.sort as 'asc' | 'desc' | undefined - }; -}; - /** * Custom hook for server-side paginated DataGrid with search and sort. * @@ -105,13 +90,14 @@ export const useServerPaginatedDataGrid = ( const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); // Data loader - const dataLoader = useDataLoader((search: string | undefined, pagination: ApiPaginationRequestOptions) => + const dataLoader = useDataLoader((search: string, pagination: ApiPaginationRequestOptions) => fetcher(search, pagination) ); // Load data on mount useEffect(() => { - dataLoader.load(debouncedSearchTerm || undefined, toApiPagination(paginationModel, sortModel)); + const apiPagination = toApiPagination(paginationModel, sortModel); + dataLoader.load(debouncedSearchTerm, apiPagination); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -120,8 +106,9 @@ export const useServerPaginatedDataGrid = ( const debouncedRefresh = useDebounce((term: string) => { setDebouncedSearchTerm(term); setPaginationModel((prev) => ({ ...prev, page: 0 })); - dataLoader.refresh(term || undefined, { - ...toApiPagination(paginationModel, sortModel), + const apiPagination = toApiPagination(paginationModel, sortModel); + dataLoader.refresh(term, { + ...apiPagination, page: 1 }); }, debounceMs); @@ -139,7 +126,8 @@ export const useServerPaginatedDataGrid = ( const handlePaginationChange = useCallback( (model: GridPaginationModel) => { setPaginationModel(model); - dataLoader.refresh(debouncedSearchTerm || undefined, toApiPagination(model, sortModel)); + const apiPagination = toApiPagination(model, sortModel); + dataLoader.refresh(debouncedSearchTerm, apiPagination); }, // eslint-disable-next-line react-hooks/exhaustive-deps [sortModel, debouncedSearchTerm] @@ -149,7 +137,8 @@ export const useServerPaginatedDataGrid = ( const handleSortChange = useCallback( (model: GridSortModel) => { setSortModel(model); - dataLoader.refresh(debouncedSearchTerm || undefined, toApiPagination(paginationModel, model)); + const apiPagination = toApiPagination(paginationModel, model); + dataLoader.refresh(debouncedSearchTerm, apiPagination); }, // eslint-disable-next-line react-hooks/exhaustive-deps [paginationModel, debouncedSearchTerm] @@ -157,7 +146,8 @@ export const useServerPaginatedDataGrid = ( // Manual refresh with current params const refresh = useCallback(() => { - dataLoader.refresh(debouncedSearchTerm || undefined, toApiPagination(paginationModel, sortModel)); + const apiPagination = toApiPagination(paginationModel, sortModel); + dataLoader.refresh(debouncedSearchTerm, apiPagination); // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedSearchTerm, paginationModel, sortModel]); @@ -178,5 +168,3 @@ export const useServerPaginatedDataGrid = ( refresh }; }; - -export default useServerPaginatedDataGrid; diff --git a/app/src/interfaces/useTeamPoliciesApi.interface.ts b/app/src/interfaces/useTeamPoliciesApi.interface.ts index 7a094b308..7a8be8146 100644 --- a/app/src/interfaces/useTeamPoliciesApi.interface.ts +++ b/app/src/interfaces/useTeamPoliciesApi.interface.ts @@ -35,3 +35,26 @@ export interface ITeamPolicy { team_id: string; policy_id: string; } + +/** + * Request payload for bulk policy assignment to a team. + */ +export interface ICreateTeamPoliciesRequest { + policies: string[]; +} + +/** + * Team-policy record returned by bulk assignment endpoint. + */ +export interface ITeamPolicyAssignment { + team_policy_id: string; + team_id: string; + policy_id: string; +} + +/** + * Response payload for bulk team-policy assignment. + */ +export interface ICreateTeamPoliciesResponse { + team_policies: ITeamPolicyAssignment[]; +} diff --git a/app/src/utils/pagination.ts b/app/src/utils/pagination.ts new file mode 100644 index 000000000..8585c68b1 --- /dev/null +++ b/app/src/utils/pagination.ts @@ -0,0 +1,19 @@ +import { GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; +import { ApiPaginationRequestOptions } from 'types/pagination'; + +/** + * Converts DataGrid-style pagination/sort state into API pagination options. + */ +export const toApiPagination = ( + paginationModel: GridPaginationModel, + sortModel: GridSortModel +): ApiPaginationRequestOptions => { + const sort = sortModel[0]; + + return { + page: paginationModel.page + 1, + limit: paginationModel.pageSize, + sort: sort?.field, + order: sort?.sort ?? undefined + }; +};