From 1845f8ee72d04d19fe43f8e96a9d4a7a3139706b Mon Sep 17 00:00:00 2001 From: harishsurf Date: Fri, 2 Dec 2022 18:29:17 +0530 Subject: [PATCH] ui: Default Permissions tab (PROJQUAY-4570) Signed-off-by: harishsurf --- src/atoms/OrganizationListState.ts | 17 + src/atoms/UserState.ts | 16 - src/components/EntitySearch.tsx | 54 ++- src/components/header/HeaderToolbar.css | 8 - .../modals/wizard/AddToRepository.tsx | 3 + .../toolbar/AllSelectedToggleButton.tsx | 45 +++ src/components/toolbar/DropdownCheckbox.tsx | 19 +- src/hooks/UseDefaultPermissions.ts | 190 ++++++++++ src/hooks/UseEntities.ts | 3 +- src/hooks/UseMembers.ts | 105 ++++++ src/hooks/UseRobotAccounts.ts | 22 ++ src/hooks/UseTeams.ts | 61 +++ src/index.tsx | 2 + src/resources/DefaultPermissionResource.ts | 48 +++ src/resources/MembersResource.ts | 15 +- src/resources/OrganizationResource.ts | 19 +- src/resources/RobotsResource.ts | 4 +- src/resources/TeamResource.ts | 25 ++ src/resources/UserResource.ts | 23 +- .../Organization/Organization.tsx | 114 ++++-- .../DefaultPermissions/DefaultPermissions.tsx | 165 ++++++++ .../DefaultPermissionsToolbar.tsx | 97 +++++ .../DeleteDefaultPermissionKebab.tsx | 84 +++++ .../UpdateDefaultPermissionsDropDown.tsx | 82 ++++ .../CreatePermissionDrawer.tsx | 355 ++++++++++++++++++ .../CreateTeamModal.tsx | 122 ++++++ .../createTeamWizard/AddTeamMember.tsx | 127 +++++++ .../createTeamWizard/AddTeamToolbar.tsx | 131 +++++++ .../createTeamWizard/CreateTeamWizard.tsx | 169 +++++++++ .../createTeamWizard/NameAndDescription.tsx | 51 +++ .../ReviewAndFinishFooter.tsx | 58 +++ .../createTeamWizard/ReviewTeam.tsx | 75 ++++ .../createTeamWizard/SelectRepository.tsx | 0 .../Tabs/UsageLogs/UsageLogsTab.tsx | 3 - .../OrganizationsList/OrganizationToolBar.tsx | 1 + .../OrganizationsList/OrganizationsList.tsx | 7 +- .../OrganizationsListTableData.tsx | 3 +- .../RepositoryDetails/RepositoryDetails.tsx | 8 +- 38 files changed, 2230 insertions(+), 101 deletions(-) create mode 100644 src/components/modals/wizard/AddToRepository.tsx create mode 100644 src/components/toolbar/AllSelectedToggleButton.tsx create mode 100644 src/hooks/UseDefaultPermissions.ts create mode 100644 src/hooks/UseMembers.ts create mode 100644 src/hooks/UseRobotAccounts.ts create mode 100644 src/hooks/UseTeams.ts create mode 100644 src/resources/DefaultPermissionResource.ts create mode 100644 src/resources/TeamResource.ts create mode 100644 src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/DefaultPermissions.tsx create mode 100644 src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/DefaultPermissionsToolbar.tsx create mode 100644 src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/DeleteDefaultPermissionKebab.tsx create mode 100644 src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/UpdateDefaultPermissionsDropDown.tsx create mode 100644 src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createPermissionDrawer/CreatePermissionDrawer.tsx create mode 100644 src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createPermissionDrawer/CreateTeamModal.tsx create mode 100644 src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/AddTeamMember.tsx create mode 100644 src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/AddTeamToolbar.tsx create mode 100644 src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/CreateTeamWizard.tsx create mode 100644 src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/NameAndDescription.tsx create mode 100644 src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/ReviewAndFinishFooter.tsx create mode 100644 src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/ReviewTeam.tsx create mode 100644 src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/SelectRepository.tsx delete mode 100644 src/routes/OrganizationsList/Organization/Tabs/UsageLogs/UsageLogsTab.tsx diff --git a/src/atoms/OrganizationListState.ts b/src/atoms/OrganizationListState.ts index 8668db84..a7296002 100644 --- a/src/atoms/OrganizationListState.ts +++ b/src/atoms/OrganizationListState.ts @@ -1,6 +1,23 @@ import {atom} from 'recoil'; +import {SearchState} from 'src/components/toolbar/SearchTypes'; +import {IOrganization} from 'src/resources/OrganizationResource'; +import ColumnNames from 'src/routes/OrganizationsList/ColumnNames'; +import {permissionColumnNames} from 'src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/DefaultPermissions'; export const refreshPageState = atom({ key: 'refreshOrgPageState', default: 0, }); + +// Organization List page +export const selectedOrgsState = atom({ + key: 'selectedOrgsState', + default: [], +}); +export const searchOrgsState = atom({ + key: 'searchOrgsState', + default: { + query: '', + field: ColumnNames.name, + }, +}); diff --git a/src/atoms/UserState.ts b/src/atoms/UserState.ts index 325f4cbb..df9fa680 100644 --- a/src/atoms/UserState.ts +++ b/src/atoms/UserState.ts @@ -1,22 +1,6 @@ import {atom} from 'recoil'; -import {IOrganization} from 'src/resources/OrganizationResource'; -import ColumnNames from 'src/routes/OrganizationsList/ColumnNames'; -import {SearchState} from 'src/components/toolbar/SearchTypes'; export const CurrentUsernameState = atom({ key: 'currentUsernameState', default: '', }); - -export const selectedOrgsState = atom({ - key: 'selectedOrgsState', - default: [], -}); - -export const searchOrgsState = atom({ - key: 'searchOrgsState', - default: { - query: '', - field: ColumnNames.name, - }, -}); diff --git a/src/components/EntitySearch.tsx b/src/components/EntitySearch.tsx index acb6a8a6..30375f85 100644 --- a/src/components/EntitySearch.tsx +++ b/src/components/EntitySearch.tsx @@ -1,4 +1,9 @@ -import {Select, SelectOption, SelectVariant} from '@patternfly/react-core'; +import { + Select, + SelectGroup, + SelectOption, + SelectVariant, +} from '@patternfly/react-core'; import {useEffect, useState} from 'react'; import {useEntities} from 'src/hooks/UseEntities'; import {Entity, getMemberType} from 'src/resources/UserResource'; @@ -9,13 +14,19 @@ export default function EntitySearch(props: EntitySearchProps) { const {entities, isError, searchTerm, setSearchTerm} = useEntities(props.org); useEffect(() => { - if (selectedEntityName != undefined && selectedEntityName != '') { + if ( + selectedEntityName !== undefined && + selectedEntityName !== '' && + entities.length > 0 + ) { const filteredEntity = entities.filter( (e) => e.name === selectedEntityName, ); const selectedEntity = filteredEntity.length > 0 ? filteredEntity[0] : null; - props.onSelect(selectedEntity); + if (selectedEntity !== null) { + props.onSelect(selectedEntity); + } } }, [selectedEntityName]); @@ -29,10 +40,15 @@ export default function EntitySearch(props: EntitySearchProps) { ); } interface EntitySearchProps { org: string; - onSelect: (entity: Entity) => void; + onSelect: (selectedItem: Entity) => void; onError?: () => void; id?: string; + defaultOptions?: any; + defaultSelection?: string; + placeholderText?: string; } diff --git a/src/components/header/HeaderToolbar.css b/src/components/header/HeaderToolbar.css index 9c08d7db..75907c3d 100644 --- a/src/components/header/HeaderToolbar.css +++ b/src/components/header/HeaderToolbar.css @@ -3,14 +3,6 @@ --pf-c-form--m-horizontal__group-control--md--GridColumnWidth: 1; } -.pf-c-form__label { - --pf-c-form__label--FontSize: 1rem; -} - -.pf-c-form__label-text { - --pf-c-form__label-text--FontWeight: 1; -} - .pf-c-switch { --pf-c-switch--Height: 1rem; } diff --git a/src/components/modals/wizard/AddToRepository.tsx b/src/components/modals/wizard/AddToRepository.tsx new file mode 100644 index 00000000..b630964c --- /dev/null +++ b/src/components/modals/wizard/AddToRepository.tsx @@ -0,0 +1,3 @@ +export default function AddToRepository() { + return
step 2
; +} diff --git a/src/components/toolbar/AllSelectedToggleButton.tsx b/src/components/toolbar/AllSelectedToggleButton.tsx new file mode 100644 index 00000000..0a33312b --- /dev/null +++ b/src/components/toolbar/AllSelectedToggleButton.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { + ToggleGroup, + ToggleGroupItem, + ToolbarItem, +} from '@patternfly/react-core'; + +export default function AllSelectedToggleButton( + props: AllSelectedToggleButtonProps, +) { + const [isSelected, setIsSelected] = React.useState(''); + + const handleItemClick = ( + isSelected: boolean, + event: React.MouseEvent | React.KeyboardEvent | MouseEvent, + ) => { + const id = event.currentTarget.id; + setIsSelected(id); + }; + + return ( + + + + + + + ); +} + +interface AllSelectedToggleButtonProps { + selectedItems: any[]; + allItemsList: any[]; + itemsPerPageList: any[]; +} diff --git a/src/components/toolbar/DropdownCheckbox.tsx b/src/components/toolbar/DropdownCheckbox.tsx index 547c7ddb..b45903cd 100644 --- a/src/components/toolbar/DropdownCheckbox.tsx +++ b/src/components/toolbar/DropdownCheckbox.tsx @@ -17,14 +17,15 @@ export function DropdownCheckbox(props: DropdownCheckboxProps) { const selectPageItems = () => { props.deSelectAll([]); - props.itemsPerPageList.map((value, index) => + props.itemsPerPageList?.map((value, index) => props.onItemSelect(value, index, true), ); setIsOpen(false); }; const selectAllItems = () => { - props.allItemsList.map((value, index) => + deSelectAll(); + props.allItemsList?.map((value, index) => props.onItemSelect(value, index, true), ); setIsOpen(false); @@ -44,9 +45,9 @@ export function DropdownCheckbox(props: DropdownCheckboxProps) { onClick={selectPageItems} > Select page ( - {props.allItemsList.length > props.itemsPerPageList.length - ? props.itemsPerPageList.length - : props.allItemsList.length} + {props.allItemsList?.length > props.itemsPerPageList?.length + ? props.itemsPerPageList?.length + : props.allItemsList?.length} ) , - Select all ({props.allItemsList.length}) + Select all ({props.allItemsList?.length}) , ]; @@ -68,13 +69,13 @@ export function DropdownCheckbox(props: DropdownCheckboxProps) { id={props.id ? props.id : 'split-button-text-checkbox'} key="split-checkbox" aria-label="Select all" - isChecked={props.selectedItems.length > 0 ? true : false} + isChecked={props.selectedItems?.length > 0 ? true : false} onChange={(checked) => checked ? selectPageItems() : deSelectAll() } > - {props.selectedItems.length != 0 - ? props.selectedItems.length + ' selected' + {props.selectedItems?.length != 0 + ? props.selectedItems?.length + ' selected' : ''} , ]} diff --git a/src/hooks/UseDefaultPermissions.ts b/src/hooks/UseDefaultPermissions.ts new file mode 100644 index 00000000..b193d4a9 --- /dev/null +++ b/src/hooks/UseDefaultPermissions.ts @@ -0,0 +1,190 @@ +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import {useState} from 'react'; +import {SearchState} from 'src/components/toolbar/SearchTypes'; +import { + createDefaultPermission, + deleteDefaultPermission, + fetchDefaultPermissions, + updateDefaultPermission, +} from 'src/resources/DefaultPermissionResource'; +import {Entity} from 'src/resources/UserResource'; +import { + permissionColumnNames, + repoPermissions, +} from 'src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/DefaultPermissions'; + +export interface IDefaultPermission { + createdBy: string; + appliedTo: string; + permission: string; + id?: string; +} + +export interface IPrototype { + activating_user?: { + name: string; + }; + delegate: { + name: string; + }; + role: string; + id?: string; +} + +interface createDefaultPermissionParams { + repoCreator?: Entity; + appliedTo: Entity; + newRole: string; +} + +export function useFetchDefaultPermissions(org: string) { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(10); + const [search, setSearch] = useState({ + query: '', + field: permissionColumnNames.repoCreatedBy, + }); + + const { + data: permissions, + isError: errorLoadingPermissions, + isLoading: loadingPermissions, + isPlaceholderData, + } = useQuery( + ['defaultpermissions', org], + () => fetchDefaultPermissions(org), + { + placeholderData: [], + }, + ); + + const defaultPermissions: IDefaultPermission[] = []; + permissions.map((perm) => { + defaultPermissions.push({ + createdBy: perm.activating_user + ? perm.activating_user.name + : 'organization default', + appliedTo: perm.delegate.name, + permission: perm.role, + id: perm.id, + }); + }); + + const filteredPermissions = + search.query !== '' + ? defaultPermissions?.filter((permission) => + permission.createdBy.includes(search.query), + ) + : defaultPermissions; + + const paginatedPermissions = filteredPermissions?.slice( + page * perPage - perPage, + page * perPage - perPage + perPage, + ); + + return { + loading: loadingPermissions || isPlaceholderData, + error: errorLoadingPermissions, + defaultPermissions: defaultPermissions, + paginatedPermissions: paginatedPermissions, + + page, + setPage, + perPage, + setPerPage, + + search, + setSearch, + }; +} + +export function useUpdateDefaultPermission(org: string) { + const queryClient = useQueryClient(); + const { + mutate: setDefaultPermission, + isError: errorSetDefaultPermission, + isSuccess: successSetDefaultPermission, + reset: resetSetDefaultPermission, + } = useMutation( + async ({id, newRole}: {id: string; newRole: string}) => { + return updateDefaultPermission(org, id, newRole); + }, + { + onSuccess: (_, variables) => { + queryClient.invalidateQueries(['defaultpermissions']); + }, + }, + ); + return { + setDefaultPermission, + errorSetDefaultPermission, + successSetDefaultPermission, + resetSetDefaultPermission, + }; +} + +export function useDeleteDefaultPermission(org: string) { + const queryClient = useQueryClient(); + const { + mutate: removeDefaultPermission, + isError: errorDeleteDefaultPermission, + isSuccess: successDeleteDefaultPermission, + reset: resetDeleteDefaultPermission, + } = useMutation( + async ({id}: {id: string}) => { + return deleteDefaultPermission(org, id); + }, + { + onSuccess: (_, variables) => { + queryClient.invalidateQueries(['defaultpermissions']); + }, + }, + ); + return { + removeDefaultPermission, + errorDeleteDefaultPermission, + successDeleteDefaultPermission, + resetDeleteDefaultPermission, + }; +} + +export function useCreateDefaultPermission(orgName) { + const queryClient = useQueryClient(); + + const createDefaultPermissionMutator = useMutation( + async ({ + repoCreator, + appliedTo, + newRole, + }: createDefaultPermissionParams) => { + const permissionData = { + delegate: { + name: appliedTo.name, + kind: appliedTo.kind, + is_robot: appliedTo.is_robot, + }, + role: newRole, + }; + + if (repoCreator) { + permissionData['activating_user'] = { + name: repoCreator.name, + kind: repoCreator.kind, + is_robot: repoCreator.is_robot, + }; + } + + return createDefaultPermission(orgName, permissionData); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['defaultpermissions']); + }, + }, + ); + + return { + createDefaultPermission: async (params: createDefaultPermissionParams) => + createDefaultPermissionMutator.mutate(params), + }; +} diff --git a/src/hooks/UseEntities.ts b/src/hooks/UseEntities.ts index 37d79d62..8e11d875 100644 --- a/src/hooks/UseEntities.ts +++ b/src/hooks/UseEntities.ts @@ -9,7 +9,7 @@ export function useEntities(org: string) { const search = async () => { try { - const entityResults = await fetchEntities(org, searchTerm); + const entityResults = await fetchEntities(searchTerm, org); setEntities(entityResults); } catch (err) { setIsError(true); @@ -32,5 +32,6 @@ export function useEntities(org: string) { isError: isError, searchTerm: searchTerm, setSearchTerm: setSearchTerm, + setEntities: setEntities, }; } diff --git a/src/hooks/UseMembers.ts b/src/hooks/UseMembers.ts new file mode 100644 index 00000000..a662992f --- /dev/null +++ b/src/hooks/UseMembers.ts @@ -0,0 +1,105 @@ +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import {useState} from 'react'; +import { + addMemberToTeamAPI, + fetchMembersForOrg, + IMember, +} from 'src/resources/MembersResource'; +import {fetchRobotsForNamespace} from 'src/resources/RobotsResource'; + +export enum AccountType { + member = 'Team member', + robot = 'Robot account', + invited = 'Invited', +} +export interface ITeamMember { + name: string; + account: AccountType; +} + +export function useFetchMembers(orgName: string) { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(10); + + const { + data: members, + isLoading: isLoadingMembers, + isError: errorLoadingMembers, + } = useQuery( + ['members', orgName], + ({signal}) => fetchMembersForOrg(orgName, signal), + { + placeholderData: [], + }, + ); + + const { + data: robots, + isLoading: isLoadingRobots, + isError: errorLoadingRobots, + } = useQuery( + ['robots', orgName], + ({signal}) => fetchRobotsForNamespace(orgName, false, signal), + { + placeholderData: [], + }, + ); + + const teamMembers: ITeamMember[] = []; + members?.map((member) => + teamMembers.push({ + name: member.name, + account: AccountType.member, + }), + ); + robots?.map((robot) => + teamMembers.push({ + name: robot.name, + account: AccountType.robot, + }), + ); + + const paginatedMembers = teamMembers?.slice( + page * perPage - perPage, + page * perPage - perPage + perPage, + ); + + return { + teamMembers, + robots, + paginatedMembers: paginatedMembers, + isLoading: isLoadingMembers || isLoadingRobots, + + error: errorLoadingMembers || errorLoadingRobots, + + page, + setPage, + perPage, + setPerPage, + }; +} + +export function useAddMembersToTeam(org: string) { + const queryClient = useQueryClient(); + const { + mutate: addMemberToTeam, + isError: errorAddingMemberToTeam, + isSuccess: successAddingMemberToTeam, + reset: resetAddingMemberToTeam, + } = useMutation( + async ({team, member}: {team: string; member: string}) => { + return addMemberToTeamAPI(org, team, member); + }, + { + onSuccess: (_, variables) => { + queryClient.invalidateQueries(['members']); + }, + }, + ); + return { + addMemberToTeam, + errorAddingMemberToTeam, + successAddingMemberToTeam, + resetAddingMemberToTeam, + }; +} diff --git a/src/hooks/UseRobotAccounts.ts b/src/hooks/UseRobotAccounts.ts new file mode 100644 index 00000000..7a8b92a3 --- /dev/null +++ b/src/hooks/UseRobotAccounts.ts @@ -0,0 +1,22 @@ +import {useQuery} from '@tanstack/react-query'; +import {fetchRobotsForNamespace} from 'src/resources/RobotsResource'; + +export function useFetchRobotAccounts(orgName: string) { + const { + data: robots, + isLoading, + error, + } = useQuery( + ['robots'], + ({signal}) => fetchRobotsForNamespace(orgName, false, signal), + { + placeholderData: [], + }, + ); + + return { + error, + loading: isLoading, + robots, + }; +} diff --git a/src/hooks/UseTeams.ts b/src/hooks/UseTeams.ts new file mode 100644 index 00000000..9b2251c6 --- /dev/null +++ b/src/hooks/UseTeams.ts @@ -0,0 +1,61 @@ +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import {IAvatar} from 'src/resources/OrganizationResource'; +import { + createTeamForOrg, + fetchTeamsForNamespace, +} from 'src/resources/TeamResource'; + +interface ITeams { + name: string; + description: string; + role: string; + avatar: IAvatar; + can_view: boolean; + repo_count: number; + member_count: number; + is_synced: boolean; +} + +export function useFetchTeams(orgName: string) { + const {data, isLoading, isPlaceholderData, error} = useQuery( + ['teams'], + ({signal}) => fetchTeamsForNamespace(orgName, signal), + { + placeholderData: {}, + }, + ); + + const teams: ITeams[] = Object.values(data); + + return { + error, + loading: isLoading, + teams, + }; +} + +export function useCreateTeam(orgName) { + const queryClient = useQueryClient(); + + const createTeamMutator = useMutation( + async ({ + teamName, + description, + }: { + teamName: string; + description: string; + }) => { + return createTeamForOrg(orgName, teamName, description); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['teams']); + }, + }, + ); + + return { + createTeam: async (teamName: string, description: string) => + createTeamMutator.mutate({teamName, description}), + }; +} diff --git a/src/index.tsx b/src/index.tsx index 1618bb05..a0f7fd80 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,6 +5,7 @@ import {RecoilRoot} from 'recoil'; import '@patternfly/react-core/dist/styles/base.css'; import '@patternfly/patternfly/patternfly-addons.css'; import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; +// import {ReactQueryDevtools} from '@tanstack/react-query-devtools'; // Load App after patternfly so custom CSS that overrides patternfly doesn't require !important import App from './App'; @@ -23,6 +24,7 @@ ReactDOM.render( + {/* */} , diff --git a/src/resources/DefaultPermissionResource.ts b/src/resources/DefaultPermissionResource.ts new file mode 100644 index 00000000..700b2236 --- /dev/null +++ b/src/resources/DefaultPermissionResource.ts @@ -0,0 +1,48 @@ +import {IPrototype} from 'src/hooks/UseDefaultPermissions'; +import axios from 'src/libs/axios'; +import {assertHttpCode, ResourceError} from './ErrorHandling'; +import {RepoMemberPermissions} from './RepositoryResource'; + +export async function fetchDefaultPermissions(org: string) { + const response = await axios.get(`/api/v1/organization/${org}/prototypes`); + assertHttpCode(response.status, 200); + return response.data.prototypes; +} + +export async function updateDefaultPermission( + org: string, + id: string, + newRole: string, +) { + try { + await axios.put(`/api/v1/organization/${org}/prototypes/${id}`, { + id: id, + role: newRole.toLowerCase(), + }); + } catch (err) { + throw new ResourceError('failed to set default permissions', newRole, err); + } +} + +export async function deleteDefaultPermission(org: string, id: string) { + try { + await axios.delete(`/api/v1/organization/${org}/prototypes/${id}`); + } catch (err) { + console.error('Unable to delete default permission', err); + } +} + +export async function createDefaultPermission( + orgName: string, + permObj: IPrototype, +) { + try { + await axios.post(`/api/v1/organization/${orgName}/prototypes`, permObj); + } catch (err) { + throw new ResourceError( + 'failed to create default permissions for creator:', + permObj.activating_user.name, + err, + ); + } +} diff --git a/src/resources/MembersResource.ts b/src/resources/MembersResource.ts index 0b4df3f1..8f4da0ec 100644 --- a/src/resources/MembersResource.ts +++ b/src/resources/MembersResource.ts @@ -5,7 +5,7 @@ import {assertHttpCode} from './ErrorHandling'; export interface IMember { name: string; kind: string; - teams: ITeam[]; + teams?: ITeam[]; repositories: string[]; } @@ -29,3 +29,16 @@ export async function fetchMembersForOrg( assertHttpCode(response.status, 200); return response.data?.members; } + +export async function addMemberToTeamAPI( + orgName: string, + teamName: string, + member: string, +) { + const addMemberUrl = `/api/v1/organization/${orgName}/team/${teamName}/members/${member}`; + try { + await axios.put(addMemberUrl, {}); + } catch (err) { + console.error('Unable to add member to team', err); + } +} diff --git a/src/resources/OrganizationResource.ts b/src/resources/OrganizationResource.ts index e5a64b7c..31d4eba5 100644 --- a/src/resources/OrganizationResource.ts +++ b/src/resources/OrganizationResource.ts @@ -1,6 +1,10 @@ import {AxiosError, AxiosResponse} from 'axios'; import axios from 'src/libs/axios'; -import {assertHttpCode, BulkOperationError} from './ErrorHandling'; +import { + assertHttpCode, + BulkOperationError, + ResourceError, +} from './ErrorHandling'; export interface IAvatar { name: string; @@ -108,3 +112,16 @@ export async function createOrg(name: string, email?: string) { assertHttpCode(response.status, 201); return response.data; } + +// API calls for Org -> Default Permissions tab + +// export async function searchRepositoryCreatorDropdown( +// org: string, +// searchQuery: string, +// ) { +// const userAndRobotResponse = await axios.get( +// `/api/v1/entities/${searchQuery}?namespace=${org}`, +// ); +// assertHttpCode(userAndRobotResponse.status, 200); +// return userAndRobotResponse.data?.results; +// } diff --git a/src/resources/RobotsResource.ts b/src/resources/RobotsResource.ts index ef87f68e..6c8b007c 100644 --- a/src/resources/RobotsResource.ts +++ b/src/resources/RobotsResource.ts @@ -6,8 +6,8 @@ export interface IRobot { name: string; created: string; last_accessed: string; - teams: string[]; - repositories: string[]; + teams?: string[]; + repositories?: string[]; description: string; } diff --git a/src/resources/TeamResource.ts b/src/resources/TeamResource.ts new file mode 100644 index 00000000..67606300 --- /dev/null +++ b/src/resources/TeamResource.ts @@ -0,0 +1,25 @@ +import {AxiosResponse} from 'axios'; +import axios from 'src/libs/axios'; +import {assertHttpCode} from './ErrorHandling'; + +export async function fetchTeamsForNamespace( + org: string, + signal?: AbortSignal, +) { + const teamsForOrgUrl = `/api/v1/organization/${org}`; + const teamsResponse = await axios.get(teamsForOrgUrl, {signal}); + assertHttpCode(teamsResponse.status, 200); + return teamsResponse.data?.teams; +} + +export async function createTeamForOrg( + orgName: string, + name: string, + description: string, +) { + const createTeamUrl = `/api/v1/organization/${orgName}/team/${name}`; + const payload = {name: name, role: 'member', description: description}; + const response: AxiosResponse = await axios.put(createTeamUrl, payload); + assertHttpCode(response.status, 200); + return response.data; +} diff --git a/src/resources/UserResource.ts b/src/resources/UserResource.ts index b9724a67..a4184349 100644 --- a/src/resources/UserResource.ts +++ b/src/resources/UserResource.ts @@ -56,10 +56,10 @@ export async function fetchUsersAsSuperUser() { } export interface Entity { - avatar: IAvatar; - is_org_member: boolean; + avatar?: IAvatar; + is_org_member?: boolean; name: string; - kind: string; + kind?: string; is_robot?: boolean; } @@ -77,15 +77,18 @@ export function getMemberType(entity: Entity) { } } -export async function fetchEntities(org: string, search: string) { +export async function fetchEntities(searchInput: string, org?: string) { // Handles the case of robot accounts, API doesn't recognize anything before the + sign - if (search.indexOf('+') > -1) { - const splitSearchTerm = search.split('+'); - search = splitSearchTerm.length > 1 ? splitSearchTerm[1] : ''; + if (searchInput.indexOf('+') > -1) { + const splitSearchTerm = searchInput.split('+'); + searchInput = splitSearchTerm.length > 1 ? splitSearchTerm[1] : ''; } - const response: AxiosResponse = await axios.get( - `/api/v1/entities/${search}?namespace=${org}&includeTeams=true`, - ); + const searchUrl = org + ? `/api/v1/entities/${searchInput}?namespace=${org}&includeTeams=true` + : `/api/v1/entities/${searchInput}`; + + const response: AxiosResponse = await axios.get(searchUrl); + assertHttpCode(response.status, 200); return response.data?.results; } diff --git a/src/routes/OrganizationsList/Organization/Organization.tsx b/src/routes/OrganizationsList/Organization/Organization.tsx index cba89ed2..0e4409fc 100644 --- a/src/routes/OrganizationsList/Organization/Organization.tsx +++ b/src/routes/OrganizationsList/Organization/Organization.tsx @@ -1,4 +1,11 @@ import { + Drawer, + DrawerActions, + DrawerCloseButton, + DrawerContent, + DrawerContentBody, + DrawerHead, + DrawerPanelContent, Page, PageSection, PageSectionVariants, @@ -8,15 +15,22 @@ import { Title, } from '@patternfly/react-core'; import {useLocation, useSearchParams} from 'react-router-dom'; -import {useCallback, useState} from 'react'; +import {useCallback, useRef, useState} from 'react'; import RepositoriesList from 'src/routes/RepositoriesList/RepositoriesList'; import Settings from './Tabs/Settings/Settings'; import {QuayBreadcrumb} from 'src/components/breadcrumb/Breadcrumb'; +import DefaultPermissions from './Tabs/DefaultPermissions/DefaultPermissions'; +import CreatePermissionDrawer from './Tabs/DefaultPermissions/createPermissionDrawer/CreatePermissionDrawer'; + +export enum DrawerContentType { + None, + CreatePermissionSpecificUser, +} export default function Organization() { const location = useLocation(); - const repositoryName = location.pathname.split('/')[2]; const [searchParams, setSearchParams] = useSearchParams(); + const orgName = location.pathname.split('/')[2]; const [activeTabKey, setActiveTabKey] = useState( searchParams.get('tab') || 'Repositories', @@ -30,11 +44,44 @@ export default function Organization() { [], ); + // DRAWER STUFF + + const [drawerContent, setDrawerContent] = useState( + DrawerContentType.None, + ); + + const closeDrawer = () => { + setDrawerContent(DrawerContentType.None); + }; + + const drawerRef = useRef(); + + const drawerContentOptions = { + [DrawerContentType.None]: null, + [DrawerContentType.CreatePermissionSpecificUser]: ( + + ), + }; + const repositoriesSubNav = [ { name: 'Repositories', component: , }, + { + name: 'Default Permissions', + component: ( + + ), + }, { name: 'Settings', component: , @@ -42,32 +89,43 @@ export default function Organization() { ]; return ( - - - - - {repositoryName} - - - - - {repositoriesSubNav.map((nav) => ( - {nav.name}} + { + drawerRef.current && drawerRef.current.focus(); + }} + > + + + + + + + {orgName} + + + - {nav.component} - - ))} - - - + + {repositoriesSubNav.map((nav) => ( + {nav.name}} + > + {nav.component} + + ))} + + + + + + ); } diff --git a/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/DefaultPermissions.tsx b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/DefaultPermissions.tsx new file mode 100644 index 00000000..726c392d --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/DefaultPermissions.tsx @@ -0,0 +1,165 @@ +import { + PageSection, + PageSectionVariants, + TextContent, + Text, + TextVariants, + PanelFooter, + Dropdown, + Spinner, +} from '@patternfly/react-core'; +import { + TableComposable, + Tbody, + Td, + Th, + Thead, + Tr, +} from '@patternfly/react-table'; +import {useState} from 'react'; +import {ToolbarPagination} from 'src/components/toolbar/ToolbarPagination'; +import { + IDefaultPermission, + useFetchDefaultPermissions, +} from 'src/hooks/UseDefaultPermissions'; +import DefaultPermissionsDropDown from './UpdateDefaultPermissionsDropDown'; +import DefaultPermissionsToolbar from './DefaultPermissionsToolbar'; +import DeleteDefaultPermissionKebab from './DeleteDefaultPermissionKebab'; +import {Link} from 'react-router-dom'; + +export const permissionColumnNames = { + repoCreatedBy: 'Repository Created By', + permAppliedTo: 'Permission Applied To', + permission: 'Permission', +}; + +export enum repoPermissions { + ADMIN = 'Admin', + READ = 'Read', + WRITE = 'Write', +} + +export default function DefaultPermissions(props: DefaultPermissionsProps) { + const { + loading, + error, + defaultPermissions, + paginatedPermissions, + page, + setPage, + perPage, + setPerPage, + search, + setSearch, + } = useFetchDefaultPermissions(props.orgName); + + const [selectedPermissions, setSelectedPermissions] = useState< + IDefaultPermission[] + >([]); + + const onSelectPermission = ( + permission: IDefaultPermission, + rowIndex: number, + isSelecting: boolean, + ) => { + setSelectedPermissions((prevSelected) => { + const otherSelectedPermissions = prevSelected.filter( + (p) => p.createdBy !== permission.createdBy, + ); + return isSelecting + ? [...otherSelectedPermissions, permission] + : otherSelectedPermissions; + }); + }; + + if (loading) { + return ; + } + + if (error) { + return <>Unable to load default permissions list; + } + + return ( + + + + The Default permissions panel defines permissions that should be + granted automatically to a repository when it is created, in addition + to the default of the repository's creator. Permissions are + assigned based on the user who created the repository. + + + Note: Permissions added here do not automatically get added to + existing repositories. + + + setSelectedPermissions([])} + allItems={defaultPermissions} + paginatedItems={paginatedPermissions} + onItemSelect={onSelectPermission} + page={page} + setPage={setPage} + perPage={perPage} + setPerPage={setPerPage} + search={search} + setSearch={setSearch} + searchOptions={[permissionColumnNames.repoCreatedBy]} + setDrawerContent={props.setDrawerContent} + > + + + + + {permissionColumnNames.repoCreatedBy} + {permissionColumnNames.permAppliedTo} + {permissionColumnNames.permission} + + + + + {paginatedPermissions?.map((permission, rowIndex) => ( + + + onSelectPermission(permission, rowIndex, isSelecting), + isSelected: selectedPermissions.some( + (p) => p.createdBy === permission.createdBy, + ), + }} + /> + + {permission.createdBy} + + + {permission.appliedTo} + + + + + + + + + ))} + + + + + ); +} + +interface DefaultPermissionsProps { + setDrawerContent: (any) => void; + orgName: string; +} diff --git a/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/DefaultPermissionsToolbar.tsx b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/DefaultPermissionsToolbar.tsx new file mode 100644 index 00000000..2a974ef8 --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/DefaultPermissionsToolbar.tsx @@ -0,0 +1,97 @@ +import { + Button, + Flex, + FlexItem, + PanelFooter, + Toolbar, + ToolbarContent, +} from '@patternfly/react-core'; +import {DropdownCheckbox} from 'src/components/toolbar/DropdownCheckbox'; +import {SearchDropdown} from 'src/components/toolbar/SearchDropdown'; +import {SearchInput} from 'src/components/toolbar/SearchInput'; +import {SearchState} from 'src/components/toolbar/SearchTypes'; +import {ToolbarPagination} from 'src/components/toolbar/ToolbarPagination'; +import {IDefaultPermission} from 'src/hooks/UseDefaultPermissions'; +import {DrawerContentType} from '../../Organization'; + +export default function DefaultPermissionsToolbar( + props: DefaultPermissionsToolbarProps, +) { + return ( + <> + + + + + + + + + + + + + + {props.children} + + + + + ); +} + +interface DefaultPermissionsToolbarProps { + selectedItems: IDefaultPermission[]; + deSelectAll: () => void; + allItems: IDefaultPermission[]; + paginatedItems: IDefaultPermission[]; + onItemSelect: ( + item: IDefaultPermission, + rowIndex: number, + isSelecting: boolean, + ) => void; + page: number; + setPage: (page: number) => void; + perPage: number; + setPerPage: (perPage: number) => void; + searchOptions: string[]; + search: SearchState; + setSearch: (search: SearchState) => void; + setDrawerContent: (content: DrawerContentType) => void; + children?: React.ReactNode; +} diff --git a/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/DeleteDefaultPermissionKebab.tsx b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/DeleteDefaultPermissionKebab.tsx new file mode 100644 index 00000000..2273ab7d --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/DeleteDefaultPermissionKebab.tsx @@ -0,0 +1,84 @@ +import { + Alert, + AlertActionCloseButton, + AlertGroup, + Dropdown, + DropdownItem, + KebabToggle, +} from '@patternfly/react-core'; +import {useState} from 'react'; +import Conditional from 'src/components/empty/Conditional'; +import { + IDefaultPermission, + useDeleteDefaultPermission, +} from 'src/hooks/UseDefaultPermissions'; + +export default function DeleteDefaultPermissionKebab({ + org, + permission, +}: DefaultPermissionsDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const { + removeDefaultPermission, + errorDeleteDefaultPermission: error, + successDeleteDefaultPermission: success, + resetDeleteDefaultPermission: reset, + } = useDeleteDefaultPermission(org); + + const onSelect = () => { + setIsOpen(false); + const element = document.getElementById( + `${permission.createdBy}-toggle-kebab`, + ); + element.focus(); + }; + + return ( + <> + + + } + /> + + + + + } + /> + + + { + setIsOpen(!isOpen); + }} + /> + } + isOpen={isOpen} + dropdownItems={[ + removeDefaultPermission({id: permission.id})} + > + Delete Permission + , + ]} + isPlain + /> + + ); +} + +interface DefaultPermissionsDropdownProps { + org: string; + permission: IDefaultPermission; +} diff --git a/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/UpdateDefaultPermissionsDropDown.tsx b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/UpdateDefaultPermissionsDropDown.tsx new file mode 100644 index 00000000..6fe5dfbb --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/UpdateDefaultPermissionsDropDown.tsx @@ -0,0 +1,82 @@ +import { + Alert, + AlertActionCloseButton, + AlertGroup, + Dropdown, + DropdownItem, + DropdownToggle, +} from '@patternfly/react-core'; +import {useState} from 'react'; +import Conditional from 'src/components/empty/Conditional'; +import { + IDefaultPermission, + useUpdateDefaultPermission, +} from 'src/hooks/UseDefaultPermissions'; +import {repoPermissions} from './DefaultPermissions'; + +export default function DefaultPermissionsDropDown({ + org, + permission, +}: DefaultPermissionsDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const { + setDefaultPermission, + successSetDefaultPermission: success, + errorSetDefaultPermission: error, + resetSetDefaultPermission, + } = useUpdateDefaultPermission(org); + + return ( + <> + + + + } + /> + + + + + + } + /> + + + setIsOpen(false)} + toggle={ + setIsOpen(!isOpen)}> + {permission.permission} + + } + isOpen={isOpen} + dropdownItems={Object.keys(repoPermissions).map((key) => ( + + setDefaultPermission({ + id: permission.id, + newRole: repoPermissions[key], + }) + } + > + {repoPermissions[key]} + + ))} + /> + + ); +} + +interface DefaultPermissionsDropdownProps { + org: string; + permission: IDefaultPermission; +} diff --git a/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createPermissionDrawer/CreatePermissionDrawer.tsx b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createPermissionDrawer/CreatePermissionDrawer.tsx new file mode 100644 index 00000000..1e8ffc7e --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createPermissionDrawer/CreatePermissionDrawer.tsx @@ -0,0 +1,355 @@ +import { + ActionGroup, + Button, + Divider, + DrawerActions, + DrawerCloseButton, + DrawerHead, + DrawerPanelBody, + DrawerPanelContent, + Dropdown, + DropdownItem, + DropdownToggle, + Form, + FormGroup, + Radio, + Select, + SelectGroup, + SelectOption, + Spinner, +} from '@patternfly/react-core'; +import {DesktopIcon, UsersIcon} from '@patternfly/react-icons'; +import {useState, Ref, useEffect} from 'react'; +import EntitySearch from 'src/components/EntitySearch'; +import {useCreateDefaultPermission} from 'src/hooks/UseDefaultPermissions'; +import {useFetchRobotAccounts} from 'src/hooks/UseRobotAccounts'; +import {useFetchTeams} from 'src/hooks/UseTeams'; +import {Entity} from 'src/resources/UserResource'; +import {DrawerContentType} from '../../../Organization'; +import {CreateTeamWizard} from '../createTeamWizard/CreateTeamWizard'; +import {repoPermissions} from '../DefaultPermissions'; +import {CreateTeamModal} from './CreateTeamModal'; +import React from 'react'; + +export default function CreatePermissionDrawer( + props: CreatePermissionDrawerProps, +) { + enum userType { + ANYONE = 'Anyone', + SPECIFIC_USER = 'Specific user', + } + + const [createdBy, setCreatedBy] = useState(userType.SPECIFIC_USER); + const [repositoryCreator, setRepositoryCreator] = useState(null); + const [appliedTo, setAppliedTo] = useState(null); + const [permission, setPermission] = useState( + repoPermissions.WRITE, + ); + const [permissionDropDown, setPermissionDropDown] = useState(false); + const [error, setError] = useState(''); + + // Get robots + const {robots} = useFetchRobotAccounts(props.orgName); + // Get teams + const {teams, loading} = useFetchTeams(props.orgName); + + const permissionRadioButtons = ( + <> + setCreatedBy(userType.ANYONE)} + label={userType.ANYONE} + id={userType.ANYONE} + value={userType.ANYONE} + /> + setCreatedBy(userType.SPECIFIC_USER)} + label={userType.SPECIFIC_USER} + id={userType.SPECIFIC_USER} + value={userType.SPECIFIC_USER} + /> + + ); + + const handleCreateRobotAccount = () => { + console.log('tba'); + }; + + // TODO: https://www.patternfly.org/v4/components/select#view-more + + const creatorDefaultOptions = [ + + + {robots?.map((r) => ( + { + setRepositoryCreator({ + is_robot: true, + name: r.name, + kind: 'user', + }); + }} + /> + ))} + + + +   Create robot account + + + , + ]; + + const dropdownForCreator = ( + setRepositoryCreator(e)} + onError={() => setError('Unable to look up users')} + defaultOptions={creatorDefaultOptions} + placeholderText="Search user/robot" + /> + ); + + const [isTeamModalOpen, setIsTeamModalOpen] = useState(false); + const [isTeamWizardOpen, setIsTeamWizardOpen] = useState(false); + + const validateTeamName = (name: string) => { + return /^[a-z][a-z0-9]+$/.test(name); + }; + + const [teamName, setTeamName] = useState(''); + const [teamDescription, setTeamDescription] = useState(''); + + const createTeamModal = ( + setIsTeamModalOpen(!isTeamModalOpen)} + handleWizardToggle={() => setIsTeamWizardOpen(!isTeamWizardOpen)} + validateName={validateTeamName} + > + ); + + const createTeamWizard = ( + setIsTeamWizardOpen(!isTeamWizardOpen)} + orgName={props.orgName} + setAppliedTo={setAppliedTo} + > + ); + + const appliedToDefaultOptions = [ + + + {loading ? ( + + ) : ( + teams?.map((t) => ( + { + setAppliedTo({ + is_robot: false, + name: t.name, + kind: 'team', + }); + }} + /> + )) + )} + + + + {robots?.map((r) => { + return ( + { + console.log('child'); + setAppliedTo({ + is_robot: true, + name: r.name, + kind: 'user', + }); + }} + /> + ); + })} + + + setIsTeamModalOpen(!isTeamModalOpen)} + isPlaceholder + // value={teamName} + value={{toString: () => appliedTo.name}} + > +   Create team + + +   Create robot account + + , + ]; + + const dropdownForAppliedTo = ( + { + console.log('parent'); + setAppliedTo(e); + }} + // onSelect={setAppliedTo} + onError={() => setError('Unable to look up teams')} + defaultOptions={appliedToDefaultOptions} + placeholderText="Search, invite or add robot/team" + /> + ); + + const optionsForPermission = Object.keys(repoPermissions).map((key) => ( + { + setPermission(repoPermissions[key]); + setPermissionDropDown(!permissionDropDown); + }} + > + {repoPermissions[key]} + + )); + + const dropdownForPermission = ( + setPermissionDropDown(!permissionDropDown)} + > + {permission} + + } + isOpen={permissionDropDown} + dropdownItems={optionsForPermission} + /> + ); + const {createDefaultPermission} = useCreateDefaultPermission(props.orgName); + + const createDefaultPermissionHandler = async () => { + if (createdBy === userType.SPECIFIC_USER) { + await createDefaultPermission({ + repoCreator: repositoryCreator, + appliedTo: appliedTo, + newRole: permission.toLowerCase(), + }); + } else if (createdBy === userType.ANYONE) { + await createDefaultPermission({ + appliedTo: appliedTo, + newRole: permission.toLowerCase(), + }); + } + setTeamName(''); + props.closeDrawer(); + }; + + return ( + <> + {isTeamModalOpen ? createTeamModal : null} + {isTeamWizardOpen ? createTeamWizard : null} + + + +
+ Create default permission +
+ + + + +
+ +

+ Applies when a repository is created by: +

+
+ +
+ + {permissionRadioButtons} + + {createdBy === userType.SPECIFIC_USER ? ( + + {dropdownForCreator} + + ) : null} + + {dropdownForAppliedTo} + + + {dropdownForPermission} + + + + +
+
+
+ + ); +} + +interface CreatePermissionDrawerProps { + orgName: string; + closeDrawer: () => void; + drawerRef: Ref; + drawerContent: DrawerContentType; +} diff --git a/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createPermissionDrawer/CreateTeamModal.tsx b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createPermissionDrawer/CreateTeamModal.tsx new file mode 100644 index 00000000..7eb63bd5 --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createPermissionDrawer/CreateTeamModal.tsx @@ -0,0 +1,122 @@ +import { + Button, + Form, + FormGroup, + Modal, + ModalVariant, + TextInput, +} from '@patternfly/react-core'; +import {ExclamationCircleIcon} from '@patternfly/react-icons'; +import {useEffect, useState} from 'react'; +import {useCreateTeam} from 'src/hooks/UseTeams'; + +type validate = 'success' | 'error' | 'default'; + +export const CreateTeamModal = (props: CreateTeamModalProps): JSX.Element => { + const [validatedName, setValidatedName] = useState('default'); + const [nameHelperText, setNameHelperText] = useState(props.nameHelperText); + + const handleNameChange = (name: string) => { + props.setName(name); + setNameHelperText('Validating...'); + }; + + useEffect(() => { + if (props.name === '') { + return; + } + props.validateName(props.name) + ? setValidatedName('success') + : setValidatedName('error'); + + setNameHelperText(props.nameHelperText); + }, [props.name]); + + const {createTeam} = useCreateTeam(props.orgName); + + const onCreateTeam = async () => { + await createTeam(props.name, props.description); + props.handleWizardToggle(); + props.handleModalToggle(); + }; + + return ( + + Proceed + , + , + ]} + > +
+ } + > + + + + props.setDescription(descr)} + /> + +
+
+ ); +}; + +interface CreateTeamModalProps { + name: string; + setName: (teamName) => void; + description: string; + setDescription: (descr: string) => void; + orgName: string; + nameLabel: string; + descriptionLabel: string; + helperText: string; + nameHelperText: string; + validateName: (string) => boolean; + isModalOpen: boolean; + handleModalToggle: () => void; + handleWizardToggle: () => void; +} diff --git a/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/AddTeamMember.tsx b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/AddTeamMember.tsx new file mode 100644 index 00000000..918d7521 --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/AddTeamMember.tsx @@ -0,0 +1,127 @@ +import {PageSection, Spinner} from '@patternfly/react-core'; + +import { + TableComposable, + Tbody, + Td, + Th, + Thead, + Tr, +} from '@patternfly/react-table'; +import {ITeamMember, useFetchMembers} from 'src/hooks/UseMembers'; +import {IMember} from 'src/resources/MembersResource'; +import {Link} from 'react-router-dom'; +import AddTeamToolbar from './AddTeamToolbar'; + +const memberColNames = { + teamMember: 'Team Member', + account: 'Account', +}; + +export default function AddTeamMember(props: AddTeamMemberProps) { + // const [tableItems, setTableItems] = useState([]); + // const [search, setSearch] = useRecoilState(searchRepoState); + + const { + teamMembers, + robots, + paginatedMembers, + isLoading, + error, + page, + setPage, + perPage, + setPerPage, + } = useFetchMembers(props.orgName); + + // const [tableMemberData, setTableMemberData] = useState([]); + + // useEffect(() => { + // if (members && tableMemberData.length === 0) { + // setTableMemberData(members); + // } + // }); + + const onSelectMember = ( + member: ITeamMember, + rowIndex: number, + isSelecting: boolean, + ) => { + props.setSelectedMembers((prevSelected) => { + const otherSelectedMembers = prevSelected.filter( + (m) => m.name !== member.name, + ); + return isSelecting + ? [...otherSelectedMembers, member] + : otherSelectedMembers; + }); + }; + + if (isLoading) { + return ; + } + + if (error) { + return <>Unable to load members list; + } + + return ( + <> + + props.setSelectedMembers([])} + onItemSelect={onSelectMember} + page={page} + setPage={setPage} + perPage={perPage} + setPerPage={setPerPage} + robots={robots} + > + + + + + {memberColNames.teamMember} + {memberColNames.account} + + + + {paginatedMembers?.map((member, rowIndex) => ( + + + onSelectMember(member, rowIndex, isSelecting), + isSelected: props.selectedMembers.some( + (p) => p.name === member.name, + ), + }} + /> + + {member.name} + + + {member.account} + + + ))} + + + + + + ); +} + +interface AddTeamMemberProps { + orgName: string; + selectedMembers: ITeamMember[]; + // setSelectedMembers: React.Dispatch>; + setSelectedMembers: (teams: any) => void; +} diff --git a/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/AddTeamToolbar.tsx b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/AddTeamToolbar.tsx new file mode 100644 index 00000000..83ebd11e --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/AddTeamToolbar.tsx @@ -0,0 +1,131 @@ +import { + Divider, + SelectGroup, + SelectOption, + Toolbar, + ToolbarContent, + ToolbarItem, +} from '@patternfly/react-core'; +import {DesktopIcon} from '@patternfly/react-icons'; +import React from 'react'; +import {useState} from 'react'; +import EntitySearch from 'src/components/EntitySearch'; +import AllSelectedToggleButton from 'src/components/toolbar/AllSelectedToggleButton'; +import {DropdownCheckbox} from 'src/components/toolbar/DropdownCheckbox'; +import {ToolbarPagination} from 'src/components/toolbar/ToolbarPagination'; +import {AccountType, ITeamMember} from 'src/hooks/UseMembers'; +import {IRobot} from 'src/resources/RobotsResource'; + +export default function AddTeamToolbar(props: AddTeamToolbarProps) { + type TableModeType = 'All' | 'Selected'; + const [tableMode, setTableMode] = useState('All'); + const [error, setError] = useState(''); + + const createRobotaccnt = () => { + console.log('create rbt accnt modal'); + }; + + const searchRobotAccntOptions = [ + + + {props?.robots.map((r) => { + return ( + + props.setSelectedMembers({ + selectedMembers: { + name: r.name, + account: AccountType.robot, + }, + }) + } + /> + ); + })} + + + +   Create robot account + + , + ]; + + return ( + <> + + + + + { + createRobotaccnt(); + }} + onError={() => setError('Unable to look up robot accounts')} + defaultOptions={searchRobotAccntOptions} + placeholderText="Search, add, invite members" + /> + + + + + + {props.children} + {/* + + */} + + ); +} + +interface AddTeamToolbarProps { + selectedItems: ITeamMember[]; + // setSelectedMembers: React.Dispatch>; + setSelectedMembers: (teams: any) => void; + deSelectAll: () => void; + allItems: ITeamMember[]; + paginatedItems: ITeamMember[]; + onItemSelect: ( + item: ITeamMember, + rowIndex: number, + isSelecting: boolean, + ) => void; + page: number; + setPage: (page: number) => void; + perPage: number; + setPerPage: (perPage: number) => void; + children?: React.ReactNode; + orgName?: string; + robots: IRobot[]; +} diff --git a/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/CreateTeamWizard.tsx b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/CreateTeamWizard.tsx new file mode 100644 index 00000000..2e2ed746 --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/CreateTeamWizard.tsx @@ -0,0 +1,169 @@ +import { + Modal, + ModalVariant, + TextContent, + Text, + TextVariants, + Wizard, + AlertGroup, + Alert, + AlertActionCloseButton, +} from '@patternfly/react-core'; +import {useState} from 'react'; +import AddToRepository from 'src/components/modals/wizard/AddToRepository'; +import NameAndDescription from 'src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/NameAndDescription'; +import {ITeamMember, useAddMembersToTeam} from 'src/hooks/UseMembers'; +import AddTeamMember from './AddTeamMember'; +import ReviewAndFinishFooter from './ReviewAndFinishFooter'; +import Review from './ReviewTeam'; +import Conditional from 'src/components/empty/Conditional'; + +export const CreateTeamWizard = (props: CreateTeamWizardProps): JSX.Element => { + const [selectedMembers, setSelectedMembers] = useState([]); + + const [selectedRepos, setSeletectedRepos] = useState<[]>(); + + const { + addMemberToTeam, + errorAddingMemberToTeam: error, + successAddingMemberToTeam: success, + resetAddingMemberToTeam: reset, + } = useAddMembersToTeam(props.orgName); + + const onSubmitTeamWizard = async () => { + console.log('selectedMembers', selectedMembers); + props.setAppliedTo({ + is_robot: false, + name: props.teamName, + kind: 'team', + }); + if (selectedRepos?.length > 0) { + // copy over add to repo api call from robot accounts wizard + } + if (selectedMembers?.length > 0) { + selectedMembers.map(async (mem) => { + await addMemberToTeam({team: props.teamName, member: mem.name}); + }); + } + props.handleWizardToggle(); + }; + + const steps = [ + { + name: 'Name & Description', + component: ( + <> + + Team name and description + + + + ), + }, + { + name: 'Add to repository', + component: ( + <> + + Add to repository + + + + ), + }, + { + name: 'Add team member (optional)', + component: ( + <> + + Add team member (optional) + + + + ), + }, + { + name: 'Review and Finish', + component: ( + <> + + Review + + + + ), + }, + ]; + + return ( + <> + + + } + /> + + + + + } + /> + + + + + } + /> + + + ); +}; + +interface CreateTeamWizardProps { + teamName: string; + teamDescription: string; + isTeamWizardOpen: boolean; + handleWizardToggle?: () => void; + orgName: string; + setAppliedTo: (string) => void; +} diff --git a/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/NameAndDescription.tsx b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/NameAndDescription.tsx new file mode 100644 index 00000000..3c9cc56d --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/NameAndDescription.tsx @@ -0,0 +1,51 @@ +import {Form, FormGroup, TextInput} from '@patternfly/react-core'; +import ExclamationCircleIcon from '@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon'; + +export default function NameAndDescription(props: NameAndDescriptionProps) { + return ( +
+ } + > + + + + + +
+ ); +} + +interface NameAndDescriptionProps { + name: string; + setName?: (robotName) => void; + description: string; + setDescription?: (descr: string) => void; + nameLabel: string; + descriptionLabel: string; + helperText?: string; + nameHelperText?: string; + validateName?: (string) => boolean; +} diff --git a/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/ReviewAndFinishFooter.tsx b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/ReviewAndFinishFooter.tsx new file mode 100644 index 00000000..b5672508 --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/ReviewAndFinishFooter.tsx @@ -0,0 +1,58 @@ +import { + Button, + WizardContextConsumer, + WizardFooter, +} from '@patternfly/react-core'; + +export default function ReviewAndFinishFooter( + props: ReviewAndFinishFooterProps, +) { + return ( + + + {({ + activeStep, + goToStepByName, + goToStepById, + onNext, + onBack, + onClose, + }) => { + return ( + <> + {activeStep.name !== 'Review and Finish' ? ( + + ) : null} + {activeStep.name !== 'Name & Description' ? ( + + ) : null} + {activeStep.name === 'Add team member (optional)' || + activeStep.name === 'Review and Finish' ? ( + + ) : null} + + + ); + }} + + + ); +} + +interface ReviewAndFinishFooterProps { + onSubmit: () => void; + canSubmit: boolean; +} diff --git a/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/ReviewTeam.tsx b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/ReviewTeam.tsx new file mode 100644 index 00000000..2bf5ff61 --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/ReviewTeam.tsx @@ -0,0 +1,75 @@ +import { + Text, + TextContent, + TextVariants, + Form, + FormGroup, + TextInput, +} from '@patternfly/react-core'; +import {ITeamMember} from 'src/hooks/UseMembers'; + +export default function Review(props: ReviewProps) { + return ( + <> +
+ + + + + + + + + + + item.name).join(', ')} + type="text" + aria-label="team-members" + isDisabled + /> + +
+ + ); +} + +interface ReviewProps { + orgName?: string; + teamName: string; + description: string; + selectedMembers: ITeamMember[]; +} diff --git a/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/SelectRepository.tsx b/src/routes/OrganizationsList/Organization/Tabs/DefaultPermissions/createTeamWizard/SelectRepository.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/routes/OrganizationsList/Organization/Tabs/UsageLogs/UsageLogsTab.tsx b/src/routes/OrganizationsList/Organization/Tabs/UsageLogs/UsageLogsTab.tsx deleted file mode 100644 index a8917641..00000000 --- a/src/routes/OrganizationsList/Organization/Tabs/UsageLogs/UsageLogsTab.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function UsageLogsTab() { - return

UsageLogsTab

; -} diff --git a/src/routes/OrganizationsList/OrganizationToolBar.tsx b/src/routes/OrganizationsList/OrganizationToolBar.tsx index 60024a8c..d46a4a76 100644 --- a/src/routes/OrganizationsList/OrganizationToolBar.tsx +++ b/src/routes/OrganizationsList/OrganizationToolBar.tsx @@ -5,6 +5,7 @@ import {SearchInput} from 'src/components/toolbar/SearchInput'; import {ToolbarButton} from 'src/components/toolbar/ToolbarButton'; import {Kebab} from 'src/components/toolbar/Kebab'; import {ToolbarPagination} from 'src/components/toolbar/ToolbarPagination'; + import * as React from 'react'; import ColumnNames from './ColumnNames'; import {SearchState} from 'src/components/toolbar/SearchTypes'; diff --git a/src/routes/OrganizationsList/OrganizationsList.tsx b/src/routes/OrganizationsList/OrganizationsList.tsx index 361035e8..b55fdd0e 100644 --- a/src/routes/OrganizationsList/OrganizationsList.tsx +++ b/src/routes/OrganizationsList/OrganizationsList.tsx @@ -15,8 +15,11 @@ import { } from '@patternfly/react-core'; import './css/Organizations.scss'; import {CreateOrganizationModal} from './CreateOrganizationModal'; -import {useRecoilState, useRecoilValue} from 'recoil'; -import {selectedOrgsState} from 'src/atoms/UserState'; +import {useRecoilState} from 'recoil'; +import { + searchOrgsState, + selectedOrgsState, +} from 'src/atoms/OrganizationListState'; import {useEffect, useState} from 'react'; import {IOrganization} from 'src/resources/OrganizationResource'; import OrgTableData from './OrganizationsListTableData'; diff --git a/src/routes/OrganizationsList/OrganizationsListTableData.tsx b/src/routes/OrganizationsList/OrganizationsListTableData.tsx index b39e4bc7..c7db6861 100644 --- a/src/routes/OrganizationsList/OrganizationsListTableData.tsx +++ b/src/routes/OrganizationsList/OrganizationsListTableData.tsx @@ -12,8 +12,7 @@ import {fetchRobotsForNamespace} from 'src/resources/RobotsResource'; import {formatDate} from 'src/libs/utils'; import ColumnNames from './ColumnNames'; import {OrganizationsTableItem} from './OrganizationsList'; -import {useQuery, useQueryClient} from '@tanstack/react-query'; -import {useEffect} from 'react'; +import {useQuery} from '@tanstack/react-query'; interface CountProps { count: string | number; diff --git a/src/routes/RepositoryDetails/RepositoryDetails.tsx b/src/routes/RepositoryDetails/RepositoryDetails.tsx index 01d6276c..8727027f 100644 --- a/src/routes/RepositoryDetails/RepositoryDetails.tsx +++ b/src/routes/RepositoryDetails/RepositoryDetails.tsx @@ -168,10 +168,10 @@ export default function RepositoryDetails() { Settings} - isHidden={ - config?.features.UI_V2_REPO_SETTINGS != true || - !repoDetails?.can_admin - } + // isHidden={ + // config?.features.UI_V2_REPO_SETTINGS != true || + // !repoDetails?.can_admin + // } >