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:
committed by
GitHub
parent
ee2e12abd4
commit
226684dfc7
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
188
web/cypress/e2e/teams-and-membership.cy.ts
Normal file
188
web/cypress/e2e/teams-and-membership.cy.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
--
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
254
web/src/hooks/UseMembers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
267
web/src/hooks/UseTeams.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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/`,
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user