From f9427c8fb247e3afb9ebe0a6baa0e37e7a09c4a7 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Fri, 2 Sep 2022 18:30:34 +0300 Subject: [PATCH] refactor(teams): migrate teams to react [EE-2273] (#6691) closes [EE-2273] --- api/http/handler/teams/team_list.go | 16 +- api/http/security/filter.go | 25 +- app/portainer/__module.js | 26 -- .../AccessControlForm.test.tsx | 2 +- .../AccessControlPanel/AccessControlPanel.tsx | 4 +- .../AccessControlPanelDetails.tsx | 27 +- .../access-control/EditDetails/TeamsField.tsx | 2 +- .../EditDetails/useLoadState.ts | 2 +- .../access-control/EditDetails/useOptions.tsx | 2 +- app/portainer/access-control/types.ts | 2 +- app/portainer/access-control/utils.ts | 3 +- .../teams-datatable/teamsDatatable.html | 95 ------- .../teams-datatable/teamsDatatable.js | 14 - .../environments/environment.service/index.ts | 2 +- .../environment.service/registries.ts | 2 +- .../EnvironmentItem/EnvironmentItem.tsx | 4 +- .../home/EnvironmentList/EnvironmentList.tsx | 4 +- app/portainer/hooks/useUser.tsx | 5 - app/portainer/react/views/index.ts | 3 +- app/portainer/react/views/teams.ts | 34 +++ app/portainer/settings/types.ts | 2 +- .../CreateTeamForm/CreateTeamForm.stories.tsx | 37 --- app/portainer/teams/CreateTeamForm/index.ts | 11 - app/portainer/teams/index.ts | 8 - app/portainer/teams/queries.ts | 19 -- app/portainer/teams/teams.service.ts | 22 -- app/portainer/teams/types.ts | 20 -- app/portainer/users/queries.ts | 6 +- app/portainer/users/types.ts | 4 +- app/portainer/users/user.service.ts | 3 +- app/portainer/views/teams/edit/team.html | 223 ---------------- .../views/teams/edit/teamController.js | 214 --------------- app/portainer/views/teams/teams.html | 9 - app/portainer/views/teams/teamsController.js | 103 -------- app/react-tools/test-mocks.ts | 2 +- .../CreateView/useLoadFormState.ts | 4 +- .../PaginationControls/PageSelector.tsx | 2 + .../TeamsSelector/TeamsSelector.tsx | 2 +- app/react/components/Widget/WidgetTitle.tsx | 2 +- .../datatables/ColumnVisibilityMenu.tsx | 4 - app/react/components/datatables/NameCell.tsx | 30 +++ app/react/components/datatables/Table.tsx | 6 +- .../components/datatables/TableActions.tsx | 8 +- .../components/datatables/TableContainer.tsx | 24 +- .../components/datatables/TableFooter.tsx | 4 - .../components/datatables/TableHeaderCell.tsx | 3 - .../components/datatables/TableHeaderRow.tsx | 3 - app/react/components/datatables/TableRow.tsx | 4 - .../datatables/TableSettingsMenu.tsx | 4 - .../components/datatables/TableTitle.tsx | 4 - .../datatables/TableTitleActions.tsx | 8 +- app/react/portainer/users/teams/.keep | 0 .../portainer/users/teams/ItemView/.keep | 0 .../users/teams/ItemView/Details.tsx | 103 ++++++++ .../users/teams/ItemView/ItemView.tsx | 72 ++++++ .../TeamAssociationSelector.module.css | 11 + .../TeamAssociationSelector.stories.tsx | 55 ++++ .../TeamAssociationSelector.test.tsx | 21 ++ .../TeamAssociationSelector.tsx | 48 ++++ .../TeamMembersList/RowContext.tsx | 14 + .../TeamMembersList.module.css | 2 + .../TeamMembersList.stories.tsx | 50 ++++ .../TeamMembersList/TeamMembersList.test.tsx | 24 ++ .../TeamMembersList/TeamMembersList.tsx | 218 ++++++++++++++++ .../TeamMembersList/index.ts | 1 + .../TeamMembersList/name-column.tsx | 62 +++++ .../TeamMembersList/team-role-column.tsx | 113 ++++++++ .../UsersList/RowContext.tsx | 12 + .../UsersList/UsersList.module.css | 2 + .../UsersList/UsersList.stories.tsx | 22 ++ .../UsersList/UsersList.test.tsx | 23 ++ .../UsersList/UsersList.tsx | 199 ++++++++++++++ .../UsersList/index.ts | 1 + .../UsersList/name-column.tsx | 54 ++++ .../ItemView/TeamAssociationSelector/index.ts | 1 + .../portainer/users/teams/ItemView/index.ts | 1 + .../users/teams/ItemView/useTeamIdParam.ts | 14 + .../portainer/users/teams/ListView/.keep | 0 .../CreateTeamForm/CreateTeamForm.mocks.ts | 0 .../CreateTeamForm/CreateTeamForm.stories.tsx | 23 ++ .../CreateTeamForm/CreateTeamForm.test.tsx | 6 +- .../CreateTeamForm/CreateTeamForm.tsx | 66 +++-- .../CreateTeamForm.validation.ts | 4 +- .../teams/ListView/CreateTeamForm/index.ts | 1 + .../teams/ListView/CreateTeamForm/types.ts | 6 + .../users/teams/ListView/ListView.tsx | 30 +++ .../TeamsDatatable/TeamsDatatable.tsx | 244 ++++++++++++++++++ .../teams/ListView/TeamsDatatable/index.ts | 1 + .../teams/ListView/TeamsDatatable/types.ts | 8 + .../portainer/users/teams/ListView/index.ts | 1 + app/react/portainer/users/teams/index.ts | 2 + app/react/portainer/users/teams/queries.ts | 134 ++++++++++ .../users/teams/team-membership.service.ts | 47 ++++ .../portainer/users/teams/teams.service.ts | 71 +++++ app/react/portainer/users/teams/types.ts | 22 ++ app/setup-tests/server-handlers.ts | 7 + app/setup-tests/setup-handlers/users.ts | 2 +- 97 files changed, 1929 insertions(+), 938 deletions(-) delete mode 100644 app/portainer/components/datatables/teams-datatable/teamsDatatable.html delete mode 100644 app/portainer/components/datatables/teams-datatable/teamsDatatable.js create mode 100644 app/portainer/react/views/teams.ts delete mode 100644 app/portainer/teams/CreateTeamForm/CreateTeamForm.stories.tsx delete mode 100644 app/portainer/teams/CreateTeamForm/index.ts delete mode 100644 app/portainer/teams/index.ts delete mode 100644 app/portainer/teams/queries.ts delete mode 100644 app/portainer/teams/teams.service.ts delete mode 100644 app/portainer/teams/types.ts delete mode 100644 app/portainer/views/teams/edit/team.html delete mode 100644 app/portainer/views/teams/edit/teamController.js delete mode 100644 app/portainer/views/teams/teams.html delete mode 100644 app/portainer/views/teams/teamsController.js create mode 100644 app/react/components/datatables/NameCell.tsx delete mode 100644 app/react/portainer/users/teams/.keep delete mode 100644 app/react/portainer/users/teams/ItemView/.keep create mode 100644 app/react/portainer/users/teams/ItemView/Details.tsx create mode 100644 app/react/portainer/users/teams/ItemView/ItemView.tsx create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.module.css create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.stories.tsx create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.test.tsx create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.tsx create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/RowContext.tsx create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.module.css create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.stories.tsx create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.test.tsx create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.tsx create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/index.ts create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/name-column.tsx create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/team-role-column.tsx create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/RowContext.tsx create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.module.css create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.stories.tsx create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.test.tsx create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.tsx create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/index.ts create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/name-column.tsx create mode 100644 app/react/portainer/users/teams/ItemView/TeamAssociationSelector/index.ts create mode 100644 app/react/portainer/users/teams/ItemView/index.ts create mode 100644 app/react/portainer/users/teams/ItemView/useTeamIdParam.ts delete mode 100644 app/react/portainer/users/teams/ListView/.keep rename app/{portainer/teams => react/portainer/users/teams/ListView}/CreateTeamForm/CreateTeamForm.mocks.ts (100%) create mode 100644 app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.stories.tsx rename app/{portainer/teams => react/portainer/users/teams/ListView}/CreateTeamForm/CreateTeamForm.test.tsx (78%) rename app/{portainer/teams => react/portainer/users/teams/ListView}/CreateTeamForm/CreateTeamForm.tsx (69%) rename app/{portainer/teams => react/portainer/users/teams/ListView}/CreateTeamForm/CreateTeamForm.validation.ts (74%) create mode 100644 app/react/portainer/users/teams/ListView/CreateTeamForm/index.ts create mode 100644 app/react/portainer/users/teams/ListView/CreateTeamForm/types.ts create mode 100644 app/react/portainer/users/teams/ListView/ListView.tsx create mode 100644 app/react/portainer/users/teams/ListView/TeamsDatatable/TeamsDatatable.tsx create mode 100644 app/react/portainer/users/teams/ListView/TeamsDatatable/index.ts create mode 100644 app/react/portainer/users/teams/ListView/TeamsDatatable/types.ts create mode 100644 app/react/portainer/users/teams/ListView/index.ts create mode 100644 app/react/portainer/users/teams/index.ts create mode 100644 app/react/portainer/users/teams/queries.ts create mode 100644 app/react/portainer/users/teams/team-membership.service.ts create mode 100644 app/react/portainer/users/teams/teams.service.ts create mode 100644 app/react/portainer/users/teams/types.ts diff --git a/api/http/handler/teams/team_list.go b/api/http/handler/teams/team_list.go index 72fb36c81..db012e331 100644 --- a/api/http/handler/teams/team_list.go +++ b/api/http/handler/teams/team_list.go @@ -4,6 +4,7 @@ import ( "net/http" httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api/http/security" ) @@ -13,6 +14,7 @@ import ( // @description List teams. For non-administrator users, will only list the teams they are member of. // @description **Access policy**: restricted // @tags teams +// @param onlyLedTeams query boolean false "Only list teams that the user is leader of" // @security ApiKeyAuth // @security jwt // @produce json @@ -22,15 +24,23 @@ import ( func (handler *Handler) teamList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { teams, err := handler.DataStore.Team().Teams() if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve teams from the database", err} + return httperror.InternalServerError("Unable to retrieve teams from the database", err) } securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + return httperror.InternalServerError("Unable to retrieve info from request context", err) } - filteredTeams := security.FilterUserTeams(teams, securityContext) + onlyLedTeams, _ := request.RetrieveBooleanQueryParameter(r, "onlyLedTeams", true) + + filteredTeams := teams + + if onlyLedTeams { + filteredTeams = security.FilterLeaderTeams(filteredTeams, securityContext) + } + + filteredTeams = security.FilterUserTeams(filteredTeams, securityContext) return response.JSON(w, filteredTeams) } diff --git a/api/http/security/filter.go b/api/http/security/filter.go index 923ff0977..3f746c890 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -27,17 +27,22 @@ func FilterUserTeams(teams []portainer.Team, context *RestrictedRequestContext) // FilterLeaderTeams filters teams based on user role. // Team leaders only have access to team they lead. func FilterLeaderTeams(teams []portainer.Team, context *RestrictedRequestContext) []portainer.Team { - filteredTeams := teams + filteredTeams := []portainer.Team{} - if context.IsTeamLeader { - filteredTeams = make([]portainer.Team, 0) - for _, membership := range context.UserMemberships { - for _, team := range teams { - if team.ID == membership.TeamID && membership.Role == portainer.TeamLeader { - filteredTeams = append(filteredTeams, team) - break - } - } + if !context.IsTeamLeader { + return filteredTeams + } + + leaderSet := map[portainer.TeamID]bool{} + for _, membership := range context.UserMemberships { + if membership.Role == portainer.TeamLeader && membership.UserID == context.UserID { + leaderSet[membership.TeamID] = true + } + } + + for _, team := range teams { + if leaderSet[team.ID] { + filteredTeams = append(filteredTeams, team) } } diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 5b758de45..28c7e8a94 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -6,7 +6,6 @@ import settingsModule from './settings'; import featureFlagModule from './feature-flags'; import userActivityModule from './user-activity'; import servicesModule from './services'; -import teamsModule from './teams'; import homeModule from './home'; import { accessControlModule } from './access-control'; import { reactModule } from './react'; @@ -40,7 +39,6 @@ angular userActivityModule, 'portainer.shared.datatable', servicesModule, - teamsModule, accessControlModule, reactModule, sidebarModule, @@ -425,28 +423,6 @@ angular }, }; - var teams = { - name: 'portainer.teams', - url: '/teams', - views: { - 'content@': { - templateUrl: './views/teams/teams.html', - controller: 'TeamsController', - }, - }, - }; - - var team = { - name: 'portainer.teams.team', - url: '/:id', - views: { - 'content@': { - templateUrl: './views/teams/edit/team.html', - controller: 'TeamController', - }, - }, - }; - $stateRegistryProvider.register(root); $stateRegistryProvider.register(endpointRoot); $stateRegistryProvider.register(portainer); @@ -478,8 +454,6 @@ angular $stateRegistryProvider.register(tags); $stateRegistryProvider.register(users); $stateRegistryProvider.register(user); - $stateRegistryProvider.register(teams); - $stateRegistryProvider.register(team); }, ]); diff --git a/app/portainer/access-control/AccessControlForm/AccessControlForm.test.tsx b/app/portainer/access-control/AccessControlForm/AccessControlForm.test.tsx index c37fd9360..0775d8999 100644 --- a/app/portainer/access-control/AccessControlForm/AccessControlForm.test.tsx +++ b/app/portainer/access-control/AccessControlForm/AccessControlForm.test.tsx @@ -2,7 +2,7 @@ import { server, rest } from '@/setup-tests/server'; import { UserContext } from '@/portainer/hooks/useUser'; import { UserViewModel } from '@/portainer/models/user'; import { renderWithQueryClient, within } from '@/react-tools/test-utils'; -import { Team, TeamId } from '@/portainer/teams/types'; +import { Team, TeamId } from '@/react/portainer/users/teams/types'; import { createMockTeams } from '@/react-tools/test-mocks'; import { UserId } from '@/portainer/users/types'; diff --git a/app/portainer/access-control/AccessControlPanel/AccessControlPanel.tsx b/app/portainer/access-control/AccessControlPanel/AccessControlPanel.tsx index 9e43e3de9..96de9606a 100644 --- a/app/portainer/access-control/AccessControlPanel/AccessControlPanel.tsx +++ b/app/portainer/access-control/AccessControlPanel/AccessControlPanel.tsx @@ -3,7 +3,7 @@ import { useReducer } from 'react'; import { useUser } from '@/portainer/hooks/useUser'; import { Icon } from '@/react/components/Icon'; import { r2a } from '@/react-tools/react2angular'; -import { TeamMembership, Role } from '@/portainer/teams/types'; +import { TeamMembership, TeamRole } from '@/react/portainer/users/teams/types'; import { useUserMembership } from '@/portainer/users/queries'; import { TableContainer, TableTitle } from '@@/datatables'; @@ -138,7 +138,7 @@ function isLeaderOfAnyRestrictedTeams( ) { return userMemberships.some( (membership) => - membership.Role === Role.TeamLeader && + membership.Role === TeamRole.Leader && resourceControl.TeamAccesses.some((ta) => ta.TeamId === membership.TeamID) ); } diff --git a/app/portainer/access-control/AccessControlPanel/AccessControlPanelDetails.tsx b/app/portainer/access-control/AccessControlPanel/AccessControlPanelDetails.tsx index e9eb9b670..d2f7f8ec0 100644 --- a/app/portainer/access-control/AccessControlPanel/AccessControlPanelDetails.tsx +++ b/app/portainer/access-control/AccessControlPanel/AccessControlPanelDetails.tsx @@ -4,8 +4,8 @@ import _ from 'lodash'; import { ownershipIcon, truncate } from '@/portainer/filters/filters'; import { UserId } from '@/portainer/users/types'; -import { TeamId } from '@/portainer/teams/types'; -import { useTeams } from '@/portainer/teams/queries'; +import { TeamId } from '@/react/portainer/users/teams/types'; +import { useTeams } from '@/react/portainer/users/teams/queries'; import { useUsers } from '@/portainer/users/queries'; import { Link } from '@@/Link'; @@ -178,17 +178,20 @@ function InheritanceMessage({ } function useAuthorizedTeams(authorizedTeamIds: TeamId[]) { - return useTeams(authorizedTeamIds.length > 0, (teams) => { - if (authorizedTeamIds.length === 0) { - return []; - } + return useTeams(false, { + enabled: authorizedTeamIds.length > 0, + select: (teams) => { + if (authorizedTeamIds.length === 0) { + return []; + } - return _.compact( - authorizedTeamIds.map((id) => { - const team = teams.find((u) => u.Id === id); - return team?.Name; - }) - ); + return _.compact( + authorizedTeamIds.map((id) => { + const team = teams.find((u) => u.Id === id); + return team?.Name; + }) + ); + }, }); } diff --git a/app/portainer/access-control/EditDetails/TeamsField.tsx b/app/portainer/access-control/EditDetails/TeamsField.tsx index 456b40de8..02f9f3aa3 100644 --- a/app/portainer/access-control/EditDetails/TeamsField.tsx +++ b/app/portainer/access-control/EditDetails/TeamsField.tsx @@ -1,4 +1,4 @@ -import { Team } from '@/portainer/teams/types'; +import { Team } from '@/react/portainer/users/teams/types'; import { TeamsSelector } from '@@/TeamsSelector'; import { FormControl } from '@@/form-components/FormControl'; diff --git a/app/portainer/access-control/EditDetails/useLoadState.ts b/app/portainer/access-control/EditDetails/useLoadState.ts index 0cbeadea1..991dcb6e0 100644 --- a/app/portainer/access-control/EditDetails/useLoadState.ts +++ b/app/portainer/access-control/EditDetails/useLoadState.ts @@ -1,4 +1,4 @@ -import { useTeams } from '@/portainer/teams/queries'; +import { useTeams } from '@/react/portainer/users/teams/queries'; import { useUsers } from '@/portainer/users/queries'; export function useLoadState() { diff --git a/app/portainer/access-control/EditDetails/useOptions.tsx b/app/portainer/access-control/EditDetails/useOptions.tsx index 3b5f19715..3f8289fdc 100644 --- a/app/portainer/access-control/EditDetails/useOptions.tsx +++ b/app/portainer/access-control/EditDetails/useOptions.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { buildOption } from '@/portainer/components/BoxSelector'; import { ownershipIcon } from '@/portainer/filters/filters'; -import { Team } from '@/portainer/teams/types'; +import { Team } from '@/react/portainer/users/teams/types'; import { BoxSelectorOption } from '@@/BoxSelector/types'; import { BadgeIcon } from '@@/BoxSelector/BadgeIcon'; diff --git a/app/portainer/access-control/types.ts b/app/portainer/access-control/types.ts index dce3dcccb..a9a08c856 100644 --- a/app/portainer/access-control/types.ts +++ b/app/portainer/access-control/types.ts @@ -1,4 +1,4 @@ -import { TeamId } from '@/portainer/teams/types'; +import { TeamId } from '@/react/portainer/users/teams/types'; import { UserId } from '@/portainer/users/types'; export type ResourceControlId = number; diff --git a/app/portainer/access-control/utils.ts b/app/portainer/access-control/utils.ts index d692f500e..c88cca09c 100644 --- a/app/portainer/access-control/utils.ts +++ b/app/portainer/access-control/utils.ts @@ -1,4 +1,5 @@ -import { TeamId } from '../teams/types'; +import { TeamId } from '@/react/portainer/users/teams/types'; + import { UserId } from '../users/types'; import { diff --git a/app/portainer/components/datatables/teams-datatable/teamsDatatable.html b/app/portainer/components/datatables/teams-datatable/teamsDatatable.html deleted file mode 100644 index 48de95a99..000000000 --- a/app/portainer/components/datatables/teams-datatable/teamsDatatable.html +++ /dev/null @@ -1,95 +0,0 @@ -
- - -
-
- - {{ $ctrl.titleText }} -
- -
- -
-
-
- - - - - - - - - - - - - - - - - -
-
- - - - - -
-
- - - - - {{ item.Name }} -
Loading...
No team available.
-
- -
-
-
diff --git a/app/portainer/components/datatables/teams-datatable/teamsDatatable.js b/app/portainer/components/datatables/teams-datatable/teamsDatatable.js deleted file mode 100644 index 08f290bfc..000000000 --- a/app/portainer/components/datatables/teams-datatable/teamsDatatable.js +++ /dev/null @@ -1,14 +0,0 @@ -angular.module('portainer.app').component('teamsDatatable', { - templateUrl: './teamsDatatable.html', - controller: 'GenericDatatableController', - bindings: { - titleText: '@', - titleIcon: '@', - dataset: '<', - tableKey: '@', - orderBy: '@', - reverseOrder: '<', - removeAction: '<', - isAdmin: '<', - }, -}); diff --git a/app/portainer/environments/environment.service/index.ts b/app/portainer/environments/environment.service/index.ts index 0732e371e..2aaa0e40f 100644 --- a/app/portainer/environments/environment.service/index.ts +++ b/app/portainer/environments/environment.service/index.ts @@ -2,7 +2,7 @@ import axios, { parseAxiosError } from '@/portainer/services/axios'; import { type EnvironmentGroupId } from '@/portainer/environment-groups/types'; import { type TagId } from '@/portainer/tags/types'; import { UserId } from '@/portainer/users/types'; -import { TeamId } from '@/portainer/teams/types'; +import { TeamId } from '@/react/portainer/users/teams/types'; import type { Environment, diff --git a/app/portainer/environments/environment.service/registries.ts b/app/portainer/environments/environment.service/registries.ts index 0bac8578a..2be438755 100644 --- a/app/portainer/environments/environment.service/registries.ts +++ b/app/portainer/environments/environment.service/registries.ts @@ -1,5 +1,5 @@ import axios, { parseAxiosError } from '@/portainer/services/axios'; -import { TeamId } from '@/portainer/teams/types'; +import { TeamId } from '@/react/portainer/users/teams/types'; import { UserId } from '@/portainer/users/types'; import { EnvironmentId } from '../types'; diff --git a/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentItem.tsx b/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentItem.tsx index e8a11efc6..892e2d5f1 100644 --- a/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentItem.tsx +++ b/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentItem.tsx @@ -14,8 +14,8 @@ import { isEdgeEnvironment, } from '@/portainer/environments/utils'; import type { TagId } from '@/portainer/tags/types'; -import { useIsAdmin } from '@/portainer/hooks/useUser'; import { useTags } from '@/portainer/tags/queries'; +import { useUser } from '@/portainer/hooks/useUser'; import { Icon } from '@@/Icon'; import { Link } from '@@/Link'; @@ -34,7 +34,7 @@ interface Props { } export function EnvironmentItem({ environment, onClick, groupName }: Props) { - const isAdmin = useIsAdmin(); + const { isAdmin } = useUser(); const isEdge = isEdgeEnvironment(environment.Type); const snapshotTime = getSnapshotTime(environment); diff --git a/app/portainer/home/EnvironmentList/EnvironmentList.tsx b/app/portainer/home/EnvironmentList/EnvironmentList.tsx index 02b63c016..0cde258dd 100644 --- a/app/portainer/home/EnvironmentList/EnvironmentList.tsx +++ b/app/portainer/home/EnvironmentList/EnvironmentList.tsx @@ -12,7 +12,6 @@ import { EdgeTypes, } from '@/portainer/environments/types'; import { EnvironmentGroupId } from '@/portainer/environment-groups/types'; -import { useIsAdmin } from '@/portainer/hooks/useUser'; import { HomepageFilter, useHomePageFilter, @@ -27,6 +26,7 @@ import { useTags } from '@/portainer/tags/queries'; import { Filter } from '@/portainer/home/types'; import { useAgentVersionsList } from '@/portainer/environments/queries/useAgentVersionsList'; import { EnvironmentsQueryParams } from '@/portainer/environments/environment.service'; +import { useUser } from '@/portainer/hooks/useUser'; import { TableFooter } from '@@/datatables/TableFooter'; import { TableActions, TableContainer, TableTitle } from '@@/datatables'; @@ -69,7 +69,7 @@ enum ConnectionType { const storageKey = 'home_endpoints'; export function EnvironmentList({ onClickItem, onRefresh }: Props) { - const isAdmin = useIsAdmin(); + const { isAdmin } = useUser(); const [platformTypes, setPlatformTypes] = useHomePageFilter< Filter[] diff --git a/app/portainer/hooks/useUser.tsx b/app/portainer/hooks/useUser.tsx index 58c62faa2..4f61d9502 100644 --- a/app/portainer/hooks/useUser.tsx +++ b/app/portainer/hooks/useUser.tsx @@ -176,8 +176,3 @@ export function UserProvider({ children }: UserProviderProps) { setUser(user); } } - -export function useIsAdmin() { - const { user } = useUser(); - return !!user && isAdmin(user); -} diff --git a/app/portainer/react/views/index.ts b/app/portainer/react/views/index.ts index 66b654ea8..80bfb0b48 100644 --- a/app/portainer/react/views/index.ts +++ b/app/portainer/react/views/index.ts @@ -9,9 +9,10 @@ import { } from '@/react/portainer/registries/ListView/DefaultRegistry'; import { wizardModule } from './wizard'; +import { teamsModule } from './teams'; export const viewsModule = angular - .module('portainer.app.react.views', [wizardModule]) + .module('portainer.app.react.views', [wizardModule, teamsModule]) .component('defaultRegistryName', r2a(DefaultRegistryName, [])) .component('defaultRegistryAction', r2a(DefaultRegistryAction, [])) .component('defaultRegistryDomain', r2a(DefaultRegistryDomain, [])) diff --git a/app/portainer/react/views/teams.ts b/app/portainer/react/views/teams.ts new file mode 100644 index 000000000..309e880b6 --- /dev/null +++ b/app/portainer/react/views/teams.ts @@ -0,0 +1,34 @@ +import angular from 'angular'; +import { StateRegistry } from '@uirouter/angularjs'; + +import { ItemView, ListView } from '@/react/portainer/users/teams'; +import { r2a } from '@/react-tools/react2angular'; + +export const teamsModule = angular + .module('portainer.app.teams', []) + .config(config) + .component('teamView', r2a(ItemView, [])) + .component('teamsView', r2a(ListView, [])).name; + +/* @ngInject */ +function config($stateRegistryProvider: StateRegistry) { + $stateRegistryProvider.register({ + name: 'portainer.teams', + url: '/teams', + views: { + 'content@': { + component: 'teamsView', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'portainer.teams.team', + url: '/:id', + views: { + 'content@': { + component: 'teamView', + }, + }, + }); +} diff --git a/app/portainer/settings/types.ts b/app/portainer/settings/types.ts index d7d54e41b..f054dab05 100644 --- a/app/portainer/settings/types.ts +++ b/app/portainer/settings/types.ts @@ -1,4 +1,4 @@ -import { TeamId } from '../teams/types'; +import { TeamId } from '@/react/portainer/users/teams/types'; export interface FDOConfiguration { enabled: boolean; diff --git a/app/portainer/teams/CreateTeamForm/CreateTeamForm.stories.tsx b/app/portainer/teams/CreateTeamForm/CreateTeamForm.stories.tsx deleted file mode 100644 index c7589a9b4..000000000 --- a/app/portainer/teams/CreateTeamForm/CreateTeamForm.stories.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Meta } from '@storybook/react'; -import { useState } from 'react'; - -import { CreateTeamForm, FormValues } from './CreateTeamForm'; -import { mockExampleData } from './CreateTeamForm.mocks'; - -const meta: Meta = { - title: 'teams/CreateTeamForm', - component: CreateTeamForm, -}; - -export default meta; - -export { Example }; - -function Example() { - const [message, setMessage] = useState(''); - const { teams, users } = mockExampleData(); - - return ( -
- -
{message}
-
- ); - - function handleSubmit(values: FormValues) { - return new Promise((resolve) => { - setTimeout(() => { - setMessage( - `created team ${values.name} with ${values.leaders.length} leaders` - ); - resolve(); - }, 3000); - }); - } -} diff --git a/app/portainer/teams/CreateTeamForm/index.ts b/app/portainer/teams/CreateTeamForm/index.ts deleted file mode 100644 index 5cdccb886..000000000 --- a/app/portainer/teams/CreateTeamForm/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { r2a } from '@/react-tools/react2angular'; - -import { CreateTeamForm } from './CreateTeamForm'; - -export { CreateTeamForm }; - -export const CreateTeamFormAngular = r2a(CreateTeamForm, [ - 'users', - 'onSubmit', - 'teams', -]); diff --git a/app/portainer/teams/index.ts b/app/portainer/teams/index.ts deleted file mode 100644 index 42c42eb49..000000000 --- a/app/portainer/teams/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import angular from 'angular'; - -import { CreateTeamFormAngular } from './CreateTeamForm'; - -export default angular - .module('portainer.app.teams', []) - - .component('createTeamForm', CreateTeamFormAngular).name; diff --git a/app/portainer/teams/queries.ts b/app/portainer/teams/queries.ts deleted file mode 100644 index 71cb476f3..000000000 --- a/app/portainer/teams/queries.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useQuery } from 'react-query'; - -import { getTeams } from './teams.service'; -import { Team } from './types'; - -export function useTeams( - enabled = true, - select: (data: Team[]) => T = (data) => data as unknown as T -) { - const teams = useQuery(['teams'], () => getTeams(), { - meta: { - error: { title: 'Failure', message: 'Unable to load teams' }, - }, - enabled, - select, - }); - - return teams; -} diff --git a/app/portainer/teams/teams.service.ts b/app/portainer/teams/teams.service.ts deleted file mode 100644 index c4de675b8..000000000 --- a/app/portainer/teams/teams.service.ts +++ /dev/null @@ -1,22 +0,0 @@ -import axios, { parseAxiosError } from '@/portainer/services/axios'; - -import { Team, TeamId } from './types'; - -export async function getTeams() { - try { - const { data } = await axios.get(buildUrl()); - return data; - } catch (error) { - throw parseAxiosError(error as Error); - } -} - -function buildUrl(id?: TeamId) { - let url = '/teams'; - - if (id) { - url += `/${id}`; - } - - return url; -} diff --git a/app/portainer/teams/types.ts b/app/portainer/teams/types.ts deleted file mode 100644 index 157c1e804..000000000 --- a/app/portainer/teams/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { UserId } from '../users/types'; - -export type TeamId = number; - -export enum Role { - TeamLeader = 1, - TeamMember, -} - -export interface Team { - Id: TeamId; - Name: string; -} - -export interface TeamMembership { - Id: number; - UserID: UserId; - TeamID: TeamId; - Role: Role; -} diff --git a/app/portainer/users/queries.ts b/app/portainer/users/queries.ts index 246d61e91..34b57792b 100644 --- a/app/portainer/users/queries.ts +++ b/app/portainer/users/queries.ts @@ -1,6 +1,6 @@ import { useQuery } from 'react-query'; -import { Role as TeamRole, TeamMembership } from '../teams/types'; +import { TeamRole, TeamMembership } from '@/react/portainer/users/teams/types'; import { User, UserId } from './types'; import { isAdmin } from './user.helpers'; @@ -26,14 +26,14 @@ export function useIsTeamLeader(user: User) { const query = useUserMembership(user.Id, { enabled: !isAdmin(user), select: (memberships) => - memberships.some((membership) => membership.Role === TeamRole.TeamLeader), + memberships.some((membership) => membership.Role === TeamRole.Leader), }); return isAdmin(user) ? true : query.data; } export function useUsers( - includeAdministrator: boolean, + includeAdministrator = false, enabled = true, select: (data: User[]) => T = (data) => data as unknown as T ) { diff --git a/app/portainer/users/types.ts b/app/portainer/users/types.ts index e2d893db7..b51673f20 100644 --- a/app/portainer/users/types.ts +++ b/app/portainer/users/types.ts @@ -11,7 +11,7 @@ interface AuthorizationMap { [authorization: string]: boolean; } -export interface User { +export type User = { Id: UserId; Username: string; Role: Role; @@ -29,4 +29,4 @@ export interface User { // this.AuthenticationMethod = data.AuthenticationMethod; // this.Checked = false; // this.EndpointAuthorizations = data.EndpointAuthorizations; -} +}; diff --git a/app/portainer/users/user.service.ts b/app/portainer/users/user.service.ts index 7a7aba283..f9b2decf0 100644 --- a/app/portainer/users/user.service.ts +++ b/app/portainer/users/user.service.ts @@ -1,6 +1,5 @@ import axios, { parseAxiosError } from '@/portainer/services/axios'; - -import { TeamMembership } from '../teams/types'; +import { TeamMembership } from '@/react/portainer/users/teams/types'; import { User, UserId } from './types'; import { filterNonAdministratorUsers } from './user.helpers'; diff --git a/app/portainer/views/teams/edit/team.html b/app/portainer/views/teams/edit/team.html deleted file mode 100644 index 3eb223443..000000000 --- a/app/portainer/views/teams/edit/team.html +++ /dev/null @@ -1,223 +0,0 @@ - - -
-
- - - - - - - - - - - - - - - - - - -
Name - {{ team.Name }} - -
Leaders - {{ leaderCount }} -
Total users in team{{ teamMembers.length }}
-
-
-
-
- -
-
- - The team leader feature is disabled as external authentication is currently enabled with team sync. -
-
- -
-
- - -
- Items per page: - -
-
- -
- -
-
- -
-
- -
- - - - - - - - - - - - - - - - - -
- -
- - {{ user.Username }} - - - Add - - -
Loading...
No users.
-
- -
-
-
-
-
-
- - -
- Items per page: - -
-
- -
- -
-
- -
-
- -
- - - - - - - - - - - - - - - - - - - -
- - - -
- {{ user.Username }} - - - Remove - - - - - - {{ user.TeamRole }} - - - Leader - - - Member - - -
Loading...
No team members.
-
- -
-
-
-
-
-
diff --git a/app/portainer/views/teams/edit/teamController.js b/app/portainer/views/teams/edit/teamController.js deleted file mode 100644 index 1a2b5b52d..000000000 --- a/app/portainer/views/teams/edit/teamController.js +++ /dev/null @@ -1,214 +0,0 @@ -angular.module('portainer.app').controller('TeamController', [ - '$q', - '$scope', - '$state', - '$transition$', - 'TeamService', - 'UserService', - 'TeamMembershipService', - 'ModalService', - 'Notifications', - 'PaginationService', - 'Authentication', - 'SettingsService', - function ($q, $scope, $state, $transition$, TeamService, UserService, TeamMembershipService, ModalService, Notifications, PaginationService, Authentication, SettingsService) { - $scope.state = { - pagination_count_users: PaginationService.getPaginationLimit('team_available_users'), - pagination_count_members: PaginationService.getPaginationLimit('team_members'), - }; - - $scope.sortTypeUsers = 'Username'; - $scope.sortReverseUsers = true; - $scope.users = []; - $scope.teamMembers = []; - $scope.leaderCount = 0; - - $scope.orderUsers = function (sortType) { - $scope.sortReverseUsers = $scope.sortTypeUsers === sortType ? !$scope.sortReverseUsers : false; - $scope.sortTypeUsers = sortType; - }; - - $scope.changePaginationCountUsers = function () { - PaginationService.setPaginationLimit('team_available_users', $scope.state.pagination_count_users); - }; - - $scope.sortTypeGroupMembers = 'TeamRole'; - $scope.sortReverseGroupMembers = false; - - $scope.orderGroupMembers = function (sortType) { - $scope.sortReverseGroupMembers = $scope.sortTypeGroupMembers === sortType ? !$scope.sortReverseGroupMembers : false; - $scope.sortTypeGroupMembers = sortType; - }; - - $scope.changePaginationCountGroupMembers = function () { - PaginationService.setPaginationLimit('team_members', $scope.state.pagination_count_members); - }; - - $scope.deleteTeam = function () { - ModalService.confirmDeletion('Do you want to delete this team? Users in this team will not be deleted.', function onConfirm(confirmed) { - if (!confirmed) { - return; - } - deleteTeam(); - }); - }; - - $scope.promoteToLeader = function (user) { - TeamMembershipService.updateMembership(user.MembershipId, user.Id, $scope.team.Id, 1) - .then(function success() { - $scope.leaderCount++; - user.TeamRole = 'Leader'; - Notifications.success('User is now team leader', user.Username); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to update user role'); - }); - }; - - $scope.demoteToMember = function (user) { - TeamMembershipService.updateMembership(user.MembershipId, user.Id, $scope.team.Id, 2) - .then(function success() { - user.TeamRole = 'Member'; - $scope.leaderCount--; - Notifications.success('User is now team member', user.Username); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to update user role'); - }); - }; - - $scope.addAllUsers = function () { - var teamMembershipQueries = []; - angular.forEach($scope.users, function (user) { - teamMembershipQueries.push(TeamMembershipService.createMembership(user.Id, $scope.team.Id, 2)); - }); - $q.all(teamMembershipQueries) - .then(function success(data) { - var users = $scope.users; - for (var i = 0; i < users.length; i++) { - var user = users[i]; - user.MembershipId = data[i].Id; - user.TeamRole = 'Member'; - } - $scope.teamMembers = $scope.teamMembers.concat(users); - $scope.users = []; - Notifications.success('Success', 'All users successfully added'); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to update team members'); - }); - }; - - $scope.addUser = function (user) { - TeamMembershipService.createMembership(user.Id, $scope.team.Id, 2) - .then(function success(data) { - removeUserFromArray(user.Id, $scope.users); - user.TeamRole = 'Member'; - user.MembershipId = data.Id; - $scope.teamMembers.push(user); - Notifications.success('User added to team', user.Username); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to update team members'); - }); - }; - - $scope.removeAllUsers = function () { - var teamMembershipQueries = []; - angular.forEach($scope.teamMembers, function (user) { - teamMembershipQueries.push(TeamMembershipService.deleteMembership(user.MembershipId)); - }); - $q.all(teamMembershipQueries) - .then(function success() { - $scope.users = $scope.users.concat($scope.teamMembers); - $scope.teamMembers = []; - $scope.leaderCount = 0; - Notifications.success('Success', 'All users successfully removed'); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to update team members'); - }); - }; - - $scope.removeUser = function (user) { - TeamMembershipService.deleteMembership(user.MembershipId) - .then(function success() { - removeUserFromArray(user.Id, $scope.teamMembers); - if (user.TeamRole === 'Leader') { - $scope.leaderCount--; - } - $scope.users.push(user); - Notifications.success('User removed from team', user.Username); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to update team members'); - }); - }; - - function deleteTeam() { - TeamService.deleteTeam($scope.team.Id) - .then(function success() { - Notifications.success('Team successfully deleted', $scope.team.Name); - $state.go('portainer.teams'); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to remove team'); - }); - } - - function removeUserFromArray(id, users) { - for (var i = 0, l = users.length; i < l; i++) { - if (users[i].Id === id) { - users.splice(i, 1); - return; - } - } - } - - function assignUsersAndMembers(users, memberships) { - for (var i = 0; i < users.length; i++) { - var user = users[i]; - var member = false; - for (var j = 0; j < memberships.length; j++) { - var membership = memberships[j]; - if (user.Id === membership.UserId) { - member = true; - if (membership.Role === 1) { - user.TeamRole = 'Leader'; - $scope.leaderCount++; - } else { - user.TeamRole = 'Member'; - } - user.MembershipId = membership.Id; - $scope.teamMembers.push(user); - break; - } - } - if (!member) { - $scope.users.push(user); - } - } - } - - async function initView() { - $scope.isAdmin = Authentication.isAdmin(); - - try { - $scope.settings = await SettingsService.publicSettings(); - - const data = await $q.all({ - team: TeamService.team($transition$.params().id), - users: UserService.users($scope.isAdmin && $scope.settings.TeamSync), - memberships: TeamService.userMemberships($transition$.params().id), - }); - - $scope.team = data.team; - assignUsersAndMembers(data.users, data.memberships); - } catch (err) { - Notifications.error('Failure', err, 'Unable to retrieve team details'); - } - } - - initView(); - }, -]); diff --git a/app/portainer/views/teams/teams.html b/app/portainer/views/teams/teams.html deleted file mode 100644 index f3108efe3..000000000 --- a/app/portainer/views/teams/teams.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - -
-
- -
-
diff --git a/app/portainer/views/teams/teamsController.js b/app/portainer/views/teams/teamsController.js deleted file mode 100644 index fdee63dcb..000000000 --- a/app/portainer/views/teams/teamsController.js +++ /dev/null @@ -1,103 +0,0 @@ -import _ from 'lodash-es'; - -angular.module('portainer.app').controller('TeamsController', [ - '$q', - '$scope', - '$state', - 'TeamService', - 'UserService', - 'ModalService', - 'Notifications', - 'Authentication', - function ($q, $scope, $state, TeamService, UserService, ModalService, Notifications, Authentication) { - $scope.state = { - actionInProgress: false, - }; - - $scope.formValues = { - Name: '', - Leaders: [], - }; - - $scope.checkNameValidity = function (form) { - var valid = true; - for (var i = 0; i < $scope.teams.length; i++) { - if ($scope.formValues.Name === $scope.teams[i].Name) { - valid = false; - break; - } - } - form.team_name.$setValidity('validName', valid); - }; - - $scope.addTeam = function (formValues) { - const teamName = formValues.name; - - $scope.state.actionInProgress = true; - TeamService.createTeam(teamName, formValues.leaders) - .then(function success() { - Notifications.success('Team successfully created', teamName); - $state.reload(); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to create team'); - }) - .finally(function final() { - $scope.state.actionInProgress = false; - }); - }; - - $scope.removeAction = function (selectedItems) { - ModalService.confirmDeletion('Do you want to delete the selected team(s)? Users in the team(s) will not be deleted.', function onConfirm(confirmed) { - if (!confirmed) { - return; - } - deleteSelectedTeams(selectedItems); - }); - }; - - function deleteSelectedTeams(selectedItems) { - var actionCount = selectedItems.length; - angular.forEach(selectedItems, function (team) { - TeamService.deleteTeam(team.Id) - .then(function success() { - Notifications.success('Team successfully removed', team.Name); - var index = $scope.teams.indexOf(team); - $scope.teams.splice(index, 1); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to remove team'); - }) - .finally(function final() { - --actionCount; - if (actionCount === 0) { - $state.reload(); - } - }); - }); - } - - function initView() { - var userDetails = Authentication.getUserDetails(); - var isAdmin = Authentication.isAdmin(); - $scope.isAdmin = isAdmin; - $q.all({ - users: UserService.users(false), - teams: isAdmin ? TeamService.teams() : UserService.userLeadingTeams(userDetails.ID), - }) - .then(function success(data) { - var teams = data.teams; - $scope.teams = teams; - $scope.users = _.orderBy(data.users, 'Username', 'asc'); - $scope.isTeamLeader = !!teams.length; - }) - .catch(function error(err) { - $scope.teams = []; - $scope.users = []; - Notifications.error('Failure', err, 'Unable to retrieve teams'); - }); - } - - initView(); - }, -]); diff --git a/app/react-tools/test-mocks.ts b/app/react-tools/test-mocks.ts index eefa43943..68cf61392 100644 --- a/app/react-tools/test-mocks.ts +++ b/app/react-tools/test-mocks.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; -import { Team } from '@/portainer/teams/types'; +import { Team } from '@/react/portainer/users/teams/types'; import { Role, User, UserId } from '@/portainer/users/types'; import { Environment } from '@/portainer/environments/types'; diff --git a/app/react/azure/container-instances/CreateView/useLoadFormState.ts b/app/react/azure/container-instances/CreateView/useLoadFormState.ts index 3bb932e0f..ffd717812 100644 --- a/app/react/azure/container-instances/CreateView/useLoadFormState.ts +++ b/app/react/azure/container-instances/CreateView/useLoadFormState.ts @@ -6,7 +6,7 @@ import { Subscription, } from '@/react/azure/types'; import { parseAccessControlFormData } from '@/portainer/access-control/utils'; -import { useIsAdmin } from '@/portainer/hooks/useUser'; +import { useUser } from '@/portainer/hooks/useUser'; import { useProvider } from '@/react/azure/queries/useProvider'; import { useResourceGroups } from '@/react/azure/queries/useResourceGroups'; import { useSubscriptions } from '@/react/azure/queries/useSubscriptions'; @@ -37,7 +37,7 @@ export function useFormState( resourceGroups: Record = {}, providers: Record = {} ) { - const isAdmin = useIsAdmin(); + const { isAdmin } = useUser(); const subscriptionOptions = subscriptions.map((s) => ({ value: s.subscriptionId, diff --git a/app/react/components/PaginationControls/PageSelector.tsx b/app/react/components/PaginationControls/PageSelector.tsx index 728a54791..6fa3af1fd 100644 --- a/app/react/components/PaginationControls/PageSelector.tsx +++ b/app/react/components/PaginationControls/PageSelector.tsx @@ -1,3 +1,5 @@ +import './pagination-controls.css'; + import { generatePagesArray } from './generatePagesArray'; import { PageButton } from './PageButton'; import { PageInput } from './PageInput'; diff --git a/app/react/components/TeamsSelector/TeamsSelector.tsx b/app/react/components/TeamsSelector/TeamsSelector.tsx index 4bd71cdc9..e03e1ffa3 100644 --- a/app/react/components/TeamsSelector/TeamsSelector.tsx +++ b/app/react/components/TeamsSelector/TeamsSelector.tsx @@ -1,4 +1,4 @@ -import { Team, TeamId } from '@/portainer/teams/types'; +import { Team, TeamId } from '@/react/portainer/users/teams/types'; import { Select } from '@@/form-components/ReactSelect'; diff --git a/app/react/components/Widget/WidgetTitle.tsx b/app/react/components/Widget/WidgetTitle.tsx index 26f40504e..a781298e5 100644 --- a/app/react/components/Widget/WidgetTitle.tsx +++ b/app/react/components/Widget/WidgetTitle.tsx @@ -24,7 +24,7 @@ export function WidgetTitle({ return (
- +
{ columns: ColumnInstance[]; onChange: (value: string[]) => void; @@ -18,8 +16,6 @@ export function ColumnVisibilityMenu({ onChange, value, }: Props) { - useTableContext(); - return ( {({ isExpanded }) => ( diff --git a/app/react/components/datatables/NameCell.tsx b/app/react/components/datatables/NameCell.tsx new file mode 100644 index 000000000..576a74026 --- /dev/null +++ b/app/react/components/datatables/NameCell.tsx @@ -0,0 +1,30 @@ +import { CellProps, Column } from 'react-table'; + +import { Link } from '@@/Link'; + +export function buildNameColumn>( + nameKey: string, + idKey: string, + path: string +) { + const name: Column = { + Header: 'Name', + accessor: (row) => row[nameKey], + id: 'name', + Cell: NameCell, + disableFilters: true, + Filter: () => null, + canHide: false, + sortType: 'string', + }; + + return name; + + function NameCell({ value: name, row }: CellProps) { + return ( + + {name} + + ); + } +} diff --git a/app/react/components/datatables/Table.tsx b/app/react/components/datatables/Table.tsx index 818cb1478..28b856253 100644 --- a/app/react/components/datatables/Table.tsx +++ b/app/react/components/datatables/Table.tsx @@ -2,15 +2,15 @@ import clsx from 'clsx'; import { PropsWithChildren } from 'react'; import { TableProps } from 'react-table'; -import { useTableContext, TableContainer } from './TableContainer'; +import { TableContainer } from './TableContainer'; import { TableActions } from './TableActions'; import { TableTitleActions } from './TableTitleActions'; +import { TableContent } from './TableContent'; import { TableHeaderCell } from './TableHeaderCell'; import { TableSettingsMenu } from './TableSettingsMenu'; import { TableTitle } from './TableTitle'; import { TableHeaderRow } from './TableHeaderRow'; import { TableRow } from './TableRow'; -import { TableContent } from './TableContent'; import { TableFooter } from './TableFooter'; function MainComponent({ @@ -19,8 +19,6 @@ function MainComponent({ role, style, }: PropsWithChildren) { - useTableContext(); - return (
) { - useTableContext(); + if (Children.count(children) === 0) { + return null; + } return
{children}
; } diff --git a/app/react/components/datatables/TableContainer.tsx b/app/react/components/datatables/TableContainer.tsx index de7eea6b4..76ae3cef0 100644 --- a/app/react/components/datatables/TableContainer.tsx +++ b/app/react/components/datatables/TableContainer.tsx @@ -1,25 +1,13 @@ -import { createContext, PropsWithChildren, useContext } from 'react'; +import { PropsWithChildren } from 'react'; import { Widget, WidgetBody } from '@@/Widget'; -const Context = createContext(null); - -export function useTableContext() { - const context = useContext(Context); - - if (context == null) { - throw new Error('Should be nested inside a TableContainer component'); - } -} - export function TableContainer({ children }: PropsWithChildren) { return ( - -
- - {children} - -
-
+
+ + {children} + +
); } diff --git a/app/react/components/datatables/TableFooter.tsx b/app/react/components/datatables/TableFooter.tsx index 98d2d572f..1f97148ea 100644 --- a/app/react/components/datatables/TableFooter.tsx +++ b/app/react/components/datatables/TableFooter.tsx @@ -1,9 +1,5 @@ import { PropsWithChildren } from 'react'; -import { useTableContext } from './TableContainer'; - export function TableFooter({ children }: PropsWithChildren) { - useTableContext(); - return
{children}
; } diff --git a/app/react/components/datatables/TableHeaderCell.tsx b/app/react/components/datatables/TableHeaderCell.tsx index 822447243..efb0e01dc 100644 --- a/app/react/components/datatables/TableHeaderCell.tsx +++ b/app/react/components/datatables/TableHeaderCell.tsx @@ -2,7 +2,6 @@ import clsx from 'clsx'; import { PropsWithChildren, ReactNode } from 'react'; import { TableHeaderProps } from 'react-table'; -import { useTableContext } from './TableContainer'; import { TableHeaderSortIcons } from './TableHeaderSortIcons'; import styles from './TableHeaderCell.module.css'; @@ -27,8 +26,6 @@ export function TableHeaderCell({ canFilter, renderFilter, }: Props) { - useTableContext(); - return (
{headers.map((column) => ( diff --git a/app/react/components/datatables/TableRow.tsx b/app/react/components/datatables/TableRow.tsx index 7431d3aff..c687bfcc3 100644 --- a/app/react/components/datatables/TableRow.tsx +++ b/app/react/components/datatables/TableRow.tsx @@ -1,7 +1,5 @@ import { Cell, TableRowProps } from 'react-table'; -import { useTableContext } from './TableContainer'; - interface Props = Record> extends Omit { cells: Cell[]; @@ -10,8 +8,6 @@ interface Props = Record> export function TableRow< D extends Record = Record >({ cells, className, role, style }: Props) { - useTableContext(); - return ( {cells.map((cell) => { diff --git a/app/react/components/datatables/TableSettingsMenu.tsx b/app/react/components/datatables/TableSettingsMenu.tsx index e183520e0..ea5dcae7e 100644 --- a/app/react/components/datatables/TableSettingsMenu.tsx +++ b/app/react/components/datatables/TableSettingsMenu.tsx @@ -3,8 +3,6 @@ import { Menu, MenuButton, MenuList } from '@reach/menu-button'; import { PropsWithChildren, ReactNode } from 'react'; import { MoreVertical } from 'react-feather'; -import { useTableContext } from './TableContainer'; - interface Props { quickActions?: ReactNode; } @@ -13,8 +11,6 @@ export function TableSettingsMenu({ quickActions, children, }: PropsWithChildren) { - useTableContext(); - return ( {({ isExpanded }) => ( diff --git a/app/react/components/datatables/TableTitle.tsx b/app/react/components/datatables/TableTitle.tsx index 5eb0fe1f2..b31d82e5a 100644 --- a/app/react/components/datatables/TableTitle.tsx +++ b/app/react/components/datatables/TableTitle.tsx @@ -2,8 +2,6 @@ import { ComponentType, PropsWithChildren, ReactNode } from 'react'; import { Icon } from '@@/Icon'; -import { useTableContext } from './TableContainer'; - interface Props { icon?: ReactNode | ComponentType; featherIcon?: boolean; @@ -16,8 +14,6 @@ export function TableTitle({ label, children, }: PropsWithChildren) { - useTableContext(); - return (
diff --git a/app/react/components/datatables/TableTitleActions.tsx b/app/react/components/datatables/TableTitleActions.tsx index b9135cdbc..710a9e1dd 100644 --- a/app/react/components/datatables/TableTitleActions.tsx +++ b/app/react/components/datatables/TableTitleActions.tsx @@ -1,9 +1,9 @@ -import { PropsWithChildren } from 'react'; - -import { useTableContext } from './TableContainer'; +import { Children, PropsWithChildren } from 'react'; export function TableTitleActions({ children }: PropsWithChildren) { - useTableContext(); + if (Children.count(children) === 0) { + return null; + } return
{children}
; } diff --git a/app/react/portainer/users/teams/.keep b/app/react/portainer/users/teams/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/portainer/users/teams/ItemView/.keep b/app/react/portainer/users/teams/ItemView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/portainer/users/teams/ItemView/Details.tsx b/app/react/portainer/users/teams/ItemView/Details.tsx new file mode 100644 index 000000000..5d2074e28 --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/Details.tsx @@ -0,0 +1,103 @@ +import { useRouter } from '@uirouter/react'; +import { useMutation, useQueryClient } from 'react-query'; +import { Trash2, Users } from 'react-feather'; + +import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm'; +import { usePublicSettings } from '@/portainer/settings/queries'; +import { + mutationOptions, + withError, + withInvalidate, +} from '@/react-tools/react-query'; + +import { Button } from '@@/buttons'; +import { Widget } from '@@/Widget'; + +import { Team, TeamId, TeamMembership, TeamRole } from '../types'; +import { deleteTeam } from '../teams.service'; + +interface Props { + team: Team; + memberships: TeamMembership[]; + isAdmin: boolean; +} + +export function Details({ team, memberships, isAdmin }: Props) { + const deleteMutation = useDeleteTeam(); + const router = useRouter(); + const teamSyncQuery = usePublicSettings({ + select: (settings) => settings.TeamSync, + }); + + const leaderCount = memberships.filter( + (m) => m.Role === TeamRole.Leader + ).length; + + return ( +
+
+ + + + +
diff --git a/app/react/components/datatables/TableHeaderRow.tsx b/app/react/components/datatables/TableHeaderRow.tsx index 0cf005488..499735307 100644 --- a/app/react/components/datatables/TableHeaderRow.tsx +++ b/app/react/components/datatables/TableHeaderRow.tsx @@ -1,6 +1,5 @@ import { HeaderGroup, TableHeaderProps } from 'react-table'; -import { useTableContext } from './TableContainer'; import { TableHeaderCell } from './TableHeaderCell'; interface Props = Record> { @@ -17,8 +16,6 @@ export function TableHeaderRow< role, style, }: Props & TableHeaderProps) { - useTableContext(); - return (
+ + + + + + + + + + + + + + +
Name + {!teamSyncQuery.data && team.Name} + {isAdmin && ( + + )} +
Leaders{!teamSyncQuery.data && leaderCount}
Total users in team{memberships.length}
+ + +
+
+ ); + + async function handleDeleteClick() { + const confirmed = await confirmDeletionAsync( + `Do you want to delete this team? Users in this team will not be deleted.` + ); + if (!confirmed) { + return; + } + + deleteMutation.mutate(team.Id, { + onSuccess() { + router.stateService.go('portainer.teams'); + }, + }); + } +} + +function useDeleteTeam() { + const queryClient = useQueryClient(); + return useMutation( + (id: TeamId) => deleteTeam(id), + + mutationOptions( + withError('Unable to delete team'), + withInvalidate(queryClient, [['teams']]) + ) + ); +} diff --git a/app/react/portainer/users/teams/ItemView/ItemView.tsx b/app/react/portainer/users/teams/ItemView/ItemView.tsx new file mode 100644 index 000000000..89c711374 --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/ItemView.tsx @@ -0,0 +1,72 @@ +import { useRouter } from '@uirouter/react'; + +import { useUsers } from '@/portainer/users/queries'; +import { useUser } from '@/portainer/hooks/useUser'; +import { usePublicSettings } from '@/portainer/settings/queries'; + +import { TextTip } from '@@/Tip/TextTip'; +import { PageHeader } from '@@/PageHeader'; + +import { useTeam, useTeamMemberships } from '../queries'; + +import { Details } from './Details'; +import { TeamAssociationSelector } from './TeamAssociationSelector'; +import { useTeamIdParam } from './useTeamIdParam'; + +export function ItemView() { + const teamId = useTeamIdParam(); + + const { isAdmin } = useUser(); + const router = useRouter(); + const teamQuery = useTeam(teamId, () => + router.stateService.go('portainer.teams') + ); + const usersQuery = useUsers(); + const membershipsQuery = useTeamMemberships(teamId); + const teamSyncQuery = usePublicSettings({ + select: (settings) => settings.TeamSync, + }); + + if (!teamQuery.data) { + return null; + } + + const team = teamQuery.data; + + return ( + <> + + + {membershipsQuery.data && ( +
+ )} + + {teamSyncQuery.data && ( +
+
+ + The team leader feature is disabled as external authentication is + currently enabled with team sync. + +
+
+ )} + + {usersQuery.data && membershipsQuery.data && ( + + )} + + ); +} diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.module.css b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.module.css new file mode 100644 index 000000000..ac047a087 --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.module.css @@ -0,0 +1,11 @@ +.root { + display: flex; + flex-direction: row; + width: 100%; + justify-content: space-around; + gap: 30px; +} + +.root > * { + flex: 1; +} diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.stories.tsx b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.stories.tsx new file mode 100644 index 000000000..e6099f206 --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.stories.tsx @@ -0,0 +1,55 @@ +import { Meta } from '@storybook/react'; +import { useMemo, useState } from 'react'; + +import { createMockUsers } from '@/react-tools/test-mocks'; +import { Role, User } from '@/portainer/users/types'; +import { UserViewModel } from '@/portainer/models/user'; +import { UserContext } from '@/portainer/hooks/useUser'; + +import { TeamMembership, TeamRole } from '../../types'; + +import { TeamAssociationSelector } from './TeamAssociationSelector'; + +const meta: Meta = { + title: 'teams/TeamAssociationSelector', + component: TeamAssociationSelector, +}; + +export default meta; + +export { Example }; + +interface Args { + userRole: Role; +} + +function Example({ userRole }: Args) { + const userProviderState = useMemo( + () => ({ user: new UserViewModel({ Role: userRole }) }), + [userRole] + ); + const [users] = useState(createMockUsers(20) as User[]); + + const [memberships] = useState[]>( + users + .filter(() => Math.random() > 0.5) + .map((u) => ({ + UserID: u.Id, + Role: Math.random() > 0.5 ? TeamRole.Leader : TeamRole.Member, + })) + ); + + return ( + + + + ); +} + +Example.args = { + userRole: TeamRole.Leader, +}; diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.test.tsx b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.test.tsx new file mode 100644 index 000000000..c7410dacc --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.test.tsx @@ -0,0 +1,21 @@ +import { UserContext } from '@/portainer/hooks/useUser'; +import { UserViewModel } from '@/portainer/models/user'; +import { renderWithQueryClient } from '@/react-tools/test-utils'; + +import { TeamAssociationSelector } from './TeamAssociationSelector'; + +test('renders correctly', () => { + const queries = renderComponent(); + + expect(queries).toBeTruthy(); +}); + +function renderComponent() { + const user = new UserViewModel({ Username: 'user' }); + + return renderWithQueryClient( + + + + ); +} diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.tsx b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.tsx new file mode 100644 index 000000000..5db5b4aee --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamAssociationSelector.tsx @@ -0,0 +1,48 @@ +import _ from 'lodash'; + +import { User } from '@/portainer/users/types'; + +import { TeamId, TeamMembership } from '../../types'; + +import { UsersList } from './UsersList'; +import { TeamMembersList } from './TeamMembersList'; + +interface Props { + users: User[]; + memberships: TeamMembership[]; + disabled?: boolean; + teamId: TeamId; +} + +export function TeamAssociationSelector({ + users, + memberships, + disabled, + teamId, +}: Props) { + const teamUsers = _.compact( + memberships.map((m) => users.find((user) => user.Id === m.UserID)) + ); + const usersNotInTeam = users.filter( + (user) => !memberships.some((m) => m.UserID === user.Id) + ); + const userRoles = Object.fromEntries( + memberships.map((m) => [m.UserID, m.Role]) + ); + + return ( +
+
+ +
+
+ +
+
+ ); +} diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/RowContext.tsx b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/RowContext.tsx new file mode 100644 index 000000000..ddfc7ea02 --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/RowContext.tsx @@ -0,0 +1,14 @@ +import { TeamRole, TeamId } from '@/react/portainer/users/teams/types'; +import { UserId } from '@/portainer/users/types'; + +import { createRowContext } from '@@/datatables/RowContext'; + +export interface RowContext { + getRole(userId: UserId): TeamRole; + disabled?: boolean; + teamId: TeamId; +} + +const { RowProvider, useRowContext } = createRowContext(); + +export { RowProvider, useRowContext }; diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.module.css b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.module.css new file mode 100644 index 000000000..c3a2af639 --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.module.css @@ -0,0 +1,2 @@ +.root { +} diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.stories.tsx b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.stories.tsx new file mode 100644 index 000000000..0858f56c4 --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.stories.tsx @@ -0,0 +1,50 @@ +import { Meta } from '@storybook/react'; +import { useMemo, useState } from 'react'; + +import { UserContext } from '@/portainer/hooks/useUser'; +import { createMockUsers } from '@/react-tools/test-mocks'; +import { UserViewModel } from '@/portainer/models/user'; +import { Role } from '@/portainer/users/types'; +import { TeamRole } from '@/react/portainer/users/teams/types'; + +import { TeamMembersList } from './TeamMembersList'; + +const meta: Meta = { + title: 'Teams/TeamAssociationSelector/TeamMembersList', + component: TeamMembersList, +}; + +export default meta; + +export { Example }; + +interface Args { + userRole: Role; +} + +function Example({ userRole }: Args) { + const userProviderState = useMemo( + () => ({ user: new UserViewModel({ Role: userRole }) }), + [userRole] + ); + + const [users] = useState(createMockUsers(20)); + const [roles] = useState( + Object.fromEntries( + users.map((user) => [ + user.Id, + Math.random() > 0.5 ? TeamRole.Leader : TeamRole.Member, + ]) + ) + ); + + return ( + + + + ); +} + +Example.args = { + userRole: Role.Admin, +}; diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.test.tsx b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.test.tsx new file mode 100644 index 000000000..88d67af73 --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.test.tsx @@ -0,0 +1,24 @@ +import { UserContext } from '@/portainer/hooks/useUser'; +import { UserViewModel } from '@/portainer/models/user'; +import { renderWithQueryClient } from '@/react-tools/test-utils'; + +import { TeamMembersList } from './TeamMembersList'; + +test('renders correctly', () => { + const queries = renderComponent(); + + expect(queries).toBeTruthy(); +}); + +function renderComponent() { + const user = new UserViewModel({ Username: 'user' }); + + return renderWithQueryClient( + + + + ); +} + +test.todo('when users list is empty, add all users button is disabled'); +test.todo('filter displays expected users'); diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.tsx b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.tsx new file mode 100644 index 000000000..8cf5257f4 --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/TeamMembersList.tsx @@ -0,0 +1,218 @@ +import { + useGlobalFilter, + usePagination, + useSortBy, + useTable, +} from 'react-table'; +import { useMemo, useState } from 'react'; +import { Users, UserX } from 'react-feather'; + +import { User, UserId } from '@/portainer/users/types'; +import { TeamId, TeamRole } from '@/react/portainer/users/teams/types'; +import { useUser } from '@/portainer/hooks/useUser'; +import { notifySuccess } from '@/portainer/services/notifications'; +import { + useRemoveMemberMutation, + useTeamMemberships, +} from '@/react/portainer/users/teams/queries'; + +import { Widget } from '@@/Widget'; +import { PageSelector } from '@@/PaginationControls/PageSelector'; +import { Button } from '@@/buttons'; +import { Table } from '@@/datatables'; +import { Input } from '@@/form-components/Input'; + +import { name } from './name-column'; +import { RowContext, RowProvider } from './RowContext'; +import { teamRole } from './team-role-column'; + +const columns = [name, teamRole]; + +interface Props { + users: User[]; + roles: Record; + disabled?: boolean; + teamId: TeamId; +} + +export function TeamMembersList({ users, roles, disabled, teamId }: Props) { + const membershipsQuery = useTeamMemberships(teamId); + + const removeMemberMutation = useRemoveMemberMutation( + teamId, + membershipsQuery.data + ); + + const [search, setSearch] = useState(''); + const [pageSize, setPageSize] = useState(10); + + const { isAdmin } = useUser(); + const { + getTableProps, + getTableBodyProps, + headerGroups, + page, + prepareRow, + gotoPage, + setPageSize: setPageSizeInternal, + setGlobalFilter: setGlobalFilterInternal, + state: { pageIndex }, + setSortBy, + rows, + } = useTable( + { + defaultCanFilter: false, + columns, + data: users, + initialState: { + pageSize, + + sortBy: [{ id: 'name', desc: false }], + globalFilter: search, + }, + }, + useGlobalFilter, + useSortBy, + usePagination + ); + + const tableProps = getTableProps(); + const tbodyProps = getTableBodyProps(); + const rowContext = useMemo( + () => ({ + getRole(userId: UserId) { + return roles[userId]; + }, + disabled, + teamId, + }), + [roles, disabled, teamId] + ); + return ( + + + Items per page: + + + +
+ {isAdmin && ( + + )} +
+
+ +
+
+ + + + {headerGroups.map((headerGroup) => { + const { key, className, role, style } = + headerGroup.getHeaderGroupProps(); + + return ( + + key={key} + className={className} + role={role} + style={style} + headers={headerGroup.headers} + onSortChange={handleSortChange} + /> + ); + })} + + + ( + + + cells={row.cells} + key={key} + className={className} + role={role} + style={style} + /> + + )} + /> + +
+ + {pageSize !== 0 && ( +
+ gotoPage(p - 1)} + currentPage={pageIndex + 1} + itemsPerPage={pageSize} + totalCount={rows.length} + /> +
+ )} +
+
+
+ ); + + function handlePageSizeChange(e: React.ChangeEvent) { + const pageSize = parseInt(e.target.value, 10); + setPageSize(pageSize); + setPageSizeInternal(pageSize); + } + + function handleSearchBarChange(e: React.ChangeEvent) { + const { value } = e.target; + setSearch(value); + setGlobalFilterInternal(value); + } + + function handleSortChange(id: string, desc: boolean) { + setSortBy([{ id, desc }]); + } + + function handleRemoveMembers(userIds: UserId[]) { + removeMemberMutation.mutate(userIds, { + onSuccess() { + notifySuccess('Success', 'All users successfully removed'); + }, + }); + } +} diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/index.ts b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/index.ts new file mode 100644 index 000000000..2ee4b36b2 --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/index.ts @@ -0,0 +1 @@ +export { TeamMembersList } from './TeamMembersList'; diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/name-column.tsx b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/name-column.tsx new file mode 100644 index 000000000..f76f825d1 --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/name-column.tsx @@ -0,0 +1,62 @@ +import { CellProps, Column } from 'react-table'; +import { MinusCircle } from 'react-feather'; + +import { User, UserId } from '@/portainer/users/types'; +import { notifySuccess } from '@/portainer/services/notifications'; +import { + useRemoveMemberMutation, + useTeamMemberships, +} from '@/react/portainer/users/teams/queries'; + +import { Button } from '@@/buttons'; + +import { useRowContext } from './RowContext'; + +export const name: Column = { + Header: 'Name', + accessor: (row) => row.Username, + id: 'name', + Cell: NameCell, + disableFilters: true, + Filter: () => null, + canHide: false, + sortType: 'string', +}; + +export function NameCell({ + value: name, + row: { original: user }, +}: CellProps) { + const { disabled, teamId } = useRowContext(); + + const membershipsQuery = useTeamMemberships(teamId); + + const removeMemberMutation = useRemoveMemberMutation( + teamId, + membershipsQuery.data + ); + + return ( + <> + {name} + + + + ); + + function handleRemoveMember(userId: UserId) { + removeMemberMutation.mutate([userId], { + onSuccess() { + notifySuccess('User removed from team', name); + }, + }); + } +} diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/team-role-column.tsx b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/team-role-column.tsx new file mode 100644 index 000000000..e7aad5ea5 --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/TeamMembersList/team-role-column.tsx @@ -0,0 +1,113 @@ +import { CellProps, Column } from 'react-table'; +import { User as UserIcon, UserPlus, UserX } from 'react-feather'; + +import { User } from '@/portainer/users/types'; +import { useUser as useCurrentUser } from '@/portainer/hooks/useUser'; +import { TeamRole } from '@/react/portainer/users/teams/types'; +import { notifySuccess } from '@/portainer/services/notifications'; +import { + useTeamMemberships, + useUpdateRoleMutation, +} from '@/react/portainer/users/teams/queries'; + +import { Button } from '@@/buttons'; +import { Icon } from '@@/Icon'; + +import { useRowContext } from './RowContext'; + +export const teamRole: Column = { + Header: 'Team Role', + accessor: 'Id', + id: 'role', + Cell: RoleCell, + disableFilters: true, + Filter: () => null, + canHide: false, + sortType: 'string', +}; + +export function RoleCell({ row: { original: user } }: CellProps) { + const { getRole, disabled, teamId } = useRowContext(); + const membershipsQuery = useTeamMemberships(teamId); + const updateRoleMutation = useUpdateRoleMutation( + teamId, + membershipsQuery.data + ); + + const role = getRole(user.Id); + + const { isAdmin } = useCurrentUser(); + + const Cell = role === TeamRole.Leader ? LeaderCell : MemberCell; + + return ( + + ); + + function handleUpdateRole(role: TeamRole, onSuccessMessage: string) { + updateRoleMutation.mutate( + { userId: user.Id, role }, + { + onSuccess() { + notifySuccess(onSuccessMessage, user.Username); + }, + } + ); + } +} + +interface LeaderCellProps { + isAdmin: boolean; + onClick: (role: TeamRole, onSuccessMessage: string) => void; + disabled?: boolean; +} + +function LeaderCell({ isAdmin, onClick, disabled }: LeaderCellProps) { + return ( +
+ + + {isAdmin && ( + + )} +
+ ); +} + +interface MemberCellProps { + onClick: (role: TeamRole, onSuccessMessage: string) => void; + disabled?: boolean; +} + +function MemberCell({ onClick, disabled }: MemberCellProps) { + return ( +
+ + +
+ ); +} diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/RowContext.tsx b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/RowContext.tsx new file mode 100644 index 000000000..c7b21f0bd --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/RowContext.tsx @@ -0,0 +1,12 @@ +import { TeamId } from '@/react/portainer/users/teams/types'; + +import { createRowContext } from '@@/datatables/RowContext'; + +interface RowContext { + disabled?: boolean; + teamId: TeamId; +} + +const { RowProvider, useRowContext } = createRowContext(); + +export { RowProvider, useRowContext }; diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.module.css b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.module.css new file mode 100644 index 000000000..c3a2af639 --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.module.css @@ -0,0 +1,2 @@ +.root { +} diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.stories.tsx b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.stories.tsx new file mode 100644 index 000000000..bbbcc6498 --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.stories.tsx @@ -0,0 +1,22 @@ +import { Meta } from '@storybook/react'; + +import { createMockUsers } from '@/react-tools/test-mocks'; + +import { UsersList } from './UsersList'; + +const meta: Meta = { + title: 'Teams/TeamAssociationSelector/UsersList', + component: UsersList, +}; + +export default meta; + +export { Example }; + +function Example() { + const users = createMockUsers(20); + + return ; +} + +Example.args = {}; diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.test.tsx b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.test.tsx new file mode 100644 index 000000000..c115fda0e --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.test.tsx @@ -0,0 +1,23 @@ +import { UserContext } from '@/portainer/hooks/useUser'; +import { UserViewModel } from '@/portainer/models/user'; +import { renderWithQueryClient } from '@/react-tools/test-utils'; + +import { UsersList } from './UsersList'; + +test('renders correctly', () => { + const queries = renderComponent(); + + expect(queries).toBeTruthy(); +}); + +function renderComponent() { + const user = new UserViewModel({ Username: 'user' }); + return renderWithQueryClient( + + + + ); +} + +test.todo('when users list is empty, add all users button is disabled'); +test.todo('filter displays expected users'); diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.tsx b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.tsx new file mode 100644 index 000000000..d568e0808 --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/UsersList.tsx @@ -0,0 +1,199 @@ +import { + useGlobalFilter, + usePagination, + useSortBy, + useTable, +} from 'react-table'; +import { useMemo, useState } from 'react'; +import { UserPlus, Users } from 'react-feather'; + +import { User, UserId } from '@/portainer/users/types'; +import { useUser } from '@/portainer/hooks/useUser'; +import { notifySuccess } from '@/portainer/services/notifications'; +import { useAddMemberMutation } from '@/react/portainer/users/teams/queries'; +import { TeamId } from '@/react/portainer/users/teams/types'; + +import { Widget } from '@@/Widget'; +import { PageSelector } from '@@/PaginationControls/PageSelector'; +import { Button } from '@@/buttons'; +import { Table } from '@@/datatables'; +import { TableFooter } from '@@/datatables/TableFooter'; +import { Input } from '@@/form-components/Input'; + +import { name } from './name-column'; +import { RowProvider } from './RowContext'; + +const columns = [name]; + +interface Props { + users: User[]; + disabled?: boolean; + teamId: TeamId; +} + +export function UsersList({ users, disabled, teamId }: Props) { + const [search, setSearch] = useState(''); + const [pageSize, setPageSize] = useState(10); + const addMemberMutation = useAddMemberMutation(teamId); + + const { isAdmin } = useUser(); + const { + getTableProps, + getTableBodyProps, + headerGroups, + page, + prepareRow, + gotoPage, + setPageSize: setPageSizeInternal, + setGlobalFilter: setGlobalFilterInternal, + state: { pageIndex }, + setSortBy, + rows, + } = useTable( + { + defaultCanFilter: false, + columns, + data: users, + initialState: { + pageSize, + + sortBy: [{ id: 'name', desc: false }], + globalFilter: search, + }, + }, + useGlobalFilter, + useSortBy, + usePagination + ); + + const tableProps = getTableProps(); + const tbodyProps = getTableBodyProps(); + const rowContext = useMemo(() => ({ disabled, teamId }), [disabled, teamId]); + return ( + + + Items per page: + + + +
+ {isAdmin && ( + + )} +
+
+ +
+
+ + + + {headerGroups.map((headerGroup) => { + const { key, className, role, style } = + headerGroup.getHeaderGroupProps(); + + return ( + + key={key} + className={className} + role={role} + style={style} + headers={headerGroup.headers} + onSortChange={handleSortChange} + /> + ); + })} + + + ( + + + cells={row.cells} + key={key} + className={className} + role={role} + style={style} + /> + + )} + /> + +
+ + {pageSize !== 0 && ( +
+ gotoPage(p - 1)} + currentPage={pageIndex + 1} + itemsPerPage={pageSize} + totalCount={rows.length} + /> +
+ )} +
+
+
+ ); + + function handlePageSizeChange(e: React.ChangeEvent) { + const pageSize = parseInt(e.target.value, 10); + setPageSize(pageSize); + setPageSizeInternal(pageSize); + } + + function handleSearchBarChange(e: React.ChangeEvent) { + const { value } = e.target; + setSearch(value); + setGlobalFilterInternal(value); + } + + function handleSortChange(id: string, desc: boolean) { + setSortBy([{ id, desc }]); + } + + function handleAddAllMembers(userIds: UserId[]) { + addMemberMutation.mutate(userIds, { + onSuccess() { + notifySuccess('Success', 'All users successfully added'); + }, + }); + } +} diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/index.ts b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/index.ts new file mode 100644 index 000000000..f484059cd --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/index.ts @@ -0,0 +1 @@ +export { UsersList } from './UsersList'; diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/name-column.tsx b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/name-column.tsx new file mode 100644 index 000000000..abb8f5023 --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/UsersList/name-column.tsx @@ -0,0 +1,54 @@ +import { CellProps, Column } from 'react-table'; +import { PlusCircle } from 'react-feather'; + +import { User } from '@/portainer/users/types'; +import { notifySuccess } from '@/portainer/services/notifications'; +import { useAddMemberMutation } from '@/react/portainer/users/teams/queries'; + +import { Button } from '@@/buttons'; + +import { useRowContext } from './RowContext'; + +export const name: Column = { + Header: 'Name', + accessor: (row) => row.Username, + id: 'name', + Cell: NameCell, + disableFilters: true, + Filter: () => null, + canHide: false, + sortType: 'string', +}; + +export function NameCell({ + value: name, + row: { original: user }, +}: CellProps) { + const { disabled, teamId } = useRowContext(); + + const addMemberMutation = useAddMemberMutation(teamId); + + return ( + <> + {name} + + + + ); + + function handleAddMember() { + addMemberMutation.mutate([user.Id], { + onSuccess() { + notifySuccess('User added to team', name); + }, + }); + } +} diff --git a/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/index.ts b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/index.ts new file mode 100644 index 000000000..d88f65e68 --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/TeamAssociationSelector/index.ts @@ -0,0 +1 @@ +export { TeamAssociationSelector } from './TeamAssociationSelector'; diff --git a/app/react/portainer/users/teams/ItemView/index.ts b/app/react/portainer/users/teams/ItemView/index.ts new file mode 100644 index 000000000..a09ab2dde --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/index.ts @@ -0,0 +1 @@ +export { ItemView } from './ItemView'; diff --git a/app/react/portainer/users/teams/ItemView/useTeamIdParam.ts b/app/react/portainer/users/teams/ItemView/useTeamIdParam.ts new file mode 100644 index 000000000..0c8959396 --- /dev/null +++ b/app/react/portainer/users/teams/ItemView/useTeamIdParam.ts @@ -0,0 +1,14 @@ +import { useCurrentStateAndParams } from '@uirouter/react'; + +export function useTeamIdParam() { + const { + params: { id: teamIdParam }, + } = useCurrentStateAndParams(); + const teamId = parseInt(teamIdParam, 10); + + if (!teamIdParam || Number.isNaN(teamId)) { + throw new Error('Team ID is missing'); + } + + return teamId; +} diff --git a/app/react/portainer/users/teams/ListView/.keep b/app/react/portainer/users/teams/ListView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/portainer/teams/CreateTeamForm/CreateTeamForm.mocks.ts b/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.mocks.ts similarity index 100% rename from app/portainer/teams/CreateTeamForm/CreateTeamForm.mocks.ts rename to app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.mocks.ts diff --git a/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.stories.tsx b/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.stories.tsx new file mode 100644 index 000000000..4bfb0aa1e --- /dev/null +++ b/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.stories.tsx @@ -0,0 +1,23 @@ +import { Meta } from '@storybook/react'; + +import { CreateTeamForm } from './CreateTeamForm'; +import { mockExampleData } from './CreateTeamForm.mocks'; + +const meta: Meta = { + title: 'teams/CreateTeamForm', + component: CreateTeamForm, +}; + +export default meta; + +export { Example }; + +function Example() { + const { teams, users } = mockExampleData(); + + return ( +
+ +
+ ); +} diff --git a/app/portainer/teams/CreateTeamForm/CreateTeamForm.test.tsx b/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.test.tsx similarity index 78% rename from app/portainer/teams/CreateTeamForm/CreateTeamForm.test.tsx rename to app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.test.tsx index f02ea42d1..0f72684b7 100644 --- a/app/portainer/teams/CreateTeamForm/CreateTeamForm.test.tsx +++ b/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.test.tsx @@ -1,12 +1,12 @@ import userEvent from '@testing-library/user-event'; -import { render, waitFor } from '@/react-tools/test-utils'; +import { renderWithQueryClient, waitFor } from '@/react-tools/test-utils'; import { CreateTeamForm } from './CreateTeamForm'; test('filling the name should make the submit button clickable and emptying it should make it disabled', async () => { - const { findByLabelText, findByText } = render( - {}} /> + const { findByLabelText, findByText } = renderWithQueryClient( + ); const button = await findByText('Create team'); diff --git a/app/portainer/teams/CreateTeamForm/CreateTeamForm.tsx b/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.tsx similarity index 69% rename from app/portainer/teams/CreateTeamForm/CreateTeamForm.tsx rename to app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.tsx index bfeb6a5bb..662ad32d5 100644 --- a/app/portainer/teams/CreateTeamForm/CreateTeamForm.tsx +++ b/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.tsx @@ -1,29 +1,32 @@ import { Formik, Field, Form } from 'formik'; +import { useMutation, useQueryClient } from 'react-query'; +import { useReducer } from 'react'; -import { UserViewModel } from '@/portainer/models/user'; import { Icon } from '@/react/components/Icon'; -import { TeamViewModel } from '@/portainer/models/team'; +import { User } from '@/portainer/users/types'; +import { notifySuccess } from '@/portainer/services/notifications'; import { FormControl } from '@@/form-components/FormControl'; -import { Widget, WidgetBody, WidgetTitle } from '@@/Widget'; +import { Widget } from '@@/Widget'; import { Input } from '@@/form-components/Input'; import { UsersSelector } from '@@/UsersSelector'; import { LoadingButton } from '@@/buttons/LoadingButton'; +import { createTeam } from '../../teams.service'; +import { Team } from '../../types'; + +import { FormValues } from './types'; import { validationSchema } from './CreateTeamForm.validation'; -export interface FormValues { - name: string; - leaders: number[]; -} - interface Props { - users: UserViewModel[]; - teams: TeamViewModel[]; - onSubmit(values: FormValues): void; + users: User[]; + teams: Team[]; } -export function CreateTeamForm({ users, teams, onSubmit }: Props) { +export function CreateTeamForm({ users, teams }: Props) { + const addTeamMutation = useAddTeamMutation(); + const [formKey, incFormKey] = useReducer((state: number) => state + 1, 0); + const initialValues = { name: '', leaders: [], @@ -33,18 +36,19 @@ export function CreateTeamForm({ users, teams, onSubmit }: Props) {
- - + validationSchema(teams)} - onSubmit={onSubmit} + onSubmit={handleAddTeamClick} validateOnMount + key={formKey} > {({ values, @@ -100,7 +104,7 @@ export function CreateTeamForm({ users, teams, onSubmit }: Props) { @@ -111,9 +115,37 @@ export function CreateTeamForm({ users, teams, onSubmit }: Props) { )} - +
); + + async function handleAddTeamClick(values: FormValues) { + addTeamMutation.mutate(values, { + onSuccess() { + incFormKey(); + notifySuccess('Team successfully added', ''); + }, + }); + } +} + +export function useAddTeamMutation() { + const queryClient = useQueryClient(); + + return useMutation( + (values: FormValues) => createTeam(values.name, values.leaders), + { + meta: { + error: { + title: 'Failure', + message: 'Failed to create team', + }, + }, + onSuccess() { + return queryClient.invalidateQueries(['teams']); + }, + } + ); } diff --git a/app/portainer/teams/CreateTeamForm/CreateTeamForm.validation.ts b/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.validation.ts similarity index 74% rename from app/portainer/teams/CreateTeamForm/CreateTeamForm.validation.ts rename to app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.validation.ts index 05944d3d1..b9c408886 100644 --- a/app/portainer/teams/CreateTeamForm/CreateTeamForm.validation.ts +++ b/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.validation.ts @@ -1,8 +1,8 @@ import { object, string, array, number } from 'yup'; -import { TeamViewModel } from '@/portainer/models/team'; +import { Team } from '@/react/portainer/users/teams/types'; -export function validationSchema(teams: TeamViewModel[]) { +export function validationSchema(teams: Team[]) { return object().shape({ name: string() .required('This field is required.') diff --git a/app/react/portainer/users/teams/ListView/CreateTeamForm/index.ts b/app/react/portainer/users/teams/ListView/CreateTeamForm/index.ts new file mode 100644 index 000000000..3e03079cf --- /dev/null +++ b/app/react/portainer/users/teams/ListView/CreateTeamForm/index.ts @@ -0,0 +1 @@ +export { CreateTeamForm } from './CreateTeamForm'; diff --git a/app/react/portainer/users/teams/ListView/CreateTeamForm/types.ts b/app/react/portainer/users/teams/ListView/CreateTeamForm/types.ts new file mode 100644 index 000000000..7f95e48ba --- /dev/null +++ b/app/react/portainer/users/teams/ListView/CreateTeamForm/types.ts @@ -0,0 +1,6 @@ +import { UserId } from '@/portainer/users/types'; + +export interface FormValues { + name: string; + leaders: UserId[]; +} diff --git a/app/react/portainer/users/teams/ListView/ListView.tsx b/app/react/portainer/users/teams/ListView/ListView.tsx new file mode 100644 index 000000000..28798ba01 --- /dev/null +++ b/app/react/portainer/users/teams/ListView/ListView.tsx @@ -0,0 +1,30 @@ +import { useUsers } from '@/portainer/users/queries'; +import { useUser } from '@/portainer/hooks/useUser'; + +import { PageHeader } from '@@/PageHeader'; + +import { useTeams } from '../queries'; + +import { CreateTeamForm } from './CreateTeamForm'; +import { TeamsDatatableContainer } from './TeamsDatatable/TeamsDatatable'; + +export function ListView() { + const { isAdmin } = useUser(); + + const usersQuery = useUsers(false); + const teamsQuery = useTeams(!isAdmin, { enabled: !!usersQuery.data }); + + return ( + <> + + + {usersQuery.data && teamsQuery.data && ( + + )} + + {teamsQuery.data && ( + + )} + + ); +} diff --git a/app/react/portainer/users/teams/ListView/TeamsDatatable/TeamsDatatable.tsx b/app/react/portainer/users/teams/ListView/TeamsDatatable/TeamsDatatable.tsx new file mode 100644 index 000000000..f8ea2ca12 --- /dev/null +++ b/app/react/portainer/users/teams/ListView/TeamsDatatable/TeamsDatatable.tsx @@ -0,0 +1,244 @@ +import { useRowSelectColumn } from '@lineup-lite/hooks'; +import { + Column, + useGlobalFilter, + usePagination, + useRowSelect, + useSortBy, + useTable, +} from 'react-table'; +import { useMutation, useQueryClient } from 'react-query'; +import { Trash2, Users } from 'react-feather'; + +import { notifySuccess } from '@/portainer/services/notifications'; +import { promiseSequence } from '@/portainer/helpers/promise-utils'; +import { Team, TeamId } from '@/react/portainer/users/teams/types'; +import { deleteTeam } from '@/react/portainer/users/teams/teams.service'; +import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm'; + +import { PaginationControls } from '@@/PaginationControls'; +import { Checkbox } from '@@/form-components/Checkbox'; +import { Table } from '@@/datatables'; +import { Button } from '@@/buttons'; +import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar'; +import { TableFooter } from '@@/datatables/TableFooter'; +import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount'; +import { + TableSettingsProvider, + useTableSettings, +} from '@@/datatables/useTableSettings'; +import { TableContent } from '@@/datatables/TableContent'; +import { buildNameColumn } from '@@/datatables/NameCell'; + +import { TableSettings } from './types'; + +const tableKey = 'teams'; + +const columns: readonly Column[] = [ + buildNameColumn('Name', 'Id', 'portainer.teams.team'), +] as const; + +interface Props { + teams: Team[]; + isAdmin: boolean; +} + +export function TeamsDatatable({ teams, isAdmin }: Props) { + const { handleRemove } = useRemoveMutation(); + + const [searchBarValue, setSearchBarValue] = useSearchBarState(tableKey); + const { settings, setTableSettings } = useTableSettings(); + + const { + getTableProps, + getTableBodyProps, + headerGroups, + page, + prepareRow, + selectedFlatRows, + gotoPage, + setPageSize, + setGlobalFilter, + state: { pageIndex, pageSize }, + } = useTable( + { + defaultCanFilter: false, + columns, + data: teams, + + initialState: { + pageSize: settings.pageSize || 10, + sortBy: [settings.sortBy], + globalFilter: searchBarValue, + }, + selectCheckboxComponent: Checkbox, + }, + + useGlobalFilter, + useSortBy, + usePagination, + useRowSelect, + isAdmin ? useRowSelectColumn : emptyPlugin + ); + + const tableProps = getTableProps(); + const tbodyProps = getTableBodyProps(); + + return ( +
+
+ + + + + {isAdmin && ( + + + + )} + + + + + {headerGroups.map((headerGroup) => { + const { key, className, role, style } = + headerGroup.getHeaderGroupProps(); + + return ( + + key={key} + className={className} + role={role} + style={style} + headers={headerGroup.headers} + onSortChange={handleSortChange} + /> + ); + })} + + + ( + + cells={row.cells} + key={key} + className={className} + role={role} + style={style} + /> + )} + rows={page} + emptyContent="No teams found" + /> + +
+ + + + gotoPage(p - 1)} + totalCount={teams.length} + onPageLimitChange={handlePageSizeChange} + /> + +
+
+
+ ); + + function handlePageSizeChange(pageSize: number) { + setPageSize(pageSize); + setTableSettings({ pageSize }); + } + + function handleSearchBarChange(value: string) { + setSearchBarValue(value); + setGlobalFilter(value); + } + + function handleSortChange(id: string, desc: boolean) { + setTableSettings({ sortBy: { id, desc } }); + } + + function handleRemoveClick() { + const ids = selectedFlatRows.map((row) => row.original.Id); + handleRemove(ids); + } +} + +const defaultSettings: TableSettings = { + pageSize: 10, + sortBy: { id: 'name', desc: false }, +}; + +export function TeamsDatatableContainer(props: Props) { + return ( + + defaults={defaultSettings} + storageKey={tableKey} + > + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + ); +} + +function useRemoveMutation() { + const queryClient = useQueryClient(); + + const deleteMutation = useMutation( + async (ids: TeamId[]) => + promiseSequence(ids.map((id) => () => deleteTeam(id))), + { + meta: { + error: { title: 'Failure', message: 'Unable to remove team' }, + }, + onSuccess() { + return queryClient.invalidateQueries(['teams']); + }, + } + ); + + return { handleRemove }; + + async function handleRemove(teams: TeamId[]) { + const confirmed = await confirmDeletionAsync( + 'Are you sure you want to remove the selected teams?' + ); + + if (!confirmed) { + return; + } + + deleteMutation.mutate(teams, { + onSuccess: () => { + notifySuccess('Teams successfully removed', ''); + }, + }); + } +} + +function emptyPlugin() {} +emptyPlugin.pluginName = 'emptyPlugin'; diff --git a/app/react/portainer/users/teams/ListView/TeamsDatatable/index.ts b/app/react/portainer/users/teams/ListView/TeamsDatatable/index.ts new file mode 100644 index 000000000..44557571a --- /dev/null +++ b/app/react/portainer/users/teams/ListView/TeamsDatatable/index.ts @@ -0,0 +1 @@ +export { TeamsDatatable } from './TeamsDatatable'; diff --git a/app/react/portainer/users/teams/ListView/TeamsDatatable/types.ts b/app/react/portainer/users/teams/ListView/TeamsDatatable/types.ts new file mode 100644 index 000000000..37da06ab7 --- /dev/null +++ b/app/react/portainer/users/teams/ListView/TeamsDatatable/types.ts @@ -0,0 +1,8 @@ +import { + PaginationTableSettings, + SortableTableSettings, +} from '@@/datatables/types-old'; + +export interface TableSettings + extends PaginationTableSettings, + SortableTableSettings {} diff --git a/app/react/portainer/users/teams/ListView/index.ts b/app/react/portainer/users/teams/ListView/index.ts new file mode 100644 index 000000000..dd06dfd19 --- /dev/null +++ b/app/react/portainer/users/teams/ListView/index.ts @@ -0,0 +1 @@ +export { ListView } from './ListView'; diff --git a/app/react/portainer/users/teams/index.ts b/app/react/portainer/users/teams/index.ts new file mode 100644 index 000000000..1ee3d38e3 --- /dev/null +++ b/app/react/portainer/users/teams/index.ts @@ -0,0 +1,2 @@ +export { ItemView } from './ItemView'; +export { ListView } from './ListView'; diff --git a/app/react/portainer/users/teams/queries.ts b/app/react/portainer/users/teams/queries.ts new file mode 100644 index 000000000..bd8c40755 --- /dev/null +++ b/app/react/portainer/users/teams/queries.ts @@ -0,0 +1,134 @@ +import { useMutation, useQuery, useQueryClient } from 'react-query'; + +import { promiseSequence } from '@/portainer/helpers/promise-utils'; +import { notifyError } from '@/portainer/services/notifications'; +import { UserId } from '@/portainer/users/types'; + +import { + createTeamMembership, + deleteTeamMembership, + updateTeamMembership, +} from './team-membership.service'; +import { getTeam, getTeamMemberships, getTeams } from './teams.service'; +import { Team, TeamId, TeamMembership, TeamRole } from './types'; + +export function useTeams( + onlyLedTeams = false, + { + enabled = true, + select = (data) => data as unknown as T, + }: { + enabled?: boolean; + select?: (data: Team[]) => T; + } = {} +) { + const teams = useQuery( + ['teams', { onlyLedTeams }], + () => getTeams(onlyLedTeams), + { + meta: { + error: { title: 'Failure', message: 'Unable to load teams' }, + }, + enabled, + select, + } + ); + + return teams; +} + +export function useTeam(id: TeamId, onError?: (error: unknown) => void) { + return useQuery(['teams', id], () => getTeam(id), { + meta: { + error: { title: 'Failure', message: 'Unable to load team' }, + }, + onError, + }); +} + +export function useTeamMemberships(id: TeamId) { + return useQuery(['teams', id, 'memberships'], () => getTeamMemberships(id), { + meta: { + error: { title: 'Failure', message: 'Unable to load team memberships' }, + }, + }); +} + +export function useAddMemberMutation(teamId: TeamId) { + const queryClient = useQueryClient(); + + return useMutation( + (userIds: UserId[]) => + promiseSequence( + userIds.map( + (userId) => () => + createTeamMembership(userId, teamId, TeamRole.Member) + ) + ), + { + onError(error) { + notifyError('Failure', error as Error, 'Failure to add membership'); + }, + onSuccess() { + return queryClient.invalidateQueries(['teams', teamId, 'memberships']); + }, + } + ); +} + +export function useRemoveMemberMutation( + teamId: TeamId, + teamMemberships: TeamMembership[] = [] +) { + const queryClient = useQueryClient(); + + return useMutation( + (userIds: UserId[]) => + promiseSequence( + userIds.map((userId) => () => { + const membership = teamMemberships.find( + (membership) => membership.UserID === userId + ); + if (!membership) { + throw new Error('Membership not found'); + } + return deleteTeamMembership(membership.Id); + }) + ), + { + onError(error) { + notifyError('Failure', error as Error, 'Failure to add membership'); + }, + onSuccess() { + queryClient.invalidateQueries(['teams', teamId, 'memberships']); + }, + } + ); +} + +export function useUpdateRoleMutation( + teamId: TeamId, + teamMemberships: TeamMembership[] = [] +) { + const queryClient = useQueryClient(); + + return useMutation( + ({ userId, role }: { userId: UserId; role: TeamRole }) => { + const membership = teamMemberships.find( + (membership) => membership.UserID === userId + ); + if (!membership) { + throw new Error('Membership not found'); + } + return updateTeamMembership(membership.Id, userId, teamId, role); + }, + { + onError(error) { + notifyError('Failure', error as Error, 'Failure to update membership'); + }, + onSuccess() { + queryClient.invalidateQueries(['teams', teamId, 'memberships']); + }, + } + ); +} diff --git a/app/react/portainer/users/teams/team-membership.service.ts b/app/react/portainer/users/teams/team-membership.service.ts new file mode 100644 index 000000000..bac4c3013 --- /dev/null +++ b/app/react/portainer/users/teams/team-membership.service.ts @@ -0,0 +1,47 @@ +import { UserId } from '@/portainer/users/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { TeamId, TeamRole, TeamMembershipId } from './types'; + +export async function createTeamMembership( + userId: UserId, + teamId: TeamId, + role: TeamRole +) { + try { + await axios.post(buildUrl(), { userId, teamId, role }); + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to create team membership'); + } +} + +export async function deleteTeamMembership(id: TeamMembershipId) { + try { + await axios.delete(buildUrl(id)); + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to delete team membership'); + } +} + +export async function updateTeamMembership( + id: TeamMembershipId, + userId: UserId, + teamId: TeamId, + role: TeamRole +) { + try { + await axios.put(buildUrl(id), { userId, teamId, role }); + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to update team membership'); + } +} + +function buildUrl(id?: TeamMembershipId) { + let url = '/team_memberships'; + + if (id) { + url += `/${id}`; + } + + return url; +} diff --git a/app/react/portainer/users/teams/teams.service.ts b/app/react/portainer/users/teams/teams.service.ts new file mode 100644 index 000000000..1195a1b46 --- /dev/null +++ b/app/react/portainer/users/teams/teams.service.ts @@ -0,0 +1,71 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { type UserId } from '@/portainer/users/types'; + +import { createTeamMembership } from './team-membership.service'; +import { Team, TeamId, TeamMembership, TeamRole } from './types'; + +export async function getTeams(onlyLedTeams = false) { + try { + const { data } = await axios.get(buildUrl(), { + params: { onlyLedTeams }, + }); + return data; + } catch (error) { + throw parseAxiosError(error as Error); + } +} + +export async function getTeam(id: TeamId) { + try { + const { data } = await axios.get(buildUrl(id)); + return data; + } catch (error) { + throw parseAxiosError(error as Error); + } +} + +export async function deleteTeam(id: TeamId) { + try { + await axios.delete(buildUrl(id)); + } catch (error) { + throw parseAxiosError(error as Error); + } +} + +export async function createTeam(name: string, leaders: UserId[]) { + try { + const { data: team } = await axios.post(buildUrl(), { name }); + await Promise.all( + leaders.map((leaderId) => + createTeamMembership(leaderId, team.Id, TeamRole.Leader) + ) + ); + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to create team'); + } +} + +export async function getTeamMemberships(teamId: TeamId) { + try { + const { data } = await axios.get( + buildUrl(teamId, 'memberships') + ); + return data; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to get team memberships'); + } +} + +function buildUrl(id?: TeamId, action?: string) { + let url = '/teams'; + + if (id) { + url += `/${id}`; + } + + if (action) { + url += `/${action}`; + } + + return url; +} diff --git a/app/react/portainer/users/teams/types.ts b/app/react/portainer/users/teams/types.ts new file mode 100644 index 000000000..b8a24ca29 --- /dev/null +++ b/app/react/portainer/users/teams/types.ts @@ -0,0 +1,22 @@ +import { type UserId } from '@/portainer/users/types'; + +export type TeamId = number; + +export enum TeamRole { + Leader = 1, + Member, +} + +export type Team = { + Id: TeamId; + Name: string; +}; + +export type TeamMembershipId = number; + +export interface TeamMembership { + Id: TeamMembershipId; + Role: TeamRole; + UserID: UserId; + TeamID: TeamId; +} diff --git a/app/setup-tests/server-handlers.ts b/app/setup-tests/server-handlers.ts index 339266266..734a43509 100644 --- a/app/setup-tests/server-handlers.ts +++ b/app/setup-tests/server-handlers.ts @@ -10,6 +10,7 @@ import { Tag } from '@/portainer/tags/types'; import { StatusResponse } from '@/portainer/services/api/status.service'; import { createMockTeams } from '@/react-tools/test-mocks'; import { PublicSettingsResponse } from '@/portainer/settings/types'; +import { UserId } from '@/portainer/users/types'; import { azureHandlers } from './setup-handlers/azure'; import { dockerHandlers } from './setup-handlers/docker'; @@ -36,6 +37,12 @@ export const handlers = [ res(ctx.json(createMockTeams(10))) ), + rest.post<{ name: string }>('/api/teams', (req, res, ctx) => + res(ctx.status(204)) + ), + rest.post<{ userId: UserId }>('/api/team_memberships', (req, res, ctx) => + res(ctx.status(204)) + ), ...azureHandlers, ...dockerHandlers, ...userHandlers, diff --git a/app/setup-tests/setup-handlers/users.ts b/app/setup-tests/setup-handlers/users.ts index ff3ca2286..7d4155072 100644 --- a/app/setup-tests/setup-handlers/users.ts +++ b/app/setup-tests/setup-handlers/users.ts @@ -1,6 +1,6 @@ import { DefaultRequestBody, PathParams, rest } from 'msw'; -import { TeamMembership } from '@/portainer/teams/types'; +import { TeamMembership } from '@/react/portainer/users/teams/types'; import { createMockUsers } from '@/react-tools/test-mocks'; export const userHandlers = [