From 226684dfc784b2cd549cf39aba5cc3184d015020 Mon Sep 17 00:00:00 2001 From: Harish Govindarajulu Date: Tue, 29 Aug 2023 15:03:56 -0400 Subject: [PATCH] ui: Teams and members (PROJQUAY-4569) (#2007) Add team and membership tab for organization (PROJQUAY-4569) Signed-off-by: harishsurf --- web/.eslintrc.js | 4 + web/cypress/README.md | 15 +- web/cypress/e2e/repository-permissions.cy.ts | 4 +- web/cypress/e2e/robot-accounts.cy.ts | 16 +- web/cypress/e2e/teams-and-membership.cy.ts | 188 +++++++++++ web/cypress/test/quay-db-data.txt | 37 ++- web/src/atoms/AlertState.ts | 20 +- web/src/components/breadcrumb/Breadcrumb.tsx | 15 +- .../modals/robotAccountWizard/AddToTeam.tsx | 22 +- web/src/components/toolbar/Kebab.tsx | 3 +- web/src/hooks/UseMembers.ts | 254 +++++++++++++++ web/src/hooks/UseRepositoryPermissions.ts | 4 +- web/src/hooks/UseTeams.ts | 267 +++++++++++++++ web/src/hooks/useTeams.ts | 30 -- web/src/libs/utils.ts | 12 +- web/src/resources/MembersResource.ts | 65 +++- web/src/resources/OrganizationResource.ts | 3 +- web/src/resources/RepositoryResource.ts | 5 +- web/src/resources/TeamResources.ts | 115 ++++++- web/src/routes/Alerts.tsx | 45 +-- web/src/routes/NavigationPath.tsx | 84 +++-- .../Organization/Organization.tsx | 54 ++- .../Organization/Tabs/Settings/Settings.tsx | 69 ++-- .../CollaboratorsDeleteModal.tsx | 79 +++++ .../CollaboratorsViewList.tsx | 177 ++++++++++ .../CollaboratorsViewToolbar.tsx | 65 ++++ .../MembersView/MembersViewList.tsx | 213 ++++++++++++ .../MembersView/MembersViewToolbar.tsx | 63 ++++ .../TeamsAndMembershipList.tsx | 92 ++++++ .../ManageMembers/ManageMembersList.tsx | 272 ++++++++++++++++ .../ManageMembers/ManageMembersToolbar.tsx | 82 +++++ .../SetRepoPermForTeamRoleDropDown.tsx | 62 ++++ .../SetRepoPermissionForTeamModal.tsx | 261 +++++++++++++++ .../SetRepoPermissionsForTeamToolbar.tsx | 119 +++++++ .../TeamsView/TeamViewKebab.tsx | 92 ++++++ .../TeamsView/TeamsRoleDropDown.tsx | 73 +++++ .../TeamsView/TeamsViewList.tsx | 307 ++++++++++++++++++ .../TeamsView/TeamsViewToolbar.tsx | 90 +++++ web/src/routes/PluginMain.tsx | 22 +- .../RepositoriesList/RobotAccountsList.tsx | 19 +- web/src/routes/StandaloneMain.tsx | 7 +- 41 files changed, 3201 insertions(+), 225 deletions(-) create mode 100644 web/cypress/e2e/teams-and-membership.cy.ts create mode 100644 web/src/hooks/UseMembers.ts create mode 100644 web/src/hooks/UseTeams.ts delete mode 100644 web/src/hooks/useTeams.ts create mode 100644 web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/CollaboratorsView/CollaboratorsDeleteModal.tsx create mode 100644 web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/CollaboratorsView/CollaboratorsViewList.tsx create mode 100644 web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/CollaboratorsView/CollaboratorsViewToolbar.tsx create mode 100644 web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/MembersView/MembersViewList.tsx create mode 100644 web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/MembersView/MembersViewToolbar.tsx create mode 100644 web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsAndMembershipList.tsx create mode 100644 web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/ManageMembers/ManageMembersList.tsx create mode 100644 web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/ManageMembers/ManageMembersToolbar.tsx create mode 100644 web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/SetRepoPermissionsModal/SetRepoPermForTeamRoleDropDown.tsx create mode 100644 web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/SetRepoPermissionsModal/SetRepoPermissionForTeamModal.tsx create mode 100644 web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/SetRepoPermissionsModal/SetRepoPermissionsForTeamToolbar.tsx create mode 100644 web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/TeamViewKebab.tsx create mode 100644 web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/TeamsRoleDropDown.tsx create mode 100644 web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/TeamsViewList.tsx create mode 100644 web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/TeamsViewToolbar.tsx diff --git a/web/.eslintrc.js b/web/.eslintrc.js index e54876dfa..691c6ee9c 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -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', + }, }, }; diff --git a/web/cypress/README.md b/web/cypress/README.md index 050b0afb8..a2f728dc6 100644 --- a/web/cypress/README.md +++ b/web/cypress/README.md @@ -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 diff --git a/web/cypress/e2e/repository-permissions.cy.ts b/web/cypress/e2e/repository-permissions.cy.ts index 9041ba0b3..79521ad96 100644 --- a/web/cypress/e2e/repository-permissions.cy.ts +++ b/web/cypress/e2e/repository-permissions.cy.ts @@ -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(); diff --git a/web/cypress/e2e/robot-accounts.cy.ts b/web/cypress/e2e/robot-accounts.cy.ts index 26f1881e0..94a3cc985 100644 --- a/web/cypress/e2e/robot-accounts.cy.ts +++ b/web/cypress/e2e/robot-accounts.cy.ts @@ -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(); diff --git a/web/cypress/e2e/teams-and-membership.cy.ts b/web/cypress/e2e/teams-and-membership.cy.ts new file mode 100644 index 000000000..7804c0b9c --- /dev/null +++ b/web/cypress/e2e/teams-and-membership.cy.ts @@ -0,0 +1,188 @@ +/// + +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'); + }); +}); diff --git a/web/cypress/test/quay-db-data.txt b/web/cypress/test/quay-db-data.txt index 3485471ca..9f8e379ca 100644 --- a/web/cypress/test/quay-db-data.txt +++ b/web/cypress/test/quay-db-data.txt @@ -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); -- diff --git a/web/src/atoms/AlertState.ts b/web/src/atoms/AlertState.ts index 32004ca34..95d1bba7b 100644 --- a/web/src/atoms/AlertState.ts +++ b/web/src/atoms/AlertState.ts @@ -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({ - key: 'alertState', - default: [], + key: 'alertState', + default: [], }); diff --git a/web/src/components/breadcrumb/Breadcrumb.tsx b/web/src/components/breadcrumb/Breadcrumb.tsx index 5c580bc29..1e1bc4feb 100644 --- a/web/src/components/breadcrumb/Breadcrumb.tsx +++ b/web/src/components/breadcrumb/Breadcrumb.tsx @@ -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( [], ); - 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 (
diff --git a/web/src/components/modals/robotAccountWizard/AddToTeam.tsx b/web/src/components/modals/robotAccountWizard/AddToTeam.tsx index 137794804..ba781fa9d 100644 --- a/web/src/components/modals/robotAccountWizard/AddToTeam.tsx +++ b/web/src/components/modals/robotAccountWizard/AddToTeam.tsx @@ -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(); - const {createNewTeamHook} = useTeams(props.namespace); + const {createNewTeamHook} = useCreateTeam(props.namespace); const createNewTeam = () => { props.setDrawerExpanded(true); diff --git a/web/src/components/toolbar/Kebab.tsx b/web/src/components/toolbar/Kebab.tsx index 2749c3d4c..9281519a6 100644 --- a/web/src/components/toolbar/Kebab.tsx +++ b/web/src/components/toolbar/Kebab.tsx @@ -8,7 +8,7 @@ export function Kebab(props: KebabProps) { const fetchToggle = () => { if (!props.useActions) { - return ; + return ; } return ( void; kebabItems: React.ReactElement[]; useActions?: boolean; + id?: string; }; diff --git a/web/src/hooks/UseMembers.ts b/web/src/hooks/UseMembers.ts new file mode 100644 index 000000000..b77cc28d8 --- /dev/null +++ b/web/src/hooks/UseMembers.ts @@ -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({ + query: '', + field: memberViewColumnNames.username, + }); + + const { + data: members, + isLoading, + isPlaceholderData, + isError: errorLoadingMembers, + } = useQuery( + ['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({ + query: '', + field: manageMemberColumnNames.teamMember, + }); + + const { + data, + isLoading, + isPlaceholderData, + isError: errorLoadingTeamMembers, + } = useQuery( + ['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({ + query: '', + field: collaboratorViewColumnNames.username, + }); + + const { + data: collaborators, + isLoading, + isPlaceholderData, + isError: errorLoadingCollaborators, + } = useQuery( + ['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, + }; +} diff --git a/web/src/hooks/UseRepositoryPermissions.ts b/web/src/hooks/UseRepositoryPermissions.ts index fda2673a2..712325a1d 100644 --- a/web/src/hooks/UseRepositoryPermissions.ts +++ b/web/src/hooks/UseRepositoryPermissions.ts @@ -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: {}, }, diff --git a/web/src/hooks/UseTeams.ts b/web/src/hooks/UseTeams.ts new file mode 100644 index 000000000..7424b2163 --- /dev/null +++ b/web/src/hooks/UseTeams.ts @@ -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({ + query: '', + field: teamViewColumnNames.teamName, + }); + + const { + data, + isLoading, + isPlaceholderData, + isError: errorLoadingTeams, + } = useQuery( + ['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({ + 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( + ['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, + successUpdateRepoPerm, + resetUpdateRepoPerm, + }; +} diff --git a/web/src/hooks/useTeams.ts b/web/src/hooks/useTeams.ts deleted file mode 100644 index 862b4641a..000000000 --- a/web/src/hooks/useTeams.ts +++ /dev/null @@ -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; -} diff --git a/web/src/libs/utils.ts b/web/src/libs/utils.ts index 104cbfa09..078f3c170 100644 --- a/web/src/libs/utils.ts +++ b/web/src/libs/utils.ts @@ -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 /organization//teams/ + 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(); } diff --git a/web/src/resources/MembersResource.ts b/web/src/resources/MembersResource.ts index 0b4df3f1e..2fd7e5137 100644 --- a/web/src/resources/MembersResource.ts +++ b/web/src/resources/MembersResource.ts @@ -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 { +): Promise { 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 { + 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); + } +} diff --git a/web/src/resources/OrganizationResource.ts b/web/src/resources/OrganizationResource.ts index de0934b5d..9f1365da5 100644 --- a/web/src/resources/OrganizationResource.ts +++ b/web/src/resources/OrganizationResource.ts @@ -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; } diff --git a/web/src/resources/RepositoryResource.ts b/web/src/resources/RepositoryResource.ts index f6ce80bbb..1bed18c8d 100644 --- a/web/src/resources/RepositoryResource.ts +++ b/web/src/resources/RepositoryResource.ts @@ -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 = await axios.get( `/api/v1/repository/${org}/${repo}/permissions/team/`, ); diff --git a/web/src/resources/TeamResources.ts b/web/src/resources/TeamResources.ts index 3674792b8..38746c2ac 100644 --- a/web/src/resources/TeamResources.ts +++ b/web/src/resources/TeamResources.ts @@ -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'); +} diff --git a/web/src/routes/Alerts.tsx b/web/src/routes/Alerts.tsx index 8a6e60f14..4cb4c016e 100644 --- a/web/src/routes/Alerts.tsx +++ b/web/src/routes/Alerts.tsx @@ -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 ( - {alerts.map(alert=> ( + {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.message} + + ))} - ) + ); } diff --git a/web/src/routes/NavigationPath.tsx b/web/src/routes/NavigationPath.tsx index 831fc780c..8376c2bb1 100644 --- a/web/src/routes/NavigationPath.tsx +++ b/web/src/routes/NavigationPath.tsx @@ -16,12 +16,17 @@ const tagNameBreadcrumb = (match) => { return {match.params.tagName}; }; +const teamMemberBreadcrumb = (match) => { + return {match.params.teamName}; +}; + 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: , - breadcrumb: Breadcrumb.organizationsListBreadcrumb, - }, - { - path: domainRoute(currentRoute, NavigationPath.organizationDetail), - Component: , - breadcrumb: Breadcrumb.organizationDetailBreadcrumb, - }, - { - path: domainRoute(currentRoute, NavigationPath.repositoriesList), - Component: , - breadcrumb: Breadcrumb.repositoriesListBreadcrumb, - }, - { - path: domainRoute(currentRoute, NavigationPath.repositoryDetail), - Component: , - breadcrumb: Breadcrumb.repositoryDetailBreadcrumb, - }, - { - path: domainRoute(currentRoute, NavigationPath.tagDetail), - Component: , - breadcrumb: Breadcrumb.tagDetailBreadcrumb, - }, -]; -export {NavigationRoutes}; + const NavigationRoutes = [ + { + path: domainRoute(currentRoute, NavigationPath.organizationsList), + Component: , + breadcrumb: Breadcrumb.organizationsListBreadcrumb, + }, + { + path: domainRoute(currentRoute, NavigationPath.organizationDetail), + Component: , + breadcrumb: Breadcrumb.organizationDetailBreadcrumb, + }, + { + path: domainRoute(currentRoute, NavigationPath.repositoriesList), + Component: , + breadcrumb: Breadcrumb.repositoriesListBreadcrumb, + }, + { + path: domainRoute(currentRoute, NavigationPath.repositoryDetail), + Component: , + breadcrumb: Breadcrumb.repositoryDetailBreadcrumb, + }, + { + path: domainRoute(currentRoute, NavigationPath.tagDetail), + Component: , + breadcrumb: Breadcrumb.tagDetailBreadcrumb, + }, + ]; + return NavigationRoutes; +}; diff --git a/web/src/routes/OrganizationsList/Organization/Organization.tsx b/web/src/routes/OrganizationsList/Organization/Organization.tsx index 985e02fec..0f13d35f4 100644 --- a/web/src/routes/OrganizationsList/Organization/Organization.tsx +++ b/web/src/routes/OrganizationsList/Organization/Organization.tsx @@ -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, 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: , visible: true, }, + { + name: 'Teams and membership', + component: !teamName ? ( + + ) : ( + + ), + visible: + !isUserOrganization && + organization?.is_member && + organization?.is_admin, + }, { name: 'Robot accounts', component: , visible: fetchTabVisibility('Robot accounts'), - }, { name: 'Settings', @@ -86,15 +106,17 @@ export default function Organization() { padding={{default: 'noPadding'}} > - {repositoriesSubNav.filter((nav) => nav.visible).map((nav)=> ( - {nav.name}} - > - {nav.component} - - ))} + {repositoriesSubNav + .filter((nav) => nav.visible) + .map((nav) => ( + {nav.name}} + > + {nav.component} + + ))} diff --git a/web/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx b/web/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx index 499aa8cb4..f017042ff 100644 --- a/web/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx +++ b/web/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx @@ -46,17 +46,31 @@ const GeneralSettings = (props: GeneralSettingsProps) => { const {updateOrgSettings} = useOrganizationSettings({ name: props.organizationName, onSuccess: (result) => { - setAlerts(prevAlerts => { - return [...prevAlerts, - - ] + setAlerts((prevAlerts) => { + return [ + ...prevAlerts, + , + ]; }); }, onError: (err) => { - setAlerts(prevAlerts => { - return [...prevAlerts, - - ] + setAlerts((prevAlerts) => { + return [ + ...prevAlerts, + , + ]; }); }, }); @@ -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(''); const [validated, setValidated] = useState('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) => ( { - - {alerts} - + {alerts} ); }; -// const BillingInformation = () => { -// return

Hello

; -// }; - export default function Settings(props: SettingsProps) { const [activeTabIndex, setActiveTabIndex] = useState(0); @@ -239,11 +249,6 @@ export default function Settings(props: SettingsProps) { id: 'generalsettings', content: , }, - // { - // name: 'Billing Information', - // id: 'billinginformation', - // content: , - // }, ]; return ( diff --git a/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/CollaboratorsView/CollaboratorsDeleteModal.tsx b/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/CollaboratorsView/CollaboratorsDeleteModal.tsx new file mode 100644 index 000000000..b77eef0b7 --- /dev/null +++ b/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/CollaboratorsView/CollaboratorsDeleteModal.tsx @@ -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 = ( + + ); + + 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 ( + { + removeCollaborator({ + collaborator: props.collaborator.name, + }); + props.toggleModal; + }} + data-testid={`${props.collaborator.name}-del-btn`} + > + Delete + , + , + ]} + > + Are you sure you want to delete {props.collaborator.name} ? + + ); +} + +interface CollaboratorsDeleteModalProps { + isModalOpen: boolean; + toggleModal: () => void; + collaborator: IMembers; + organizationName: string; +} diff --git a/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/CollaboratorsView/CollaboratorsViewList.tsx b/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/CollaboratorsView/CollaboratorsViewList.tsx new file mode 100644 index 000000000..83a7468ff --- /dev/null +++ b/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/CollaboratorsView/CollaboratorsViewList.tsx @@ -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(); + + 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 = ( + setIsDeleteModalOpen(!isDeleteModalOpen)} + collaborator={collaboratorToBeDeleted} + organizationName={props.organizationName} + /> + ); + + if (loading) { + return ; + } + + return ( + + setSelectedCollaborators([])} + allItems={filteredCollaborators} + paginatedItems={paginatedCollaborators} + onItemSelect={onSelectCollaborator} + page={page} + setPage={setPage} + perPage={perPage} + setPerPage={setPerPage} + search={search} + setSearch={setSearch} + searchOptions={[collaboratorViewColumnNames.username]} + /> + {props.children} + {deleteCollabModal} + + + + + {collaboratorViewColumnNames.username} + {collaboratorViewColumnNames.directRepositoryPermissions} + + + + + {paginatedCollaborators?.map((collaborator, rowIndex) => ( + + + onSelectCollaborator(collaborator, rowIndex, isSelecting), + isSelected: selectedCollaborators.some( + (t) => t.name === collaborator.name, + ), + }} + /> + + {collaborator.name} + + + Direct permissions on {collaborator.repositories?.length}{' '} + repositories under this organization + + + , + , + ]} + > + {!teamRepoPerms?.length ? ( + emptyPermComponent + ) : ( + 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} + > + + + + + {setRepoPermForTeamColumnNames.repoName} + {setRepoPermForTeamColumnNames.permissions} + {setRepoPermForTeamColumnNames.lastUpdate} + + + + {paginatedTeamRepoPerms?.map((repoPerm, rowIndex) => ( + + + onSelectRepoPerm(repoPerm, rowIndex, isSelecting), + isSelected: isItemSelected(repoPerm), + }} + /> + + {repoPerm.repoName} + + + + + + {formatDate(repoPerm.lastModified)} + + + ))} + + + + )} + + ); +} + +interface SetRepoPermissionForTeamModalProps { + organizationName: string; + teamName: string; + isModalOpen: boolean; + handleModalToggle: () => void; +} diff --git a/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/SetRepoPermissionsModal/SetRepoPermissionsForTeamToolbar.tsx b/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/SetRepoPermissionsModal/SetRepoPermissionsForTeamToolbar.tsx new file mode 100644 index 000000000..f5db3aab5 --- /dev/null +++ b/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/SetRepoPermissionsModal/SetRepoPermissionsForTeamToolbar.tsx @@ -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 ( + <> + + + + + + + + + + + 0}> + ( + dropdownOnSelect(item.name)} + > + {item.name} + + ))} + useActions={false} + id="toggle-bulk-perms-kebab" + /> + + + + + + {props.children} + + + + + ); +} + +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; +} diff --git a/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/TeamViewKebab.tsx b/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/TeamViewKebab.tsx new file mode 100644 index 000000000..1128afa77 --- /dev/null +++ b/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/TeamViewKebab.tsx @@ -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(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 ( + setIsKebabOpen(!isKebabOpen)} + toggle={ + { + setIsKebabOpen(!isKebabOpen); + }} + /> + } + isOpen={isKebabOpen} + dropdownItems={[ + + Manage team members + + } + >, + + Set repository permissions + , + removeTeam(props.team)} + className="red-color" + data-testid={`${props.team.name}-del-option`} + > + Delete + , + ]} + isPlain + position={DropdownPosition.right} + /> + ); +} + +interface TeamViewKebabProps { + organizationName: string; + team: ITeams; + deSelectAll: () => void; + onSelectRepo: () => void; +} diff --git a/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/TeamsRoleDropDown.tsx b/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/TeamsRoleDropDown.tsx new file mode 100644 index 000000000..a4560970c --- /dev/null +++ b/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/TeamsRoleDropDown.tsx @@ -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(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 ( + setIsOpen(false)} + toggle={ + setIsOpen(!isOpen)}> + {props.teamRole.charAt(0).toUpperCase() + props.teamRole.slice(1)} + + } + isOpen={isOpen} + dropdownItems={Object.keys(teamPermissions).map((key) => ( + + updateTeamRole({ + teamName: props.teamName, + teamRole: teamPermissions[key], + }) + } + > + {key} + + ))} + /> + ); +} + +interface TeamsRoleDropDownProps { + organizationName: string; + teamName: string; + teamRole: string; +} diff --git a/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/TeamsViewList.tsx b/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/TeamsViewList.tsx new file mode 100644 index 000000000..ea0d055fb --- /dev/null +++ b/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/TeamsViewList.tsx @@ -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([]); + const [isKebabOpen, setKebabOpen] = useState(false); + const [deleteModalIsOpen, setDeleteModalIsOpen] = useState(false); + const [err, setIsError] = useState(); + const [searchParams] = useSearchParams(); + const {addAlert} = useAlerts(); + const [isSetRepoPermModalOpen, setIsSetRepoPermModalOpen] = useState(false); + const [repoPermForTeam, setRepoPermForTeam] = useState(''); + + useEffect(() => { + if (error) { + addAlert({variant: AlertVariant.Failure, title: `Could not load teams`}); + } + }, [error]); + + const handleDeleteModalToggle = () => { + setKebabOpen(!isKebabOpen); + setDeleteModalIsOpen(!deleteModalIsOpen); + }; + + const kebabItems = [ + + Delete + , + ]; + + /* 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 + 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) => ( + + {team.role} +
+ } + 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 = ( + + 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 = ( + + setIsSetRepoPermModalOpen(!isSetRepoPermModalOpen) + } + organizationName={props.organizationName} + teamName={repoPermForTeam} + /> + ); + + const openSetRepoPermModal = (teamName: string) => { + setRepoPermForTeam(teamName); + setIsSetRepoPermModalOpen(!isSetRepoPermModalOpen); + }; + + if (loading) { + return ; + } + + return ( + + + 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} + + + + + {teamViewColumnNames.teamName} + {teamViewColumnNames.members} + {teamViewColumnNames.repositories} + {teamViewColumnNames.teamRole} + + + + + {paginatedTeams?.map((team, rowIndex) => ( + + + onSelectTeam(team, rowIndex, isSelecting), + isSelected: selectedTeams.some((t) => t.name === team.name), + }} + /> + {team.name} + + + {team.member_count} + + + + { + openSetRepoPermModal(team.name); + }} + > + {team.repo_count} + + + + + + + setSelectedTeams([])} + onSelectRepo={() => { + openSetRepoPermModal(team.name); + }} + /> + + + ))} + + + + + + + ); +} + +interface TeamsViewListProps { + organizationName: string; + children?: React.ReactNode; +} diff --git a/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/TeamsViewToolbar.tsx b/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/TeamsViewToolbar.tsx new file mode 100644 index 000000000..4e9ba2267 --- /dev/null +++ b/web/src/routes/OrganizationsList/Organization/Tabs/TeamsAndMembership/TeamsView/TeamsViewToolbar.tsx @@ -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 ( + + + + + + + + + + + + + + + {props.deleteModal} + + + {props.setRepoPermModal} + + + + + + ); +} + +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; +} diff --git a/web/src/routes/PluginMain.tsx b/web/src/routes/PluginMain.tsx index 1a366bec0..aaecc4fc2 100644 --- a/web/src/routes/PluginMain.tsx +++ b/web/src/routes/PluginMain.tsx @@ -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: , }, + { + path: NavigationPath.teamMember, + Component: , + }, ]; 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 ( - {user?.prompts && user.prompts.includes('confirm_username') ? ( - + ) : ( } /> diff --git a/web/src/routes/RepositoriesList/RobotAccountsList.tsx b/web/src/routes/RepositoriesList/RobotAccountsList.tsx index d80e355f6..070a16082 100644 --- a/web/src/routes/RepositoriesList/RobotAccountsList.tsx +++ b/web/src/routes/RepositoriesList/RobotAccountsList.tsx @@ -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([]); - const [robotForModalView, setRobotForModalView] = useState(EmptyRobotAccount); + const [robotForModalView, setRobotForModalView] = useState(emptyRobotAccount); const [isTokenModalOpen, setTokenModalOpen] = useState(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 = { diff --git a/web/src/routes/StandaloneMain.tsx b/web/src/routes/StandaloneMain.tsx index a6546035f..3646230b6 100644 --- a/web/src/routes/StandaloneMain.tsx +++ b/web/src/routes/StandaloneMain.tsx @@ -22,6 +22,10 @@ import axiosIns from 'src/libs/axios'; import Alerts from './Alerts'; const NavigationRoutes = [ + { + path: NavigationPath.teamMember, + Component: , + }, { path: NavigationPath.organizationsList, Component: , @@ -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() { - + } /> {NavigationRoutes.map(({path, Component}, key) => (