diff --git a/api/app/Enums/Role.php b/api/app/Enums/Role.php new file mode 100644 index 00000000000..119dfe29877 --- /dev/null +++ b/api/app/Enums/Role.php @@ -0,0 +1,13 @@ +setRoleAssignmentsInputAttribute + // Do this by inserting the team id into each attach/detach/sync key of role assignments + $roleAssignments = $args['roleAssignments']; + foreach ($roleAssignments as $key => $value) { + $roleAssignments[$key]['team'] = $team->id; + } + $user->setRoleAssignmentsInputAttribute($roleAssignments); + + return $team->fresh(); + } +} diff --git a/api/app/GraphQL/Validators/TeamRolesInputValidator.php b/api/app/GraphQL/Validators/TeamRolesInputValidator.php new file mode 100644 index 00000000000..c84b9260aa3 --- /dev/null +++ b/api/app/GraphQL/Validators/TeamRolesInputValidator.php @@ -0,0 +1,30 @@ +> + */ + public function rules(): array + { + return [ + 'roles.*' => [ + 'distinct', + Rule::exists('roles', 'id')->where(function ($query) { + return $query->where('is_team_based', true); + }), + ], + ]; + } +} diff --git a/api/app/Policies/TeamPolicy.php b/api/app/Policies/TeamPolicy.php index 6a919f2b1a7..3ad05241f6f 100644 --- a/api/app/Policies/TeamPolicy.php +++ b/api/app/Policies/TeamPolicy.php @@ -66,7 +66,6 @@ public function delete(User $user) /** * Determine whether the user can view a specific teams, team members. - * Likely to be updated later to allow the team admin and teammates to view their own team * * @param \App\Models\Team/null $team * @return \Illuminate\Auth\Access\Response|bool @@ -75,4 +74,14 @@ public function viewTeamMembers(User $user, Team $team) { return $user->isAbleTo('view-any-teamMembers') || $user->isAbleTo('view-team-teamMembers', $team); } + + /** + * Determine whether the user can assign any user to this team (giving them any team-based role) + * + * @return \Illuminate\Auth\Access\Response|bool + */ + public function assignTeamMembers(User $user, Team $team) + { + return $user->isAbleTo('assign-any-role') || $user->isAbleTo('assign-any-teamRole') || $user->isAbleTo('assign-team-role', $team); + } } diff --git a/api/config/rolepermission.php b/api/config/rolepermission.php index f1e28cb7c39..c71584d84c7 100644 --- a/api/config/rolepermission.php +++ b/api/config/rolepermission.php @@ -78,6 +78,7 @@ 'role' => 'role', 'directiveForm' => 'directiveForm', 'applicantProfile' => 'applicantProfile', + 'teamRole' => 'teamRole', ], /* @@ -381,6 +382,10 @@ 'en' => 'Update metadata associated with any Role', 'fr' => 'Mettre à jour des métadonnées associées à tout rôle', ], + 'assign-any-teamRole' => [ + 'en' => 'Assign any user to any team, with any role.', + 'fr' => 'Affecter n\'importe quel utilisateur à n\'importe quelle équipe, avec n\'importe quel rôle.', + ], 'create-any-directiveForm' => [ 'en' => 'Create any directive form', @@ -474,6 +479,18 @@ 'is_team_based' => false, ], + 'community_manager' => [ + 'display_name' => [ + 'en' => 'Community Manager', + 'fr' => 'Gestionnaire de communauté', + ], + 'description' => [ + 'en' => 'Publishes pools, creates teams, and adds Pool Operators to teams.', + 'fr' => 'Publie des pools, crée des équipes et ajoute des opérateurs des bassins aux équipes.', + ], + 'is_team_based' => false, + ], + 'platform_admin' => [ 'display_name' => [ 'en' => 'Platform Administrator', @@ -625,6 +642,24 @@ ], ], + 'community_manager' => [ + 'userBasicInfo' => [ + 'any' => ['view'], + ], + 'pool' => [ + 'any' => ['view', 'publish'], + ], + 'teamMembers' => [ + 'any' => ['view'], + ], + 'team' => [ + 'any' => ['view', 'create', 'update', 'delete'], + ], + 'teamRole' => [ + 'any' => ['assign'], + ], + ], + 'platform_admin' => [ 'classification' => [ 'any' => ['create', 'update', 'delete'], @@ -660,7 +695,7 @@ 'any' => ['view'], ], 'team' => [ - 'any' => ['create', 'update', 'delete'], + 'any' => ['view', 'create', 'update', 'delete'], ], 'role' => [ 'any' => ['view', 'assign'], diff --git a/api/database/factories/UserFactory.php b/api/database/factories/UserFactory.php index 433ad6c51c7..037f5afa180 100644 --- a/api/database/factories/UserFactory.php +++ b/api/database/factories/UserFactory.php @@ -234,7 +234,7 @@ public function asRequestResponder() /** * Attach the pool operator role to a user after creation. * - * @param string $team Name of the team to attach the role to + * @param string|array $team Name of the team or teams to attach the role to * @return $this */ public function asPoolOperator(string|array $team) @@ -250,6 +250,18 @@ public function asPoolOperator(string|array $team) }); } + /** + * Attach the Community Manager role to a user after creation. + * + * @return $this + */ + public function asCommunityManager() + { + return $this->afterCreating(function (User $user) { + $user->addRole('community_manager'); + }); + } + /** * Attach the admin role to a user after creation. * diff --git a/api/database/seeders/UserSeederLocal.php b/api/database/seeders/UserSeederLocal.php index 0b173c0c31e..b7f90972827 100644 --- a/api/database/seeders/UserSeederLocal.php +++ b/api/database/seeders/UserSeederLocal.php @@ -23,6 +23,7 @@ public function run() User::factory() ->asApplicant() ->asRequestResponder() + ->asCommunityManager() ->asAdmin() ->asPoolOperator(['digital-community-management', 'test-team']) ->withExperiences() @@ -47,6 +48,18 @@ public function run() 'sub' => 'platform@test.com', ]); + User::factory() + ->asApplicant() + ->asCommunityManager() + ->withExperiences() + ->asGovEmployee() + ->create([ + 'first_name' => 'Community', + 'last_name' => 'Manager', + 'email' => 'community@test.com', + 'sub' => 'community@test.com', + ]); + User::factory() ->asApplicant() ->asRequestResponder() diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 384dda9328b..a9ef7f38662 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -150,7 +150,7 @@ type PoolCandidateStatusChangedNotification implements Notification { poolName: LocalizedString @rename(attribute: "pool_name") } -type UserPublicProfile { +type UserPublicProfile @model(class: "\\App\\Models\\User") { id: ID! email: Email firstName: String @rename(attribute: "first_name") @@ -174,7 +174,7 @@ type Pool { operationalRequirements: [OperationalRequirement] @rename(attribute: "operational_requirements") poolCandidates: [PoolCandidate] - @hasMany(relation: "publishedPoolCandidates") + @hasMany(relation: "publishedPoolCandidates", scopes: ["authorizedToView"]) @can(ability: "view", resolved: true) keyTasks: LocalizedString @rename(attribute: "key_tasks") yourImpact: LocalizedString @rename(attribute: "your_impact") @@ -651,6 +651,13 @@ type Query { @deprecated( reason: "applicants is deprecated. Use usersPaginated instead. Remove in #7652." ) + userPublicProfiles: [UserPublicProfile]! + @all + @guard + @can(ability: "viewBasicInfo", model: "User") + @deprecated( + reason: "We should avoid non-paginated queries when we know a query will return thousands of values. In this case, we must write an alternative first!" + ) # countApplicants returns the number of candidates matching its filters, and requires no special permissions. countApplicants(where: ApplicantFilterInput): Int! @count(model: "User", scopes: ["inITPublishingGroup"]) @@ -1380,6 +1387,25 @@ input RoleAssignmentHasMany { detach: RolesInput sync: RolesInput } + +# This input only accepts Team-Based roles. It is assumed that a team id will be provided at a higher level of input. +input RolesForTeamInput + @validator(class: "App\\GraphQL\\Validators\\TeamRolesInputValidator") { + roles: [ID!] +} + +input RolesForTeamHasMany { + attach: RolesForTeamInput + detach: RolesForTeamInput + sync: RolesForTeamInput +} + +input UpdateUserTeamRolesInput { + teamId: ID! + userId: UUID! + roleAssignments: RolesForTeamHasMany! +} + input ScreeningResponseBelongsTo { connect: ID! } @@ -1655,6 +1681,9 @@ type Mutation { @delete @guard @can(ability: "delete", find: "id") + updateUserTeamRoles( + teamRoleAssignments: UpdateUserTeamRolesInput! @spread + ): Team @guard @can(ability: "assignTeamMembers", find: "teamId") # Notifications markNotificationAsRead(id: UUID!): Notification @guard # can only affect notifications belonging to the logged-in user diff --git a/api/storage/app/lighthouse-schema.graphql b/api/storage/app/lighthouse-schema.graphql index 29beada6e1d..67ffbb97465 100755 --- a/api/storage/app/lighthouse-schema.graphql +++ b/api/storage/app/lighthouse-schema.graphql @@ -12,6 +12,7 @@ type Query { ): [User]! @deprecated(reason: "users is deprecated. Use usersPaginated instead. Remove in #7660.") applicant(id: UUID!): User @deprecated(reason: "applicant is deprecated. Use user instead. Remove in #7651.") applicants(includeIds: [ID]!): [User]! @deprecated(reason: "applicants is deprecated. Use usersPaginated instead. Remove in #7652.") + userPublicProfiles: [UserPublicProfile]! @deprecated(reason: "We should avoid non-paginated queries when we know a query will return thousands of values. In this case, we must write an alternative first!") countApplicants(where: ApplicantFilterInput): Int! pool(id: UUID!): Pool publishedPools(closingAfter: DateTime, publishingGroup: PublishingGroup): [Pool!]! @@ -132,6 +133,7 @@ type Mutation { createTeam(team: CreateTeamInput!): Team updateTeam(id: UUID!, team: UpdateTeamInput!): Team deleteTeam(id: UUID!): Team + updateUserTeamRoles(teamRoleAssignments: UpdateUserTeamRolesInput!): Team markNotificationAsRead(id: UUID!): Notification createUserSkill(userId: UUID!, skillId: UUID!, userSkill: CreateUserSkillInput): UserSkill updateUserSkill(id: UUID!, userSkill: UpdateUserSkillInput): UserSkill @@ -1158,6 +1160,22 @@ input RoleAssignmentHasMany { sync: RolesInput } +input RolesForTeamInput { + roles: [ID!] +} + +input RolesForTeamHasMany { + attach: RolesForTeamInput + detach: RolesForTeamInput + sync: RolesForTeamInput +} + +input UpdateUserTeamRolesInput { + teamId: ID! + userId: UUID! + roleAssignments: RolesForTeamHasMany! +} + input ScreeningResponseBelongsTo { connect: ID! } diff --git a/api/tests/Feature/Mutation/UpdateUserTeamRolesTest.php b/api/tests/Feature/Mutation/UpdateUserTeamRolesTest.php new file mode 100644 index 00000000000..ed650df8f05 --- /dev/null +++ b/api/tests/Feature/Mutation/UpdateUserTeamRolesTest.php @@ -0,0 +1,258 @@ +seed(RolePermissionSeeder::class); + $this->bootRefreshesSchemaCache(); + + $this->applicant = User::factory() + ->asApplicant() + ->create([ + 'email' => 'applicant-user@test.com', + 'sub' => 'applicant-user@test.com', + ]); + + $this->adminUser = User::factory() + ->asApplicant() + ->asRequestResponder() + ->asAdmin() + ->create([ + 'email' => 'admin-user@test.com', + 'sub' => 'admin-user@test.com', + ]); + + $this->team = Team::factory()->create(); + + $this->poolOperatorId = Role::where('name', EnumsRole::POOL_OPERATOR)->first()->id; + } + + public function testAdminCanAttachUserToTeam() + { + $this->actingAs($this->adminUser, 'api')->graphQL( + /** @lang GraphQL */ + ' + mutation updateUserTeamRoles($teamRoleAssignments: UpdateUserTeamRolesInput!) { + updateUserTeamRoles(teamRoleAssignments: $teamRoleAssignments) { + id + } + } + ', + [ + + 'teamRoleAssignments' => [ + 'teamId' => $this->team->id, + 'userId' => $this->applicant->id, + 'roleAssignments' => [ + 'attach' => [ + 'roles' => [$this->poolOperatorId], + ], + ], + ], + ] + ); + + $this->assertTrue($this->applicant->hasRole(EnumsRole::POOL_OPERATOR, $this->team)); + } + + public function testAdminCanDetachUserFromTeam() + { + $this->applicant->addRole(EnumsRole::POOL_OPERATOR, $this->team); + $this->actingAs($this->adminUser, 'api')->graphQL( + /** @lang GraphQL */ + ' + mutation updateUserTeamRoles($teamRoleAssignments: UpdateUserTeamRolesInput!) { + updateUserTeamRoles(teamRoleAssignments: $teamRoleAssignments) { + id + } + } + ', + [ + + 'teamRoleAssignments' => [ + 'teamId' => $this->team->id, + 'userId' => $this->applicant->id, + 'roleAssignments' => [ + 'detach' => [ + 'roles' => [$this->poolOperatorId], + ], + ], + ], + ] + ); + $this->assertFalse($this->applicant->hasRole(EnumsRole::POOL_OPERATOR, $this->team)); + } + + public function testAdminCanSyncUserToAndFromTeam() + { + $this->actingAs($this->adminUser, 'api')->graphQL( + /** @lang GraphQL */ + ' + mutation updateUserTeamRoles($teamRoleAssignments: UpdateUserTeamRolesInput!) { + updateUserTeamRoles(teamRoleAssignments: $teamRoleAssignments) { + id + } + } + ', + [ + + 'teamRoleAssignments' => [ + 'teamId' => $this->team->id, + 'userId' => $this->applicant->id, + 'roleAssignments' => [ + 'sync' => [ + 'roles' => [$this->poolOperatorId], + ], + ], + ], + ] + ); + $this->assertTrue($this->applicant->hasRole(EnumsRole::POOL_OPERATOR, $this->team)); + + $this->actingAs($this->adminUser, 'api')->graphQL( + /** @lang GraphQL */ + ' + mutation updateUserTeamRoles($teamRoleAssignments: UpdateUserTeamRolesInput!) { + updateUserTeamRoles(teamRoleAssignments: $teamRoleAssignments) { + id + } + } + ', + [ + + 'teamRoleAssignments' => [ + 'teamId' => $this->team->id, + 'userId' => $this->applicant->id, + 'roleAssignments' => [ + 'sync' => [ + 'roles' => [], + ], + ], + ], + ] + ); + $this->assertFalse($this->applicant->hasRole(EnumsRole::POOL_OPERATOR, $this->team)); + } + + // Create several users with different roles. Assert that an admin can see the users in each role. + public function testAllTeamMembersCanBeReadFromResponse() + { + $otherUser = User::factory()->asPoolOperator($this->team->name)->create(); + $this->actingAs($this->adminUser, 'api')->graphQL( + /** @lang GraphQL */ + ' + mutation updateUserTeamRoles($teamRoleAssignments: UpdateUserTeamRolesInput!) { + updateUserTeamRoles(teamRoleAssignments: $teamRoleAssignments) { + id + roleAssignments { + role { name } + user { id } + } + } + } + ', + [ + + 'teamRoleAssignments' => [ + 'teamId' => $this->team->id, + 'userId' => $this->applicant->id, + 'roleAssignments' => [ + 'attach' => [ + 'roles' => [$this->poolOperatorId], + ], + ], + ], + ] + )->assertJson([ + 'data' => [ + 'updateUserTeamRoles' => [ + 'id' => $this->team->id, + 'roleAssignments' => [ + [ + + 'role' => [ + 'name' => EnumsRole::POOL_OPERATOR->value, + ], + 'user' => [ + 'id' => $otherUser->id, + ], + ], + [ + 'role' => [ + 'name' => EnumsRole::POOL_OPERATOR->value, + ], + 'user' => [ + 'id' => $this->applicant->id, + ], + ], + ], + ], + ], + ]); + } + + public function testOnlyAdminsAndCommunityManagersHavePermissionEvenToEditSelf() + { + $permissionsMap = [ + EnumsRole::BASE_USER->value => false, + EnumsRole::APPLICANT->value => false, + EnumsRole::POOL_OPERATOR->value => false, + EnumsRole::REQUEST_RESPONDER->value => false, + EnumsRole::COMMUNITY_MANAGER->value => true, + EnumsRole::PLATFORM_ADMIN->value => true, + ]; + foreach ($permissionsMap as $role => $hasPermission) { + $user = User::factory()->create(); + $user->addRole($role); + $response = $this->actingAs($user, 'api')->graphQL( + /** @lang GraphQL */ + ' + mutation updateUserTeamRoles($teamRoleAssignments: UpdateUserTeamRolesInput!) { + updateUserTeamRoles(teamRoleAssignments: $teamRoleAssignments) { + id + } + } + ', + [ + 'teamRoleAssignments' => [ + 'teamId' => $this->team->id, + 'userId' => $user->id, + 'roleAssignments' => [ + 'attach' => [ + 'roles' => [$this->poolOperatorId], + ], + ], + ], + ] + ); + if ($hasPermission) { + $response->assertGraphQLErrorFree(); + } else { + $response->assertGraphQLErrorMessage('This action is unauthorized.'); + } + } + } +} diff --git a/api/tests/Feature/RolePermissionTest.php b/api/tests/Feature/RolePermissionTest.php index 489d2824883..53399b136ab 100644 --- a/api/tests/Feature/RolePermissionTest.php +++ b/api/tests/Feature/RolePermissionTest.php @@ -61,7 +61,7 @@ public function test_guest_role() 'create-any-searchRequest', 'view-any-team', 'view-any-role', - ], true)); + ], true)); // The `true` as a second argument means user must have ALL permissions, instead of just one. $this->assertFalse(($this->user->isAbleTo('view-any-user'))); @@ -227,6 +227,33 @@ public function test_platform_admin_role() $this->cleanup(); } + /** + * Test the Community Manager Role + * + * @return void + */ + public function test_community_manager_role() + { + $communityManager = Role::where('name', 'community_manager')->sole(); + $this->user->addRole($communityManager); + + $permissionsToCheck = [ + 'view-any-userBasicInfo', + 'view-any-pool', + 'publish-any-pool', + 'view-any-teamMembers', + 'create-any-team', + 'update-any-team', + 'delete-any-team', + 'assign-any-teamRole', + ]; + + $this->assertTrue($this->user->hasRole('community_manager')); + $this->assertTrue($this->user->isAbleTo($permissionsToCheck, true)); + + $this->cleanup(); + } + /** * Test Strict Team Check * diff --git a/apps/web/src/components/AdminSideMenu/AdminSideMenu.tsx b/apps/web/src/components/AdminSideMenu/AdminSideMenu.tsx index 1348fda22a7..fdbe88566cc 100644 --- a/apps/web/src/components/AdminSideMenu/AdminSideMenu.tsx +++ b/apps/web/src/components/AdminSideMenu/AdminSideMenu.tsx @@ -44,6 +44,7 @@ const AdminSideMenu = ({ isOpen, onToggle }: AdminSideMenuProps) => { roles: [ ROLE_NAME.PoolOperator, ROLE_NAME.RequestResponder, + ROLE_NAME.CommunityManager, ROLE_NAME.PlatformAdmin, ], text: intl.formatMessage({ @@ -56,7 +57,11 @@ const AdminSideMenu = ({ isOpen, onToggle }: AdminSideMenuProps) => { key: "pools", href: paths.poolTable(), icon: Squares2X2Icon, - roles: [ROLE_NAME.PoolOperator, ROLE_NAME.PlatformAdmin], + roles: [ + ROLE_NAME.PoolOperator, + ROLE_NAME.CommunityManager, + ROLE_NAME.PlatformAdmin, + ], text: intl.formatMessage(adminMessages.pools), }, { @@ -84,7 +89,11 @@ const AdminSideMenu = ({ isOpen, onToggle }: AdminSideMenuProps) => { key: "teams", href: paths.teamTable(), icon: BuildingOffice2Icon, - roles: [ROLE_NAME.PoolOperator, ROLE_NAME.PlatformAdmin], + roles: [ + ROLE_NAME.PoolOperator, + ROLE_NAME.CommunityManager, + ROLE_NAME.PlatformAdmin, + ], text: intl.formatMessage(adminMessages.teams), }, { diff --git a/apps/web/src/components/Layout/Layout.tsx b/apps/web/src/components/Layout/Layout.tsx index b21da0513bf..d2aa0733719 100644 --- a/apps/web/src/components/Layout/Layout.tsx +++ b/apps/web/src/components/Layout/Layout.tsx @@ -78,6 +78,7 @@ const Layout = () => { [ ROLE_NAME.PoolOperator, ROLE_NAME.RequestResponder, + ROLE_NAME.CommunityManager, ROLE_NAME.PlatformAdmin, ].some( (authorizedRoleName) => userRoleNames?.includes(authorizedRoleName), diff --git a/apps/web/src/components/Router.tsx b/apps/web/src/components/Router.tsx index 51df45ce074..e9b5768fe57 100644 --- a/apps/web/src/components/Router.tsx +++ b/apps/web/src/components/Router.tsx @@ -1219,6 +1219,7 @@ const createRoute = ( roles={[ ROLE_NAME.PoolOperator, ROLE_NAME.RequestResponder, + ROLE_NAME.CommunityManager, ROLE_NAME.PlatformAdmin, ]} loginPath={loginPath} @@ -1323,7 +1324,11 @@ const createRoute = ( index: true, element: ( @@ -1334,7 +1339,10 @@ const createRoute = ( path: "create", element: ( @@ -1345,7 +1353,11 @@ const createRoute = ( path: ":teamId", element: ( @@ -1358,6 +1370,7 @@ const createRoute = ( @@ -1383,6 +1399,7 @@ const createRoute = ( @@ -1427,6 +1448,7 @@ const createRoute = ( roles={[ ROLE_NAME.PoolOperator, ROLE_NAME.RequestResponder, + ROLE_NAME.CommunityManager, ROLE_NAME.PlatformAdmin, ]} loginPath={loginPath} @@ -1442,6 +1464,7 @@ const createRoute = ( roles={[ ROLE_NAME.PoolOperator, ROLE_NAME.RequestResponder, + ROLE_NAME.CommunityManager, ROLE_NAME.PlatformAdmin, ]} loginPath={loginPath} @@ -1456,6 +1479,7 @@ const createRoute = ( { ]} /> )} + {hasRole("community_manager", roleAssignments) && ( + + )} {hasRole("platform_admin", roleAssignments) && ( { const pools = unpackMaybes(data?.pools).filter((pool) => { if ( hasRole(ROLE_NAME.PlatformAdmin, roleAssignments) || - hasRole(ROLE_NAME.RequestResponder, roleAssignments) + hasRole(ROLE_NAME.RequestResponder, roleAssignments) || + hasRole(ROLE_NAME.CommunityManager, roleAssignments) ) { return true; } diff --git a/apps/web/src/pages/Pools/ViewPoolPage/ViewPoolPage.tsx b/apps/web/src/pages/Pools/ViewPoolPage/ViewPoolPage.tsx index 1e13aa017a3..6df403b158a 100644 --- a/apps/web/src/pages/Pools/ViewPoolPage/ViewPoolPage.tsx +++ b/apps/web/src/pages/Pools/ViewPoolPage/ViewPoolPage.tsx @@ -73,7 +73,10 @@ export const ViewPool = ({ // eslint-disable-next-line @typescript-eslint/no-unused-vars const assessmentBadge = getPoolCompletenessBadge(assessmentStatus); const processBadge = getProcessStatusBadge(pool.status); - const isAdmin = checkRole([ROLE_NAME.PlatformAdmin], roleAssignments); + const canPublish = checkRole( + [ROLE_NAME.CommunityManager, ROLE_NAME.PlatformAdmin], + roleAssignments, + ); let closingDate = ""; if (pool.closingDate) { @@ -341,7 +344,7 @@ export const ViewPool = ({

)} - {!isAdmin && pool.status === PoolStatus.Draft && ( + {!canPublish && pool.status === PoolStatus.Draft && ( )} {[PoolStatus.Closed, PoolStatus.Published].includes( @@ -377,14 +380,13 @@ export const ViewPool = ({ onDelete={onDelete} /> )} - {pool.status === PoolStatus.Draft && - checkRole([ROLE_NAME.PlatformAdmin], roleAssignments) && ( - - )} + {pool.status === PoolStatus.Draft && canPublish && ( + + )} diff --git a/apps/web/src/pages/Teams/TeamMembersPage/TeamMembersPage.tsx b/apps/web/src/pages/Teams/TeamMembersPage/TeamMembersPage.tsx index 239607377f6..b46a99eac4d 100644 --- a/apps/web/src/pages/Teams/TeamMembersPage/TeamMembersPage.tsx +++ b/apps/web/src/pages/Teams/TeamMembersPage/TeamMembersPage.tsx @@ -6,15 +6,17 @@ import { useParams } from "react-router-dom"; import { Heading, Pending, ThrowNotFound } from "@gc-digital-talent/ui"; import { getLocalizedName } from "@gc-digital-talent/i18n"; import { notEmpty } from "@gc-digital-talent/helpers"; + +import SEO from "~/components/SEO/SEO"; import { - useTeamMembersQuery, Role, Scalars, Team, - User, -} from "@gc-digital-talent/graphql"; - -import SEO from "~/components/SEO/SEO"; + useAllUsersNamesQuery, + useGetTeamQuery, + useListRolesQuery, + UserPublicProfile, +} from "~/api/generated"; import { getFullNameLabel } from "~/utils/nameUtils"; import { groupRoleAssignmentsByUser, TeamMember } from "~/utils/teamUtils"; import useRoutes from "~/hooks/useRoutes"; @@ -29,7 +31,7 @@ const columnHelper = createColumnHelper(); interface TeamMembersProps { members: Array; - availableUsers: Array; + availableUsers: Array | null; roles: Array; team: Team; } @@ -142,64 +144,82 @@ const TeamMembersPage = () => { const intl = useIntl(); const routes = useRoutes(); const { teamId } = useParams(); - const [{ data, fetching, error }] = useTeamMembersQuery({ - variables: { id: teamId || "" }, + const [{ data, fetching, error }] = useGetTeamQuery({ + variables: { teamId: teamId || "" }, }); + const [{ data: rolesData, fetching: rolesFetching, error: rolesError }] = + useListRolesQuery(); + const [{ data: userData, error: userError }] = useAllUsersNamesQuery(); const team = data?.team; - const roles = React.useMemo(() => { - return data?.roles.filter(notEmpty).filter((role) => role.isTeamBased); - }, [data?.roles]); - const users = React.useMemo(() => { - return groupRoleAssignmentsByUser(data?.team?.roleAssignments || []); - }, [data?.team?.roleAssignments]); - const availableUsers = data?.users - ?.filter(notEmpty) - .filter((user) => !users.find((teamUser) => teamUser.id === user?.id)); - - const navigationCrumbs = [ - { - label: intl.formatMessage({ - defaultMessage: "Home", - id: "EBmWyo", - description: "Link text for the home link in breadcrumbs.", - }), - url: routes.adminDashboard(), - }, - { - label: intl.formatMessage(adminMessages.teams), - url: routes.teamTable(), - }, - ...(teamId - ? [ - { - label: getLocalizedName(data?.team?.displayName, intl), - url: routes.teamView(teamId), - }, - ] - : []), - ...(teamId - ? [ - { - label: intl.formatMessage({ - defaultMessage: "Members", - id: "nfZQ89", - description: "Breadcrumb title for the team members page link.", - }), - url: routes.teamMembers(teamId), - }, - ] - : []), - ]; + const roles = React.useMemo( + () => + rolesData?.roles + ? rolesData.roles.filter(notEmpty).filter((role) => role.isTeamBased) + : [], + [rolesData?.roles], + ); + const users = React.useMemo( + () => groupRoleAssignmentsByUser(data?.team?.roleAssignments || []), + [data?.team?.roleAssignments], + ); + const availableUsers = React.useMemo( + () => + userData?.userPublicProfiles + ?.filter(notEmpty) + .filter((user) => !users.find((teamUser) => teamUser.id === user?.id)), + [userData?.userPublicProfiles, users], + ); + + const navigationCrumbs = React.useMemo( + () => [ + { + label: intl.formatMessage({ + defaultMessage: "Home", + id: "EBmWyo", + description: "Link text for the home link in breadcrumbs.", + }), + url: routes.adminDashboard(), + }, + { + label: intl.formatMessage(adminMessages.teams), + url: routes.teamTable(), + }, + ...(teamId + ? [ + { + label: getLocalizedName(data?.team?.displayName, intl), + url: routes.teamView(teamId), + }, + ] + : []), + ...(teamId + ? [ + { + label: intl.formatMessage({ + defaultMessage: "Members", + id: "nfZQ89", + description: "Breadcrumb title for the team members page link.", + }), + url: routes.teamMembers(teamId), + }, + ] + : []), + ], + [data?.team?.displayName, intl, routes, teamId], + ); return ( - + {team && users ? ( ) : ( diff --git a/apps/web/src/pages/Teams/TeamMembersPage/components/AddTeamMemberDialog.tsx b/apps/web/src/pages/Teams/TeamMembersPage/components/AddTeamMemberDialog.tsx index dab767e9db7..6c8fc791251 100644 --- a/apps/web/src/pages/Teams/TeamMembersPage/components/AddTeamMemberDialog.tsx +++ b/apps/web/src/pages/Teams/TeamMembersPage/components/AddTeamMemberDialog.tsx @@ -17,8 +17,8 @@ import { import { Role, Team, - User, - useUpdateUserAsAdminMutation, + UserPublicProfile, + useUpdateUserTeamRolesMutation, } from "~/api/generated"; import { getFullNameLabel } from "~/utils/nameUtils"; @@ -27,7 +27,7 @@ import { getTeamBasedRoleOptions } from "./utils"; interface AddTeamMemberDialogProps { team: Team; - availableUsers: Array; + availableUsers: Array | null; availableRoles: Array; } @@ -38,14 +38,14 @@ const AddTeamMemberDialog = ({ }: // onSave, AddTeamMemberDialogProps) => { const intl = useIntl(); - const [, executeMutation] = useUpdateUserAsAdminMutation(); + const [, executeMutation] = useUpdateUserTeamRolesMutation(); const [isOpen, setIsOpen] = React.useState(false); const methods = useForm({ defaultValues: { - user: "", - team: team.id, - teamDisplay: team.id, + userId: "", + teamId: team.id, + teamDisplay: team.id, // This form field will be disabled and only used for display purposes. roles: [], }, }); @@ -57,12 +57,12 @@ AddTeamMemberDialogProps) => { const handleSave = async (formValues: TeamMemberFormValues) => { await executeMutation({ - id: formValues.user, - user: { - roleAssignmentsInput: { + teamRoleAssignments: { + userId: formValues.userId, + teamId: formValues.teamId, + roleAssignments: { attach: { roles: formValues.roles, - team: formValues.team, }, }, }, @@ -93,7 +93,7 @@ AddTeamMemberDialogProps) => { }; const roleOptions = getTeamBasedRoleOptions(availableRoles, intl); - const userOptions = availableUsers.map((user) => ({ + const userOptions = availableUsers?.map((user) => ({ value: user.id, label: getFullNameLabel(user.firstName, user.lastName, intl), })); @@ -104,6 +104,8 @@ AddTeamMemberDialogProps) => { description: "Label for the add member to team form", }); + const fetchingUsers = availableUsers === null; + return ( @@ -131,11 +133,14 @@ AddTeamMemberDialogProps) => { data-h2-gap="base(x1 0)" > + +
{/** Note: Only one option since we are editing this team's users */} - +