1
0
mirror of https://github.com/quay/quay.git synced 2026-01-26 06:21:37 +03:00

ui: Teams and members (PROJQUAY-4569) (#2007)

Add team and membership tab for organization (PROJQUAY-4569)

Signed-off-by: harishsurf <hgovinda@redhat.com>
This commit is contained in:
Harish Govindarajulu
2023-08-29 15:03:56 -04:00
committed by GitHub
parent ee2e12abd4
commit 226684dfc7
41 changed files with 3201 additions and 225 deletions

View File

@@ -18,6 +18,7 @@ module.exports = {
},
},
plugins: ['import', 'react', '@typescript-eslint', 'prettier'],
ignorePatterns: ['*.md'],
rules: {
'react/react-in-jsx-scope': 'off',
'prettier/prettier': ['error'],
@@ -41,5 +42,8 @@ module.exports = {
project: `${__dirname}/tsconfig.json`,
},
},
react: {
version: 'detect',
},
},
};

View File

@@ -8,16 +8,17 @@ End to end tests ensure the application pages and components work properly toget
### Directory structure
`e2e`: Contains test files, each corresponding to a page in the application
`fixtures`: Mock JSON network responses consumed by Cypress
`support`: Additional Cypress configurations eg. Adding commands
`test`: Seed data for the test instance of Quay
- `e2e`: Contains test files, each corresponding to a page in the application
- `fixtures`: Mock JSON network responses consumed by Cypress
- `support`: Additional Cypress configurations eg. Adding commands
- `test`: Seed data for the test instance of Quay
### Testing strategy
Tests are divided into two scenarios: using server or stubbed responses. Requests that reach out directly to a Quay instance are called server responses while requests that are mocked are called stubbed responses. A full comparison between the two can be found [here](https://docs.cypress.io/guides/guides/network-requests#Testing-Strategies) but it can be summarized as follows:
Server responses
- More likely to work in production
- Requires seeding the Quay instance
- Much slower
@@ -25,6 +26,7 @@ Server responses
- Use sparringly
Stubbed responses
- Control of responses to test edge cases
- Fast
- No guarantee stubbed responses match the real responses
@@ -34,11 +36,12 @@ The recommended practice is to mix and match server and stubbed responses. A sin
## Updating the Quay seed data
Both the database and blob data must be stored to seed the Quay instance. This data is stored in the `/cypress/test` directory. Running `npm run quay:seed` will seed the local instance of Quay with the test data.
Both the database and blob data must be stored to seed the Quay instance. This data is stored in the `/cypress/test` directory. Running `npm run quay:seed` will seed the local instance of Quay with the test data.
To make changes to the test data:
- Have a local instance of Quay running via `docker-compose` (`make local-dev-up`)
- Run `npm run quay:seed` to populate the instance with the test data
- Make required changes to the Quay instance
- Run `npm run quay:dump` to update the `/cypress/test` directory with the test data
> :warning: Ensure no confidential information is within the test instance when dumping the test data
> :warning: Ensure no confidential information is within the test instance when dumping the test data

View File

@@ -85,7 +85,7 @@ describe('Repository Settings - Permissions', () => {
});
it('Bulk deletes permissions', () => {
cy.contains('1 - 3 of 3').should('exist');
cy.contains('1 - 4 of 4').should('exist');
cy.get('#permissions-select-all').click();
cy.contains('Actions').click();
cy.get('#bulk-delete-permissions').contains('Delete').click();
@@ -97,7 +97,7 @@ describe('Repository Settings - Permissions', () => {
});
it('Bulk changes permissions', () => {
cy.contains('1 - 3 of 3').should('exist');
cy.contains('1 - 4 of 4').should('exist');
cy.get('#permissions-select-all').click();
cy.contains('Actions').click();
cy.contains('Change Permissions').click();

View File

@@ -11,7 +11,7 @@ describe('Robot Accounts Page', () => {
});
it('Search Filter', () => {
cy.visit('/organization/testorg?tab=Robot+accounts');
cy.visit('/organization/testorg?tab=Robotaccounts');
// Filter for a single robot account
cy.get('#robot-account-search').type('testrobot2');
@@ -25,7 +25,7 @@ describe('Robot Accounts Page', () => {
});
it('Robot Account Toolbar Items', () => {
cy.visit('/organization/testorg?tab=Robot+accounts');
cy.visit('/organization/testorg?tab=Robotaccounts');
// Open and cancel modal
cy.get('#create-robot-account-btn').click();
@@ -37,7 +37,7 @@ describe('Robot Accounts Page', () => {
});
it('Create Robot', () => {
cy.visit('/organization/testorg?tab=Robot+accounts');
cy.visit('/organization/testorg?tab=Robotaccounts');
cy.get('#create-robot-account-btn').click();
cy.get('#robot-wizard-form-name').type('newtestrob');
@@ -64,7 +64,7 @@ describe('Robot Accounts Page', () => {
});
it('Delete Robot', () => {
cy.visit('/organization/testorg?tab=Robot+accounts');
cy.visit('/organization/testorg?tab=Robotaccounts');
// Delete robot account
cy.get('#robot-account-search').type('testrobot2');
@@ -82,7 +82,7 @@ describe('Robot Accounts Page', () => {
});
it('Update Repo Permissions', () => {
cy.visit('/organization/testorg?tab=Robot+accounts');
cy.visit('/organization/testorg?tab=Robotaccounts');
cy.contains('1 repository').click();
cy.get('#add-repository-bulk-select-text').contains('1 selected');
cy.get('#toggle-descriptions').click();
@@ -97,6 +97,7 @@ describe('Robot Accounts Page', () => {
});
it('Bulk Update Repo Permissions', () => {
const robotAccnt = 'testorg+testrobot2';
cy.visit('/organization/testorg');
cy.contains('Create Repository').click();
cy.get('input[id="repository-name-input"]').type('testrepo1');
@@ -104,8 +105,9 @@ describe('Robot Accounts Page', () => {
cy.get('button:contains("Create")').click(),
);
cy.visit('/organization/testorg?tab=Robot+accounts');
cy.get('[data-label="Repositories"]').contains('No repositories').click();
cy.visit('/organization/testorg?tab=Robotaccounts');
cy.get(`[id="${robotAccnt}-toggle-kebab"]`).click();
cy.get(`[id="${robotAccnt}-set-repo-perms-btn"]`).click();
cy.get('#add-repository-bulk-select').click();
cy.get('#toggle-bulk-perms-kebab').click();
cy.get('[role="menuitem"]').contains('Write').click();

View File

@@ -0,0 +1,188 @@
/// <reference types="cypress" />
describe('Teams and membership page', () => {
beforeEach(() => {
cy.exec('npm run quay:seed');
cy.request('GET', `${Cypress.env('REACT_QUAY_APP_API_URL')}/csrf_token`)
.then((response) => response.body.csrf_token)
.then((token) => {
cy.loginByCSRF(token);
});
});
it('Search Filter for Team View', () => {
cy.visit('/organization/testorg?tab=Teamsandmembership');
cy.get('#Teams').click();
// Filter for a single team
cy.get('#teams-view-search').type('arsenal');
cy.contains('1 - 1 of 1');
cy.get('#teams-view-search').clear();
});
it('Search Filter for Members View', () => {
cy.visit('/organization/testorg?tab=Teamsandmembership');
cy.get('#Members').click();
// Filter for a single member
cy.get('#members-view-search').type('user1');
cy.contains('1 - 1 of 1');
cy.get('#members-view-search').clear();
});
it('Search Filter for Collaborators View', () => {
cy.visit('/organization/testorg?tab=Teamsandmembership');
cy.get('#Collaborators').click();
// Filter for a single collaborator
cy.get('#collaborators-view-search').type('collaborator1');
cy.contains('1 - 1 of 1');
cy.get('#collaborators-view-search').clear();
});
it('Can update team role in Team View', () => {
const teamToBeUpdated = 'arsenal';
cy.visit('/organization/testorg?tab=Teamsandmembership');
cy.get('#Teams').click();
// Search for a single team
cy.get('#teams-view-search').type(`${teamToBeUpdated}`);
cy.contains('1 - 1 of 1');
cy.get(`[data-testid="${teamToBeUpdated}-team-dropdown"]`)
.contains('Member')
.click();
cy.get(`[data-testid="${teamToBeUpdated}-Creator"]`).click();
// verify success alert
cy.get('.pf-c-alert.pf-m-success')
.contains(`Team role updated successfully for: ${teamToBeUpdated}`)
.should('exist');
});
it('Can delete team from Team View', () => {
const teamToBeDeleted = 'liverpool';
cy.visit('/organization/testorg?tab=Teamsandmembership');
cy.get('#Teams').click();
// Search for a single team
cy.get('#teams-view-search').type(`${teamToBeDeleted}`);
cy.contains('1 - 1 of 1');
cy.get(`[data-testid="${teamToBeDeleted}-toggle-kebab"]`).click();
cy.get(`[data-testid="${teamToBeDeleted}-del-option"]`)
.contains('Delete')
.click();
// verify success alert
cy.get('.pf-c-alert.pf-m-success')
.contains(`Successfully deleted team`)
.should('exist');
});
it('Can delete a member from Collaborator view', () => {
const collaboratorName = 'collaborator1';
cy.visit('/organization/testorg?tab=Teamsandmembership');
cy.get('#Collaborators').click();
// delete collaborator
cy.get(`[data-testid="${collaboratorName}-del-icon"]`).click();
cy.get(`[data-testid="${collaboratorName}-del-btn"]`).click();
// verify success alert
cy.get('.pf-c-alert.pf-m-success')
.contains(`Successfully deleted collaborator`)
.should('exist');
});
it('Can open manage team members', () => {
const team = 'owners';
cy.visit('/organization/testorg?tab=Teamsandmembership');
cy.get('#Teams').click();
// Search for a single team
cy.get('#teams-view-search').type(`${team}`);
cy.contains('1 - 1 of 1');
cy.get(`[data-testid="${team}-toggle-kebab"]`).click();
cy.get(`[data-testid="${team}-manage-team-member-option"]`)
.contains('Manage team members')
.click();
// verify manage members view is shown
cy.url().should('contain', `teams/${team}?tab=Teamsandmembership`);
cy.get(`[data-label="Team member"]`).contains('user1');
});
it('Can set repository permissions for a team', () => {
const team = 'chelsea';
const repo = 'premierleague';
cy.visit('/organization/testorg?tab=Teamsandmembership');
cy.get('#Teams').click();
// Search for a single team
cy.get('#teams-view-search').type(`${team}`);
cy.contains('1 - 1 of 1');
cy.get(`[data-testid="${team}-toggle-kebab"]`).click();
cy.get(`[data-testid="${team}-set-repo-perms-option"]`)
.contains('Set repository permissions')
.click();
// search for repo perm inside the modal
cy.get('#set-repo-perm-for-team-search').type(`${repo}`);
cy.contains('1 - 1 of 1');
cy.get(`[data-testid="${repo}-role-dropdown"]`).contains('None').click();
cy.get(`[data-testid="${repo}-Write"]`).click();
cy.get('#update-team-repo-permissions').click();
// verify success alert
cy.get('.pf-c-alert.pf-m-success')
.contains(`Updated repo perm for team: ${team} successfully`)
.should('exist');
});
it('Can perform a bulk update of repo permissions for a team', () => {
const team = 'chelsea';
cy.visit('/organization/testorg?tab=Teamsandmembership');
cy.get('#Teams').click();
// Search for a single team
cy.get('#teams-view-search').type(`${team}`);
cy.contains('1 - 1 of 1');
cy.get(`[data-testid="${team}-toggle-kebab"]`).click();
cy.get(`[data-testid="${team}-set-repo-perms-option"]`)
.contains('Set repository permissions')
.click();
// bulk select entries and change role from kebab
cy.get('#add-repository-bulk-select').click();
cy.get('#toggle-bulk-perms-kebab').click();
cy.get('[role="menuitem"]').contains('Write').click();
cy.get('#update-team-repo-permissions').click();
// verify success alert
cy.get('.pf-c-alert.pf-m-success')
.contains(`Updated repo perm for team: ${team} successfully`)
.should('exist');
});
it('Can delete a robot account from Manage team members view', () => {
const team = 'chelsea';
const robotAccntToBeDeleted = 'testorg+testrobot';
cy.visit('/organization/testorg?tab=Teamsandmembership');
cy.get('#Teams').click();
// Search for a single team
cy.get('#teams-view-search').type(`${team}`);
cy.contains('1 - 1 of 1');
cy.get(`[data-testid="${team}-toggle-kebab"]`).click();
cy.get(`[data-testid="${team}-manage-team-member-option"]`)
.contains('Manage team members')
.click();
// delete robot account
cy.get(`[data-testid="${robotAccntToBeDeleted}-delete-icon"]`).click();
// verify success alert
cy.get('.pf-c-alert.pf-m-success')
.contains(`Successfully deleted team member`)
.should('exist');
});
});

View File

@@ -5989,6 +5989,15 @@ COPY public.logentry3 (id, kind_id, account_id, performer_id, repository_id, dat
176 26 1 1 1 2023-07-27 17:30:44.484509 172.18.0.1 {"username": "user1", "repo": "hello-world", "namespace": "user1", "tag": "latest"}
177 46 1 1 1 2023-07-27 17:30:48.828245 172.18.0.1 {"username": "user1", "repo": "hello-world", "tag": "latest", "manifest_digest": "sha256:f54a58bc1aac5ea1a25d796ae155dc228b3f0e11d046ae276b39c4bf2f13d8c4"}
180 46 1 1 1 2023-07-27 17:31:10.684526 172.18.0.1 {"username": "user1", "repo": "hello-world", "tag": "latest", "manifest_digest": "sha256:f54a58bc1aac5ea1a25d796ae155dc228b3f0e11d046ae276b39c4bf2f13d8c4"}
181 32 28 1 \N 2023-08-15 20:40:53.404327 172.19.0.1 {"team": "arsenal"}
182 10 28 1 5 2023-08-15 20:41:03.55182 172.19.0.1 {"team": "arsenal", "repo": "testrepo", "role": "read"}
183 32 28 1 \N 2023-08-15 20:43:47.245598 172.19.0.1 {"team": "liverpool"}
184 14 28 1 156 2023-08-23 17:50:35.613124 172.19.0.1 {"repo": "premierleague", "namespace": "testorg"}
185 86 33 1 \N 2023-08-23 17:51:10.972139 172.19.0.1 {"email": "collaborator1@gmail.com", "username": "collaborator1"}
186 32 28 1 \N 2023-08-23 17:51:38.72938 172.19.0.1 {"team": "chelsea"}
187 10 28 1 156 2023-08-23 17:52:13.669212 172.19.0.1 {"username": "collaborator1", "repo": "premierleague", "namespace": "testorg", "role": "write"}
188 31 28 1 \N 2023-08-23 17:54:45.980317 172.19.0.1 {"member": "user1", "team": "chelsea"}
189 31 28 1 \N 2023-08-23 17:55:15.284759 172.19.0.1 {"member": "testorg+testrobot", "team": "chelsea"}
\.
@@ -6592,6 +6601,7 @@ COPY public.repository (id, namespace_user_id, name, visibility_id, description,
153 4 repo148 1 This is the description of repo148 0c796577-7825-4c9d-a4fb-2a7866544248 1 f 0
154 4 repo149 1 This is the description of repo149 8db24323-26f1-4fc1-9a10-1ac9d6f768d5 1 f 0
155 32 hello-world 1 730ebab8-f2ab-4e55-9c9d-f064d11f68d6 1 f 0
156 28 premierleague 1 94590e48-ff0e-4eb0-a74d-84416dedf157 1 f 0
\.
@@ -6755,6 +6765,7 @@ COPY public.repositoryactioncount (id, repository_id, count, date) FROM stdin;
153 153 0 2023-01-02
154 154 0 2023-01-02
155 155 0 2023-06-27
156 156 0 2023-08-22
\.
@@ -6968,6 +6979,9 @@ COPY public.repositorypermission (id, team_id, user_id, repository_id, role_id)
156 \N 1 153 1
157 \N 1 154 1
158 \N 29 155 1
159 31 \N 5 3
160 \N 1 156 1
161 \N 33 156 2
\.
@@ -7131,6 +7145,7 @@ COPY public.repositorysearchscore (id, repository_id, score, last_updated) FROM
153 153 0 \N
154 154 0 \N
155 155 0 \N
156 156 0 \N
\.
@@ -7465,6 +7480,9 @@ COPY public.team (id, name, organization_id, role_id, description) FROM stdin;
28 testteam 28 3
29 testteam2 28 3
30 owners 32 1
31 arsenal 28 3
32 liverpool 28 3
33 chelsea 28 3
\.
@@ -7500,6 +7518,8 @@ COPY public.teammember (id, user_id, team_id) FROM stdin;
27 1 27
28 29 28
29 29 30
30 1 33
31 30 33
\.
@@ -7605,6 +7625,7 @@ COPY public."user" (id, uuid, username, password_hash, email, verified, stripe_i
31 87860588-5674-4bc4-a297-abda8f99e877 testorg+testrobot2 \N c117d71c-c3cb-4513-ab37-32f0dcb16007 f \N f t f 0 2022-11-30 21:23:53.656399 1209600 t \N \N \N \N \N \N 2022-11-30 21:23:53.656402 \N
29 f96f1124-ae26-4ea6-a73b-f4b0d87e6dc0 user2 $2b$12$XA6iXw/.Ug.F8s7bWVz3VuWcOz0SVKsmnvkgl.l6ZnmWWllTgzLT6 user2@redhat.com t \N f f f 0 2022-11-29 13:47:39.871581 1209600 t \N \N \N \N \N \N 2022-11-29 13:47:39.87159 \N
32 80d4d7c4-c3f0-442b-94a6-1e151aca9ecd user2org1 \N user2org1@redhat.com f \N t f f 0 2023-06-28 18:15:16.210544 1209600 t \N \N \N \N \N \N 2023-06-28 18:15:16.210558 \N
33 e53eca92-4bbe-4853-b047-ceb86c1bb12a collaborator1 $2b$12$R0hWaRhKGFdJ16WCJ7KUzesYIxzF0o57Lq9.pUhlc4EQTXUTXmrw2 collaborator1@gmail.com t \N f f f 0 2023-08-23 17:51:10.548163 1209600 t \N \N \N \N \N \N 2023-08-23 17:51:10.548165 \N
\.
@@ -7881,7 +7902,7 @@ SELECT pg_catalog.setval('public.logentry2_id_seq', 1, false);
-- Name: logentry3_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay
--
SELECT pg_catalog.setval('public.logentry3_id_seq', 180, true);
SELECT pg_catalog.setval('public.logentry3_id_seq', 189, true);
--
@@ -8112,14 +8133,14 @@ SELECT pg_catalog.setval('public.repomirrorrule_id_seq', 1, false);
-- Name: repository_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay
--
SELECT pg_catalog.setval('public.repository_id_seq', 155, true);
SELECT pg_catalog.setval('public.repository_id_seq', 156, true);
--
-- Name: repositoryactioncount_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay
--
SELECT pg_catalog.setval('public.repositoryactioncount_id_seq', 155, true);
SELECT pg_catalog.setval('public.repositoryactioncount_id_seq', 156, true);
--
@@ -8161,14 +8182,14 @@ SELECT pg_catalog.setval('public.repositorynotification_id_seq', 5, true);
-- Name: repositorypermission_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay
--
SELECT pg_catalog.setval('public.repositorypermission_id_seq', 158, true);
SELECT pg_catalog.setval('public.repositorypermission_id_seq', 161, true);
--
-- Name: repositorysearchscore_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay
--
SELECT pg_catalog.setval('public.repositorysearchscore_id_seq', 155, true);
SELECT pg_catalog.setval('public.repositorysearchscore_id_seq', 156, true);
--
@@ -8280,14 +8301,14 @@ SELECT pg_catalog.setval('public.tagtorepositorytag_id_seq', 1, false);
-- Name: team_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay
--
SELECT pg_catalog.setval('public.team_id_seq', 30, true);
SELECT pg_catalog.setval('public.team_id_seq', 33, true);
--
-- Name: teammember_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay
--
SELECT pg_catalog.setval('public.teammember_id_seq', 29, true);
SELECT pg_catalog.setval('public.teammember_id_seq', 31, true);
--
@@ -8329,7 +8350,7 @@ SELECT pg_catalog.setval('public.uploadedblob_id_seq', 22, true);
-- Name: user_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay
--
SELECT pg_catalog.setval('public.user_id_seq', 32, true);
SELECT pg_catalog.setval('public.user_id_seq', 33, true);
--

View File

@@ -1,19 +1,19 @@
import { ReactNode } from "react";
import { atom } from "recoil";
import {ReactNode} from 'react';
import {atom} from 'recoil';
export enum AlertVariant {
Success = 'success',
Failure = 'danger',
Success = 'success',
Failure = 'danger',
}
export interface AlertDetails {
variant: AlertVariant;
title: string;
key?: string;
message?: string | ReactNode;
variant: AlertVariant;
title: string;
key?: string;
message?: string | ReactNode;
}
export const alertState = atom<AlertDetails[]>({
key: 'alertState',
default: [],
key: 'alertState',
default: [],
});

View File

@@ -3,7 +3,7 @@ import {
BreadcrumbItem,
PageBreadcrumb,
} from '@patternfly/react-core';
import {NavigationRoutes} from 'src/routes/NavigationPath';
import {getNavigationRoutes} from 'src/routes/NavigationPath';
import {Link, useParams, useLocation} from 'react-router-dom';
import React, {useEffect, useState} from 'react';
import useBreadcrumbs, {
@@ -19,10 +19,13 @@ export function QuayBreadcrumb() {
const [breadcrumbItems, setBreadcrumbItems] = useState<QuayBreadcrumbItem[]>(
[],
);
const routerBreadcrumbs: BreadcrumbData[] = useBreadcrumbs(NavigationRoutes, {
disableDefaults: true,
excludePaths: ['/'],
});
const routerBreadcrumbs: BreadcrumbData[] = useBreadcrumbs(
getNavigationRoutes(),
{
disableDefaults: true,
excludePaths: ['/'],
},
);
const urlParams = useParams();
const resetBreadCrumbs = () => {
@@ -144,7 +147,7 @@ export function QuayBreadcrumb() {
} else {
buildFromRoute();
}
}, []);
}, [window.location.pathname]);
return (
<div>

View File

@@ -1,40 +1,24 @@
import {
TableComposable,
Tbody,
Td,
Th,
Thead,
Tr,
} from '@patternfly/react-table';
import {
PageSection,
PanelFooter,
ToggleGroup,
ToggleGroupItem,
ToggleGroupItemProps,
Toolbar,
ToolbarContent,
ToolbarItem,
DropdownItem,
Button,
Text,
TextVariants,
TextContent,
} from '@patternfly/react-core';
import React, {useEffect, useState} from 'react';
import {useState} from 'react';
import {DesktopIcon} from '@patternfly/react-icons';
import ToggleDrawer from 'src/components/ToggleDrawer';
import NameAndDescription from 'src/components/modals/robotAccountWizard/NameAndDescription';
import {useTeams} from 'src/hooks/useTeams';
import {addDisplayError} from 'src/resources/ErrorHandling';
import TeamView from './TeamView';
import {useCreateTeam} from 'src/hooks/UseTeams';
export default function AddToTeam(props: AddToTeamProps) {
const [newTeamName, setNewTeamName] = useState('');
const [newTeamDescription, setNewTeamDescription] = useState('');
const [err, setErr] = useState<string>();
const {createNewTeamHook} = useTeams(props.namespace);
const {createNewTeamHook} = useCreateTeam(props.namespace);
const createNewTeam = () => {
props.setDrawerExpanded(true);

View File

@@ -8,7 +8,7 @@ export function Kebab(props: KebabProps) {
const fetchToggle = () => {
if (!props.useActions) {
return <KebabToggle onToggle={onToggle} />;
return <KebabToggle id={props?.id} onToggle={onToggle} />;
}
return (
<DropdownToggle
@@ -36,4 +36,5 @@ type KebabProps = {
setKebabOpen: (open) => void;
kebabItems: React.ReactElement[];
useActions?: boolean;
id?: string;
};

254
web/src/hooks/UseMembers.ts Normal file
View File

@@ -0,0 +1,254 @@
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useState} from 'react';
import {SearchState} from 'src/components/toolbar/SearchTypes';
import {
IMemberTeams,
IMembers,
deleteCollaboratorForOrg,
deleteTeamMemberForOrg,
fetchCollaboratorsForOrg,
fetchMembersForOrg,
fetchTeamMembersForOrg,
} from 'src/resources/MembersResource';
import {IAvatar} from 'src/resources/OrganizationResource';
import {collaboratorViewColumnNames} from 'src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/CollaboratorsView/CollaboratorsViewList';
import {memberViewColumnNames} from 'src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/MembersView/MembersViewList';
import {manageMemberColumnNames} from 'src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/ManageMembers/ManageMembersList';
export function useFetchMembers(orgName: string) {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [search, setSearch] = useState<SearchState>({
query: '',
field: memberViewColumnNames.username,
});
const {
data: members,
isLoading,
isPlaceholderData,
isError: errorLoadingMembers,
} = useQuery<IMembers[]>(
['members'],
({signal}) => fetchMembersForOrg(orgName, signal),
{
placeholderData: [],
},
);
const filteredMembers =
search.query !== ''
? members?.filter((member) => member.name.includes(search.query))
: members;
const paginatedMembers = filteredMembers?.slice(
page * perPage - perPage,
page * perPage - perPage + perPage,
);
return {
members,
filteredMembers,
paginatedMembers: paginatedMembers,
loading: isLoading || isPlaceholderData,
error: errorLoadingMembers,
page,
setPage,
perPage,
setPerPage,
search,
setSearch,
};
}
export interface ITeamMember {
name: string;
kind: string;
is_robot: false;
avatar: IAvatar;
invited: boolean;
}
export function useFetchTeamMembersForOrg(orgName: string, teamName: string) {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [search, setSearch] = useState<SearchState>({
query: '',
field: manageMemberColumnNames.teamMember,
});
const {
data,
isLoading,
isPlaceholderData,
isError: errorLoadingTeamMembers,
} = useQuery<ITeamMember[]>(
['teamMembers'],
({signal}) => fetchTeamMembersForOrg(orgName, teamName, signal),
{
placeholderData: [],
},
);
const allMembers: ITeamMember[] = data;
const filteredAllMembers =
search.query !== ''
? allMembers?.filter((member) => member.name.includes(search.query))
: allMembers;
const paginatedAllMembers = filteredAllMembers?.slice(
page * perPage - perPage,
page * perPage - perPage + perPage,
);
// Filter team members
const teamMembers = allMembers?.filter(
(team) => !team.is_robot && !team.invited,
);
const filteredTeamMembers =
search.query !== ''
? teamMembers?.filter((member) => member.name.includes(search.query))
: teamMembers;
const paginatedTeamMembers = filteredTeamMembers?.slice(
page * perPage - perPage,
page * perPage - perPage + perPage,
);
// Filter robot account
const robotAccounts = allMembers?.filter((team) => team.is_robot);
const filteredRobotAccounts =
search.query !== ''
? robotAccounts?.filter((member) => member.name.includes(search.query))
: robotAccounts;
const paginatedRobotAccounts = filteredRobotAccounts?.slice(
page * perPage - perPage,
page * perPage - perPage + perPage,
);
// Filter invited members
const invited = allMembers?.filter((team) => team.invited);
const filteredInvited =
search.query !== ''
? invited?.filter((member) => member.name.includes(search.query))
: invited;
const paginatedInvited = filteredInvited?.slice(
page * perPage - perPage,
page * perPage - perPage + perPage,
);
return {
allMembers,
teamMembers,
robotAccounts,
invited,
paginatedAllMembers,
paginatedTeamMembers,
paginatedRobotAccounts,
paginatedInvited,
loading: isLoading || isPlaceholderData,
error: errorLoadingTeamMembers,
page,
setPage,
perPage,
setPerPage,
search,
setSearch,
};
}
export function useFetchCollaborators(orgName: string) {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [search, setSearch] = useState<SearchState>({
query: '',
field: collaboratorViewColumnNames.username,
});
const {
data: collaborators,
isLoading,
isPlaceholderData,
isError: errorLoadingCollaborators,
} = useQuery<IMembers[]>(
['collaborators'],
({signal}) => fetchCollaboratorsForOrg(orgName, signal),
{
placeholderData: [],
},
);
const filteredCollaborators =
search.query !== ''
? collaborators?.filter((collaborator) =>
collaborator.name.includes(search.query),
)
: collaborators;
const paginatedCollaborators = filteredCollaborators?.slice(
page * perPage - perPage,
page * perPage - perPage + perPage,
);
return {
collaborators,
filteredCollaborators,
paginatedCollaborators,
loading: isLoading || isPlaceholderData,
error: errorLoadingCollaborators,
page,
setPage,
perPage,
setPerPage,
search,
setSearch,
};
}
export function useDeleteTeamMember(orgName: string) {
const queryClient = useQueryClient();
const {
mutate: removeTeamMember,
isError: errorDeleteTeamMember,
isSuccess: successDeleteTeamMember,
reset: resetDeleteTeamMember,
} = useMutation(
async ({teamName, memberName}: {teamName: string; memberName: string}) => {
return deleteTeamMemberForOrg(orgName, teamName, memberName);
},
{
onSuccess: (_, variables) => {
queryClient.invalidateQueries(['teamMembers']);
},
},
);
return {
removeTeamMember,
errorDeleteTeamMember,
successDeleteTeamMember,
resetDeleteTeamMember,
};
}
export function useDeleteCollaborator(orgName: string) {
const queryClient = useQueryClient();
const {
mutate: removeCollaborator,
isError: errorDeleteCollaborator,
isSuccess: successDeleteCollaborator,
reset: resetDeleteCollaborator,
} = useMutation(
async ({collaborator}: {collaborator: string}) => {
return deleteCollaboratorForOrg(orgName, collaborator);
},
{
onSuccess: (_, variables) => {
queryClient.invalidateQueries(['collaborators']);
},
},
);
return {
removeCollaborator,
errorDeleteCollaborator,
successDeleteCollaborator,
resetDeleteCollaborator,
};
}

View File

@@ -2,7 +2,7 @@ import {useQuery} from '@tanstack/react-query';
import {useState} from 'react';
import {SearchState} from 'src/components/toolbar/SearchTypes';
import {
fetchTeamRepoPermissions,
fetchAllTeamPermissionsForRepository,
fetchUserRepoPermissions,
RepoMember,
} from 'src/resources/RepositoryResource';
@@ -42,7 +42,7 @@ export function useRepositoryPermissions(org: string, repo: string) {
isPlaceholderData: isTeamPlaceholderData,
} = useQuery(
['teamrepopermissions', org, repo],
() => fetchTeamRepoPermissions(org, repo),
() => fetchAllTeamPermissionsForRepository(org, repo),
{
placeholderData: {},
},

267
web/src/hooks/UseTeams.ts Normal file
View File

@@ -0,0 +1,267 @@
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {
bulkDeleteTeams,
createNewTeamForNamespac,
fetchTeamRepoPermsForOrg,
fetchTeamsForNamespace,
updateTeamRepoPerm,
updateTeamRoleForNamespace,
} from 'src/resources/TeamResources';
import {useState} from 'react';
import {IAvatar} from 'src/resources/OrganizationResource';
import {teamViewColumnNames} from 'src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/TeamsViewList';
import {SearchState} from 'src/components/toolbar/SearchTypes';
import {setRepoPermForTeamColumnNames} from 'src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/SetRepoPermissionsModal/SetRepoPermissionForTeamModal';
import {
IRepository,
fetchRepositoriesForNamespace,
} from 'src/resources/RepositoryResource';
import {BulkOperationError, ResourceError} from 'src/resources/ErrorHandling';
import {useCurrentUser} from './UseCurrentUser';
export function useCreateTeam(ns) {
const [namespace] = useState(ns);
const queryClient = useQueryClient();
const createTeamMutator = useMutation(
async ({namespace, name, description}: createNewTeamForNamespaceParams) => {
return createNewTeamForNamespac(namespace, name, description);
},
{
onSuccess: () => {
queryClient.invalidateQueries(['organization', namespace, 'teams']);
},
},
);
return {
createNewTeamHook: async (params: createNewTeamForNamespaceParams) =>
createTeamMutator.mutate(params),
};
}
interface createNewTeamForNamespaceParams {
namespace: string;
name: string;
description: string;
}
export interface ITeams {
name: string;
description: string;
role: string;
avatar: IAvatar;
can_view: boolean;
repo_count: number;
member_count: number;
is_synced: boolean;
}
export function useFetchTeams(orgName: string) {
const {user} = useCurrentUser();
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [search, setSearch] = useState<SearchState>({
query: '',
field: teamViewColumnNames.teamName,
});
const {
data,
isLoading,
isPlaceholderData,
isError: errorLoadingTeams,
} = useQuery<ITeams[]>(
['teams'],
({signal}) => fetchTeamsForNamespace(orgName, signal),
{
placeholderData: [],
enabled: !(user.username === orgName),
},
);
const teams: ITeams[] = Object.values(data);
const filteredTeams =
search.query !== ''
? teams?.filter((team) => team.name.includes(search.query))
: teams;
const paginatedTeams = filteredTeams?.slice(
page * perPage - perPage,
page * perPage - perPage + perPage,
);
return {
teams: teams,
filteredTeams,
paginatedTeams: paginatedTeams,
loading: isLoading || isPlaceholderData,
error: errorLoadingTeams,
page,
setPage,
perPage,
setPerPage,
search,
setSearch,
};
}
export interface ITeamRepoPerms {
repoName: string;
role?: string;
lastModified: number;
}
export function useFetchRepoPermForTeam(orgName: string, teamName: string) {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [search, setSearch] = useState<SearchState>({
query: '',
field: setRepoPermForTeamColumnNames.repoName,
});
const {
data: permissions,
isLoading: loadingPerms,
isPlaceholderData,
isError: errorLoadingTeamPerms,
} = useQuery(
['teamrepopermissions'],
({signal}) => fetchTeamRepoPermsForOrg(orgName, teamName, signal),
{
placeholderData: [],
},
);
const {
data: repos,
isLoading: loadingRepos,
isError: errorLoadingRepos,
} = useQuery<IRepository[]>(
['repos'],
({signal}) => fetchRepositoriesForNamespace(orgName, signal),
{
placeholderData: [],
},
);
const teamRepoPerms: ITeamRepoPerms[] = repos.map((repo) => ({
repoName: repo.name,
lastModified: repo.last_modified ? repo.last_modified : -1,
}));
// Add role from fetch permissions API
teamRepoPerms.forEach((repo) => {
const matchingPerm = permissions?.find(
(perm) => perm.repository.name === repo.repoName,
);
if (matchingPerm) {
repo['role'] = matchingPerm.role;
} else {
repo['role'] = 'none';
}
});
const filteredTeamRepoPerms =
search.query !== ''
? teamRepoPerms?.filter((teamRepoPerm) =>
teamRepoPerm.repoName.includes(search.query),
)
: teamRepoPerms;
const paginatedTeamRepoPerms = filteredTeamRepoPerms?.slice(
page * perPage - perPage,
page * perPage - perPage + perPage,
);
return {
teamRepoPerms: teamRepoPerms,
filteredTeamRepoPerms: filteredTeamRepoPerms,
paginatedTeamRepoPerms: paginatedTeamRepoPerms,
loading: loadingPerms || loadingRepos || isPlaceholderData,
error: errorLoadingTeamPerms || errorLoadingRepos,
page,
setPage,
perPage,
setPerPage,
search,
setSearch,
};
}
export function useDeleteTeam({orgName, onSuccess, onError}) {
const queryClient = useQueryClient();
const deleteTeamsMutator = useMutation(
async (teams: ITeams[] | ITeams) => {
teams = Array.isArray(teams) ? teams : [teams];
return bulkDeleteTeams(orgName, teams);
},
{
onSuccess: () => {
queryClient.invalidateQueries(['teams']);
onSuccess();
},
onError: (err) => {
onError(err);
},
},
);
return {
removeTeam: async (teams: ITeams[] | ITeams) =>
deleteTeamsMutator.mutate(teams),
};
}
export function useUpdateTeamRole(orgName: string) {
const queryClient = useQueryClient();
const {
mutate: updateTeamRole,
isError: errorUpdateTeamRole,
isSuccess: successUpdateTeamRole,
reset: resetUpdateTeamRole,
} = useMutation(
async ({teamName, teamRole}: {teamName: string; teamRole: string}) => {
return updateTeamRoleForNamespace(orgName, teamName, teamRole);
},
{
onSuccess: () => {
queryClient.invalidateQueries(['teams']);
},
},
);
return {
updateTeamRole,
errorUpdateTeamRole,
successUpdateTeamRole,
resetUpdateTeamRole,
};
}
export function useUpdateTeamRepoPerm(orgName: string, teamName: string) {
const queryClient = useQueryClient();
const {
mutate: updateRepoPerm,
isError: errorUpdateRepoPerm,
error: detailedErrorUpdateRepoPerm,
isSuccess: successUpdateRepoPerm,
reset: resetUpdateRepoPerm,
} = useMutation(
async ({teamRepoPerms}: {teamRepoPerms: ITeamRepoPerms[]}) => {
return updateTeamRepoPerm(orgName, teamName, teamRepoPerms);
},
{
onSuccess: () => {
queryClient.invalidateQueries(['teams']);
},
},
);
return {
updateRepoPerm,
errorUpdateRepoPerm,
detailedErrorUpdateRepoPerm:
detailedErrorUpdateRepoPerm as BulkOperationError<ResourceError>,
successUpdateRepoPerm,
resetUpdateRepoPerm,
};
}

View File

@@ -1,30 +0,0 @@
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {createNewTeamForNamespac} from 'src/resources/TeamResources';
import {useState} from 'react';
export function useTeams(ns) {
const [namespace, setNamespace] = useState(ns);
const queryClient = useQueryClient();
const createTeamMutator = useMutation(
async ({namespace, name, description}: createNewTeamForNamespaceParams) => {
return createNewTeamForNamespac(namespace, name, description);
},
{
onSuccess: () => {
queryClient.invalidateQueries(['organization', namespace, 'teams']);
},
},
);
return {
createNewTeamHook: async (params: createNewTeamForNamespaceParams) =>
createTeamMutator.mutate(params),
};
}
interface createNewTeamForNamespaceParams {
namespace: string;
name: string;
description: string;
}

View File

@@ -98,6 +98,16 @@ export function parseTagNameFromUrl(url: string): string {
return urlParts[tagKeywordIndex + 1];
}
export function parseTeamNameFromUrl(url: string): string {
//url is in the format of <prefix>/organization/<org>/teams/<team>
const urlParts = url.split('/');
const teamKeywordIndex = urlParts.indexOf('teams');
if (teamKeywordIndex === -1) {
return '';
}
return urlParts[teamKeywordIndex + 1];
}
export function humanizeTimeForExpiry(time_seconds: number): string {
return moment.duration(time_seconds || 0, 's').humanize();
}
@@ -107,7 +117,7 @@ export function getSeconds(duration_str: string): number {
return 0;
}
let [number, suffix] = duration_str.split('');
const [number, suffix] = duration_str.split('');
return moment.duration(parseInt(number), suffix).asSeconds();
}

View File

@@ -2,17 +2,16 @@ import {fetchOrg, IAvatar} from './OrganizationResource';
import axios from 'src/libs/axios';
import {assertHttpCode} from './ErrorHandling';
export interface IMember {
name: string;
kind: string;
teams: ITeam[];
repositories: string[];
}
export interface ITeam {
export interface IMemberTeams {
name: string;
avatar: IAvatar;
}
export interface IMembers {
name: string;
kind: string;
teams?: IMemberTeams[];
repositories: string[];
}
export async function fetchAllMembers(orgnames: string[], signal: AbortSignal) {
return await Promise.all(
@@ -23,9 +22,57 @@ export async function fetchAllMembers(orgnames: string[], signal: AbortSignal) {
export async function fetchMembersForOrg(
orgname: string,
signal: AbortSignal,
): Promise<IMember[]> {
): Promise<IMembers[]> {
const getMembersUrl = `/api/v1/organization/${orgname}/members`;
const response = await axios.get(getMembersUrl, {signal});
assertHttpCode(response.status, 200);
return response.data?.members;
}
export async function fetchCollaboratorsForOrg(
orgname: string,
signal: AbortSignal,
): Promise<IMembers[]> {
const getCollaboratorsUrl = `/api/v1/organization/${orgname}/collaborators`;
const response = await axios.get(getCollaboratorsUrl, {signal});
assertHttpCode(response.status, 200);
return response.data?.collaborators;
}
export async function fetchTeamMembersForOrg(
org: string,
teamName: string,
signal?: AbortSignal,
) {
const teamMemberUrl = `/api/v1/organization/${org}/team/${teamName}/members?includePending=true`;
const teamMembersResponse = await axios.get(teamMemberUrl, {signal});
assertHttpCode(teamMembersResponse.status, 200);
return teamMembersResponse.data?.members;
}
export async function deleteTeamMemberForOrg(
orgName: string,
teamName: string,
memberName: string,
) {
try {
const response = await axios.delete(
`/api/v1/organization/${orgName}/team/${teamName}/members/${memberName}`,
);
} catch (err) {
console.error(`Unable to delete member for team: ${teamName}`, err);
}
}
export async function deleteCollaboratorForOrg(
orgName: string,
collaborator: string,
) {
try {
await axios.delete(
`/api/v1/organization/${orgName}/members/${collaborator}`,
);
} catch (err) {
console.error(`Unable to delete collaborator for org: ${orgName}`, err);
}
}

View File

@@ -16,6 +16,7 @@ export interface IOrganization {
public?: boolean;
is_org_admin?: boolean;
is_admin?: boolean;
is_member?: boolean;
preferred_namespace?: boolean;
teams?: string[];
tag_expiration_s: number;
@@ -121,7 +122,7 @@ export async function updateOrgSettings(
const updateSettingsUrl = isUser
? `/api/v1/user/`
: `/api/v1/organization/${namespace}`;
let payload = {};
const payload = {};
if (email) {
payload['email'] = email;
}

View File

@@ -189,7 +189,10 @@ export async function fetchUserRepoPermissions(org: string, repo: string) {
return response.data.permissions;
}
export async function fetchTeamRepoPermissions(org: string, repo: string) {
export async function fetchAllTeamPermissionsForRepository(
org: string,
repo: string,
) {
const response: AxiosResponse<RepoPermissionsResponse> = await axios.get(
`/api/v1/repository/${org}/${repo}/permissions/team/`,
);

View File

@@ -1,6 +1,18 @@
import {AxiosResponse} from 'axios';
import {AxiosError, AxiosResponse} from 'axios';
import axios from 'src/libs/axios';
import {assertHttpCode} from './ErrorHandling';
import {ResourceError, assertHttpCode, throwIfError} from './ErrorHandling';
import {ITeamRepoPerms, ITeams} from 'src/hooks/UseTeams';
export class TeamDeleteError extends Error {
error: Error;
team: string;
constructor(message: string, team: string, error: AxiosError) {
super(message);
this.team = team;
this.error = error;
Object.setPrototypeOf(this, TeamDeleteError.prototype);
}
}
export async function createNewTeamForNamespac(
namespace: string,
@@ -25,3 +37,102 @@ export async function updateTeamForRobot(
assertHttpCode(response.status, 200);
return response.data?.name;
}
export async function updateTeamRoleForNamespace(
namespace: string,
teamName: string,
teamRole: string,
) {
const updateTeamUrl = `/api/v1/organization/${namespace}/team/${teamName}`;
const payload = {name: teamName, role: teamRole};
const response: AxiosResponse = await axios.put(updateTeamUrl, payload);
assertHttpCode(response.status, 200);
return response.data?.name;
}
export async function updateTeamRepoPerm(
orgName: string,
teamName: string,
teamRepoPerms: ITeamRepoPerms[],
) {
const responses = await Promise.allSettled(
teamRepoPerms?.map(async (repoPerm) => {
console.log(
'${%s}/${%s}: teamrole %s ',
orgName,
repoPerm.repoName,
repoPerm.role,
);
if (repoPerm.role === 'none') {
try {
const response: AxiosResponse = await axios.delete(
`/api/v1/repository/${orgName}/${repoPerm.repoName}/permissions/team/${teamName}`,
);
assertHttpCode(response.status, 204);
} catch (error) {
if (error.response.status !== 400) {
throw new ResourceError(
'Unable to update repository permission for repo',
repoPerm.repoName,
error,
);
}
}
} else {
const updateTeamUrl = `/api/v1/repository/${orgName}/${repoPerm.repoName}/permissions/team/${teamName}`;
const payload = {role: repoPerm.role};
try {
const response: AxiosResponse = await axios.put(
updateTeamUrl,
payload,
);
assertHttpCode(response.status, 200);
} catch (error) {
throw new ResourceError(
'Unable to update repository permission for repo',
repoPerm.repoName,
error,
);
}
}
}),
);
throwIfError(responses, 'Error updating team repo permissions');
}
export async function fetchTeamsForNamespace(
org: string,
signal?: AbortSignal,
) {
const teamsForOrgUrl = `/api/v1/organization/${org}`;
const teamsResponse = await axios.get(teamsForOrgUrl, {signal});
assertHttpCode(teamsResponse.status, 200);
return teamsResponse.data?.teams;
}
export async function fetchTeamRepoPermsForOrg(
org: string,
teamName: string,
signal?: AbortSignal,
) {
const response: AxiosResponse = await axios.get(
`/api/v1/organization/${org}/team/${teamName}/permissions`,
{signal},
);
return response.data.permissions;
}
export async function deleteTeamForOrg(orgName: string, teamName: string) {
try {
await axios.delete(`/api/v1/organization/${orgName}/team/${teamName}`);
} catch (error) {
throw new ResourceError('Unable to delete team', teamName, error);
}
}
export async function bulkDeleteTeams(orgName: string, teams: ITeams[]) {
const responses = await Promise.allSettled(
teams.map((team) => deleteTeamForOrg(orgName, team.name)),
);
throwIfError(responses, 'Error deleting teams');
}

View File

@@ -1,26 +1,33 @@
import { Alert, AlertActionCloseButton, AlertGroup } from "@patternfly/react-core";
import { useRecoilState } from "recoil";
import { AlertVariant, alertState } from "src/atoms/AlertState";
import {
Alert,
AlertActionCloseButton,
AlertGroup,
} from '@patternfly/react-core';
import {useRecoilState} from 'recoil';
import {AlertVariant, alertState} from 'src/atoms/AlertState';
export default function Alerts(){
const [alerts, setAlerts] = useRecoilState(alertState);
return (
export default function Alerts() {
const [alerts, setAlerts] = useRecoilState(alertState);
return (
<AlertGroup isToast isLiveRegion>
{alerts.map(alert=><Alert
isExpandable={alert.message != null}
variant={alert.variant}
title={alert.title}
timeout={alert.variant === AlertVariant.Success}
actionClose={
{alerts.map((alert) => (
<Alert
isExpandable={alert.message != null}
variant={alert.variant}
title={alert.title}
timeout={alert.variant === AlertVariant.Success}
actionClose={
<AlertActionCloseButton
onClose={()=>{setAlerts(prev=>prev.filter(a=>a.key!==alert.key))}}
onClose={() => {
setAlerts((prev) => prev.filter((a) => a.key !== alert.key));
}}
/>
}
key={alert.key}
}
key={alert.key}
>
{alert.message}
</Alert>)}
{alert.message}
</Alert>
))}
</AlertGroup>
)
);
}

View File

@@ -16,12 +16,17 @@ const tagNameBreadcrumb = (match) => {
return <span>{match.params.tagName}</span>;
};
const teamMemberBreadcrumb = (match) => {
return <span>{match.params.teamName}</span>;
};
const Breadcrumb = {
organizationsListBreadcrumb: 'Organization',
repositoriesListBreadcrumb: 'Repository',
organizationDetailBreadcrumb: organizationNameBreadcrumb,
repositoryDetailBreadcrumb: repositoryNameBreadcrumb,
tagDetailBreadcrumb: tagNameBreadcrumb,
teamMemberBreadcrumb: teamMemberBreadcrumb,
};
export enum NavigationPath {
@@ -39,6 +44,9 @@ export enum NavigationPath {
// Tag Detail
tagDetail = '/repository/:organizationName/:repositoryName/tag/:tagName',
// Team Member
teamMember = '/organization/:organizationName/teams/:teamName',
}
export function getRepoDetailPath(
@@ -64,7 +72,6 @@ export function getTagDetailPath(
tagPath = tagPath.replace(':organizationName', org);
tagPath = tagPath.replace(':repositoryName', repo);
tagPath = tagPath.replace(':tagName', tagName);
if (queryParams) {
const params = [];
for (const entry of Array.from(queryParams.entries())) {
@@ -75,6 +82,21 @@ export function getTagDetailPath(
return domainRoute(currentRoute, tagPath);
}
export function getTeamMemberPath(
currentRoute: string,
orgName: string,
teamName: string,
queryParams: string = null,
): string {
let teamMemberPath = NavigationPath.teamMember.toString();
teamMemberPath = teamMemberPath.replace(':organizationName', orgName);
teamMemberPath = teamMemberPath.replace(':teamName', teamName);
if (queryParams) {
teamMemberPath = teamMemberPath + '?tab' + '=' + queryParams;
}
return domainRoute(currentRoute, teamMemberPath);
}
export function getDomain() {
return process.env.REACT_APP_QUAY_DOMAIN || 'quay.io';
}
@@ -93,33 +115,35 @@ function domainRoute(currentRoute, definedRoute) {
);
}
const currentRoute = window.location.pathname;
export const getNavigationRoutes = () => {
const currentRoute = window.location.pathname;
const NavigationRoutes = [
{
path: domainRoute(currentRoute, NavigationPath.organizationsList),
Component: <OrganizationsList />,
breadcrumb: Breadcrumb.organizationsListBreadcrumb,
},
{
path: domainRoute(currentRoute, NavigationPath.organizationDetail),
Component: <Organization />,
breadcrumb: Breadcrumb.organizationDetailBreadcrumb,
},
{
path: domainRoute(currentRoute, NavigationPath.repositoriesList),
Component: <RepositoriesList organizationName={null} />,
breadcrumb: Breadcrumb.repositoriesListBreadcrumb,
},
{
path: domainRoute(currentRoute, NavigationPath.repositoryDetail),
Component: <RepositoryDetails />,
breadcrumb: Breadcrumb.repositoryDetailBreadcrumb,
},
{
path: domainRoute(currentRoute, NavigationPath.tagDetail),
Component: <TagDetails />,
breadcrumb: Breadcrumb.tagDetailBreadcrumb,
},
];
export {NavigationRoutes};
const NavigationRoutes = [
{
path: domainRoute(currentRoute, NavigationPath.organizationsList),
Component: <OrganizationsList />,
breadcrumb: Breadcrumb.organizationsListBreadcrumb,
},
{
path: domainRoute(currentRoute, NavigationPath.organizationDetail),
Component: <Organization />,
breadcrumb: Breadcrumb.organizationDetailBreadcrumb,
},
{
path: domainRoute(currentRoute, NavigationPath.repositoriesList),
Component: <RepositoriesList organizationName={null} />,
breadcrumb: Breadcrumb.repositoriesListBreadcrumb,
},
{
path: domainRoute(currentRoute, NavigationPath.repositoryDetail),
Component: <RepositoryDetails />,
breadcrumb: Breadcrumb.repositoryDetailBreadcrumb,
},
{
path: domainRoute(currentRoute, NavigationPath.tagDetail),
Component: <TagDetails />,
breadcrumb: Breadcrumb.tagDetailBreadcrumb,
},
];
return NavigationRoutes;
};

View File

@@ -7,20 +7,21 @@ import {
TabTitleText,
Title,
} from '@patternfly/react-core';
import {useLocation, useParams, useSearchParams} from 'react-router-dom';
import {useParams, useSearchParams} from 'react-router-dom';
import {useCallback, useState} from 'react';
import RepositoriesList from 'src/routes/RepositoriesList/RepositoriesList';
import Settings from './Tabs/Settings/Settings';
import {QuayBreadcrumb} from 'src/components/breadcrumb/Breadcrumb';
import { useOrganization } from 'src/hooks/UseOrganization';
import {useOrganization} from 'src/hooks/UseOrganization';
import {useOrganizations} from 'src/hooks/UseOrganizations';
import RobotAccountsList from 'src/routes/RepositoriesList/RobotAccountsList';
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
import TeamsAndMembershipList from './Tabs/TeamsAndMembership/TeamsAndMembershipList';
import ManageMembersList from './Tabs/TeamsAndMembership/TeamsView/ManageMembers/ManageMembersList';
export default function Organization() {
const location = useLocation();
const quayConfig = useQuayConfig();
const {organizationName} = useParams();
const {organizationName, teamName} = useParams();
const {usernames} = useOrganizations();
const isUserOrganization = usernames.includes(organizationName);
@@ -34,6 +35,7 @@ export default function Organization() {
const onTabSelect = useCallback(
(_event: React.MouseEvent<HTMLElement, MouseEvent>, tabKey: string) => {
tabKey = tabKey.replace(/ /g, '');
setSearchParams({tab: tabKey});
setActiveTabKey(tabKey);
},
@@ -45,11 +47,15 @@ export default function Organization() {
return false;
}
if (!isUserOrganization && organization && (tabname == 'Settings' || tabname == 'Robot accounts')) {
if (
!isUserOrganization &&
organization &&
(tabname == 'Settings' || tabname == 'Robot accounts')
) {
return organization.is_org_admin || organization.is_admin;
}
return false;
}
};
const repositoriesSubNav = [
{
@@ -57,11 +63,25 @@ export default function Organization() {
component: <RepositoriesList organizationName={organizationName} />,
visible: true,
},
{
name: 'Teams and membership',
component: !teamName ? (
<TeamsAndMembershipList
key={window.location.pathname}
organizationName={organizationName}
/>
) : (
<ManageMembersList />
),
visible:
!isUserOrganization &&
organization?.is_member &&
organization?.is_admin,
},
{
name: 'Robot accounts',
component: <RobotAccountsList organizationName={organizationName} />,
visible: fetchTabVisibility('Robot accounts'),
},
{
name: 'Settings',
@@ -86,15 +106,17 @@ export default function Organization() {
padding={{default: 'noPadding'}}
>
<Tabs activeKey={activeTabKey} onSelect={onTabSelect}>
{repositoriesSubNav.filter((nav) => nav.visible).map((nav)=> (
<Tab
key={nav.name}
eventKey={nav.name}
title={<TabTitleText>{nav.name}</TabTitleText>}
>
{nav.component}
</Tab>
))}
{repositoriesSubNav
.filter((nav) => nav.visible)
.map((nav) => (
<Tab
key={nav.name}
eventKey={nav.name.replace(/ /g, '')}
title={<TabTitleText>{nav.name}</TabTitleText>}
>
{nav.component}
</Tab>
))}
</Tabs>
</PageSection>
</Page>

View File

@@ -46,17 +46,31 @@ const GeneralSettings = (props: GeneralSettingsProps) => {
const {updateOrgSettings} = useOrganizationSettings({
name: props.organizationName,
onSuccess: (result) => {
setAlerts(prevAlerts => {
return [...prevAlerts,
<Alert key="alert" variant="success" title="Successfully updated settings" isInline={true} timeout={5000} />
]
setAlerts((prevAlerts) => {
return [
...prevAlerts,
<Alert
key="alert"
variant="success"
title="Successfully updated settings"
isInline={true}
timeout={5000}
/>,
];
});
},
onError: (err) => {
setAlerts(prevAlerts => {
return [...prevAlerts,
<Alert key="alert" variant="danger" title={err.response.data.error_message} isInline={true} timeout={5000} />
]
setAlerts((prevAlerts) => {
return [
...prevAlerts,
<Alert
key="alert"
variant="danger"
title={err.response.data.error_message}
isInline={true}
timeout={5000}
/>,
];
});
},
});
@@ -65,27 +79,30 @@ const GeneralSettings = (props: GeneralSettingsProps) => {
// Time Machine
const [timeMachineFormValue, setTimeMachineFormValue] = useState(
timeMachineOptions[quayConfig.config.TAG_EXPIRATION_OPTIONS[0]],
timeMachineOptions[quayConfig?.config?.TAG_EXPIRATION_OPTIONS[0]],
);
const namespaceTimeMachineExpiry = isUserOrganization ? user?.tag_expiration_s : (organization as IOrganization)?.tag_expiration_s;
const namespaceTimeMachineExpiry = isUserOrganization
? user?.tag_expiration_s
: (organization as IOrganization)?.tag_expiration_s;
// Email
const namespaceEmail = isUserOrganization ? user?.email || '' : (organization as any)?.email || '';
const namespaceEmail = isUserOrganization
? user?.email || ''
: (organization as any)?.email || '';
const [emailFormValue, setEmailFormValue] = useState<string>('');
const [validated, setValidated] = useState<validate>('default');
useEffect(() => {
setEmailFormValue(namespaceEmail);
const humanized_expiry = humanizeTimeForExpiry(namespaceTimeMachineExpiry);
for (let key of Object.keys(timeMachineOptions)) {
if (humanized_expiry == timeMachineOptions[key]){
for (const key of Object.keys(timeMachineOptions)) {
if (humanized_expiry == timeMachineOptions[key]) {
setTimeMachineFormValue(key);
break;
}
}
}, [loading, isUserLoading, isUserOrganization]);
const handleEmailChange = (emailFormValue: string) => {
setEmailFormValue(emailFormValue);
if (namespaceEmail == emailFormValue) {
@@ -104,13 +121,12 @@ const GeneralSettings = (props: GeneralSettingsProps) => {
if (isValidEmail(emailFormValue)) {
setValidated('success');
setError('');
}
else {
} else {
setValidated('error');
setError('Please enter a valid email address');
}
}
}
};
const checkForChanges = () => {
if (namespaceEmail != emailFormValue) {
@@ -118,7 +134,7 @@ const GeneralSettings = (props: GeneralSettingsProps) => {
}
return getSeconds(timeMachineFormValue) != namespaceTimeMachineExpiry;
}
};
const updateSettings = async () => {
try {
@@ -135,7 +151,7 @@ const GeneralSettings = (props: GeneralSettingsProps) => {
} catch (error) {
addDisplayError('Unable to update namespace settings', error);
}
}
};
const onSubmit = (e) => {
e.preventDefault();
@@ -190,7 +206,7 @@ const GeneralSettings = (props: GeneralSettingsProps) => {
value={timeMachineFormValue}
onChange={(val) => setTimeMachineFormValue(val)}
>
{quayConfig.config.TAG_EXPIRATION_OPTIONS.map((option, index) => (
{quayConfig?.config?.TAG_EXPIRATION_OPTIONS.map((option, index) => (
<FormSelectOption
key={index}
value={option}
@@ -215,17 +231,11 @@ const GeneralSettings = (props: GeneralSettingsProps) => {
</Button>
</Flex>
</ActionGroup>
<AlertGroup isLiveRegion>
{alerts}
</AlertGroup>
<AlertGroup isLiveRegion>{alerts}</AlertGroup>
</Form>
);
};
// const BillingInformation = () => {
// return <h1>Hello</h1>;
// };
export default function Settings(props: SettingsProps) {
const [activeTabIndex, setActiveTabIndex] = useState(0);
@@ -239,11 +249,6 @@ export default function Settings(props: SettingsProps) {
id: 'generalsettings',
content: <GeneralSettings organizationName={props.organizationName} />,
},
// {
// name: 'Billing Information',
// id: 'billinginformation',
// content: <BillingInformation />,
// },
];
return (

View File

@@ -0,0 +1,79 @@
import {Alert, Button, Modal, ModalVariant} from '@patternfly/react-core';
import {useEffect} from 'react';
import {AlertVariant} from 'src/atoms/AlertState';
import {useAlerts} from 'src/hooks/UseAlerts';
import {useDeleteCollaborator} from 'src/hooks/UseMembers';
import {IMembers} from 'src/resources/MembersResource';
export default function CollaboratorsDeleteModal(
props: CollaboratorsDeleteModalProps,
) {
const {addAlert} = useAlerts();
const deleteMsg =
'User will be removed from all teams and repositories under this organization in which they are a member or have permissions.';
const deleteAlert = (
<Alert variant="warning" title={deleteMsg} ouiaId="WarningAlert" />
);
const {
removeCollaborator,
errorDeleteCollaborator,
successDeleteCollaborator,
} = useDeleteCollaborator(props.organizationName);
useEffect(() => {
if (errorDeleteCollaborator) {
addAlert({
variant: AlertVariant.Failure,
title: `Error deleting collaborator`,
});
}
}, [errorDeleteCollaborator]);
useEffect(() => {
if (successDeleteCollaborator) {
addAlert({
variant: AlertVariant.Success,
title: `Successfully deleted collaborator`,
});
}
}, [successDeleteCollaborator]);
return (
<Modal
variant={ModalVariant.medium}
title={'Remove user from organization'}
titleIconVariant="warning"
description={deleteAlert}
isOpen={props.isModalOpen}
onClose={props.toggleModal}
actions={[
<Button
key="delete"
variant="danger"
onClick={() => {
removeCollaborator({
collaborator: props.collaborator.name,
});
props.toggleModal;
}}
data-testid={`${props.collaborator.name}-del-btn`}
>
Delete
</Button>,
<Button key="cancel" variant="link" onClick={props.toggleModal}>
Cancel
</Button>,
]}
>
Are you sure you want to delete <b> {props.collaborator.name} </b>?
</Modal>
);
}
interface CollaboratorsDeleteModalProps {
isModalOpen: boolean;
toggleModal: () => void;
collaborator: IMembers;
organizationName: string;
}

View File

@@ -0,0 +1,177 @@
import {
Button,
PageSection,
PageSectionVariants,
PanelFooter,
Spinner,
} from '@patternfly/react-core';
import CollaboratorsViewToolbar from './CollaboratorsViewToolbar';
import {
TableComposable,
Tbody,
Td,
Th,
Thead,
Tr,
} from '@patternfly/react-table';
import {useFetchCollaborators} from 'src/hooks/UseMembers';
import {useEffect, useState} from 'react';
import {IMembers} from 'src/resources/MembersResource';
import {TrashIcon} from '@patternfly/react-icons';
import {useAlerts} from 'src/hooks/UseAlerts';
import {AlertVariant} from 'src/atoms/AlertState';
import {ToolbarPagination} from 'src/components/toolbar/ToolbarPagination';
import CollaboratorsDeleteModal from './CollaboratorsDeleteModal';
import Conditional from 'src/components/empty/Conditional';
export const collaboratorViewColumnNames = {
username: 'User name',
directRepositoryPermissions: 'Direct repository permissions',
};
export default function CollaboratorsViewList(
props: CollaboratorsViewListProps,
) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const {
filteredCollaborators,
paginatedCollaborators,
loading,
error,
page,
setPage,
perPage,
setPerPage,
search,
setSearch,
} = useFetchCollaborators(props.organizationName);
const [selectedCollaborators, setSelectedCollaborators] = useState<
IMembers[]
>([]);
const {addAlert} = useAlerts();
const [collaboratorToBeDeleted, setCollaboratorToBeDeleted] =
useState<IMembers>();
useEffect(() => {
if (error) {
addAlert({
variant: AlertVariant.Failure,
title: `Could not load collaborators`,
});
}
}, [error]);
const onSelectCollaborator = (
collaborator: IMembers,
rowIndex: number,
isSelecting: boolean,
) => {
setSelectedCollaborators((prevSelected) => {
const otherSelectedCollaborators = prevSelected.filter(
(m) => m.name !== collaborator.name,
);
return isSelecting
? [...otherSelectedCollaborators, collaborator]
: otherSelectedCollaborators;
});
};
const deleteCollabModal = (
<CollaboratorsDeleteModal
isModalOpen={isDeleteModalOpen}
toggleModal={() => setIsDeleteModalOpen(!isDeleteModalOpen)}
collaborator={collaboratorToBeDeleted}
organizationName={props.organizationName}
/>
);
if (loading) {
return <Spinner />;
}
return (
<PageSection variant={PageSectionVariants.light}>
<CollaboratorsViewToolbar
selectedMembers={selectedCollaborators}
deSelectAll={() => setSelectedCollaborators([])}
allItems={filteredCollaborators}
paginatedItems={paginatedCollaborators}
onItemSelect={onSelectCollaborator}
page={page}
setPage={setPage}
perPage={perPage}
setPerPage={setPerPage}
search={search}
setSearch={setSearch}
searchOptions={[collaboratorViewColumnNames.username]}
/>
{props.children}
<Conditional if={isDeleteModalOpen}>{deleteCollabModal}</Conditional>
<TableComposable aria-label="Selectable table">
<Thead>
<Tr>
<Th />
<Th>{collaboratorViewColumnNames.username}</Th>
<Th>{collaboratorViewColumnNames.directRepositoryPermissions}</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{paginatedCollaborators?.map((collaborator, rowIndex) => (
<Tr key={rowIndex}>
<Td
select={{
rowIndex,
onSelect: (_event, isSelecting) =>
onSelectCollaborator(collaborator, rowIndex, isSelecting),
isSelected: selectedCollaborators.some(
(t) => t.name === collaborator.name,
),
}}
/>
<Td dataLabel={collaboratorViewColumnNames.username}>
{collaborator.name}
</Td>
<Td
dataLabel={
collaboratorViewColumnNames.directRepositoryPermissions
}
>
Direct permissions on {collaborator.repositories?.length}{' '}
repositories under this organization
</Td>
<Td>
<Button
icon={<TrashIcon />}
variant="plain"
onClick={() => {
setCollaboratorToBeDeleted(collaborator);
setIsDeleteModalOpen(true);
}}
data-testid={`${collaborator.name}-del-icon`}
/>
</Td>
</Tr>
))}
</Tbody>
</TableComposable>
<PanelFooter>
<ToolbarPagination
itemsList={filteredCollaborators}
perPage={perPage}
page={page}
setPage={setPage}
setPerPage={setPerPage}
bottom={true}
/>
</PanelFooter>
</PageSection>
);
}
interface CollaboratorsViewListProps {
organizationName: string;
children?: React.ReactNode;
}

View File

@@ -0,0 +1,65 @@
import {Flex, FlexItem, Toolbar, ToolbarContent} from '@patternfly/react-core';
import {DropdownCheckbox} from 'src/components/toolbar/DropdownCheckbox';
import {SearchDropdown} from 'src/components/toolbar/SearchDropdown';
import {SearchInput} from 'src/components/toolbar/SearchInput';
import {SearchState} from 'src/components/toolbar/SearchTypes';
import {ToolbarPagination} from 'src/components/toolbar/ToolbarPagination';
import {IMembers} from 'src/resources/MembersResource';
export default function CollaboratorsViewToolbar(
props: CollaboratorsViewToolbarProps,
) {
return (
<Toolbar>
<ToolbarContent>
<DropdownCheckbox
selectedItems={props.selectedMembers}
deSelectAll={props.deSelectAll}
allItemsList={props.allItems}
itemsPerPageList={props.paginatedItems}
onItemSelect={props.onItemSelect}
/>
<SearchDropdown
items={props.searchOptions}
searchState={props.search}
setSearchState={props.setSearch}
/>
<Flex className="pf-u-mr-md">
<FlexItem>
<SearchInput
searchState={props.search}
onChange={props.setSearch}
id="collaborators-view-search"
/>
</FlexItem>
</Flex>
<ToolbarPagination
itemsList={props.allItems}
perPage={props.perPage}
page={props.page}
setPage={props.setPage}
setPerPage={props.setPerPage}
/>
</ToolbarContent>
</Toolbar>
);
}
interface CollaboratorsViewToolbarProps {
selectedMembers: IMembers[];
deSelectAll: () => void;
allItems: IMembers[];
paginatedItems: IMembers[];
onItemSelect: (
item: IMembers,
rowIndex: number,
isSelecting: boolean,
) => void;
page: number;
setPage: (page: number) => void;
perPage: number;
setPerPage: (perPage: number) => void;
searchOptions: string[];
search: SearchState;
setSearch: (search: SearchState) => void;
}

View File

@@ -0,0 +1,213 @@
import {
TableComposable,
Tbody,
Td,
Th,
Thead,
Tr,
} from '@patternfly/react-table';
import {Link, useSearchParams} from 'react-router-dom';
import {
Label,
PageSection,
PageSectionVariants,
PanelFooter,
Popover,
Spinner,
} from '@patternfly/react-core';
import {useEffect, useState} from 'react';
import MembersViewToolbar from './MembersViewToolbar';
import {useFetchMembers} from 'src/hooks/UseMembers';
import {IMemberTeams, IMembers} from 'src/resources/MembersResource';
import {useAlerts} from 'src/hooks/UseAlerts';
import {AlertVariant} from 'src/atoms/AlertState';
import {getTeamMemberPath} from 'src/routes/NavigationPath';
import {ToolbarPagination} from 'src/components/toolbar/ToolbarPagination';
export const memberViewColumnNames = {
username: 'User name',
teams: 'Teams',
directRepositoryPermissions: 'Direct repository permissions',
};
export default function MembersViewList(props: MembersViewListProps) {
const {
filteredMembers,
paginatedMembers,
loading,
error,
page,
setPage,
perPage,
setPerPage,
search,
setSearch,
} = useFetchMembers(props.organizationName);
const [selectedMembers, setSelectedMembers] = useState<IMembers[]>([]);
const [isPopoverOpen, setPopoverOpen] = useState(false);
const [searchParams] = useSearchParams();
const {addAlert} = useAlerts();
useEffect(() => {
if (error) {
addAlert({
variant: AlertVariant.Failure,
title: `Could not load members`,
});
}
}, [error]);
const handleClick = () => {
setPopoverOpen(!isPopoverOpen);
};
const onSelectMember = (
member: IMembers,
rowIndex: number,
isSelecting: boolean,
) => {
setSelectedMembers((prevSelected) => {
const otherSelectedMembers = prevSelected.filter(
(m) => m.name !== member.name,
);
return isSelecting
? [...otherSelectedMembers, member]
: otherSelectedMembers;
});
};
if (loading) {
return <Spinner />;
}
const renderTeamLabels = (teamsArr: IMemberTeams[], rowIndex: number) => {
const labelsToRender = teamsArr.slice(0, 4);
const remainingLabels = teamsArr.slice(4);
const labelsToDisplay = labelsToRender.map((team, idx) => (
<Link
to={getTeamMemberPath(
location.pathname,
props.organizationName,
team.name,
searchParams.get('tab'),
)}
key={idx * 4}
>
<Label key={team.name} color="blue">
{team.name}
</Label>{' '}
</Link>
));
if (remainingLabels?.length) {
labelsToDisplay.push(
<Label
key={rowIndex}
color="blue"
onClick={handleClick}
id={'team-popover'}
>
{remainingLabels.length} more{' '}
{isPopoverOpen ? (
<Popover
key={rowIndex}
headerContent={''}
bodyContent={remainingLabels.map((team, i) => (
<Link
key={i}
to={getTeamMemberPath(
location.pathname,
props.organizationName,
team.name,
searchParams.get('tab'),
)}
>
<Label key={team.name} color="blue">
{team.name}
</Label>{' '}
</Link>
))}
isVisible={isPopoverOpen}
shouldClose={handleClick}
reference={() =>
document.getElementById('team-popover') as HTMLButtonElement
}
/>
) : null}
</Label>,
);
}
return labelsToDisplay;
};
return (
<PageSection variant={PageSectionVariants.light}>
<MembersViewToolbar
selectedMembers={selectedMembers}
deSelectAll={() => setSelectedMembers([])}
allItems={filteredMembers}
paginatedItems={paginatedMembers}
onItemSelect={onSelectMember}
page={page}
setPage={setPage}
perPage={perPage}
setPerPage={setPerPage}
search={search}
setSearch={setSearch}
searchOptions={[memberViewColumnNames.username]}
/>
{props.children}
<TableComposable aria-label="Selectable table">
<Thead>
<Tr>
<Th />
<Th>{memberViewColumnNames.username}</Th>
<Th>{memberViewColumnNames.teams}</Th>
<Th>{memberViewColumnNames.directRepositoryPermissions}</Th>
</Tr>
</Thead>
<Tbody>
{paginatedMembers?.map((member, rowIndex) => (
<Tr key={rowIndex}>
<Td
select={{
rowIndex,
onSelect: (_event, isSelecting) =>
onSelectMember(member, rowIndex, isSelecting),
isSelected: selectedMembers.some(
(t) => t.name === member.name,
),
}}
/>
<Td dataLabel={memberViewColumnNames.username}>{member.name}</Td>
<Td dataLabel={memberViewColumnNames.teams}>
{renderTeamLabels(member.teams, rowIndex)}
</Td>
<Td dataLabel={memberViewColumnNames.directRepositoryPermissions}>
Direct permissions on {member.repositories?.length} repositories
under this organization
</Td>
</Tr>
))}
</Tbody>
</TableComposable>
<PanelFooter>
<ToolbarPagination
itemsList={filteredMembers}
perPage={perPage}
page={page}
setPage={setPage}
setPerPage={setPerPage}
bottom={true}
/>
</PanelFooter>
</PageSection>
);
}
interface MembersViewListProps {
organizationName: string;
children?: React.ReactNode;
}

View File

@@ -0,0 +1,63 @@
import {Flex, FlexItem, Toolbar, ToolbarContent} from '@patternfly/react-core';
import {DropdownCheckbox} from 'src/components/toolbar/DropdownCheckbox';
import {SearchDropdown} from 'src/components/toolbar/SearchDropdown';
import {SearchInput} from 'src/components/toolbar/SearchInput';
import {SearchState} from 'src/components/toolbar/SearchTypes';
import {ToolbarPagination} from 'src/components/toolbar/ToolbarPagination';
import {IMembers} from 'src/resources/MembersResource';
export default function MembersViewToolbar(props: MembersViewToolbarProps) {
return (
<Toolbar>
<ToolbarContent>
<DropdownCheckbox
selectedItems={props.selectedMembers}
deSelectAll={props.deSelectAll}
allItemsList={props.allItems}
itemsPerPageList={props.paginatedItems}
onItemSelect={props.onItemSelect}
/>
<SearchDropdown
items={props.searchOptions}
searchState={props.search}
setSearchState={props.setSearch}
/>
<Flex className="pf-u-mr-md">
<FlexItem>
<SearchInput
searchState={props.search}
onChange={props.setSearch}
id="members-view-search"
/>
</FlexItem>
</Flex>
<ToolbarPagination
itemsList={props.allItems}
perPage={props.perPage}
page={props.page}
setPage={props.setPage}
setPerPage={props.setPerPage}
/>
</ToolbarContent>
</Toolbar>
);
}
interface MembersViewToolbarProps {
selectedMembers: IMembers[];
deSelectAll: () => void;
allItems: IMembers[];
paginatedItems: IMembers[];
onItemSelect: (
item: IMembers,
rowIndex: number,
isSelecting: boolean,
) => void;
page: number;
setPage: (page: number) => void;
perPage: number;
setPerPage: (perPage: number) => void;
searchOptions: string[];
search: SearchState;
setSearch: (search: SearchState) => void;
}

View File

@@ -0,0 +1,92 @@
import {
ToggleGroup,
ToggleGroupItem,
ToggleGroupItemProps,
Toolbar,
ToolbarContent,
ToolbarItem,
} from '@patternfly/react-core';
import {useState} from 'react';
import TeamsViewList from './TeamsView/TeamsViewList';
import CollaboratorsViewList from './CollaboratorsView/CollaboratorsViewList';
import MembersViewList from './MembersView/MembersViewList';
export enum TableModeType {
Teams = 'Teams',
Members = 'Members',
Collaborators = 'Collaborators',
}
export default function TeamsAndMembershipList(
props: TeamsAndMembershipListProps,
) {
const [tableMode, setTableMode] = useState<TableModeType>(
TableModeType.Teams,
);
const onTableModeChange: ToggleGroupItemProps['onChange'] = (
_isSelected,
event,
) => {
const id = event.currentTarget.id;
setTableMode(id);
fetchTableItems();
};
const viewToggle = (
<Toolbar>
<ToolbarContent>
<ToolbarItem>
<ToggleGroup aria-label="Team and membership toggle view">
<ToggleGroupItem
text="Team View"
buttonId={TableModeType.Teams}
isSelected={tableMode == TableModeType.Teams}
onChange={onTableModeChange}
/>
<ToggleGroupItem
text="Members View"
buttonId={TableModeType.Members}
isSelected={tableMode == TableModeType.Members}
onChange={onTableModeChange}
/>
<ToggleGroupItem
text="Collaborators View"
buttonId={TableModeType.Collaborators}
isSelected={tableMode == TableModeType.Collaborators}
onChange={onTableModeChange}
/>
</ToggleGroup>
</ToolbarItem>
</ToolbarContent>
</Toolbar>
);
const fetchTableItems = () => {
if (tableMode == TableModeType.Teams) {
return (
<TeamsViewList organizationName={props.organizationName}>
{viewToggle}
</TeamsViewList>
);
} else if (tableMode == TableModeType.Members) {
return (
<MembersViewList organizationName={props.organizationName}>
{viewToggle}
</MembersViewList>
);
} else if (tableMode == TableModeType.Collaborators) {
return (
<CollaboratorsViewList organizationName={props.organizationName}>
{viewToggle}
</CollaboratorsViewList>
);
}
};
return fetchTableItems();
}
interface TeamsAndMembershipListProps {
organizationName: string;
}

View File

@@ -0,0 +1,272 @@
import {
Button,
PageSection,
PageSectionVariants,
Spinner,
ToggleGroup,
ToggleGroupItem,
ToggleGroupItemProps,
Toolbar,
ToolbarContent,
ToolbarItem,
} from '@patternfly/react-core';
import {useEffect, useState} from 'react';
import ManageMembersToolbar from './ManageMembersToolbar';
import {
TableComposable,
Tbody,
Td,
Th,
Thead,
Tr,
} from '@patternfly/react-table';
import {
ITeamMember,
useDeleteTeamMember,
useFetchTeamMembersForOrg,
} from 'src/hooks/UseMembers';
import {CubesIcon, TrashIcon} from '@patternfly/react-icons';
import {useParams} from 'react-router-dom';
import Empty from 'src/components/empty/Empty';
import {useAlerts} from 'src/hooks/UseAlerts';
import {AlertVariant} from 'src/atoms/AlertState';
export enum TableModeType {
AllMembers = 'All Members',
TeamMember = 'Team Member',
RobotAccounts = 'Robot Accounts',
Invited = 'Invited',
}
export const manageMemberColumnNames = {
teamMember: 'Team member',
account: 'Account',
};
export default function ManageMembersList() {
const {organizationName, teamName} = useParams();
const {
allMembers,
teamMembers,
robotAccounts,
invited,
paginatedAllMembers,
paginatedTeamMembers,
paginatedRobotAccounts,
paginatedInvited,
loading,
page,
setPage,
perPage,
setPerPage,
search,
setSearch,
} = useFetchTeamMembersForOrg(organizationName, teamName);
const [tableMembersList, setTableMembersList] = useState<ITeamMember[]>([]);
const [allMembersList, setAllMembersList] = useState<ITeamMember[]>([]);
const [selectedTeamMembers, setSelectedTeamMembers] = useState<ITeamMember[]>(
[],
);
const [tableMode, setTableMode] = useState<TableModeType>(
TableModeType.AllMembers,
);
const {addAlert} = useAlerts();
useEffect(() => {
switch (tableMode) {
case TableModeType.AllMembers:
setTableMembersList(paginatedAllMembers);
setAllMembersList(allMembers);
break;
case TableModeType.TeamMember:
setTableMembersList(paginatedTeamMembers);
setAllMembersList(teamMembers);
break;
case TableModeType.RobotAccounts:
setTableMembersList(paginatedRobotAccounts);
setAllMembersList(robotAccounts);
break;
case TableModeType.Invited:
setTableMembersList(paginatedInvited);
setAllMembersList(invited);
break;
default:
break;
}
}, [tableMode, allMembers]);
const onTableModeChange: ToggleGroupItemProps['onChange'] = (
_isSelected,
event,
) => {
const id = event.currentTarget.id;
setTableMode(id);
};
const onSelectTeamMember = (
teamMember: ITeamMember,
rowIndex: number,
isSelecting: boolean,
) => {
setSelectedTeamMembers((prevSelected) => {
const otherSelectedTeamMembers = prevSelected.filter(
(t) => t.name !== teamMember.name,
);
return isSelecting
? [...otherSelectedTeamMembers, teamMember]
: otherSelectedTeamMembers;
});
};
const {removeTeamMember, errorDeleteTeamMember, successDeleteTeamMember} =
useDeleteTeamMember(organizationName);
useEffect(() => {
if (successDeleteTeamMember) {
addAlert({
variant: AlertVariant.Success,
title: `Successfully deleted team member`,
});
}
}, [successDeleteTeamMember]);
useEffect(() => {
if (errorDeleteTeamMember) {
addAlert({
variant: AlertVariant.Failure,
title: `Error deleting team member`,
});
}
}, [errorDeleteTeamMember]);
const viewToggle = (
<Toolbar>
<ToolbarContent>
<ToolbarItem spacer={{default: 'spacerMd'}}>
<ToggleGroup aria-label="Manage members toggle view">
<ToggleGroupItem
text="All members"
buttonId={TableModeType.AllMembers}
isSelected={tableMode == TableModeType.AllMembers}
onChange={onTableModeChange}
/>
<ToggleGroupItem
text="Team member"
buttonId={TableModeType.TeamMember}
isSelected={tableMode == TableModeType.TeamMember}
onChange={onTableModeChange}
/>
<ToggleGroupItem
text="Robot accounts"
buttonId={TableModeType.RobotAccounts}
isSelected={tableMode == TableModeType.RobotAccounts}
onChange={onTableModeChange}
/>
<ToggleGroupItem
text="Invited"
buttonId={TableModeType.Invited}
isSelected={tableMode == TableModeType.Invited}
onChange={onTableModeChange}
/>
</ToggleGroup>
</ToolbarItem>
</ToolbarContent>
</Toolbar>
);
const getAccountTypeForMember = (member: ITeamMember): string => {
if (member.is_robot) {
return 'Robot account';
} else if (!member.is_robot && !member.invited) {
return 'Team member';
} else if (member.invited) {
return '(Invited)';
}
};
if (loading) {
return <Spinner />;
}
if (allMembers?.length === 0) {
return (
<Empty
title="There are no viewable members for this team"
icon={CubesIcon}
body="Either no team members exist yet or you may not have permission to view any."
/>
);
}
return (
<PageSection variant={PageSectionVariants.light}>
<ManageMembersToolbar
selectedTeams={selectedTeamMembers}
deSelectAll={() => setSelectedTeamMembers([])}
allItems={allMembersList}
paginatedItems={tableMembersList}
onItemSelect={onSelectTeamMember}
page={page}
setPage={setPage}
perPage={perPage}
setPerPage={setPerPage}
search={search}
setSearch={setSearch}
searchOptions={[manageMemberColumnNames.teamMember]}
>
{viewToggle}
<TableComposable aria-label="Selectable table">
<Thead>
<Tr>
<Th />
<Th>{manageMemberColumnNames.teamMember}</Th>
<Th>{manageMemberColumnNames.account}</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{tableMembersList.map((teamMember, rowIndex) => (
<Tr key={rowIndex}>
<Td
select={{
rowIndex,
onSelect: (_event, isSelecting) =>
onSelectTeamMember(teamMember, rowIndex, isSelecting),
isSelected: selectedTeamMembers.some(
(t) => t.name === teamMember.name,
),
}}
/>
<Td dataLabel={manageMemberColumnNames.teamMember}>
{teamMember.name}
</Td>
<Td dataLabel={manageMemberColumnNames.account}>
{getAccountTypeForMember(teamMember)}
</Td>
<Td>
<Button
icon={<TrashIcon />}
variant="plain"
onClick={() =>
removeTeamMember({
teamName,
memberName: teamMember.name,
})
}
data-testid={`${teamMember.name}-delete-icon`}
/>
</Td>
</Tr>
))}
</Tbody>
</TableComposable>
</ManageMembersToolbar>
</PageSection>
);
}

View File

@@ -0,0 +1,82 @@
import {
Flex,
FlexItem,
PanelFooter,
Toolbar,
ToolbarContent,
} from '@patternfly/react-core';
import {DropdownCheckbox} from 'src/components/toolbar/DropdownCheckbox';
import {SearchDropdown} from 'src/components/toolbar/SearchDropdown';
import {SearchInput} from 'src/components/toolbar/SearchInput';
import {SearchState} from 'src/components/toolbar/SearchTypes';
import {ToolbarPagination} from 'src/components/toolbar/ToolbarPagination';
import {ITeamMember} from 'src/hooks/UseMembers';
export default function ManageMembersToolbar(props: ManageMembersToolbarProps) {
return (
<>
<Toolbar>
<ToolbarContent>
<DropdownCheckbox
selectedItems={props.selectedTeams}
deSelectAll={props.deSelectAll}
allItemsList={props.allItems}
itemsPerPageList={props.paginatedItems}
onItemSelect={props.onItemSelect}
/>
<SearchDropdown
items={props.searchOptions}
searchState={props.search}
setSearchState={props.setSearch}
/>
<Flex className="pf-u-mr-md">
<FlexItem>
<SearchInput
searchState={props.search}
onChange={props.setSearch}
/>
</FlexItem>
</Flex>
<ToolbarPagination
itemsList={props.paginatedItems}
perPage={props.perPage}
page={props.page}
setPage={props.setPage}
setPerPage={props.setPerPage}
/>
</ToolbarContent>
</Toolbar>
{props.children}
<PanelFooter>
<ToolbarPagination
itemsList={props.paginatedItems}
perPage={props.perPage}
page={props.page}
setPage={props.setPage}
setPerPage={props.setPerPage}
bottom={true}
/>
</PanelFooter>
</>
);
}
interface ManageMembersToolbarProps {
selectedTeams: ITeamMember[];
deSelectAll: () => void;
allItems: ITeamMember[];
paginatedItems: ITeamMember[];
onItemSelect: (
item: ITeamMember,
rowIndex: number,
isSelecting: boolean,
) => void;
page: number;
setPage: (page: number) => void;
perPage: number;
setPerPage: (perPage: number) => void;
searchOptions: string[];
search: SearchState;
setSearch: (search: SearchState) => void;
children?: React.ReactNode;
}

View File

@@ -0,0 +1,62 @@
import {Dropdown, DropdownItem, DropdownToggle} from '@patternfly/react-core';
import {useEffect, useState} from 'react';
import {ITeamRepoPerms} from 'src/hooks/UseTeams';
import {RepoPermissionDropdownItems} from 'src/routes/RepositoriesList/RobotAccountsList';
export function SetRepoPermForTeamRoleDropDown(
props: SetRepoPermForTeamRoleDropDownProps,
) {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [dropdownValue, setDropdownValue] = useState<string>(
props.repoPerm?.role,
);
const dropdownOnSelect = (roleName) => {
setDropdownValue(roleName);
props.updateModifiedRepoPerms(roleName?.toLowerCase(), props.repoPerm);
};
useEffect(() => {
if (props?.isItemSelected) {
dropdownOnSelect(props.selectedVal);
}
}, [props.selectedVal]);
useEffect(() => {
setDropdownValue(props.repoPerm?.role);
}, [props.repoPerm?.role]);
return (
<Dropdown
data-testid={`${props.repoPerm.repoName}-role-dropdown`}
onSelect={() => setIsOpen(false)}
toggle={
<DropdownToggle onToggle={() => setIsOpen(!isOpen)}>
{dropdownValue
? dropdownValue?.charAt(0).toUpperCase() + dropdownValue?.slice(1)
: 'None'}
</DropdownToggle>
}
isOpen={isOpen}
dropdownItems={RepoPermissionDropdownItems.map((item) => (
<DropdownItem
data-testid={`${props.repoPerm.repoName}-${item.name}`}
key={item.name}
description={item.description}
onClick={() => dropdownOnSelect(item.name)}
>
{item.name}
</DropdownItem>
))}
/>
);
}
interface SetRepoPermForTeamRoleDropDownProps {
organizationName: string;
teamName: string;
repoPerm: ITeamRepoPerms;
updateModifiedRepoPerms: (role: string, repoPerm: ITeamRepoPerms) => void;
isItemSelected?: boolean;
selectedVal: string;
}

View File

@@ -0,0 +1,261 @@
import {
TableComposable,
Tbody,
Td,
Th,
Thead,
Tr,
} from '@patternfly/react-table';
import {Button, Modal, ModalVariant, Spinner} from '@patternfly/react-core';
import {useEffect, useState} from 'react';
import Empty from 'src/components/empty/Empty';
import {CubesIcon} from '@patternfly/react-icons';
import {useAlerts} from 'src/hooks/UseAlerts';
import SetRepoPermissionsToolbar from './SetRepoPermissionsForTeamToolbar';
import {
ITeamRepoPerms,
useFetchRepoPermForTeam,
useUpdateTeamRepoPerm,
} from 'src/hooks/UseTeams';
import {SetRepoPermForTeamRoleDropDown} from './SetRepoPermForTeamRoleDropDown';
import {formatDate} from 'src/libs/utils';
import {AlertVariant} from 'src/atoms/AlertState';
export const setRepoPermForTeamColumnNames = {
repoName: 'Repository',
permissions: 'Permissions',
lastUpdate: 'Last Updated',
};
export default function SetRepoPermissionForTeamModal(
props: SetRepoPermissionForTeamModalProps,
) {
const {
teamRepoPerms,
paginatedTeamRepoPerms,
filteredTeamRepoPerms,
loading,
page,
setPage,
perPage,
setPerPage,
search,
setSearch,
} = useFetchRepoPermForTeam(props.organizationName, props.teamName);
const {
updateRepoPerm,
errorUpdateRepoPerm,
detailedErrorUpdateRepoPerm,
successUpdateRepoPerm,
} = useUpdateTeamRepoPerm(props.organizationName, props.teamName);
const [selectedRepoPerms, setSelectedRepoPerms] = useState<ITeamRepoPerms[]>(
[],
);
const [modifiedRepoPerms, setModifiedRepoPerms] = useState<ITeamRepoPerms[]>(
[],
);
const [isKebabOpen, setKebabOpen] = useState(false);
const {addAlert} = useAlerts();
useEffect(() => {
if (successUpdateRepoPerm) {
addAlert({
variant: AlertVariant.Success,
title: `Updated repo perm for team: ${props.teamName} successfully`,
});
props.handleModalToggle();
}
if (errorUpdateRepoPerm) {
const errorUpdatingRepoPermMessage = (
<>
{Array.from(detailedErrorUpdateRepoPerm.getErrors()).map(
([repoPerm, error]) => (
<p key={repoPerm}>
Could not update repo permission for {repoPerm}:{' '}
{error.error.message}
</p>
),
)}
</>
);
addAlert({
variant: AlertVariant.Failure,
title: `Could not update repo permissions`,
message: errorUpdatingRepoPermMessage,
});
}
}, [successUpdateRepoPerm, errorUpdateRepoPerm]);
const onSelectRepoPerm = (
repoPerm: ITeamRepoPerms,
rowIndex: number,
isSelecting: boolean,
) => {
setSelectedRepoPerms((prevSelected) => {
const otherSelectedRepoPerms = prevSelected.filter(
(t) => t.repoName !== repoPerm.repoName,
);
return isSelecting
? [...otherSelectedRepoPerms, repoPerm]
: otherSelectedRepoPerms;
});
};
const setRepoPermForTeamHandler = () => {
// Filter to only update records which have modified role
const updatedRepoPerms = modifiedRepoPerms.filter((modifiedPerm) =>
teamRepoPerms.find(
(teamPerm) =>
teamPerm.repoName === modifiedPerm.repoName &&
teamPerm.role !== modifiedPerm.role,
),
);
setModifiedRepoPerms(updatedRepoPerms);
if (updatedRepoPerms?.length > 0) {
updateRepoPerm({teamRepoPerms: updatedRepoPerms});
} else {
props.handleModalToggle();
}
};
if (loading) {
return <Spinner />;
}
const isItemSelected = (repoPerm) =>
selectedRepoPerms.some((t) => t.repoName === repoPerm.repoName);
const fetchRepoPermission = (repoPerm) => {
for (const item of modifiedRepoPerms) {
if (repoPerm.repoName == item.repoName) {
return item.role;
}
}
return 'None';
};
const updateModifiedRepoPerms = (
roleName: string,
repoPerm: ITeamRepoPerms,
) => {
// Remove item if already present
setModifiedRepoPerms((prev) => [
...prev.filter((item) => item.repoName !== repoPerm.repoName),
{
repoName: repoPerm.repoName,
role: roleName,
lastModified: repoPerm.lastModified,
},
]);
};
const emptyPermComponent = (
<Empty
title="No matching repositories found"
icon={CubesIcon}
body="Either no repositories exist yet or you may not have permission to view any."
/>
);
return (
<Modal
title={`Set repository permissions for ${props.teamName}`}
variant={ModalVariant.large}
isOpen={props.isModalOpen}
onClose={props.handleModalToggle}
actions={[
<Button
id="update-team-repo-permissions"
key="confirm"
variant="primary"
onClick={setRepoPermForTeamHandler}
form="modal-with-form-form"
isDisabled={modifiedRepoPerms?.length === 0}
>
Update
</Button>,
<Button
id="update-team-repo-permissions-cancel"
key="cancel"
variant="link"
onClick={props.handleModalToggle}
>
Cancel
</Button>,
]}
>
{!teamRepoPerms?.length ? (
emptyPermComponent
) : (
<SetRepoPermissionsToolbar
selectedRepoPerms={selectedRepoPerms}
deSelectAll={() => setSelectedRepoPerms([])}
allItems={filteredTeamRepoPerms}
paginatedItems={paginatedTeamRepoPerms}
onItemSelect={onSelectRepoPerm}
page={page}
setPage={setPage}
perPage={perPage}
setPerPage={setPerPage}
search={search}
setSearch={setSearch}
searchOptions={[setRepoPermForTeamColumnNames.repoName]}
isKebabOpen={isKebabOpen}
setKebabOpen={setKebabOpen}
updateModifiedRepoPerms={updateModifiedRepoPerms}
>
<TableComposable aria-label="Selectable table">
<Thead>
<Tr>
<Th />
<Th>{setRepoPermForTeamColumnNames.repoName}</Th>
<Th>{setRepoPermForTeamColumnNames.permissions}</Th>
<Th>{setRepoPermForTeamColumnNames.lastUpdate}</Th>
</Tr>
</Thead>
<Tbody>
{paginatedTeamRepoPerms?.map((repoPerm, rowIndex) => (
<Tr key={rowIndex}>
<Td
select={{
rowIndex,
onSelect: (_event, isSelecting) =>
onSelectRepoPerm(repoPerm, rowIndex, isSelecting),
isSelected: isItemSelected(repoPerm),
}}
/>
<Td dataLabel={setRepoPermForTeamColumnNames.repoName}>
{repoPerm.repoName}
</Td>
<Td dataLabel={setRepoPermForTeamColumnNames.permissions}>
<SetRepoPermForTeamRoleDropDown
organizationName={props.organizationName}
teamName={props.teamName}
repoPerm={repoPerm}
updateModifiedRepoPerms={updateModifiedRepoPerms}
isItemSelected={isItemSelected(repoPerm)}
selectedVal={fetchRepoPermission(repoPerm)}
/>
</Td>
<Td dataLabel={setRepoPermForTeamColumnNames.lastUpdate}>
{formatDate(repoPerm.lastModified)}
</Td>
</Tr>
))}
</Tbody>
</TableComposable>
</SetRepoPermissionsToolbar>
)}
</Modal>
);
}
interface SetRepoPermissionForTeamModalProps {
organizationName: string;
teamName: string;
isModalOpen: boolean;
handleModalToggle: () => void;
}

View File

@@ -0,0 +1,119 @@
import {
DropdownItem,
Flex,
FlexItem,
PanelFooter,
Toolbar,
ToolbarContent,
ToolbarItem,
} from '@patternfly/react-core';
import Conditional from 'src/components/empty/Conditional';
import {DropdownCheckbox} from 'src/components/toolbar/DropdownCheckbox';
import {Kebab} from 'src/components/toolbar/Kebab';
import {SearchDropdown} from 'src/components/toolbar/SearchDropdown';
import {SearchInput} from 'src/components/toolbar/SearchInput';
import {SearchState} from 'src/components/toolbar/SearchTypes';
import {ToolbarPagination} from 'src/components/toolbar/ToolbarPagination';
import {ITeamRepoPerms} from 'src/hooks/UseTeams';
import {RepoPermissionDropdownItems} from 'src/routes/RepositoriesList/RobotAccountsList';
export default function SetRepoPermissionsForTeamModalToolbar(
props: SetRepoPermissionsForTeamModalToolbarProps,
) {
const dropdownOnSelect = (selectedVal) => {
props.selectedRepoPerms.map((repoPerm) => {
props.updateModifiedRepoPerms(selectedVal?.toLowerCase(), repoPerm);
});
};
return (
<>
<Toolbar>
<ToolbarContent>
<DropdownCheckbox
selectedItems={props.selectedRepoPerms}
deSelectAll={props.deSelectAll}
allItemsList={props.allItems}
itemsPerPageList={props.paginatedItems}
onItemSelect={props.onItemSelect}
id="add-repository-bulk-select"
/>
<SearchDropdown
items={props.searchOptions}
searchState={props.search}
setSearchState={props.setSearch}
/>
<Flex className="pf-u-mr-md">
<FlexItem>
<SearchInput
searchState={props.search}
onChange={props.setSearch}
id="set-repo-perm-for-team-search"
/>
</FlexItem>
</Flex>
<ToolbarItem>
<Conditional if={props.selectedRepoPerms?.length > 0}>
<Kebab
isKebabOpen={props.isKebabOpen}
setKebabOpen={props.setKebabOpen}
kebabItems={RepoPermissionDropdownItems.map((item) => (
<DropdownItem
key={item.name}
description={item.description}
onClick={() => dropdownOnSelect(item.name)}
>
{item.name}
</DropdownItem>
))}
useActions={false}
id="toggle-bulk-perms-kebab"
/>
</Conditional>
</ToolbarItem>
<ToolbarPagination
itemsList={props.allItems}
perPage={props.perPage}
page={props.page}
setPage={props.setPage}
setPerPage={props.setPerPage}
/>
</ToolbarContent>
</Toolbar>
{props.children}
<PanelFooter>
<ToolbarPagination
itemsList={props.allItems}
perPage={props.perPage}
page={props.page}
setPage={props.setPage}
setPerPage={props.setPerPage}
bottom={true}
/>
</PanelFooter>
</>
);
}
interface SetRepoPermissionsForTeamModalToolbarProps {
selectedRepoPerms: ITeamRepoPerms[];
deSelectAll: () => void;
allItems: ITeamRepoPerms[];
paginatedItems: ITeamRepoPerms[];
onItemSelect: (
item: ITeamRepoPerms,
rowIndex: number,
isSelecting: boolean,
) => void;
page: number;
setPage: (page: number) => void;
perPage: number;
setPerPage: (perPage: number) => void;
searchOptions: string[];
search: SearchState;
setSearch: (search: SearchState) => void;
children?: React.ReactNode;
isKebabOpen: boolean;
setKebabOpen: (open: boolean) => void;
updateModifiedRepoPerms: (item, repoPerm) => void;
}

View File

@@ -0,0 +1,92 @@
import {
Dropdown,
DropdownItem,
KebabToggle,
DropdownPosition,
} from '@patternfly/react-core';
import {useState} from 'react';
import {Link, useSearchParams} from 'react-router-dom';
import {AlertVariant} from 'src/atoms/AlertState';
import {useAlerts} from 'src/hooks/UseAlerts';
import {ITeams, useDeleteTeam} from 'src/hooks/UseTeams';
import {getTeamMemberPath} from 'src/routes/NavigationPath';
export default function TeamViewKebab(props: TeamViewKebabProps) {
const [isKebabOpen, setIsKebabOpen] = useState<boolean>(false);
const [searchParams] = useSearchParams();
const {addAlert} = useAlerts();
const {removeTeam} = useDeleteTeam({
orgName: props.organizationName,
onSuccess: () => {
props.deSelectAll();
addAlert({
variant: AlertVariant.Success,
title: `Successfully deleted team`,
});
},
onError: (err) => {
addAlert({
variant: AlertVariant.Failure,
title: `Failed to delete team: ${err}`,
});
},
});
return (
<Dropdown
onSelect={() => setIsKebabOpen(!isKebabOpen)}
toggle={
<KebabToggle
data-testid={`${props.team.name}-toggle-kebab`}
onToggle={() => {
setIsKebabOpen(!isKebabOpen);
}}
/>
}
isOpen={isKebabOpen}
dropdownItems={[
<DropdownItem
key="link"
component={
<Link
to={getTeamMemberPath(
location.pathname,
props.organizationName,
props.team.name,
searchParams.get('tab'),
)}
data-testid={`${props.team.name}-manage-team-member-option`}
>
Manage team members
</Link>
}
></DropdownItem>,
<DropdownItem
key="set-repo-perms"
onClick={props.onSelectRepo}
data-testid={`${props.team.name}-set-repo-perms-option`}
>
Set repository permissions
</DropdownItem>,
<DropdownItem
key="delete"
onClick={() => removeTeam(props.team)}
className="red-color"
data-testid={`${props.team.name}-del-option`}
>
Delete
</DropdownItem>,
]}
isPlain
position={DropdownPosition.right}
/>
);
}
interface TeamViewKebabProps {
organizationName: string;
team: ITeams;
deSelectAll: () => void;
onSelectRepo: () => void;
}

View File

@@ -0,0 +1,73 @@
import {Dropdown, DropdownItem, DropdownToggle} from '@patternfly/react-core';
import {useEffect, useState} from 'react';
import {AlertVariant} from 'src/atoms/AlertState';
import {useAlerts} from 'src/hooks/UseAlerts';
import {useUpdateTeamRole} from 'src/hooks/UseTeams';
export enum teamPermissions {
Admin = 'admin',
Member = 'member',
Creator = 'creator',
}
export function TeamsRoleDropDown(props: TeamsRoleDropDownProps) {
const [isOpen, setIsOpen] = useState<boolean>(false);
const {addAlert} = useAlerts();
const {
updateTeamRole,
errorUpdateTeamRole: error,
successUpdateTeamRole: success,
} = useUpdateTeamRole(props.organizationName);
useEffect(() => {
if (error) {
addAlert({
variant: AlertVariant.Failure,
title: `Unable to update role for team: ${props.teamName}`,
});
}
}, [error]);
useEffect(() => {
if (success) {
addAlert({
variant: AlertVariant.Success,
title: `Team role updated successfully for: ${props.teamName}`,
});
}
}, [success]);
return (
<Dropdown
data-testid={`${props.teamName}-team-dropdown`}
onSelect={() => setIsOpen(false)}
toggle={
<DropdownToggle onToggle={() => setIsOpen(!isOpen)}>
{props.teamRole.charAt(0).toUpperCase() + props.teamRole.slice(1)}
</DropdownToggle>
}
isOpen={isOpen}
dropdownItems={Object.keys(teamPermissions).map((key) => (
<DropdownItem
data-testid={`${props.teamName}-${key}`}
key={key}
onClick={() =>
updateTeamRole({
teamName: props.teamName,
teamRole: teamPermissions[key],
})
}
>
{key}
</DropdownItem>
))}
/>
);
}
interface TeamsRoleDropDownProps {
organizationName: string;
teamName: string;
teamRole: string;
}

View File

@@ -0,0 +1,307 @@
import {
TableComposable,
Tbody,
Td,
Th,
Thead,
Tr,
} from '@patternfly/react-table';
import TeamsViewToolbar from './TeamsViewToolbar';
import {Link, useSearchParams} from 'react-router-dom';
import {
Dropdown,
DropdownItem,
DropdownToggle,
PageSection,
PageSectionVariants,
PanelFooter,
Spinner,
} from '@patternfly/react-core';
import {useEffect, useState} from 'react';
import TeamViewKebab from './TeamViewKebab';
import {ITeams, useDeleteTeam, useFetchTeams} from 'src/hooks/UseTeams';
import {TeamsRoleDropDown} from './TeamsRoleDropDown';
import {BulkDeleteModalTemplate} from 'src/components/modals/BulkDeleteModalTemplate';
import {BulkOperationError, addDisplayError} from 'src/resources/ErrorHandling';
import ErrorModal from 'src/components/errors/ErrorModal';
import {getTeamMemberPath} from 'src/routes/NavigationPath';
import {useAlerts} from 'src/hooks/UseAlerts';
import {AlertVariant} from 'src/atoms/AlertState';
import SetRepoPermissionForTeamModal from 'src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/SetRepoPermissionsModal/SetRepoPermissionForTeamModal';
import {ToolbarPagination} from 'src/components/toolbar/ToolbarPagination';
export const teamViewColumnNames = {
teamName: 'Team name',
members: 'Members',
repositories: 'Repositories',
teamRole: 'Team role',
};
export default function TeamsViewList(props: TeamsViewListProps) {
const {
teams,
filteredTeams,
paginatedTeams,
loading,
error,
page,
setPage,
perPage,
setPerPage,
search,
setSearch,
} = useFetchTeams(props.organizationName);
const [selectedTeams, setSelectedTeams] = useState<ITeams[]>([]);
const [isKebabOpen, setKebabOpen] = useState(false);
const [deleteModalIsOpen, setDeleteModalIsOpen] = useState(false);
const [err, setIsError] = useState<string[]>();
const [searchParams] = useSearchParams();
const {addAlert} = useAlerts();
const [isSetRepoPermModalOpen, setIsSetRepoPermModalOpen] = useState(false);
const [repoPermForTeam, setRepoPermForTeam] = useState<string>('');
useEffect(() => {
if (error) {
addAlert({variant: AlertVariant.Failure, title: `Could not load teams`});
}
}, [error]);
const handleDeleteModalToggle = () => {
setKebabOpen(!isKebabOpen);
setDeleteModalIsOpen(!deleteModalIsOpen);
};
const kebabItems = [
<DropdownItem key="delete" onClick={handleDeleteModalToggle}>
Delete
</DropdownItem>,
];
/* Mapper object used to render bulk delete table
- keys are actual column names of the table
- value is an object type with a "label" which maps to the attributes of <T>
and an optional "transformFunc" which can be used to modify the value being displayed */
const mapOfColNamesToTableData = {
'Team Name': {
label: 'name',
transformFunc: (team: ITeams) => {
return `${team.name}`;
},
},
Members: {
label: 'members',
transformFunc: (team: ITeams) => team.member_count,
},
Repositories: {
label: 'repositories',
transformFunc: (team: ITeams) => {
return `${team.repo_count}`;
},
},
'Team Role': {
label: 'team role',
transformFunc: (team: ITeams) => (
<Dropdown
toggle={
<DropdownToggle id="toggle-disabled" isDisabled>
{team.role}
</DropdownToggle>
}
isOpen={false}
dropdownItems={[team.role]}
/>
),
},
};
const {removeTeam} = useDeleteTeam({
orgName: props.organizationName,
onSuccess: () => {
setDeleteModalIsOpen(!deleteModalIsOpen);
setSelectedTeams([]);
addAlert({
variant: AlertVariant.Success,
title: `Successfully deleted teams`,
});
},
onError: (err) => {
if (err instanceof BulkOperationError) {
const errMessages = [];
err.getErrors().forEach((error, team) => {
addAlert({
variant: AlertVariant.Failure,
title: `Could not delete team ${team}: ${error.error}`,
});
errMessages.push(
addDisplayError(`Failed to delete teams ${team}`, error.error),
);
});
setIsError(errMessages);
} else {
setIsError([addDisplayError('Failed to delete teams', err)]);
}
setDeleteModalIsOpen(!deleteModalIsOpen);
setSelectedTeams([]);
},
});
const deleteModal = (
<BulkDeleteModalTemplate
mapOfColNamesToTableData={mapOfColNamesToTableData}
handleModalToggle={handleDeleteModalToggle}
handleBulkDeletion={removeTeam}
isModalOpen={deleteModalIsOpen}
selectedItems={teams?.filter((team) =>
selectedTeams.some((selected) => team.name === selected.name),
)}
resourceName={'teams'}
/>
);
const onSelectTeam = (
team: ITeams,
rowIndex: number,
isSelecting: boolean,
) => {
setSelectedTeams((prevSelected) => {
const otherSelectedTeams = prevSelected.filter(
(t) => t.name !== team.name,
);
return isSelecting ? [...otherSelectedTeams, team] : otherSelectedTeams;
});
};
const setRepoPermModal = (
<SetRepoPermissionForTeamModal
isModalOpen={isSetRepoPermModalOpen}
handleModalToggle={() =>
setIsSetRepoPermModalOpen(!isSetRepoPermModalOpen)
}
organizationName={props.organizationName}
teamName={repoPermForTeam}
/>
);
const openSetRepoPermModal = (teamName: string) => {
setRepoPermForTeam(teamName);
setIsSetRepoPermModalOpen(!isSetRepoPermModalOpen);
};
if (loading) {
return <Spinner />;
}
return (
<PageSection variant={PageSectionVariants.light}>
<ErrorModal
title="Team deletion failed"
error={err}
setError={setIsError}
/>
<TeamsViewToolbar
selectedTeams={selectedTeams}
deSelectAll={() => setSelectedTeams([])}
allItems={filteredTeams}
paginatedItems={paginatedTeams}
onItemSelect={onSelectTeam}
page={page}
setPage={setPage}
perPage={perPage}
setPerPage={setPerPage}
search={search}
setSearch={setSearch}
searchOptions={[teamViewColumnNames.teamName]}
isKebabOpen={isKebabOpen}
setKebabOpen={setKebabOpen}
kebabItems={kebabItems}
deleteKebabIsOpen={deleteModalIsOpen}
deleteModal={deleteModal}
isSetRepoPermModalOpen={isSetRepoPermModalOpen}
setRepoPermModal={setRepoPermModal}
/>
{props.children}
<TableComposable aria-label="Selectable table">
<Thead>
<Tr>
<Th />
<Th>{teamViewColumnNames.teamName}</Th>
<Th>{teamViewColumnNames.members}</Th>
<Th>{teamViewColumnNames.repositories}</Th>
<Th>{teamViewColumnNames.teamRole}</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{paginatedTeams?.map((team, rowIndex) => (
<Tr key={rowIndex}>
<Td
select={{
rowIndex,
onSelect: (_event, isSelecting) =>
onSelectTeam(team, rowIndex, isSelecting),
isSelected: selectedTeams.some((t) => t.name === team.name),
}}
/>
<Td dataLabel={teamViewColumnNames.teamName}>{team.name}</Td>
<Td dataLabel={teamViewColumnNames.members}>
<Link
to={getTeamMemberPath(
location.pathname,
props.organizationName,
team.name,
searchParams.get('tab'),
)}
>
{team.member_count}
</Link>
</Td>
<Td dataLabel={teamViewColumnNames.repositories}>
<Link
to="#"
onClick={() => {
openSetRepoPermModal(team.name);
}}
>
{team.repo_count}
</Link>
</Td>
<Td dataLabel={teamViewColumnNames.teamRole}>
<TeamsRoleDropDown
organizationName={props.organizationName}
teamName={team.name}
teamRole={team.role}
/>
</Td>
<Td data-label="kebab">
<TeamViewKebab
organizationName={props.organizationName}
team={team}
deSelectAll={() => setSelectedTeams([])}
onSelectRepo={() => {
openSetRepoPermModal(team.name);
}}
/>
</Td>
</Tr>
))}
</Tbody>
</TableComposable>
<PanelFooter>
<ToolbarPagination
itemsList={filteredTeams}
perPage={perPage}
page={page}
setPage={setPage}
setPerPage={setPerPage}
bottom={true}
/>
</PanelFooter>
</PageSection>
);
}
interface TeamsViewListProps {
organizationName: string;
children?: React.ReactNode;
}

View File

@@ -0,0 +1,90 @@
import {
Flex,
FlexItem,
Toolbar,
ToolbarContent,
ToolbarItem,
} from '@patternfly/react-core';
import Conditional from 'src/components/empty/Conditional';
import {DropdownCheckbox} from 'src/components/toolbar/DropdownCheckbox';
import {Kebab} from 'src/components/toolbar/Kebab';
import {SearchDropdown} from 'src/components/toolbar/SearchDropdown';
import {SearchInput} from 'src/components/toolbar/SearchInput';
import {SearchState} from 'src/components/toolbar/SearchTypes';
import {ToolbarPagination} from 'src/components/toolbar/ToolbarPagination';
import {ITeams} from 'src/hooks/UseTeams';
export default function TeamsViewToolbar(props: TeamsViewToolbarProps) {
return (
<Toolbar>
<ToolbarContent>
<DropdownCheckbox
selectedItems={props.selectedTeams}
deSelectAll={props.deSelectAll}
allItemsList={props.allItems}
itemsPerPageList={props.paginatedItems}
onItemSelect={props.onItemSelect}
/>
<SearchDropdown
items={props.searchOptions}
searchState={props.search}
setSearchState={props.setSearch}
/>
<Flex className="pf-u-mr-md">
<FlexItem>
<SearchInput
searchState={props.search}
onChange={props.setSearch}
id="teams-view-search"
/>
</FlexItem>
</Flex>
<ToolbarItem>
<Conditional if={props.selectedTeams?.length !== 0}>
<Kebab
isKebabOpen={props.isKebabOpen}
setKebabOpen={props.setKebabOpen}
kebabItems={props.kebabItems}
useActions={true}
/>
</Conditional>
<Conditional if={props.deleteKebabIsOpen}>
{props.deleteModal}
</Conditional>
<Conditional if={props.isSetRepoPermModalOpen}>
{props.setRepoPermModal}
</Conditional>
</ToolbarItem>
<ToolbarPagination
itemsList={props.allItems}
perPage={props.perPage}
page={props.page}
setPage={props.setPage}
setPerPage={props.setPerPage}
/>
</ToolbarContent>
</Toolbar>
);
}
interface TeamsViewToolbarProps {
selectedTeams: ITeams[];
deSelectAll: () => void;
allItems: ITeams[];
paginatedItems: ITeams[];
onItemSelect: (item: ITeams, rowIndex: number, isSelecting: boolean) => void;
page: number;
setPage: (page: number) => void;
perPage: number;
setPerPage: (perPage: number) => void;
searchOptions: string[];
search: SearchState;
setSearch: (search: SearchState) => void;
isKebabOpen: boolean;
setKebabOpen: (open: boolean) => void;
kebabItems: React.ReactElement[];
deleteKebabIsOpen: boolean;
deleteModal: object;
isSetRepoPermModalOpen: boolean;
setRepoPermModal: object;
}

View File

@@ -1,6 +1,6 @@
import {Banner, Flex, FlexItem, Page} from '@patternfly/react-core';
import {Navigate, Outlet, Route, Router, Routes} from 'react-router-dom';
import {Navigate, Outlet, Route, Routes} from 'react-router-dom';
import {RecoilRoot, useSetRecoilState} from 'recoil';
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
@@ -22,7 +22,7 @@ import {CreateNewUser} from 'src/components/modals/CreateNewUser';
import NewUserEmptyPage from 'src/components/NewUserEmptyPage';
import axios from 'axios';
import axiosIns from 'src/libs/axios';
import ManageMembersList from './OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/ManageMembers/ManageMembersList';
const NavigationRoutes = [
{
@@ -41,6 +41,10 @@ const NavigationRoutes = [
path: NavigationPath.repositoryDetail,
Component: <RepositoryTagRouter />,
},
{
path: NavigationPath.teamMember,
Component: <ManageMembersList />,
},
];
function PluginMain() {
@@ -75,7 +79,7 @@ function PluginMain() {
useEffect(() => {
setIsPluginState(true);
if (user?.prompts && user.prompts.includes("confirm_username")) {
if (user?.prompts && user.prompts.includes('confirm_username')) {
setConfirmUserModalOpen(true);
}
}, [user]);
@@ -86,10 +90,10 @@ function PluginMain() {
return (
<Page style={{height: '100vh'}}>
<CreateNewUser
user={user}
isModalOpen={isConfirmUserModalOpen}
setModalOpen={setConfirmUserModalOpen}
<CreateNewUser
user={user}
isModalOpen={isConfirmUserModalOpen}
setModalOpen={setConfirmUserModalOpen}
/>
<Banner variant="info">
<Flex
@@ -113,9 +117,7 @@ function PluginMain() {
</Flex>
</Banner>
{user?.prompts && user.prompts.includes('confirm_username') ? (
<NewUserEmptyPage
setCreateUserModalOpen={setConfirmUserModalOpen}
/>
<NewUserEmptyPage setCreateUserModalOpen={setConfirmUserModalOpen} />
) : (
<Routes>
<Route index element={<Navigate to="organization" replace />} />

View File

@@ -23,13 +23,10 @@ import {RobotAccountColumnNames} from './ColumnNames';
import {RobotAccountsToolBar} from 'src/routes/RepositoriesList/RobotAccountsToolBar';
import CreateRobotAccountModal from 'src/components/modals/CreateRobotAccountModal';
import {IRobot} from 'src/resources/RobotsResource';
import {useRecoilState, useRecoilValue} from 'recoil';
import {
searchRobotAccountState,
selectedRobotAccountsState,
} from 'src/atoms/RobotAccountState';
import {useRecoilState} from 'recoil';
import {selectedRobotAccountsState} from 'src/atoms/RobotAccountState';
import {useRobotAccounts} from 'src/hooks/useRobotAccounts';
import {ReactElement, useState, useRef, useEffect} from 'react';
import {ReactElement, useState, useRef} from 'react';
import {ToolbarPagination} from 'src/components/toolbar/ToolbarPagination';
import RobotAccountKebab from './RobotAccountKebab';
import {useDeleteRobotAccounts} from 'src/hooks/UseDeleteRobotAccount';
@@ -53,7 +50,7 @@ import {useRobotRepoPermissions} from 'src/hooks/UseRobotRepoPermissions';
import RobotTokensModal from 'src/components/modals/RobotTokensModal';
import {SearchState} from 'src/components/toolbar/SearchTypes';
const RepoPermissionDropdownItems = [
export const RepoPermissionDropdownItems = [
{
name: 'None',
description: 'No permissions on the repository',
@@ -72,7 +69,7 @@ const RepoPermissionDropdownItems = [
},
];
const EmptyRobotAccount = {
const emptyRobotAccount = {
name: '',
created: '',
last_accessed: '',
@@ -95,7 +92,7 @@ export default function RobotAccountsList(props: RobotAccountsListProps) {
const [robotRepos, setRobotRepos] = useState([]);
const [teams, setTeams] = useState([]);
const [robotForDeletion, setRobotForDeletion] = useState<IRobot[]>([]);
const [robotForModalView, setRobotForModalView] = useState(EmptyRobotAccount);
const [robotForModalView, setRobotForModalView] = useState(emptyRobotAccount);
const [isTokenModalOpen, setTokenModalOpen] = useState<boolean>(false);
// For repository modal view
const [selectedRepoPerms, setSelectedRepoPerms] = useRecoilState(
@@ -268,7 +265,7 @@ export default function RobotAccountsList(props: RobotAccountsListProps) {
setSelectedReposForModalView([]);
setSelectedRepoPerms([]);
robotPermissionsPlaceholder.current.resetRobotPermissions();
setRobotForModalView(EmptyRobotAccount);
setRobotForModalView(emptyRobotAccount);
};
const onRepoModalSave = async () => {
@@ -360,7 +357,7 @@ export default function RobotAccountsList(props: RobotAccountsListProps) {
};
const onTokenModalClose = () => {
setRobotForModalView(EmptyRobotAccount);
setRobotForModalView(emptyRobotAccount);
};
const mapOfColNamesToTableData = {

View File

@@ -22,6 +22,10 @@ import axiosIns from 'src/libs/axios';
import Alerts from './Alerts';
const NavigationRoutes = [
{
path: NavigationPath.teamMember,
Component: <Organization />,
},
{
path: NavigationPath.organizationsList,
Component: <OrganizationsList />,
@@ -49,7 +53,6 @@ export function StandaloneMain() {
const quayConfig = useQuayConfig();
const {loading, error} = useCurrentUser();
useEffect(() => {
if (quayConfig?.config?.REGISTRY_TITLE) {
document.title = quayConfig.config.REGISTRY_TITLE;
@@ -89,7 +92,7 @@ export function StandaloneMain() {
</FlexItem>
</Flex>
</Banner>
<Alerts/>
<Alerts />
<Routes>
<Route index element={<Navigate to="/organization" replace />} />
{NavigationRoutes.map(({path, Component}, key) => (