From bf72610d86850f16c0c514d5a259b64d66bab07b Mon Sep 17 00:00:00 2001 From: Jonathan King Date: Tue, 1 Nov 2022 09:10:06 -0700 Subject: [PATCH 01/16] install dependencies --- src/App.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 32dfdf8e..43593a11 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,8 @@ const queryClient = new QueryClient({ }, }); +const queryClient = new QueryClient(); + export default function App() { useAnalytics(); From 0558d16234b2b940cf6bf0cba3d0e8ea7b23de6b Mon Sep 17 00:00:00 2001 From: Jonathan King Date: Tue, 1 Nov 2022 09:14:48 -0700 Subject: [PATCH 02/16] use hooks for auth --- src/components/header/HeaderToolbar.tsx | 18 +++++++-------- src/hooks/UseCurrentUser.ts | 19 ++++++++++++++++ src/hooks/UseQuayConfig.ts | 30 ++++++++----------------- src/routes/StandaloneMain.tsx | 25 ++++++--------------- 4 files changed, 43 insertions(+), 49 deletions(-) create mode 100644 src/hooks/UseCurrentUser.ts diff --git a/src/components/header/HeaderToolbar.tsx b/src/components/header/HeaderToolbar.tsx index 91aad365..0fdbbb75 100644 --- a/src/components/header/HeaderToolbar.tsx +++ b/src/components/header/HeaderToolbar.tsx @@ -15,22 +15,20 @@ import { import {UserIcon} from '@patternfly/react-icons'; import React from 'react'; import {useState} from 'react'; -import {useNavigate} from 'react-router-dom'; -import {useRecoilState} from 'recoil'; -import {CurrentUsernameState} from 'src/atoms/UserState'; import {GlobalAuthState, logoutUser} from 'src/resources/AuthResource'; import {addDisplayError} from 'src/resources/ErrorHandling'; import ErrorModal from '../errors/ErrorModal'; import 'src/components/header/HeaderToolbar.css'; +import {useQueryClient} from '@tanstack/react-query'; +import {useCurrentUser} from 'src/hooks/UseCurrentUser'; export function HeaderToolbar() { const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [currentUsername, setCurrentUsername] = - useRecoilState(CurrentUsernameState); - const [err, setErr] = useState(); - const navigate = useNavigate(); + const queryClient = useQueryClient(); + const {user} = useCurrentUser(); + const [err, setErr] = useState(); const onDropdownToggle = () => { setIsDropdownOpen((prev) => !prev); @@ -43,7 +41,7 @@ export function HeaderToolbar() { try { await logoutUser(); GlobalAuthState.csrfToken = undefined; - setCurrentUsername(''); + queryClient.invalidateQueries(['user']); // Ignore client side auth page and use old UI if present // TODO: replace this with navigate('/signin') once new ui supports all auth methods @@ -75,7 +73,7 @@ export function HeaderToolbar() { isOpen={isDropdownOpen} toggle={ } onToggle={onDropdownToggle}> - {currentUsername} + {user.username} } dropdownItems={userDropdownItems} @@ -132,7 +130,7 @@ export function HeaderToolbar() { - {currentUsername ? userDropdown : signInButton} + {user.username ? userDropdown : signInButton} diff --git a/src/hooks/UseCurrentUser.ts b/src/hooks/UseCurrentUser.ts new file mode 100644 index 00000000..1123922f --- /dev/null +++ b/src/hooks/UseCurrentUser.ts @@ -0,0 +1,19 @@ +import {fetchUser} from 'src/resources/UserResource'; +import {useQuery} from '@tanstack/react-query'; +import {useQuayConfig} from './UseQuayConfig'; + +export function useCurrentUser() { + const config = useQuayConfig(); + const { + data: user, + isLoading: loading, + error, + } = useQuery(['user'], fetchUser, { + staleTime: Infinity, + }); + + const isSuperUser = + config?.features?.SUPERUSERS_FULL_ACCESS && user?.super_user; + + return {user, loading, error, isSuperUser}; +} diff --git a/src/hooks/UseQuayConfig.ts b/src/hooks/UseQuayConfig.ts index f87efa55..bc29206d 100644 --- a/src/hooks/UseQuayConfig.ts +++ b/src/hooks/UseQuayConfig.ts @@ -1,26 +1,14 @@ -import {useRecoilState} from 'recoil'; -import {useEffect} from 'react'; -import {QuayConfigState} from 'src/atoms/QuayConfigState'; import {fetchQuayConfig} from 'src/resources/QuayConfig'; +import {useQuery} from '@tanstack/react-query'; export function useQuayConfig() { - const [quayConfig, setQuayConfig] = useRecoilState( - QuayConfigState, - ); + const { + data: config, + isLoading: configIsLoading, + error, + } = useQuery(['config'], fetchQuayConfig, { + staleTime: Infinity, + }); - useEffect(() => { - (async () => { - // NOTE: Making the decision that loading the Quay configuration - // is not required. If the load fails the app will continue running. - // Components using this hook should check for null values. This - // behavior may change in the future. - try { - const config = await fetchQuayConfig(); - setQuayConfig(config); - } catch (err) { - console.error('Unable to load Quay config:', err); - } - })(); - }, []); - return quayConfig; + return config; } diff --git a/src/routes/StandaloneMain.tsx b/src/routes/StandaloneMain.tsx index 2b39064c..92e5adfb 100644 --- a/src/routes/StandaloneMain.tsx +++ b/src/routes/StandaloneMain.tsx @@ -10,14 +10,12 @@ import Organization from './OrganizationsList/Organization/Organization'; import RepositoryDetails from 'src/routes/RepositoryDetails/RepositoryDetails'; import RepositoriesList from './RepositoriesList/RepositoriesList'; import TagDetails from 'src/routes/TagDetails/TagDetails'; -import {useEffect, useState} from 'react'; -import {fetchUser} from 'src/resources/UserResource'; -import {useSetRecoilState} from 'recoil'; -import {CurrentUsernameState} from 'src/atoms/UserState'; +import {useEffect} from 'react'; import ErrorBoundary from 'src/components/errors/ErrorBoundary'; import {useQuayConfig} from 'src/hooks/UseQuayConfig'; import SiteUnavailableError from 'src/components/errors/SiteUnavailableError'; import NotFound from 'src/components/errors/404'; +import {useCurrentUser} from 'src/hooks/UseCurrentUser'; const NavigationRoutes = [ { @@ -43,9 +41,8 @@ const NavigationRoutes = [ ]; export function StandaloneMain() { - const setCurrentUsername = useSetRecoilState(CurrentUsernameState); - const [err, setErr] = useState(); const quayConfig = useQuayConfig(); + const {loading, error} = useCurrentUser(); useEffect(() => { if (quayConfig?.config?.REGISTRY_TITLE) { @@ -53,19 +50,11 @@ export function StandaloneMain() { } }, [quayConfig]); - useEffect(() => { - (async () => { - try { - const user = await fetchUser(); - setCurrentUsername(user.username); - } catch (err) { - console.error(err); - setErr(true); - } - })(); - }, []); + if (loading) { + return null; + } return ( - }> + }> } sidebar={} From ca147e3ba0e6b0685e648ec83cbacc3618b44a96 Mon Sep 17 00:00:00 2001 From: Jonathan King Date: Tue, 1 Nov 2022 09:17:36 -0700 Subject: [PATCH 03/16] use hooks for orgs --- src/hooks/UseOrganization.ts | 27 +++++ src/hooks/UseOrganizations.ts | 98 +++++++++++++++++++ src/resources/OrganizationResource.ts | 7 +- .../CreateOrganizationModal.tsx | 11 +-- 4 files changed, 131 insertions(+), 12 deletions(-) create mode 100644 src/hooks/UseOrganization.ts create mode 100644 src/hooks/UseOrganizations.ts diff --git a/src/hooks/UseOrganization.ts b/src/hooks/UseOrganization.ts new file mode 100644 index 00000000..655649e2 --- /dev/null +++ b/src/hooks/UseOrganization.ts @@ -0,0 +1,27 @@ +import {fetchOrg} from 'src/resources/OrganizationResource'; +import {useQuery} from '@tanstack/react-query'; +import {useOrganizations} from './UseOrganizations'; +import {IOrganization} from 'src/resources/OrganizationResource'; + +export function useOrganization(name: string) { + // Get usernames + const {usernames} = useOrganizations(); + const isUserOrganization = usernames.includes(name); + + // Get organization + const { + data: organization, + isLoading, + error, + } = useQuery(['organization', name], () => fetchOrg(name), { + enabled: !isUserOrganization, + placeholderData: (): IOrganization[] => new Array(10).fill({}), + }); + + return { + isUserOrganization, + error, + loading: isLoading, + organization, + }; +} diff --git a/src/hooks/UseOrganizations.ts b/src/hooks/UseOrganizations.ts new file mode 100644 index 00000000..6a45e52b --- /dev/null +++ b/src/hooks/UseOrganizations.ts @@ -0,0 +1,98 @@ +import {fetchUsersAsSuperUser} from 'src/resources/UserResource'; +import { + bulkDeleteOrganizations, + fetchOrgsAsSuperUser, +} from 'src/resources/OrganizationResource'; +import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'; +import {useCurrentUser} from './UseCurrentUser'; +import {createOrg} from 'src/resources/OrganizationResource'; + +export function useOrganizations() { + // Get user and config data + const {isSuperUser, user, loading, error} = useCurrentUser(); + + // Get super user orgs + const {data: superUserOrganizations} = useQuery( + ['organization', 'superuser'], + fetchOrgsAsSuperUser, + { + enabled: isSuperUser, + }, + ); + + // Get super user users + const {data: superUserUsers} = useQuery( + ['organization', 'superuser'], + fetchUsersAsSuperUser, + { + enabled: isSuperUser, + }, + ); + + // Get org names + let orgnames: string[]; + if (isSuperUser) { + orgnames = superUserOrganizations.map((org) => org.name); + } else { + orgnames = user?.organizations.map((org) => org.name); + } + // Get user names + let usernames: string[]; + if (isSuperUser) { + usernames = superUserUsers.map((user) => user.username); + } else { + usernames = [user.username]; + } + + const organizationsTableDetails = [] as {name: string; isUser: boolean}[]; + for (const orgname of orgnames) { + organizationsTableDetails.push({ + name: orgname, + isUser: false, + }); + } + for (const username of usernames) { + organizationsTableDetails.push({ + name: username, + isUser: true, + }); + } + + // Get query client for mutations + const queryClient = useQueryClient(); + + const createOrganizationMutator = useMutation( + async ({name, email}: {name: string; email: string}) => { + return createOrg(name, email); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['user']); + }, + }, + ); + + const deleteOrganizationMutator = useMutation( + async (names: string[]) => { + return bulkDeleteOrganizations(names, isSuperUser); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['user']); + }, + }, + ); + + return { + superUserOrganizations, + superUserUsers, + organizationsTableDetails, + loading, + error, + createOrganization: async (name: string, email: string) => + createOrganizationMutator.mutate({name, email}), + deleteOrganizations: async (names: string[]) => + deleteOrganizationMutator.mutate(names), + usernames, + }; +} diff --git a/src/resources/OrganizationResource.ts b/src/resources/OrganizationResource.ts index 2a09c9d1..0709f0c1 100644 --- a/src/resources/OrganizationResource.ts +++ b/src/resources/OrganizationResource.ts @@ -16,6 +16,7 @@ export interface IOrganization { public?: boolean; is_org_admin?: boolean; preferred_namespace?: boolean; + teams?: string[]; } export async function fetchOrg(orgname: string) { @@ -23,7 +24,7 @@ export async function fetchOrg(orgname: string) { // TODO: Add return type const response: AxiosResponse = await axios.get(getOrgUrl); assertHttpCode(response.status, 200); - return response.data; + return response.data as IOrganization; } export interface SuperUserOrganizations { @@ -39,10 +40,6 @@ export async function fetchOrgsAsSuperUser() { return response.data?.organizations; } -export async function fetchAllOrgs(orgnames: string[]) { - return await Promise.all(orgnames.map((org) => fetchOrg(org))); -} - export class OrgDeleteError extends Error { error: AxiosError; org: string; diff --git a/src/routes/OrganizationsList/CreateOrganizationModal.tsx b/src/routes/OrganizationsList/CreateOrganizationModal.tsx index 08cd9117..690b0d47 100644 --- a/src/routes/OrganizationsList/CreateOrganizationModal.tsx +++ b/src/routes/OrganizationsList/CreateOrganizationModal.tsx @@ -7,12 +7,11 @@ import { TextInput, } from '@patternfly/react-core'; import './css/Organizations.scss'; -import {createOrg} from 'src/resources/OrganizationResource'; +import {useOrganizations} from 'src/hooks/UseOrganizations'; import {isValidEmail} from 'src/libs/utils'; import {useState} from 'react'; import FormError from 'src/components/errors/FormError'; import {addDisplayError} from 'src/resources/ErrorHandling'; -import {userRefreshOrgList} from 'src/hooks/UseRefreshPage'; interface Validation { message: string; @@ -35,7 +34,8 @@ export const CreateOrganizationModal = ( const [invalidEmailFlag, setInvalidEmailFlag] = useState(false); const [validation, setValidation] = useState(defaultMessage); const [err, setErr] = useState(); - const refresh = userRefreshOrgList(); + + const {createOrganization} = useOrganizations(); const handleNameInputChange = (value: any) => { const regex = /^([a-z0-9]+(?:[._-][a-z0-9]+)*)$/; @@ -72,11 +72,8 @@ export const CreateOrganizationModal = ( const createOrganizationHandler = async () => { try { - const response = await createOrg(organizationName, organizationEmail); + await createOrganization(organizationName, organizationEmail); props.handleModalToggle(); - if (response === 'Created') { - refresh(); - } } catch (err) { console.error(err); setErr(addDisplayError('Unable to create organization', err)); From 756fe5d2a487c58c4cbf990c209f3a424ed61433 Mon Sep 17 00:00:00 2001 From: Jonathan King Date: Tue, 1 Nov 2022 09:18:55 -0700 Subject: [PATCH 04/16] use hooks for repos --- .../modals/CreateRepoModalTemplate.tsx | 32 ++-- src/hooks/UseRepositories.ts | 103 +++++++++++++ src/resources/RepositoryResource.ts | 13 +- .../RepositoriesList/RepositoriesList.tsx | 139 ++++++------------ 4 files changed, 168 insertions(+), 119 deletions(-) create mode 100644 src/hooks/UseRepositories.ts diff --git a/src/components/modals/CreateRepoModalTemplate.tsx b/src/components/modals/CreateRepoModalTemplate.tsx index 0bacd48d..52c18400 100644 --- a/src/components/modals/CreateRepoModalTemplate.tsx +++ b/src/components/modals/CreateRepoModalTemplate.tsx @@ -12,17 +12,14 @@ import { Flex, FlexItem, } from '@patternfly/react-core'; -import { - createNewRepository, - IRepository, -} from 'src/resources/RepositoryResource'; +import {IRepository} from 'src/resources/RepositoryResource'; import {useRef, useState} from 'react'; import FormError from 'src/components/errors/FormError'; import {ExclamationCircleIcon} from '@patternfly/react-icons'; import {addDisplayError} from 'src/resources/ErrorHandling'; import {IOrganization} from 'src/resources/OrganizationResource'; -import {useRefreshRepoList} from 'src/hooks/UseRefreshPage'; import {useQuayConfig} from 'src/hooks/UseQuayConfig'; +import {useRepositories} from 'src/hooks/UseRepositories'; enum visibilityType { PUBLIC = 'PUBLIC', @@ -36,7 +33,6 @@ export default function CreateRepositoryModalTemplate( return null; } const [err, setErr] = useState(); - const refresh = useRefreshRepoList(); const quayConfig = useQuayConfig(); const [currentOrganization, setCurrentOrganization] = useState({ @@ -46,6 +42,8 @@ export default function CreateRepositoryModalTemplate( isDropdownOpen: false, }); + const {createRepository} = useRepositories(); + const [validationState, setValidationState] = useState({ repoName: true, namespace: true, @@ -89,16 +87,15 @@ export default function CreateRepositoryModalTemplate( return; } try { - await createNewRepository( - currentOrganization.name, - newRepository.name, - repoVisibility.toLowerCase(), - newRepository.description, - 'image', - ); - refresh(); + await createRepository({ + namespace: currentOrganization.name, + repository: newRepository.name, + visibility: repoVisibility.toLowerCase(), + description: newRepository.description, + repo_kind: 'image', + }); props.handleModalToggle(); - } catch (error: any) { + } catch (error) { console.error(error); setErr(addDisplayError('Unable to create repository', error)); } @@ -259,8 +256,3 @@ interface CreateRepositoryModalTemplateProps { username: string; organizations: IOrganization[]; } - -interface ErrorModalProps { - isOpen: boolean; - toggle?: () => void; -} diff --git a/src/hooks/UseRepositories.ts b/src/hooks/UseRepositories.ts new file mode 100644 index 00000000..777518ab --- /dev/null +++ b/src/hooks/UseRepositories.ts @@ -0,0 +1,103 @@ +import {useState} from 'react'; +import { + bulkDeleteRepositories, + createNewRepository, + fetchRepositories, +} from 'src/resources/RepositoryResource'; +import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'; +import {useCurrentUser} from './UseCurrentUser'; +import {IRepository} from 'src/resources/RepositoryResource'; + +interface createRepositoryParams { + namespace: string; + repository: string; + visibility: string; + description: string; + repo_kind: string; +} + +export function useRepositories() { + const {user} = useCurrentUser(); + + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(10); + const [organization, setOrganization] = useState(''); + + const listOfOrgNames: string[] = organization + ? [organization] + : user?.organizations.map((org) => org.name).concat(user.username); + + const { + data: repositories, + isLoading: loading, + isPlaceholderData, + error, + } = useQuery( + ['organization', organization, 'repositories'], + fetchRepositories, + { + placeholderData: [], + }, + ); + + const queryClient = useQueryClient(); + + const createRepositoryMutator = useMutation( + async ({ + namespace, + repository, + visibility, + description, + repo_kind, + }: createRepositoryParams) => { + return createNewRepository( + namespace, + repository, + visibility, + description, + repo_kind, + ); + }, + { + onSuccess: () => { + queryClient.invalidateQueries([ + 'organization', + organization, + 'repositories', + ]); + }, + }, + ); + + const deleteRepositoryMutator = useMutation( + async (repos: IRepository[]) => { + return bulkDeleteRepositories(repos); + }, + { + onSuccess: () => { + queryClient.invalidateQueries([ + 'organization', + organization, + 'repositories', + ]); + }, + }, + ); + + return { + repos: repositories, + loading: loading || isPlaceholderData, + error, + setPage, + setPerPage, + page, + perPage, + setOrganization, + organization, + totalResults: listOfOrgNames.length, + createRepository: async (params: createRepositoryParams) => + createRepositoryMutator.mutate(params), + deleteRepositories: async (repos: IRepository[]) => + deleteRepositoryMutator.mutate(repos), + }; +} diff --git a/src/resources/RepositoryResource.ts b/src/resources/RepositoryResource.ts index d033b270..cbcb3dc6 100644 --- a/src/resources/RepositoryResource.ts +++ b/src/resources/RepositoryResource.ts @@ -36,7 +36,7 @@ export interface IQuotaReport { export async function fetchAllRepos( namespaces: string[], flatten = false, -): Promise> { +): Promise { const namespacedRepos = await Promise.all( namespaces.map((ns) => fetchRepositoriesForNamespace(ns)), ); @@ -57,7 +57,16 @@ export async function fetchRepositoriesForNamespace(ns: string) { `/api/v1/repository?last_modified=true&namespace=${ns}&public=true`, ); assertHttpCode(response.status, 200); - return response.data?.repositories; + return response.data?.repositories as IRepository[]; +} + +export async function fetchRepositories() { + // TODO: Add return type to AxiosResponse + const response: AxiosResponse = await axios.get( + `/api/v1/repository?last_modified=true&public=true`, + ); + assertHttpCode(response.status, 200); + return response.data?.repositories as IRepository[]; } export interface RepositoryDetails { diff --git a/src/routes/RepositoriesList/RepositoriesList.tsx b/src/routes/RepositoriesList/RepositoriesList.tsx index 564671fe..10ae07ab 100644 --- a/src/routes/RepositoriesList/RepositoriesList.tsx +++ b/src/routes/RepositoriesList/RepositoriesList.tsx @@ -14,21 +14,13 @@ import { Tbody, Td, } from '@patternfly/react-table'; -import {useRecoilState, useRecoilValue, useResetRecoilState} from 'recoil'; -import { - bulkDeleteRepositories, - fetchAllRepos, - IRepository, -} from 'src/resources/RepositoryResource'; -import {ReactElement, useEffect, useState} from 'react'; +import {useRecoilState, useRecoilValue} from 'recoil'; +import {IRepository} from 'src/resources/RepositoryResource'; +import {ReactElement, useState} from 'react'; import {Link, useLocation} from 'react-router-dom'; import CreateRepositoryModalTemplate from 'src/components/modals/CreateRepoModalTemplate'; import {getRepoDetailPath} from 'src/routes/NavigationPath'; -import { - selectedReposState, - searchRepoState, - refreshPageState, -} from 'src/atoms/RepositoryState'; +import {selectedReposState, searchRepoState} from 'src/atoms/RepositoryState'; import {formatDate, formatSize} from 'src/libs/utils'; import {BulkDeleteModalTemplate} from 'src/components/modals/BulkDeleteModalTemplate'; import {RepositoryToolBar} from 'src/routes/RepositoriesList/RepositoryToolBar'; @@ -46,10 +38,9 @@ import ErrorModal from 'src/components/errors/ErrorModal'; import {useQuayConfig} from 'src/hooks/UseQuayConfig'; import {ToolbarPagination} from 'src/components/toolbar/ToolbarPagination'; import ColumnNames from './ColumnNames'; -import {useRefreshRepoList} from 'src/hooks/UseRefreshPage'; -import {fetchUser, IUserResource} from 'src/resources/UserResource'; import {LoadingPage} from 'src/components/LoadingPage'; - +import {useCurrentUser} from 'src/hooks/UseCurrentUser'; +import {useRepositories} from 'src/hooks/UseRepositories'; function getReponameFromURL(pathname: string): string { return pathname.includes('organization') ? pathname.split('/')[2] : null; } @@ -75,23 +66,40 @@ function RepoListHeader(props: RepoListHeaderProps) { export default function RepositoriesList() { const currentOrg = getReponameFromURL(useLocation().pathname); - const [loadingErr, setLoadingErr] = useState(); const [isCreateRepoModalOpen, setCreateRepoModalOpen] = useState(false); const [isKebabOpen, setKebabOpen] = useState(false); const [makePublicModalOpen, setmakePublicModal] = useState(false); const [makePrivateModalOpen, setmakePrivateModal] = useState(false); - const [repositoryList, setRepositoryList] = useState([]); - const [loading, setLoading] = useState(true); - const refresh = useRefreshRepoList(); - const pageRefreshIndex = useRecoilValue(refreshPageState); + const search = useRecoilValue(searchRepoState); const [err, setErr] = useState(); + const quayConfig = useQuayConfig(); - const search = useRecoilValue(searchRepoState); - const resetSearch = useResetRecoilState(searchRepoState); - const [userState, setUserState] = useState({ - username: '', - organizations: [], - } as IUserResource); + const {user} = useCurrentUser(); + const { + repos, + loading, + error, + deleteRepositories, + setPerPage, + setPage, + page, + perPage, + totalResults, + } = useRepositories(); + + repos.sort((r1, r2) => { + return r1.last_modified > r2.last_modified ? -1 : 1; + }); + + const repositoryList: RepoListTableItem[] = repos.map((repo) => { + return { + namespace: repo.namespace, + name: repo.name, + is_public: repo.is_public, + last_modified: repo.last_modified, + size: repo.quota_report?.quota_bytes, + } as RepoListTableItem; + }); // Filtering Repositories after applied filter const filteredRepos = @@ -103,13 +111,7 @@ export default function RepositoriesList() { }) : repositoryList; - // Pagination related states - const [perPage, setPerPage] = useState(10); - const [page, setPage] = useState(1); - const paginatedRepositoryList = filteredRepos.slice( - page * perPage - perPage, - page * perPage - perPage + perPage, - ); + const paginatedRepositoryList = filteredRepos; // Select related states const [selectedRepoNames, setSelectedRepoNames] = @@ -158,7 +160,7 @@ export default function RepositoriesList() { const handleRepoDeletion = async (repos: IRepository[]) => { try { - await bulkDeleteRepositories(repos); + await deleteRepositories(repos); } catch (err) { if (err instanceof BulkOperationError) { const errMessages = []; @@ -174,7 +176,6 @@ export default function RepositoriesList() { setErr([addDisplayError('Failed to delete repository', err)]); } } finally { - refresh(); setSelectedRepoNames([]); setDeleteModalOpen(!isDeleteModalOpen); } @@ -201,63 +202,6 @@ export default function RepositoriesList() { , ]; - async function fetchRepos() { - // clearing previous states - setLoading(true); - resetSearch(); - setRepositoryList([]); - setSelectedRepoNames([]); - try { - const user = await fetchUser(); - setUserState(user); - - // check if view is global vs scoped to a organization - // TODO: we inculde username as part of org list - // fix this after we have MyQuay page - const listOfOrgNames: string[] = currentOrg - ? [currentOrg] - : user.organizations.map((org) => org.name).concat(user.username); - - const repos = (await fetchAllRepos( - listOfOrgNames, - true, - )) as IRepository[]; - - // default sort by last modified - // TODO (syahmed): redo this when we have user selectable sorting - repos.sort((r1, r2) => { - return r1.last_modified > r2.last_modified ? -1 : 1; - }); - - // TODO: Here we're formatting repo's into the correct - // type. Once we know the return type from the repo's - // API we should pass 'repos' directly into 'setRepositoryList' - const formattedRepos: RepoListTableItem[] = repos.map((repo) => { - return { - namespace: repo.namespace, - name: repo.name, - is_public: repo.is_public, - last_modified: repo.last_modified, - size: repo.quota_report?.quota_bytes, - } as RepoListTableItem; - }); - setRepositoryList(formattedRepos); - } catch (err) { - console.error(err); - setLoadingErr(addDisplayError('Unable to get repositories', err)); - } finally { - setLoading(false); - } - } - - useEffect(() => { - fetchRepos(); - }, [pageRefreshIndex]); - - const updateListHandler = (value: IRepository) => { - setRepositoryList((prev) => [...prev, value]); - }; - /* Mapper object used to render bulk delete table - keys are actual column names of the table - value is an object type with a "label" which maps to the attributes of @@ -285,9 +229,9 @@ export default function RepositoriesList() { isModalOpen={isCreateRepoModalOpen} handleModalToggle={() => setCreateRepoModalOpen(!isCreateRepoModalOpen)} orgName={currentOrg} - updateListHandler={updateListHandler} - username={userState.username} - organizations={userState.organizations} + updateListHandler={() => null} + username={user.username} + organizations={user.organizations} /> ); @@ -317,11 +261,11 @@ export default function RepositoriesList() { } // Return component Error state - if (isErrorString(loadingErr)) { + if (isErrorString(error as any)) { return ( <> - + ); } @@ -441,6 +385,7 @@ export default function RepositoriesList() { Date: Tue, 1 Nov 2022 09:19:52 -0700 Subject: [PATCH 05/16] misc --- src/components/toolbar/ToolbarPagination.tsx | 3 +- .../OrganizationsList/OrganizationsList.tsx | 132 +++--------------- .../OrganizationsListTableData.tsx | 128 ++++++----------- 3 files changed, 60 insertions(+), 203 deletions(-) diff --git a/src/components/toolbar/ToolbarPagination.tsx b/src/components/toolbar/ToolbarPagination.tsx index 62448648..cc5d4f16 100644 --- a/src/components/toolbar/ToolbarPagination.tsx +++ b/src/components/toolbar/ToolbarPagination.tsx @@ -9,7 +9,7 @@ export function ToolbarPagination(props: ToolbarPaginationProps) { void; bottom?: boolean; id?: string; + total: number; }; diff --git a/src/routes/OrganizationsList/OrganizationsList.tsx b/src/routes/OrganizationsList/OrganizationsList.tsx index 787d5ca5..57d63f33 100644 --- a/src/routes/OrganizationsList/OrganizationsList.tsx +++ b/src/routes/OrganizationsList/OrganizationsList.tsx @@ -15,14 +15,10 @@ import { } from '@patternfly/react-core'; import './css/Organizations.scss'; import {CreateOrganizationModal} from './CreateOrganizationModal'; -import {useRecoilState, useRecoilValue, useResetRecoilState} from 'recoil'; +import {useRecoilState, useRecoilValue} from 'recoil'; import {searchOrgsState, selectedOrgsState} from 'src/atoms/UserState'; import {useEffect, useState} from 'react'; -import { - bulkDeleteOrganizations, - fetchOrgsAsSuperUser, - IOrganization, -} from 'src/resources/OrganizationResource'; +import {IOrganization} from 'src/resources/OrganizationResource'; import OrgTableData from './OrganizationsListTableData'; import {BulkDeleteModalTemplate} from 'src/components/modals/BulkDeleteModalTemplate'; import RequestError from 'src/components/errors/RequestError'; @@ -32,23 +28,13 @@ import {ToolbarButton} from 'src/components/toolbar/ToolbarButton'; import Empty from 'src/components/empty/Empty'; import {QuayBreadcrumb} from 'src/components/breadcrumb/Breadcrumb'; import {LoadingPage} from 'src/components/LoadingPage'; -import { - addDisplayError, - BulkOperationError, - isErrorString, -} from 'src/resources/ErrorHandling'; +import {addDisplayError, BulkOperationError} from 'src/resources/ErrorHandling'; import ErrorModal from 'src/components/errors/ErrorModal'; import {ToolbarPagination} from 'src/components/toolbar/ToolbarPagination'; -import { - fetchUser, - fetchUsersAsSuperUser, - IUserResource, -} from 'src/resources/UserResource'; import ColumnNames from './ColumnNames'; -import {userRefreshOrgList} from 'src/hooks/UseRefreshPage'; -import {refreshPageState} from 'src/atoms/OrganizationListState'; -import {fetchQuayConfig} from 'src/resources/QuayConfig'; import RepoCount from 'src/components/Table/RepoCount'; +import {useOrganizations} from 'src/hooks/UseOrganizations'; +import {useCurrentUser} from 'src/hooks/UseCurrentUser'; export interface OrganizationsTableItem { name: string; @@ -70,29 +56,24 @@ function OrgListHeader() { export default function OrganizationsList() { const [isOrganizationModalOpen, setOrganizationModalOpen] = useState(false); - const [loading, setLoading] = useState(true); const search = useRecoilValue(searchOrgsState); - const resetSearch = useResetRecoilState(searchOrgsState); const [selectedOrganization, setSelectedOrganization] = useRecoilState(selectedOrgsState); const [err, setErr] = useState(); - const [loadingErr, setLoadingErr] = useState(); - const [organizationsList, setOrganizationsList] = useState< - OrganizationsTableItem[] - >([]); const [deleteModalIsOpen, setDeleteModalIsOpen] = useState(false); const [isKebabOpen, setKebabOpen] = useState(false); const [perPage, setPerPage] = useState(10); const [page, setPage] = useState(1); - const [userData, setUserData] = useState(); - const [quayConfig, setQuayConfig] = useState(null); - const refresh = userRefreshOrgList(); - const refreshPageIndex = useRecoilValue(refreshPageState); + + const {organizationsTableDetails, loading, error, deleteOrganizations} = + useOrganizations(); const filteredOrgs = search.query !== '' - ? organizationsList?.filter((repo) => repo.name.includes(search.query)) - : organizationsList; + ? organizationsTableDetails?.filter((repo) => + repo.name.includes(search.query), + ) + : organizationsTableDetails; const paginatedOrganizationsList = filteredOrgs?.slice( page * perPage - perPage, @@ -143,7 +124,7 @@ export default function OrganizationsList() { (_x, i) => i + rowIndex, ); intermediateIndexes.forEach((index) => - setOrganizationChecked(organizationsList[index], isSelecting), + setOrganizationChecked(organizationsTableDetails[index], isSelecting), ); } else { setOrganizationChecked(currentOrganization, isSelecting); @@ -155,10 +136,8 @@ export default function OrganizationsList() { // Error handling is in BulkDeleteModalTemplate, // since that is where it is reported to the user. try { - const isSuperUser = - quayConfig?.features.SUPERUSERS_FULL_ACCESS && userData?.super_user; const orgs = selectedOrganization.map((org) => org.name); - await bulkDeleteOrganizations(orgs, isSuperUser); + await deleteOrganizations(orgs); } catch (err) { console.error(err); if (err instanceof BulkOperationError) { @@ -176,7 +155,6 @@ export default function OrganizationsList() { } } finally { setDeleteModalIsOpen(!deleteModalIsOpen); - refresh(); setSelectedOrganization([]); } }; @@ -218,7 +196,7 @@ export default function OrganizationsList() { handleModalToggle={() => setDeleteModalIsOpen(!deleteModalIsOpen)} handleBulkDeletion={handleOrgDeletion} isModalOpen={deleteModalIsOpen} - selectedItems={organizationsList?.filter((org) => + selectedItems={organizationsTableDetails?.filter((org) => selectedOrganization.some( (selectedOrg) => org.name === selectedOrg.name, ), @@ -247,80 +225,6 @@ export default function OrganizationsList() { }; }, []); - // Get initial data required for rendering page - useEffect(() => { - // TODO: Many operations in this function are ran synchronously when - // they can be ran async - look into running some of these calls in - // parallel in the future - const fetchData = async () => { - try { - setLoading(true); - resetSearch(); - setSelectedOrganization([]); - const config = await fetchQuayConfig(); - const user: IUserResource = await fetchUser(); - setQuayConfig(config); - setUserData(user); - - let orgnames: string[]; - if (config?.features.SUPERUSERS_FULL_ACCESS && user?.super_user) { - const orgs: IOrganization[] = await fetchOrgsAsSuperUser(); - orgnames = orgs.map((org) => org.name); - } else { - orgnames = user?.organizations.map((org) => org.name); - } - - // Populate org table temporarily with org names while we wait for org details to return - const tempOrgsList: OrganizationsTableItem[] = orgnames.map((org) => { - return { - name: org, - isUser: false, - } as OrganizationsTableItem; - }); - setOrganizationsList(tempOrgsList); - setLoading(false); - - const newOrgsList: OrganizationsTableItem[] = orgnames.map((org) => { - return { - name: org, - isUser: false, - } as OrganizationsTableItem; - }); - - // Add the user namespace. If superuser get all user namespaces - // otherwise default to the current user's namespace - let usernames: string[]; - if (config?.features.SUPERUSERS_FULL_ACCESS && user?.super_user) { - const users: IUserResource[] = await fetchUsersAsSuperUser(); - usernames = users.map((user) => user.username); - } else { - usernames = [user.username]; - } - - for (const username of usernames) { - newOrgsList.push({ - name: username, - isUser: true, - }); - } - - // sort on last modified - // TODO revisit this after API changes, we don't have enough info to sort 2022-09-14 - // newOrgsList.sort((r1, r2) => { - // return r1.lastModified > r2.lastModified ? -1 : 1; - // }); - setOrganizationsList(newOrgsList); - } catch (err) { - console.error(err); - setLoadingErr(addDisplayError('Unable to get organizations', err)); - } finally { - setLoading(false); - } - }; - - fetchData(); - }, [refreshPageIndex]); - // Return component Loading state if (loading) { return ( @@ -332,17 +236,17 @@ export default function OrganizationsList() { } // Return component Error state - if (isErrorString(loadingErr)) { + if (error) { return ( <> - + ); } // Return component Empty state - if (!loading && !organizationsList?.length) { + if (!loading && !organizationsTableDetails?.length) { return ( <> diff --git a/src/routes/OrganizationsList/OrganizationsListTableData.tsx b/src/routes/OrganizationsList/OrganizationsListTableData.tsx index 8a310ec0..ff26f747 100644 --- a/src/routes/OrganizationsList/OrganizationsListTableData.tsx +++ b/src/routes/OrganizationsList/OrganizationsListTableData.tsx @@ -2,7 +2,6 @@ import {Td} from '@patternfly/react-table'; import {Skeleton} from '@patternfly/react-core'; import './css/Organizations.scss'; import {Link} from 'react-router-dom'; -import {useEffect, useState} from 'react'; import {fetchOrg} from 'src/resources/OrganizationResource'; import { fetchRepositoriesForNamespace, @@ -13,7 +12,7 @@ import {fetchRobotsForNamespace} from 'src/resources/RobotsResource'; import {formatDate} from 'src/libs/utils'; import ColumnNames from './ColumnNames'; import {OrganizationsTableItem} from './OrganizationsList'; -import {AxiosError} from 'axios'; +import {useQuery} from '@tanstack/react-query'; interface CountProps { count: string | number; @@ -42,11 +41,34 @@ function RepoLastModifiedDate(props: RepoLastModifiedDateProps) { // Get and assemble data from multiple endpoints to show in Org table // Only necessary because current API structure does not return all required data export default function OrgTableData(props: OrganizationsTableItem) { - const [teamCount, setTeamCount] = useState(null); - const [memberCount, setMemberCount] = useState(null); - const [robotCount, setRobotCount] = useState(null); - const [repoCount, setRepoCount] = useState(null); - const [lastModifiedDate, setLastModifiedDate] = useState(0); + // Get organization + const {data: organization} = useQuery( + ['organization', props.name], + () => fetchOrg(props.name), + {enabled: !props.isUser}, + ); + + // Get members + const {data: members} = useQuery( + ['organization', props.name, 'members'], + () => fetchMembersForOrg(props.name), + {enabled: !props.isUser}, + ); + const memberCount = members ? members.length : null; + + // Get robots + const {data: robots} = useQuery(['organization', props.name, 'robots'], () => + fetchRobotsForNamespace(props.name), + ); + const robotCount = robots ? robots.length : null; + + // Get repositories + const {data: repositories} = useQuery( + ['organization', props.name, 'repositories'], + () => fetchRepositoriesForNamespace(props.name), + ); + const repoCount = repositories ? repositories.length : null; + const getLastModifiedRepoTime = (repos: IRepository[]) => { // get the repo with the most recent last modified if (!repos || !repos.length) { @@ -56,88 +78,18 @@ export default function OrgTableData(props: OrganizationsTableItem) { const recentRepo = repos.reduce((prev, curr) => prev.last_modified < curr.last_modified ? curr : prev, ); - return recentRepo.last_modified; + return recentRepo.last_modified || -1; }; + const lastModifiedDate = getLastModifiedRepoTime(repositories); - useEffect(() => { - let teamCountVal = null; - let memberCountVal = null; - let robotCountVal = null; - let repoCountVal = null; - let lastModifiedVal = null; - - // Grab data for Team column - const fetchTeamCount = async () => { - try { - if (!props.isUser) { - const data = await fetchOrg(props.name); - teamCountVal = data?.teams ? Object.keys(data?.teams)?.length : 0; - } else { - teamCountVal = 'N/A'; - } - } catch (err) { - console.error(err); - teamCountVal = 'Error'; - } finally { - setTeamCount(teamCountVal); - } - }; - - // Grab data for Member column - const fetchMemberCount = async () => { - try { - if (!props.isUser) { - const data = await fetchMembersForOrg(props.name); - memberCountVal = data.length; - } else { - memberCountVal = 'N/A'; - } - } catch (err) { - console.error(err); - memberCountVal = - err instanceof AxiosError && err.response?.status === 403 - ? 'N/A' - : 'Error'; - } finally { - setMemberCount(memberCountVal); - } - }; - - // Grab data for Robot column - const fetchRobotCount = async () => { - try { - const data = await fetchRobotsForNamespace(props.name); - robotCountVal = data.length; - } catch (err) { - console.error(err); - robotCountVal = 'Error'; - } finally { - setRobotCount(robotCountVal); - } - }; - - // Grab data for Repo and LastModified columns - const fetchRepoCount = async () => { - try { - const data = await fetchRepositoriesForNamespace(props.name); - repoCountVal = data.length; - lastModifiedVal = getLastModifiedRepoTime(data); - } catch (err) { - console.error(err); - repoCountVal = 'Error'; - lastModifiedVal = 0; - } finally { - setRepoCount(repoCountVal); - setLastModifiedDate(lastModifiedVal); - } - }; - - // Trigger async calls - fetchTeamCount(); - fetchMemberCount(); - fetchRobotCount(); - fetchRepoCount(); - }, []); + let teamCountVal: string; + if (!props.isUser) { + teamCountVal = organization?.teams + ? Object.keys(organization?.teams)?.length.toString() + : '0'; + } else { + teamCountVal = 'N/A'; + } return ( <> @@ -148,7 +100,7 @@ export default function OrgTableData(props: OrganizationsTableItem) { - + From 1ed1f31bb5a0c6cf34a277f0c99fda9fe23332d5 Mon Sep 17 00:00:00 2001 From: Jonathan King Date: Tue, 1 Nov 2022 09:20:21 -0700 Subject: [PATCH 06/16] add settings to repo page --- .../Organization/Organization.tsx | 36 ++-- .../Organization/Tabs/Settings/Settings.tsx | 158 ++++++++++++++++++ 2 files changed, 176 insertions(+), 18 deletions(-) create mode 100644 src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx diff --git a/src/routes/OrganizationsList/Organization/Organization.tsx b/src/routes/OrganizationsList/Organization/Organization.tsx index a43bbfb1..cba89ed2 100644 --- a/src/routes/OrganizationsList/Organization/Organization.tsx +++ b/src/routes/OrganizationsList/Organization/Organization.tsx @@ -7,38 +7,38 @@ import { TabTitleText, Title, } from '@patternfly/react-core'; -import {useLocation} from 'react-router-dom'; -import {NavigationPath} from 'src/routes/NavigationPath'; +import {useLocation, useSearchParams} from 'react-router-dom'; import {useCallback, useState} from 'react'; import RepositoriesList from 'src/routes/RepositoriesList/RepositoriesList'; +import Settings from './Tabs/Settings/Settings'; import {QuayBreadcrumb} from 'src/components/breadcrumb/Breadcrumb'; export default function Organization() { const location = useLocation(); const repositoryName = location.pathname.split('/')[2]; + const [searchParams, setSearchParams] = useSearchParams(); - const [activeTabKey, setActiveTabKey] = useState(0); + const [activeTabKey, setActiveTabKey] = useState( + searchParams.get('tab') || 'Repositories', + ); const onTabSelect = useCallback( - ( - _event: React.MouseEvent, - tabIndex: string | number, - ) => setActiveTabKey(tabIndex), + (_event: React.MouseEvent, tabKey: string) => { + setSearchParams({tab: tabKey}); + setActiveTabKey(tabKey); + }, [], ); const repositoriesSubNav = [ { - href: NavigationPath.organizationDetail, name: 'Repositories', component: , }, - // Commenting till needed. - // { - // href: NavigationPath.orgDetailUsageLogsTab, - // name: 'Usage Logs', - // component: , - // }, + { + name: 'Settings', + component: , + }, ]; return ( @@ -54,13 +54,13 @@ export default function Organization() { - {repositoriesSubNav.map((nav, idx) => ( + {repositoriesSubNav.map((nav) => ( {nav.name}} > {nav.component} diff --git a/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx b/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx new file mode 100644 index 00000000..2e5cbb78 --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx @@ -0,0 +1,158 @@ +import {useEffect, useState} from 'react'; +import { + Tabs, + Tab, + TabTitleText, + Flex, + FlexItem, + FormGroup, + Form, + TextInput, + FormSelect, + FormSelectOption, + ActionGroup, + Button, +} from '@patternfly/react-core'; +import {useLocation} from 'react-router-dom'; +import {useCurrentUser} from 'src/hooks/UseCurrentUser'; +import {useOrganization} from 'src/hooks/UseOrganization'; + +const GeneralSettings = () => { + const location = useLocation(); + const organizationName = location.pathname.split('/')[2]; + + const {user} = useCurrentUser(); + const {organization, isUserOrganization, loading} = + useOrganization(organizationName); + + // Time Machine + const timeMachineOptions = ['1 week', '1 month', '1 year', 'Never']; + const [timeMachineFormValue, setTimeMachineFormValue] = useState( + timeMachineOptions[0], + ); + + // Email + const [emailFormValue, setEmailFormValue] = useState(''); + useEffect(() => { + if (!loading && organization) { + setEmailFormValue((organization as any).email); + } else if (isUserOrganization) { + setEmailFormValue(user.email); + } + }, [loading, isUserOrganization]); + + return ( +
+ + + + + + setEmailFormValue(val)} + /> + + + + setTimeMachineFormValue(val)} + > + {timeMachineOptions.map((option, index) => ( + + ))} + + + + + + + + + +
+ ); +}; + +const BillingInformation = () => { + return

Hello

; +}; + +export default function Settings() { + const [activeTabIndex, setActiveTabIndex] = useState(0); + + const handleTabClick = (event, tabIndex) => { + setActiveTabIndex(tabIndex); + }; + + const tabs = [ + { + name: 'General Settings', + id: 'generalsettings', + content: , + }, + { + name: 'Billing Information', + id: 'billinginformation', + content: , + }, + ]; + + return ( + + + + {tabs.map((tab, tabIndex) => ( + {tab.name}} + /> + ))} + + + + + {tabs.at(activeTabIndex).content} + + + ); +} From 09a70552f1d401985a9a4bfe0baca06342e57215 Mon Sep 17 00:00:00 2001 From: Jonathan King Date: Fri, 18 Nov 2022 17:40:12 -0800 Subject: [PATCH 07/16] update repos list components to use pagination from hooks --- src/components/toolbar/ToolbarPagination.tsx | 8 +- src/hooks/UseOrganization.ts | 2 +- src/hooks/UseOrganizations.ts | 26 ++++++ src/hooks/UseRepositories.ts | 40 ++++++--- src/populate.js | 90 +++++++++++++++++++ src/resources/MembersResource.ts | 13 ++- src/resources/OrganizationResource.ts | 4 +- src/resources/RepositoryResource.ts | 10 ++- src/resources/RobotsResource.ts | 9 +- .../OrganizationsList/OrganizationToolBar.tsx | 15 ++-- .../OrganizationsList/OrganizationsList.tsx | 18 +++- .../OrganizationsListTableData.tsx | 23 +++-- .../RepositoriesList/RepositoriesList.tsx | 13 ++- .../RepositoriesList/RepositoryToolBar.tsx | 14 +-- 14 files changed, 234 insertions(+), 51 deletions(-) create mode 100644 src/populate.js diff --git a/src/components/toolbar/ToolbarPagination.tsx b/src/components/toolbar/ToolbarPagination.tsx index cc5d4f16..90cb7d36 100644 --- a/src/components/toolbar/ToolbarPagination.tsx +++ b/src/components/toolbar/ToolbarPagination.tsx @@ -4,12 +4,14 @@ import { PaginationVariant, } from '@patternfly/react-core'; -export function ToolbarPagination(props: ToolbarPaginationProps) { +export const ToolbarPagination = (props: ToolbarPaginationProps) => { + const {total} = props; + return ( ); -} +}; type ToolbarPaginationProps = { itemsList: any[]; diff --git a/src/hooks/UseOrganization.ts b/src/hooks/UseOrganization.ts index 655649e2..8f1d06c0 100644 --- a/src/hooks/UseOrganization.ts +++ b/src/hooks/UseOrganization.ts @@ -13,7 +13,7 @@ export function useOrganization(name: string) { data: organization, isLoading, error, - } = useQuery(['organization', name], () => fetchOrg(name), { + } = useQuery(['organization', name], ({signal}) => fetchOrg(name, signal), { enabled: !isUserOrganization, placeholderData: (): IOrganization[] => new Array(10).fill({}), }); diff --git a/src/hooks/UseOrganizations.ts b/src/hooks/UseOrganizations.ts index 6a45e52b..bb6d1fdc 100644 --- a/src/hooks/UseOrganizations.ts +++ b/src/hooks/UseOrganizations.ts @@ -6,11 +6,21 @@ import { import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'; import {useCurrentUser} from './UseCurrentUser'; import {createOrg} from 'src/resources/OrganizationResource'; +import {useState} from 'react'; +import {SearchState} from 'src/components/toolbar/SearchTypes'; export function useOrganizations() { // Get user and config data const {isSuperUser, user, loading, error} = useCurrentUser(); + // Keep state of current search in this hook + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(10); + const [search, setSearch] = useState({ + field: '', + query: '', + }); + // Get super user orgs const {data: superUserOrganizations} = useQuery( ['organization', 'superuser'], @@ -84,11 +94,27 @@ export function useOrganizations() { ); return { + // Data superUserOrganizations, superUserUsers, organizationsTableDetails, + + // Fetching State loading, error, + + // Search Query State + search, + setSearch, + page, + setPage, + perPage, + setPerPage, + + // Useful Metadata + totalResults: organizationsTableDetails.length, + + // Mutations createOrganization: async (name: string, email: string) => createOrganizationMutator.mutate({name, email}), deleteOrganizations: async (names: string[]) => diff --git a/src/hooks/UseRepositories.ts b/src/hooks/UseRepositories.ts index 777518ab..1e33460e 100644 --- a/src/hooks/UseRepositories.ts +++ b/src/hooks/UseRepositories.ts @@ -3,10 +3,12 @@ import { bulkDeleteRepositories, createNewRepository, fetchRepositories, + fetchRepositoriesForNamespace, } from 'src/resources/RepositoryResource'; import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'; import {useCurrentUser} from './UseCurrentUser'; import {IRepository} from 'src/resources/RepositoryResource'; +import {SearchState} from 'src/components/toolbar/SearchTypes'; interface createRepositoryParams { namespace: string; @@ -16,15 +18,20 @@ interface createRepositoryParams { repo_kind: string; } -export function useRepositories() { +export function useRepositories(organization?: string) { const {user} = useCurrentUser(); + // Keep state of current search in this hook const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); - const [organization, setOrganization] = useState(''); + const [search, setSearch] = useState({ + field: '', + query: '', + }); + const [currentOrganization, setCurrentOrganization] = useState(organization); - const listOfOrgNames: string[] = organization - ? [organization] + const listOfOrgNames: string[] = currentOrganization + ? [currentOrganization] : user?.organizations.map((org) => org.name).concat(user.username); const { @@ -34,7 +41,9 @@ export function useRepositories() { error, } = useQuery( ['organization', organization, 'repositories'], - fetchRepositories, + currentOrganization + ? ({signal}) => fetchRepositoriesForNamespace(currentOrganization, signal) + : fetchRepositories, { placeholderData: [], }, @@ -85,16 +94,27 @@ export function useRepositories() { ); return { + // Data repos: repositories, - loading: loading || isPlaceholderData, + + // Fetching State + loading: loading || isPlaceholderData || !listOfOrgNames, error, - setPage, - setPerPage, + + // Search Query State + search, + setSearch, page, + setPage, perPage, - setOrganization, + setPerPage, organization, - totalResults: listOfOrgNames.length, + setCurrentOrganization, + + // Useful Metadata + totalResults: repositories.length, + + // Mutations createRepository: async (params: createRepositoryParams) => createRepositoryMutator.mutate(params), deleteRepositories: async (repos: IRepository[]) => diff --git a/src/populate.js b/src/populate.js new file mode 100644 index 00000000..f737061b --- /dev/null +++ b/src/populate.js @@ -0,0 +1,90 @@ +// SHOW OLD WEBSITE +// SHOW MULTIPLE CALLS TO EXPLAIN WHY USE CACHE +// SHOW REACT HOOKS + +const axios = require('axios'); + +(async () => { + for (let i = 1000; i < 3000; i++) { + await axios.post( + 'http://localhost:8080/api/v1/organization/', + { + email: 'organization-' + i + '@gmail.com', + name: 'organization-' + i, + }, + { + credentials: 'include', + headers: { + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:106.0) Gecko/20100101 Firefox/106.0', + Accept: 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.5', + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/json', + 'X-CSRF-Token': + 'a1JfYi1RmJ6swBOysMv5KY20hEV88foUgrKkqwYvcSayPvkaN_75F_aXz0yCRGsN', + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'same-site', + Cookie: + '__stripe_mid=31f1e8d7-278d-4222-bb2e-0300ac007e66ab30b9; _csrf_token=.eJw1z8tqwlAUheFXCWds4Ox9bjsZtlRB0RZ7oY7CuWqwJm0SI1Z891pKh_9k8a0Lq3zfpWpo97FhJbMwT5sa1oe57k93j-d-OarFBvnu4Y0ota_bbrH_Om1G_2zPT-PeriqjppV9_-bn-_WsX7EJq1IX-x0rh-4Yb1WH2ywEiq5IMbnCCOnJclBKm8IbC9ZJMEpTCGjRhGRiQqDokzIUMAUuUXIZPE8FWINcRCek9OoW2gORTiiVAZu4csIJoQFQSS-t0ASkuRVkflmfsTvYJjbDP-2j3dZNNdSHyMoLy36d066eZEDZqh0z5IgZYCmh5CabLV_YdcKOfez-LnnvSCv0eSKyudRK5E5LytFLxUUhpArIrj-ZZWau.Y3d_uQ.xglRztGUAtRM8pLnhnuezp069y0; quay.loggedin=true; PGADMIN_LANGUAGE=en; pga4_session=1a098b9a-18f4-4209-82e4-dcdaba8d3e73!68h7VeWCYcGzJtZK6K4f7g3mJ9BjovTrQCDMBS3e0oU=', + }, + referrer: 'http://127.0.0.1:9000/', + mode: 'cors', + }, + ); + for (let j = 0; j < 10; j++) { + await axios.post( + 'http://localhost:8080/api/v1/repository', + { + namespace: 'organization-' + i, + visibility: 'public', + repository: 'repository-' + j, + description: 'Hello World', + repo_kind: 'image', + }, + { + credentials: 'include', + headers: { + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:106.0) Gecko/20100101 Firefox/106.0', + Accept: 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.5', + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/json', + 'X-CSRF-Token': + 'a1JfYi1RmJ6swBOysMv5KY20hEV88foUgrKkqwYvcSayPvkaN_75F_aXz0yCRGsN', + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'same-site', + Cookie: + '__stripe_mid=31f1e8d7-278d-4222-bb2e-0300ac007e66ab30b9; _csrf_token=.eJw1z8tqwlAUheFXCWds4Ox9bjsZtlRB0RZ7oY7CuWqwJm0SI1Z891pKh_9k8a0Lq3zfpWpo97FhJbMwT5sa1oe57k93j-d-OarFBvnu4Y0ota_bbrH_Om1G_2zPT-PeriqjppV9_-bn-_WsX7EJq1IX-x0rh-4Yb1WH2ywEiq5IMbnCCOnJclBKm8IbC9ZJMEpTCGjRhGRiQqDokzIUMAUuUXIZPE8FWINcRCek9OoW2gORTiiVAZu4csIJoQFQSS-t0ASkuRVkflmfsTvYJjbDP-2j3dZNNdSHyMoLy36d066eZEDZqh0z5IgZYCmh5CabLV_YdcKOfez-LnnvSCv0eSKyudRK5E5LytFLxUUhpArIrj-ZZWau.Y3d_uQ.xglRztGUAtRM8pLnhnuezp069y0; quay.loggedin=true; PGADMIN_LANGUAGE=en; pga4_session=1a098b9a-18f4-4209-82e4-dcdaba8d3e73!68h7VeWCYcGzJtZK6K4f7g3mJ9BjovTrQCDMBS3e0oU=', + }, + referrer: 'http://127.0.0.1:9000/', + mode: 'cors', + }, + ); + } + } +})(); + +// await fetch('http://localhost:8080/api/v1/organization/', { +// credentials: 'include', +// headers: { +// 'User-Agent': +// 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:107.0) Gecko/20100101 Firefox/107.0', +// Accept: 'application/json, text/plain, */*', +// 'Accept-Language': 'en-US,en;q=0.5', +// 'X-Requested-With': 'XMLHttpRequest', +// 'Content-Type': 'application/json', +// 'X-CSRF-Token': +// 'a1JfYi1RmJ6swBOysMv5KY20hEV88foUgrKkqwYvcSayPvkaN_75F_aXz0yCRGsN', +// 'Sec-Fetch-Dest': 'empty', +// 'Sec-Fetch-Mode': 'cors', +// 'Sec-Fetch-Site': 'same-site', +// }, +// referrer: 'http://localhost:9000/', +// body: '{"name":"trhee","email":"jon@gmail.com"}', +// method: 'POST', +// mode: 'cors', +// }); diff --git a/src/resources/MembersResource.ts b/src/resources/MembersResource.ts index e76e436d..0b4df3f1 100644 --- a/src/resources/MembersResource.ts +++ b/src/resources/MembersResource.ts @@ -14,13 +14,18 @@ export interface ITeam { avatar: IAvatar; } -export async function fetchAllMembers(orgnames: string[]) { - return await Promise.all(orgnames.map((org) => fetchMembersForOrg(org))); +export async function fetchAllMembers(orgnames: string[], signal: AbortSignal) { + return await Promise.all( + orgnames.map((org) => fetchMembersForOrg(org, signal)), + ); } -export async function fetchMembersForOrg(orgname: string): Promise { +export async function fetchMembersForOrg( + orgname: string, + signal: AbortSignal, +): Promise { const getMembersUrl = `/api/v1/organization/${orgname}/members`; - const response = await axios.get(getMembersUrl); + const response = await axios.get(getMembersUrl, {signal}); assertHttpCode(response.status, 200); return response.data?.members; } diff --git a/src/resources/OrganizationResource.ts b/src/resources/OrganizationResource.ts index 0709f0c1..e5a64b7c 100644 --- a/src/resources/OrganizationResource.ts +++ b/src/resources/OrganizationResource.ts @@ -19,10 +19,10 @@ export interface IOrganization { teams?: string[]; } -export async function fetchOrg(orgname: string) { +export async function fetchOrg(orgname: string, signal: AbortSignal) { const getOrgUrl = `/api/v1/organization/${orgname}`; // TODO: Add return type - const response: AxiosResponse = await axios.get(getOrgUrl); + const response: AxiosResponse = await axios.get(getOrgUrl, {signal}); assertHttpCode(response.status, 200); return response.data as IOrganization; } diff --git a/src/resources/RepositoryResource.ts b/src/resources/RepositoryResource.ts index cbcb3dc6..523cc809 100644 --- a/src/resources/RepositoryResource.ts +++ b/src/resources/RepositoryResource.ts @@ -36,9 +36,10 @@ export interface IQuotaReport { export async function fetchAllRepos( namespaces: string[], flatten = false, + signal: AbortSignal, ): Promise { const namespacedRepos = await Promise.all( - namespaces.map((ns) => fetchRepositoriesForNamespace(ns)), + namespaces.map((ns) => fetchRepositoriesForNamespace(ns, signal)), ); // Flatten responses to a single list of all repositories if (flatten) { @@ -51,10 +52,14 @@ export async function fetchAllRepos( } } -export async function fetchRepositoriesForNamespace(ns: string) { +export async function fetchRepositoriesForNamespace( + ns: string, + signal: AbortSignal, +) { // TODO: Add return type to AxiosResponse const response: AxiosResponse = await axios.get( `/api/v1/repository?last_modified=true&namespace=${ns}&public=true`, + {signal}, ); assertHttpCode(response.status, 200); return response.data?.repositories as IRepository[]; @@ -65,6 +70,7 @@ export async function fetchRepositories() { const response: AxiosResponse = await axios.get( `/api/v1/repository?last_modified=true&public=true`, ); + console.log('HELLOOO', response.data); assertHttpCode(response.status, 200); return response.data?.repositories as IRepository[]; } diff --git a/src/resources/RobotsResource.ts b/src/resources/RobotsResource.ts index 44a2a195..ef87f68e 100644 --- a/src/resources/RobotsResource.ts +++ b/src/resources/RobotsResource.ts @@ -11,17 +11,20 @@ export interface IRobot { description: string; } -export async function fetchAllRobots(orgnames: string[]) { - return await Promise.all(orgnames.map((org) => fetchRobotsForNamespace(org))); +export async function fetchAllRobots(orgnames: string[], signal: AbortSignal) { + return await Promise.all( + orgnames.map((org) => fetchRobotsForNamespace(org, false, signal)), + ); } export async function fetchRobotsForNamespace( orgname: string, isUser = false, + signal: AbortSignal, ): Promise { const userOrOrgPath = isUser ? 'user' : `organization/${orgname}`; const getRobotsUrl = `/api/v1/${userOrOrgPath}/robots?permissions=true&token=false`; - const response: AxiosResponse = await axios.get(getRobotsUrl); + const response: AxiosResponse = await axios.get(getRobotsUrl, {signal}); assertHttpCode(response.status, 200); return response.data?.robots; } diff --git a/src/routes/OrganizationsList/OrganizationToolBar.tsx b/src/routes/OrganizationsList/OrganizationToolBar.tsx index 2cd9cdf1..76964076 100644 --- a/src/routes/OrganizationsList/OrganizationToolBar.tsx +++ b/src/routes/OrganizationsList/OrganizationToolBar.tsx @@ -5,14 +5,11 @@ 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 {searchOrgsState} from 'src/atoms/UserState'; -import {useRecoilState} from 'recoil'; import * as React from 'react'; import ColumnNames from './ColumnNames'; +import {SearchState} from 'src/components/toolbar/SearchTypes'; export function OrganizationToolBar(props: OrganizationToolBarProps) { - const [search, setSearch] = useRecoilState(searchOrgsState); - return ( @@ -25,10 +22,10 @@ export function OrganizationToolBar(props: OrganizationToolBarProps) { /> - + void; setPerPage: (perPageNumber) => void; + total: number; + search: SearchState; + setSearch: (searchState) => void; setSelectedOrganization: (selectedOrgList) => void; paginatedOrganizationsList: any[]; onSelectOrganization: (Org, rowIndex, isSelecting) => void; diff --git a/src/routes/OrganizationsList/OrganizationsList.tsx b/src/routes/OrganizationsList/OrganizationsList.tsx index 57d63f33..b9a54399 100644 --- a/src/routes/OrganizationsList/OrganizationsList.tsx +++ b/src/routes/OrganizationsList/OrganizationsList.tsx @@ -16,7 +16,7 @@ import { import './css/Organizations.scss'; import {CreateOrganizationModal} from './CreateOrganizationModal'; import {useRecoilState, useRecoilValue} from 'recoil'; -import {searchOrgsState, selectedOrgsState} from 'src/atoms/UserState'; +import {selectedOrgsState} from 'src/atoms/UserState'; import {useEffect, useState} from 'react'; import {IOrganization} from 'src/resources/OrganizationResource'; import OrgTableData from './OrganizationsListTableData'; @@ -56,7 +56,6 @@ function OrgListHeader() { export default function OrganizationsList() { const [isOrganizationModalOpen, setOrganizationModalOpen] = useState(false); - const search = useRecoilValue(searchOrgsState); const [selectedOrganization, setSelectedOrganization] = useRecoilState(selectedOrgsState); const [err, setErr] = useState(); @@ -65,8 +64,15 @@ export default function OrganizationsList() { const [perPage, setPerPage] = useState(10); const [page, setPage] = useState(1); - const {organizationsTableDetails, loading, error, deleteOrganizations} = - useOrganizations(); + const { + organizationsTableDetails, + loading, + error, + deleteOrganizations, + totalResults, + search, + setSearch, + } = useOrganizations(); const filteredOrgs = search.query !== '' @@ -274,6 +280,9 @@ export default function OrganizationsList() { { + // return () => { + // queryClient.cancelQueries(['organization', props.name]); + // queryClient.cancelQueries(['organization', props.name, 'members']); + // queryClient.cancelQueries(['organization', props.name, 'robots']); + // queryClient.cancelQueries(['organization', props.name, 'repositories']); + // }; + // }, [props.name]); // Get organization const {data: organization} = useQuery( ['organization', props.name], - () => fetchOrg(props.name), + ({signal}) => fetchOrg(props.name, signal), {enabled: !props.isUser}, ); // Get members const {data: members} = useQuery( ['organization', props.name, 'members'], - () => fetchMembersForOrg(props.name), + ({signal}) => fetchMembersForOrg(props.name, signal), {enabled: !props.isUser}, ); const memberCount = members ? members.length : null; // Get robots - const {data: robots} = useQuery(['organization', props.name, 'robots'], () => - fetchRobotsForNamespace(props.name), + const {data: robots} = useQuery( + ['organization', props.name, 'robots'], + ({signal}) => fetchRobotsForNamespace(props.name, false, signal), ); const robotCount = robots ? robots.length : null; // Get repositories const {data: repositories} = useQuery( ['organization', props.name, 'repositories'], - () => fetchRepositoriesForNamespace(props.name), + ({signal}) => fetchRepositoriesForNamespace(props.name, signal), ); const repoCount = repositories ? repositories.length : null; diff --git a/src/routes/RepositoriesList/RepositoriesList.tsx b/src/routes/RepositoriesList/RepositoriesList.tsx index 10ae07ab..2f536231 100644 --- a/src/routes/RepositoriesList/RepositoriesList.tsx +++ b/src/routes/RepositoriesList/RepositoriesList.tsx @@ -70,7 +70,6 @@ export default function RepositoriesList() { const [isKebabOpen, setKebabOpen] = useState(false); const [makePublicModalOpen, setmakePublicModal] = useState(false); const [makePrivateModalOpen, setmakePrivateModal] = useState(false); - const search = useRecoilValue(searchRepoState); const [err, setErr] = useState(); const quayConfig = useQuayConfig(); @@ -82,10 +81,12 @@ export default function RepositoriesList() { deleteRepositories, setPerPage, setPage, + search, + setSearch, page, perPage, totalResults, - } = useRepositories(); + } = useRepositories(currentOrg); repos.sort((r1, r2) => { return r1.last_modified > r2.last_modified ? -1 : 1; @@ -111,7 +112,10 @@ export default function RepositoriesList() { }) : repositoryList; - const paginatedRepositoryList = filteredRepos; + const paginatedRepositoryList = filteredRepos.slice( + page * perPage - perPage, + page * perPage - perPage + perPage, + ); // Select related states const [selectedRepoNames, setSelectedRepoNames] = @@ -295,6 +299,9 @@ export default function RepositoriesList() { { if (props.selectedRepoNames.length == 1) { return props.selectedRepoNames[0]; @@ -55,10 +53,10 @@ export function RepositoryToolBar(props: RepositoryToolBarProps) { /> - + void; setPerPage: (perPageNumber) => void; + search: SearchState; + setSearch: (searchState) => void; setSelectedRepoNames: (selectedRepoList) => void; paginatedRepositoryList: any[]; onSelectRepo: (Repo, rowIndex, isSelecting) => void; From 167de13dd456c4d40df7a3160f769274172c8d57 Mon Sep 17 00:00:00 2001 From: Jonathan King Date: Wed, 30 Nov 2022 08:35:50 -0800 Subject: [PATCH 08/16] address requested changes --- .../modals/CreateRepoModalTemplate.tsx | 31 +++++++------ src/hooks/UseCreateOrganization.ts | 25 ++++++++++ src/hooks/UseCreateRepository.ts | 46 +++++++++++++++++++ src/hooks/UseOrganizations.ts | 13 ------ src/hooks/UseRepositories.ts | 29 ------------ .../CreateOrganizationModal.tsx | 17 ++++--- .../OrganizationsListTableData.tsx | 2 +- 7 files changed, 96 insertions(+), 67 deletions(-) create mode 100644 src/hooks/UseCreateOrganization.ts create mode 100644 src/hooks/UseCreateRepository.ts diff --git a/src/components/modals/CreateRepoModalTemplate.tsx b/src/components/modals/CreateRepoModalTemplate.tsx index 52c18400..e7e50dc2 100644 --- a/src/components/modals/CreateRepoModalTemplate.tsx +++ b/src/components/modals/CreateRepoModalTemplate.tsx @@ -19,7 +19,7 @@ import {ExclamationCircleIcon} from '@patternfly/react-icons'; import {addDisplayError} from 'src/resources/ErrorHandling'; import {IOrganization} from 'src/resources/OrganizationResource'; import {useQuayConfig} from 'src/hooks/UseQuayConfig'; -import {useRepositories} from 'src/hooks/UseRepositories'; +import {useCreateRepository} from 'src/hooks/UseCreateRepository'; enum visibilityType { PUBLIC = 'PUBLIC', @@ -42,7 +42,14 @@ export default function CreateRepositoryModalTemplate( isDropdownOpen: false, }); - const {createRepository} = useRepositories(); + const {createRepository} = useCreateRepository({ + onSuccess: () => { + props.handleModalToggle(); + }, + onError: (error) => { + setErr(addDisplayError('Unable to create repository', error)); + }, + }); const [validationState, setValidationState] = useState({ repoName: true, @@ -86,19 +93,13 @@ export default function CreateRepositoryModalTemplate( if (!validateInput()) { return; } - try { - await createRepository({ - namespace: currentOrganization.name, - repository: newRepository.name, - visibility: repoVisibility.toLowerCase(), - description: newRepository.description, - repo_kind: 'image', - }); - props.handleModalToggle(); - } catch (error) { - console.error(error); - setErr(addDisplayError('Unable to create repository', error)); - } + await createRepository({ + namespace: currentOrganization.name, + repository: newRepository.name, + visibility: repoVisibility.toLowerCase(), + description: newRepository.description, + repo_kind: 'image', + }); }; const handleNamespaceSelection = (e, value) => { diff --git a/src/hooks/UseCreateOrganization.ts b/src/hooks/UseCreateOrganization.ts new file mode 100644 index 00000000..aeed07cc --- /dev/null +++ b/src/hooks/UseCreateOrganization.ts @@ -0,0 +1,25 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query'; +import {createOrg} from 'src/resources/OrganizationResource'; +export function useCreateOrganization({onSuccess, onError}) { + const queryClient = useQueryClient(); + + const createOrganizationMutator = useMutation( + async ({name, email}: {name: string; email: string}) => { + return createOrg(name, email); + }, + { + onSuccess: () => { + onSuccess(); + queryClient.invalidateQueries(['user']); + }, + onError: (err) => { + onError(err); + }, + }, + ); + + return { + createOrganization: async (name: string, email: string) => + createOrganizationMutator.mutate({name, email}), + }; +} diff --git a/src/hooks/UseCreateRepository.ts b/src/hooks/UseCreateRepository.ts new file mode 100644 index 00000000..94217fc7 --- /dev/null +++ b/src/hooks/UseCreateRepository.ts @@ -0,0 +1,46 @@ +import {createNewRepository} from 'src/resources/RepositoryResource'; +import {useMutation, useQueryClient} from '@tanstack/react-query'; + +interface createRepositoryParams { + namespace: string; + repository: string; + visibility: string; + description: string; + repo_kind: string; +} + +export function useCreateRepository({onError, onSuccess}) { + const queryClient = useQueryClient(); + + const createRepositoryMutator = useMutation( + async ({ + namespace, + repository, + visibility, + description, + repo_kind, + }: createRepositoryParams) => { + return createNewRepository( + namespace, + repository, + visibility, + description, + repo_kind, + ); + }, + { + onSuccess: () => { + onSuccess(); + queryClient.invalidateQueries(); + }, + onError: (err) => { + onError(err); + }, + }, + ); + + return { + createRepository: async (params: createRepositoryParams) => + createRepositoryMutator.mutate(params), + }; +} diff --git a/src/hooks/UseOrganizations.ts b/src/hooks/UseOrganizations.ts index bb6d1fdc..e791db4c 100644 --- a/src/hooks/UseOrganizations.ts +++ b/src/hooks/UseOrganizations.ts @@ -71,17 +71,6 @@ export function useOrganizations() { // Get query client for mutations const queryClient = useQueryClient(); - const createOrganizationMutator = useMutation( - async ({name, email}: {name: string; email: string}) => { - return createOrg(name, email); - }, - { - onSuccess: () => { - queryClient.invalidateQueries(['user']); - }, - }, - ); - const deleteOrganizationMutator = useMutation( async (names: string[]) => { return bulkDeleteOrganizations(names, isSuperUser); @@ -115,8 +104,6 @@ export function useOrganizations() { totalResults: organizationsTableDetails.length, // Mutations - createOrganization: async (name: string, email: string) => - createOrganizationMutator.mutate({name, email}), deleteOrganizations: async (names: string[]) => deleteOrganizationMutator.mutate(names), usernames, diff --git a/src/hooks/UseRepositories.ts b/src/hooks/UseRepositories.ts index 1e33460e..96c2e76c 100644 --- a/src/hooks/UseRepositories.ts +++ b/src/hooks/UseRepositories.ts @@ -51,33 +51,6 @@ export function useRepositories(organization?: string) { const queryClient = useQueryClient(); - const createRepositoryMutator = useMutation( - async ({ - namespace, - repository, - visibility, - description, - repo_kind, - }: createRepositoryParams) => { - return createNewRepository( - namespace, - repository, - visibility, - description, - repo_kind, - ); - }, - { - onSuccess: () => { - queryClient.invalidateQueries([ - 'organization', - organization, - 'repositories', - ]); - }, - }, - ); - const deleteRepositoryMutator = useMutation( async (repos: IRepository[]) => { return bulkDeleteRepositories(repos); @@ -115,8 +88,6 @@ export function useRepositories(organization?: string) { totalResults: repositories.length, // Mutations - createRepository: async (params: createRepositoryParams) => - createRepositoryMutator.mutate(params), deleteRepositories: async (repos: IRepository[]) => deleteRepositoryMutator.mutate(repos), }; diff --git a/src/routes/OrganizationsList/CreateOrganizationModal.tsx b/src/routes/OrganizationsList/CreateOrganizationModal.tsx index 690b0d47..2a7d5367 100644 --- a/src/routes/OrganizationsList/CreateOrganizationModal.tsx +++ b/src/routes/OrganizationsList/CreateOrganizationModal.tsx @@ -7,11 +7,11 @@ import { TextInput, } from '@patternfly/react-core'; import './css/Organizations.scss'; -import {useOrganizations} from 'src/hooks/UseOrganizations'; import {isValidEmail} from 'src/libs/utils'; import {useState} from 'react'; import FormError from 'src/components/errors/FormError'; import {addDisplayError} from 'src/resources/ErrorHandling'; +import {useCreateOrganization} from 'src/hooks/UseCreateOrganization'; interface Validation { message: string; @@ -35,7 +35,12 @@ export const CreateOrganizationModal = ( const [validation, setValidation] = useState(defaultMessage); const [err, setErr] = useState(); - const {createOrganization} = useOrganizations(); + const {createOrganization} = useCreateOrganization({ + onSuccess: () => props.handleModalToggle(), + onError: (err) => { + setErr(addDisplayError('Unable to create organization', err)); + }, + }); const handleNameInputChange = (value: any) => { const regex = /^([a-z0-9]+(?:[._-][a-z0-9]+)*)$/; @@ -71,13 +76,7 @@ export const CreateOrganizationModal = ( }; const createOrganizationHandler = async () => { - try { - await createOrganization(organizationName, organizationEmail); - props.handleModalToggle(); - } catch (err) { - console.error(err); - setErr(addDisplayError('Unable to create organization', err)); - } + await createOrganization(organizationName, organizationEmail); }; const onInputBlur = () => { diff --git a/src/routes/OrganizationsList/OrganizationsListTableData.tsx b/src/routes/OrganizationsList/OrganizationsListTableData.tsx index 81c561c2..771e3f0c 100644 --- a/src/routes/OrganizationsList/OrganizationsListTableData.tsx +++ b/src/routes/OrganizationsList/OrganizationsListTableData.tsx @@ -62,7 +62,7 @@ export default function OrgTableData(props: OrganizationsTableItem) { const {data: members} = useQuery( ['organization', props.name, 'members'], ({signal}) => fetchMembersForOrg(props.name, signal), - {enabled: !props.isUser}, + {placeholderData: props.isUser ? [] : undefined}, ); const memberCount = members ? members.length : null; From 2c5340e05d9ed33e00d32f000b33773080204f10 Mon Sep 17 00:00:00 2001 From: Jonathan King Date: Mon, 5 Dec 2022 13:27:56 -0800 Subject: [PATCH 09/16] disable retries on queries --- src/App.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 43593a11..39cf093b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,7 +17,13 @@ const queryClient = new QueryClient({ }, }); -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); export default function App() { useAnalytics(); From ccf99ab12ef111aaa07918328ddbcb83445201d4 Mon Sep 17 00:00:00 2001 From: Jonathan King Date: Sun, 11 Dec 2022 21:16:10 -0800 Subject: [PATCH 10/16] fix visibility not refreshing --- src/App.tsx | 29 ++++--------------- .../modals/BulkDeleteModalTemplate.tsx | 2 ++ src/components/modals/ConfirmationModal.tsx | 10 +++---- src/index.tsx | 14 ++++++++- src/resources/RepositoryResource.ts | 1 - 5 files changed, 25 insertions(+), 31 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 39cf093b..107d0692 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,5 @@ -import React, {Suspense, useEffect} from 'react'; +import React, {Suspense} from 'react'; import {BrowserRouter, Route, Routes} from 'react-router-dom'; -import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; import 'src/App.css'; @@ -9,22 +8,6 @@ import {StandaloneMain} from 'src/routes/StandaloneMain'; import {Signin} from 'src/routes/Signin/Signin'; import {useAnalytics} from 'src/hooks/UseAnalytics'; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - }, - }, -}); - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, -}); - export default function App() { useAnalytics(); @@ -32,12 +15,10 @@ export default function App() {
}> - - - } /> - } /> - - + + } /> + } /> +
diff --git a/src/components/modals/BulkDeleteModalTemplate.tsx b/src/components/modals/BulkDeleteModalTemplate.tsx index 0fdb6c3e..be1bd3e8 100644 --- a/src/components/modals/BulkDeleteModalTemplate.tsx +++ b/src/components/modals/BulkDeleteModalTemplate.tsx @@ -124,6 +124,7 @@ export const BulkDeleteModalTemplate = ( />
( (); - const refresh = useRefreshRepoList(); + const queryClient = useQueryClient(); const changeVisibility = async () => { const visibility = props.makePublic ? 'public' : 'private'; try { // TODO: Could replace this with a 'bulkSetRepoVisibility' // function in RepositoryResource in the future await Promise.all( - props.selectedItems.map((item) => { + props.selectedItems.map(async (item) => { const [org, ...repoArray] = item.split('/'); const repo = repoArray.join('/'); - return setRepositoryVisibility(org, repo, visibility); + await setRepositoryVisibility(org, repo, visibility); }), ); - refresh(); + queryClient.invalidateQueries(['organization']); props.toggleModal(); props.selectAllRepos(false); } catch (error: any) { diff --git a/src/index.tsx b/src/index.tsx index 530c3c44..1618bb05 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,14 +4,26 @@ import reportWebVitals from './reportWebVitals'; 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'; // Load App after patternfly so custom CSS that overrides patternfly doesn't require !important import App from './App'; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + }, +}); + ReactDOM.render( - + + + , document.getElementById('root'), diff --git a/src/resources/RepositoryResource.ts b/src/resources/RepositoryResource.ts index 523cc809..3843082a 100644 --- a/src/resources/RepositoryResource.ts +++ b/src/resources/RepositoryResource.ts @@ -70,7 +70,6 @@ export async function fetchRepositories() { const response: AxiosResponse = await axios.get( `/api/v1/repository?last_modified=true&public=true`, ); - console.log('HELLOOO', response.data); assertHttpCode(response.status, 200); return response.data?.repositories as IRepository[]; } From 7975f76e18cdfb6da8b1cfe8facb04b93299006f Mon Sep 17 00:00:00 2001 From: Jonathan King Date: Sun, 11 Dec 2022 21:43:01 -0800 Subject: [PATCH 11/16] fix error modals for organization and repo delete --- src/hooks/UseDeleteOrganizations.ts | 27 +++++++++++++++ src/hooks/UseDeleteRepositories.ts | 28 +++++++++++++++ src/hooks/UseRepositories.ts | 34 +------------------ .../OrganizationsList/OrganizationsList.tsx | 24 +++++++------ .../RepositoriesList/RepositoriesList.tsx | 21 +++++++----- 5 files changed, 81 insertions(+), 53 deletions(-) create mode 100644 src/hooks/UseDeleteOrganizations.ts create mode 100644 src/hooks/UseDeleteRepositories.ts diff --git a/src/hooks/UseDeleteOrganizations.ts b/src/hooks/UseDeleteOrganizations.ts new file mode 100644 index 00000000..34804b61 --- /dev/null +++ b/src/hooks/UseDeleteOrganizations.ts @@ -0,0 +1,27 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query'; +import {bulkDeleteOrganizations} from 'src/resources/OrganizationResource'; + +export function useDeleteOrganizations({onSuccess, onError}) { + const queryClient = useQueryClient(); + + const deleteOrganizationsMutator = useMutation( + async (orgs: string[]) => { + await bulkDeleteOrganizations(orgs); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['user']); + onSuccess(); + }, + onError: (err) => { + onError(err); + }, + }, + ); + + return { + // Mutations + deleteOrganizations: async (orgs: string[]) => + deleteOrganizationsMutator.mutate(orgs), + }; +} diff --git a/src/hooks/UseDeleteRepositories.ts b/src/hooks/UseDeleteRepositories.ts new file mode 100644 index 00000000..516a8ce6 --- /dev/null +++ b/src/hooks/UseDeleteRepositories.ts @@ -0,0 +1,28 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query'; +import {bulkDeleteRepositories} from 'src/resources/RepositoryResource'; +import {IRepository} from 'src/resources/RepositoryResource'; + +export function useDeleteRepositories({onSuccess, onError}) { + const queryClient = useQueryClient(); + + const deleteRepositoriesMutator = useMutation( + async (repos: IRepository[]) => { + return bulkDeleteRepositories(repos); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['organization']); + onSuccess(); + }, + onError: (err) => { + onError(err); + }, + }, + ); + + return { + // Mutations + deleteRepositories: async (repos: IRepository[]) => + deleteRepositoriesMutator.mutate(repos), + }; +} diff --git a/src/hooks/UseRepositories.ts b/src/hooks/UseRepositories.ts index 96c2e76c..e6b16660 100644 --- a/src/hooks/UseRepositories.ts +++ b/src/hooks/UseRepositories.ts @@ -1,23 +1,12 @@ import {useState} from 'react'; import { - bulkDeleteRepositories, - createNewRepository, fetchRepositories, fetchRepositoriesForNamespace, } from 'src/resources/RepositoryResource'; -import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'; +import {useQuery} from '@tanstack/react-query'; import {useCurrentUser} from './UseCurrentUser'; -import {IRepository} from 'src/resources/RepositoryResource'; import {SearchState} from 'src/components/toolbar/SearchTypes'; -interface createRepositoryParams { - namespace: string; - repository: string; - visibility: string; - description: string; - repo_kind: string; -} - export function useRepositories(organization?: string) { const {user} = useCurrentUser(); @@ -49,23 +38,6 @@ export function useRepositories(organization?: string) { }, ); - const queryClient = useQueryClient(); - - const deleteRepositoryMutator = useMutation( - async (repos: IRepository[]) => { - return bulkDeleteRepositories(repos); - }, - { - onSuccess: () => { - queryClient.invalidateQueries([ - 'organization', - organization, - 'repositories', - ]); - }, - }, - ); - return { // Data repos: repositories, @@ -86,9 +58,5 @@ export function useRepositories(organization?: string) { // Useful Metadata totalResults: repositories.length, - - // Mutations - deleteRepositories: async (repos: IRepository[]) => - deleteRepositoryMutator.mutate(repos), }; } diff --git a/src/routes/OrganizationsList/OrganizationsList.tsx b/src/routes/OrganizationsList/OrganizationsList.tsx index b9a54399..9dcf6a4a 100644 --- a/src/routes/OrganizationsList/OrganizationsList.tsx +++ b/src/routes/OrganizationsList/OrganizationsList.tsx @@ -34,7 +34,7 @@ import {ToolbarPagination} from 'src/components/toolbar/ToolbarPagination'; import ColumnNames from './ColumnNames'; import RepoCount from 'src/components/Table/RepoCount'; import {useOrganizations} from 'src/hooks/UseOrganizations'; -import {useCurrentUser} from 'src/hooks/UseCurrentUser'; +import {useDeleteOrganizations} from 'src/hooks/UseDeleteOrganizations'; export interface OrganizationsTableItem { name: string; @@ -68,7 +68,6 @@ export default function OrganizationsList() { organizationsTableDetails, loading, error, - deleteOrganizations, totalResults, search, setSearch, @@ -138,13 +137,12 @@ export default function OrganizationsList() { setRecentSelectedRowIndex(rowIndex); }; - const handleOrgDeletion = async () => { - // Error handling is in BulkDeleteModalTemplate, - // since that is where it is reported to the user. - try { - const orgs = selectedOrganization.map((org) => org.name); - await deleteOrganizations(orgs); - } catch (err) { + const {deleteOrganizations} = useDeleteOrganizations({ + onSuccess: () => { + setDeleteModalIsOpen(!deleteModalIsOpen); + setSelectedOrganization([]); + }, + onError: (err) => { console.error(err); if (err instanceof BulkOperationError) { const errMessages = []; @@ -159,10 +157,14 @@ export default function OrganizationsList() { } else { setErr([addDisplayError('Failed to delete orgs', err)]); } - } finally { setDeleteModalIsOpen(!deleteModalIsOpen); setSelectedOrganization([]); - } + }, + }); + + const handleOrgDeletion = async () => { + const orgs = selectedOrganization.map((org) => org.name); + await deleteOrganizations(orgs); }; const handleDeleteModalToggle = () => { diff --git a/src/routes/RepositoriesList/RepositoriesList.tsx b/src/routes/RepositoriesList/RepositoriesList.tsx index 2f536231..ed673f5a 100644 --- a/src/routes/RepositoriesList/RepositoriesList.tsx +++ b/src/routes/RepositoriesList/RepositoriesList.tsx @@ -41,6 +41,8 @@ import ColumnNames from './ColumnNames'; import {LoadingPage} from 'src/components/LoadingPage'; import {useCurrentUser} from 'src/hooks/UseCurrentUser'; import {useRepositories} from 'src/hooks/UseRepositories'; +import {useDeleteRepositories} from 'src/hooks/UseDeleteRepositories'; + function getReponameFromURL(pathname: string): string { return pathname.includes('organization') ? pathname.split('/')[2] : null; } @@ -78,7 +80,6 @@ export default function RepositoriesList() { repos, loading, error, - deleteRepositories, setPerPage, setPage, search, @@ -162,10 +163,12 @@ export default function RepositoriesList() { setDeleteModalOpen(!isDeleteModalOpen); }; - const handleRepoDeletion = async (repos: IRepository[]) => { - try { - await deleteRepositories(repos); - } catch (err) { + const {deleteRepositories} = useDeleteRepositories({ + onSuccess: () => { + setSelectedRepoNames([]); + setDeleteModalOpen(!isDeleteModalOpen); + }, + onError: (err) => { if (err instanceof BulkOperationError) { const errMessages = []; // TODO: Would like to use for .. of instead of foreach @@ -179,11 +182,10 @@ export default function RepositoriesList() { } else { setErr([addDisplayError('Failed to delete repository', err)]); } - } finally { setSelectedRepoNames([]); setDeleteModalOpen(!isDeleteModalOpen); - } - }; + }, + }); const kebabItems: ReactElement[] = [ @@ -243,7 +245,7 @@ export default function RepositoriesList() { selectedRepoNames.some( @@ -283,6 +285,7 @@ export default function RepositoriesList() { body="Either no repositories exist yet or you may not have permission to view any. If you have permission, try creating a new repository." button={ Date: Sun, 11 Dec 2022 21:49:20 -0800 Subject: [PATCH 12/16] fix member count for user org showing skeleton --- .../modals/BulkDeleteModalTemplate.tsx | 2 - src/components/toolbar/ToolbarPagination.tsx | 6 +- src/hooks/UseRepositories.ts | 4 +- src/populate.js | 90 ------------------- .../OrganizationsList/OrganizationToolBar.tsx | 1 - .../OrganizationsList/OrganizationsList.tsx | 1 - .../OrganizationsListTableData.tsx | 2 +- .../RepositoriesList/RepositoriesList.tsx | 1 - .../RepositoriesList/RepositoryToolBar.tsx | 1 - 9 files changed, 5 insertions(+), 103 deletions(-) delete mode 100644 src/populate.js diff --git a/src/components/modals/BulkDeleteModalTemplate.tsx b/src/components/modals/BulkDeleteModalTemplate.tsx index be1bd3e8..0fdb6c3e 100644 --- a/src/components/modals/BulkDeleteModalTemplate.tsx +++ b/src/components/modals/BulkDeleteModalTemplate.tsx @@ -124,7 +124,6 @@ export const BulkDeleteModalTemplate = ( /> ( { - const {total} = props; - return ( void; bottom?: boolean; id?: string; - total: number; + total?: number; }; diff --git a/src/hooks/UseRepositories.ts b/src/hooks/UseRepositories.ts index e6b16660..81e63363 100644 --- a/src/hooks/UseRepositories.ts +++ b/src/hooks/UseRepositories.ts @@ -1,6 +1,6 @@ import {useState} from 'react'; import { - fetchRepositories, + fetchAllRepos, fetchRepositoriesForNamespace, } from 'src/resources/RepositoryResource'; import {useQuery} from '@tanstack/react-query'; @@ -32,7 +32,7 @@ export function useRepositories(organization?: string) { ['organization', organization, 'repositories'], currentOrganization ? ({signal}) => fetchRepositoriesForNamespace(currentOrganization, signal) - : fetchRepositories, + : ({signal}) => fetchAllRepos(listOfOrgNames, true, signal), { placeholderData: [], }, diff --git a/src/populate.js b/src/populate.js deleted file mode 100644 index f737061b..00000000 --- a/src/populate.js +++ /dev/null @@ -1,90 +0,0 @@ -// SHOW OLD WEBSITE -// SHOW MULTIPLE CALLS TO EXPLAIN WHY USE CACHE -// SHOW REACT HOOKS - -const axios = require('axios'); - -(async () => { - for (let i = 1000; i < 3000; i++) { - await axios.post( - 'http://localhost:8080/api/v1/organization/', - { - email: 'organization-' + i + '@gmail.com', - name: 'organization-' + i, - }, - { - credentials: 'include', - headers: { - 'User-Agent': - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:106.0) Gecko/20100101 Firefox/106.0', - Accept: 'application/json, text/plain, */*', - 'Accept-Language': 'en-US,en;q=0.5', - 'X-Requested-With': 'XMLHttpRequest', - 'Content-Type': 'application/json', - 'X-CSRF-Token': - 'a1JfYi1RmJ6swBOysMv5KY20hEV88foUgrKkqwYvcSayPvkaN_75F_aXz0yCRGsN', - 'Sec-Fetch-Dest': 'empty', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Site': 'same-site', - Cookie: - '__stripe_mid=31f1e8d7-278d-4222-bb2e-0300ac007e66ab30b9; _csrf_token=.eJw1z8tqwlAUheFXCWds4Ox9bjsZtlRB0RZ7oY7CuWqwJm0SI1Z891pKh_9k8a0Lq3zfpWpo97FhJbMwT5sa1oe57k93j-d-OarFBvnu4Y0ota_bbrH_Om1G_2zPT-PeriqjppV9_-bn-_WsX7EJq1IX-x0rh-4Yb1WH2ywEiq5IMbnCCOnJclBKm8IbC9ZJMEpTCGjRhGRiQqDokzIUMAUuUXIZPE8FWINcRCek9OoW2gORTiiVAZu4csIJoQFQSS-t0ASkuRVkflmfsTvYJjbDP-2j3dZNNdSHyMoLy36d066eZEDZqh0z5IgZYCmh5CabLV_YdcKOfez-LnnvSCv0eSKyudRK5E5LytFLxUUhpArIrj-ZZWau.Y3d_uQ.xglRztGUAtRM8pLnhnuezp069y0; quay.loggedin=true; PGADMIN_LANGUAGE=en; pga4_session=1a098b9a-18f4-4209-82e4-dcdaba8d3e73!68h7VeWCYcGzJtZK6K4f7g3mJ9BjovTrQCDMBS3e0oU=', - }, - referrer: 'http://127.0.0.1:9000/', - mode: 'cors', - }, - ); - for (let j = 0; j < 10; j++) { - await axios.post( - 'http://localhost:8080/api/v1/repository', - { - namespace: 'organization-' + i, - visibility: 'public', - repository: 'repository-' + j, - description: 'Hello World', - repo_kind: 'image', - }, - { - credentials: 'include', - headers: { - 'User-Agent': - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:106.0) Gecko/20100101 Firefox/106.0', - Accept: 'application/json, text/plain, */*', - 'Accept-Language': 'en-US,en;q=0.5', - 'X-Requested-With': 'XMLHttpRequest', - 'Content-Type': 'application/json', - 'X-CSRF-Token': - 'a1JfYi1RmJ6swBOysMv5KY20hEV88foUgrKkqwYvcSayPvkaN_75F_aXz0yCRGsN', - 'Sec-Fetch-Dest': 'empty', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Site': 'same-site', - Cookie: - '__stripe_mid=31f1e8d7-278d-4222-bb2e-0300ac007e66ab30b9; _csrf_token=.eJw1z8tqwlAUheFXCWds4Ox9bjsZtlRB0RZ7oY7CuWqwJm0SI1Z891pKh_9k8a0Lq3zfpWpo97FhJbMwT5sa1oe57k93j-d-OarFBvnu4Y0ota_bbrH_Om1G_2zPT-PeriqjppV9_-bn-_WsX7EJq1IX-x0rh-4Yb1WH2ywEiq5IMbnCCOnJclBKm8IbC9ZJMEpTCGjRhGRiQqDokzIUMAUuUXIZPE8FWINcRCek9OoW2gORTiiVAZu4csIJoQFQSS-t0ASkuRVkflmfsTvYJjbDP-2j3dZNNdSHyMoLy36d066eZEDZqh0z5IgZYCmh5CabLV_YdcKOfez-LnnvSCv0eSKyudRK5E5LytFLxUUhpArIrj-ZZWau.Y3d_uQ.xglRztGUAtRM8pLnhnuezp069y0; quay.loggedin=true; PGADMIN_LANGUAGE=en; pga4_session=1a098b9a-18f4-4209-82e4-dcdaba8d3e73!68h7VeWCYcGzJtZK6K4f7g3mJ9BjovTrQCDMBS3e0oU=', - }, - referrer: 'http://127.0.0.1:9000/', - mode: 'cors', - }, - ); - } - } -})(); - -// await fetch('http://localhost:8080/api/v1/organization/', { -// credentials: 'include', -// headers: { -// 'User-Agent': -// 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:107.0) Gecko/20100101 Firefox/107.0', -// Accept: 'application/json, text/plain, */*', -// 'Accept-Language': 'en-US,en;q=0.5', -// 'X-Requested-With': 'XMLHttpRequest', -// 'Content-Type': 'application/json', -// 'X-CSRF-Token': -// 'a1JfYi1RmJ6swBOysMv5KY20hEV88foUgrKkqwYvcSayPvkaN_75F_aXz0yCRGsN', -// 'Sec-Fetch-Dest': 'empty', -// 'Sec-Fetch-Mode': 'cors', -// 'Sec-Fetch-Site': 'same-site', -// }, -// referrer: 'http://localhost:9000/', -// body: '{"name":"trhee","email":"jon@gmail.com"}', -// method: 'POST', -// mode: 'cors', -// }); diff --git a/src/routes/OrganizationsList/OrganizationToolBar.tsx b/src/routes/OrganizationsList/OrganizationToolBar.tsx index 76964076..60024a8c 100644 --- a/src/routes/OrganizationsList/OrganizationToolBar.tsx +++ b/src/routes/OrganizationsList/OrganizationToolBar.tsx @@ -44,7 +44,6 @@ export function OrganizationToolBar(props: OrganizationToolBarProps) { {props.deleteKebabIsOpen ? props.deleteModal : null} fetchMembersForOrg(props.name, signal), {placeholderData: props.isUser ? [] : undefined}, ); - const memberCount = members ? members.length : null; + const memberCount = props.isUser ? 0 : members ? members.length : null; // Get robots const {data: robots} = useQuery( diff --git a/src/routes/RepositoriesList/RepositoriesList.tsx b/src/routes/RepositoriesList/RepositoriesList.tsx index ed673f5a..474169f8 100644 --- a/src/routes/RepositoriesList/RepositoriesList.tsx +++ b/src/routes/RepositoriesList/RepositoriesList.tsx @@ -395,7 +395,6 @@ export default function RepositoriesList() { Date: Tue, 13 Dec 2022 05:28:41 -0800 Subject: [PATCH 13/16] small fixes --- .github/workflows/ci.yml | 16 ++++++++++++++++ src/hooks/UseOrganizations.ts | 9 +++++---- src/hooks/UseRepositories.ts | 3 ++- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0720fa8e..9ed4d55a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,21 @@ jobs: with: repository: quay/quay path: './quay' + + - name: install yq + env: + VERSION: v4.14.2 + BINARY: yq_linux_amd64 + run: | + wget https://github.com/mikefarah/yq/releases/download/${VERSION}/${BINARY} -O /usr/local/bin/yq + chmod +x /usr/local/bin/yq + + - name: Update images + run: | + yq e -i ' + .services.quay.image = "quay.io/projectquay/quay" ' ./quay/docker-compose.yaml && \ + yq e -i 'del(.services.quay.build)' ./quay/docker-compose.yaml + - name: Start Quay run: | # This rebuilds Quay from scratch, should look to use images built off of Quay master @@ -23,6 +38,7 @@ jobs: docker-compose up -d redis quay-db docker exec -t quay-db bash -c 'while ! pg_isready; do echo "waiting for postgres"; sleep 2; done' DOCKER_USER="1001:0" docker-compose up -d quay + - name: Cypress run uses: cypress-io/github-action@v4.2.0 with: diff --git a/src/hooks/UseOrganizations.ts b/src/hooks/UseOrganizations.ts index e791db4c..f9fbcde9 100644 --- a/src/hooks/UseOrganizations.ts +++ b/src/hooks/UseOrganizations.ts @@ -8,6 +8,7 @@ import {useCurrentUser} from './UseCurrentUser'; import {createOrg} from 'src/resources/OrganizationResource'; import {useState} from 'react'; import {SearchState} from 'src/components/toolbar/SearchTypes'; +import ColumnNames from 'src/routes/OrganizationsList/ColumnNames'; export function useOrganizations() { // Get user and config data @@ -17,7 +18,7 @@ export function useOrganizations() { const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); const [search, setSearch] = useState({ - field: '', + field: ColumnNames.name, query: '', }); @@ -42,14 +43,14 @@ export function useOrganizations() { // Get org names let orgnames: string[]; if (isSuperUser) { - orgnames = superUserOrganizations.map((org) => org.name); + orgnames = (superUserOrganizations || []).map((org) => org.name); } else { - orgnames = user?.organizations.map((org) => org.name); + orgnames = (user?.organizations || []).map((org) => org.name); } // Get user names let usernames: string[]; if (isSuperUser) { - usernames = superUserUsers.map((user) => user.username); + usernames = (superUserUsers || []).map((user) => user.username); } else { usernames = [user.username]; } diff --git a/src/hooks/UseRepositories.ts b/src/hooks/UseRepositories.ts index 81e63363..95607804 100644 --- a/src/hooks/UseRepositories.ts +++ b/src/hooks/UseRepositories.ts @@ -6,6 +6,7 @@ import { import {useQuery} from '@tanstack/react-query'; import {useCurrentUser} from './UseCurrentUser'; import {SearchState} from 'src/components/toolbar/SearchTypes'; +import ColumnNames from 'src/routes/RepositoriesList/ColumnNames'; export function useRepositories(organization?: string) { const {user} = useCurrentUser(); @@ -14,7 +15,7 @@ export function useRepositories(organization?: string) { const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); const [search, setSearch] = useState({ - field: '', + field: ColumnNames.name, query: '', }); const [currentOrganization, setCurrentOrganization] = useState(organization); From cbfa5352e7509a15325c542cea2f2ab97eeb2e8b Mon Sep 17 00:00:00 2001 From: Jonathan King Date: Mon, 12 Dec 2022 06:24:45 -0800 Subject: [PATCH 14/16] settings: Add organization and user settings (PROJQUAY-4553) - This PR includes a few features that enhance the org/settings customization flows - Users can now convert their account from individual to organization - Users can update their e-mail and other similar metadata regarding their account - Error handling has been added to the settings pages / clear back to defaults - Added ability to generate CLI token for user account --- package-lock.json | 14 + package.json | 1 + .../modals/GenerateEncryptedPasswordModal.tsx | 81 ++++ .../modals/UserConvertConflictsModal.tsx | 142 +++++++ src/hooks/UseConvertAccount.ts | 30 ++ src/hooks/UseCreateClientKey.ts | 30 ++ src/hooks/UseOrganization.ts | 3 +- src/hooks/UsePlan.ts | 24 ++ src/hooks/UseUpdateOrganization.ts | 36 ++ src/hooks/UseUpdateUser.ts | 35 ++ src/resources/OrganizationResource.ts | 21 + src/resources/PlanResource.ts | 32 ++ src/resources/UserResource.ts | 42 ++ .../Tabs/Settings/BillingInformation.tsx | 364 ++++++++++++++++++ .../Tabs/Settings/CLIConfiguration.tsx | 33 ++ .../Tabs/Settings/GeneralSettings.tsx | 280 ++++++++++++++ .../Organization/Tabs/Settings/Settings.tsx | 128 +----- 17 files changed, 1187 insertions(+), 109 deletions(-) create mode 100644 src/components/modals/GenerateEncryptedPasswordModal.tsx create mode 100644 src/components/modals/UserConvertConflictsModal.tsx create mode 100644 src/hooks/UseConvertAccount.ts create mode 100644 src/hooks/UseCreateClientKey.ts create mode 100644 src/hooks/UsePlan.ts create mode 100644 src/hooks/UseUpdateOrganization.ts create mode 100644 src/hooks/UseUpdateUser.ts create mode 100644 src/resources/PlanResource.ts create mode 100644 src/routes/OrganizationsList/Organization/Tabs/Settings/BillingInformation.tsx create mode 100644 src/routes/OrganizationsList/Organization/Tabs/Settings/CLIConfiguration.tsx create mode 100644 src/routes/OrganizationsList/Organization/Tabs/Settings/GeneralSettings.tsx diff --git a/package-lock.json b/package-lock.json index 686d1bcf..590c6695 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "axios": "^0.27.2", "js-sha1": "^0.6.0", "mini-css-extract-plugin": "^2.6.1", + "moment": "^2.29.4", "null-loader": "^4.0.1", "react": "^17.0.2", "react-router-dom": "^6.3.0", @@ -18867,6 +18868,14 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "engines": { + "node": "*" + } + }, "node_modules/move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", @@ -43373,6 +43382,11 @@ "minimist": "^1.2.6" } }, + "moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" + }, "move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", diff --git a/package.json b/package.json index 0c627058..76582d07 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "axios": "^0.27.2", "js-sha1": "^0.6.0", "mini-css-extract-plugin": "^2.6.1", + "moment": "^2.29.4", "null-loader": "^4.0.1", "react": "^17.0.2", "react-router-dom": "^6.3.0", diff --git a/src/components/modals/GenerateEncryptedPasswordModal.tsx b/src/components/modals/GenerateEncryptedPasswordModal.tsx new file mode 100644 index 00000000..70d926b3 --- /dev/null +++ b/src/components/modals/GenerateEncryptedPasswordModal.tsx @@ -0,0 +1,81 @@ +import {Modal, ModalVariant, Button, TextInput} from '@patternfly/react-core'; +import {useState} from 'react'; +import FormError from 'src/components/errors/FormError'; +import {addDisplayError} from 'src/resources/ErrorHandling'; +import {useCreateClientKey} from 'src/hooks/UseCreateClientKey'; + +export function GenerateEncryptedPassword(props: ConfirmationModalProps) { + const [err, setErr] = useState(); + + const [password, setPassword] = useState(''); + const [step, setStep] = useState(1); + const {createClientKey, clientKey} = useCreateClientKey({ + onError: (error) => { + console.error(error); + setErr(addDisplayError('Error', error)); + }, + onSuccess: () => { + setStep(step + 1); + }, + }); + + const handleModalConfirm = async () => { + createClientKey(password); + }; + + return ( + + {props.buttonText} + , + , + ] + : [ + , + ] + } + > + {step == 1 && ( + <> + + setPassword(value)} + aria-label="text input example" + label="Password" + /> + Please enter your password in order to generate + + )} + {step == 2 && ( + <> + Your encrypted password is:
{clientKey} + + )} +
+ ); +} + +type ConfirmationModalProps = { + title: string; + modalOpen: boolean; + buttonText: string; + toggleModal: () => void; +}; diff --git a/src/components/modals/UserConvertConflictsModal.tsx b/src/components/modals/UserConvertConflictsModal.tsx new file mode 100644 index 00000000..eed9935f --- /dev/null +++ b/src/components/modals/UserConvertConflictsModal.tsx @@ -0,0 +1,142 @@ +import { + Button, + Modal, + ModalVariant, + PageSection, + PageSectionVariants, + TextInput, + Toolbar, + ToolbarContent, + ToolbarItem, +} 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 {IOrganization} from 'src/resources/OrganizationResource'; + +export const UserConvertConflictsModal = ( + props: UserConvertConflictsModal, +): JSX.Element => { + const [itemsMarkedForDelete, setItemsMarkedForDelete] = useState< + IOrganization[] + >(props.items); + + const [searchInput, setSearchInput] = useState(''); + + const [bulkModalPerPage, setBulkModalPerPage] = useState(10); + const [bulkModalPage, setBulkModalPage] = useState(1); + + const paginatedBulkItemsList = itemsMarkedForDelete.slice( + bulkModalPage * bulkModalPerPage - bulkModalPerPage, + bulkModalPage * bulkModalPerPage - bulkModalPerPage + bulkModalPerPage, + ); + + const onSearch = (value: string) => { + setSearchInput(value); + if (value === '') { + setItemsMarkedForDelete(props.items); + } else { + /* Note: This search filter assumes that the search is always based on the 1st column, + hence we do "colNames[0]" */ + const filteredTableRow = props.items.filter((item) => + item.name?.toLowerCase().includes(value.toLowerCase()), + ); + setItemsMarkedForDelete(filteredTableRow); + } + }; + + return ( + + Close + , + ]} + > + + This account cannot be converted into an organization, as it is a member + of another organization. Please leave the following organization(s) + first: + + + + + + + + + + + + + + Organization + Role + + + + {paginatedBulkItemsList.map((item, idx) => ( + + {item.name} + {item.is_org_admin ? 'Admin' : 'User'} + + ))} + + + + + + + + ); +}; + +type UserConvertConflictsModal = { + mapOfColNamesToTableData: { + [key: string]: {label?: string; transformFunc?: (value) => any}; + }; + isModalOpen: boolean; + handleModalToggle?: () => void; + items: IOrganization[]; +}; diff --git a/src/hooks/UseConvertAccount.ts b/src/hooks/UseConvertAccount.ts new file mode 100644 index 00000000..5667ae79 --- /dev/null +++ b/src/hooks/UseConvertAccount.ts @@ -0,0 +1,30 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query'; +import {convert, ConvertUserRequest} from 'src/resources/UserResource'; + +export function useConvertAccount({onSuccess, onError}) { + const queryClient = useQueryClient(); + + const convertAccountMutator = useMutation( + async ({adminUser, adminPassword}: ConvertUserRequest) => { + return convert({adminUser, adminPassword}); + }, + { + onSuccess: () => { + onSuccess(); + queryClient.invalidateQueries(['user']); + queryClient.invalidateQueries(['organization']); + }, + onError: (err) => { + onError(err); + }, + }, + ); + + return { + convert: async (convertUserRequest: ConvertUserRequest) => + convertAccountMutator.mutate(convertUserRequest), + loading: convertAccountMutator.isLoading, + error: convertAccountMutator.error, + clientKey: convertAccountMutator.data, + }; +} diff --git a/src/hooks/UseCreateClientKey.ts b/src/hooks/UseCreateClientKey.ts new file mode 100644 index 00000000..43ce6239 --- /dev/null +++ b/src/hooks/UseCreateClientKey.ts @@ -0,0 +1,30 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query'; +import {createClientKey} from 'src/resources/UserResource'; + +export function useCreateClientKey({onSuccess, onError}) { + const queryClient = useQueryClient(); + + const createClientKeyMutator = useMutation( + async ({password}: {password: string}) => { + return createClientKey(password); + }, + { + onSuccess: () => { + onSuccess(); + queryClient.invalidateQueries(['user']); + queryClient.invalidateQueries(['organization']); + }, + onError: (err) => { + onError(err); + }, + }, + ); + + return { + createClientKey: async (password: string) => + createClientKeyMutator.mutate({password}), + loading: createClientKeyMutator.isLoading, + error: createClientKeyMutator.error, + clientKey: createClientKeyMutator.data, + }; +} diff --git a/src/hooks/UseOrganization.ts b/src/hooks/UseOrganization.ts index 8f1d06c0..57e09f98 100644 --- a/src/hooks/UseOrganization.ts +++ b/src/hooks/UseOrganization.ts @@ -13,6 +13,7 @@ export function useOrganization(name: string) { data: organization, isLoading, error, + isPlaceholderData, } = useQuery(['organization', name], ({signal}) => fetchOrg(name, signal), { enabled: !isUserOrganization, placeholderData: (): IOrganization[] => new Array(10).fill({}), @@ -21,7 +22,7 @@ export function useOrganization(name: string) { return { isUserOrganization, error, - loading: isLoading, + loading: isLoading || isPlaceholderData, organization, }; } diff --git a/src/hooks/UsePlan.ts b/src/hooks/UsePlan.ts new file mode 100644 index 00000000..b6860390 --- /dev/null +++ b/src/hooks/UsePlan.ts @@ -0,0 +1,24 @@ +import {fetchPlan} from 'src/resources/PlanResource'; +import {useQuery} from '@tanstack/react-query'; +import {useOrganization} from './UseOrganization'; + +export function usePlan(name: string) { + // Get usernames + const {isUserOrganization} = useOrganization(name); + + // Get organization plan + const { + data: plan, + isLoading, + error, + isPlaceholderData, + } = useQuery(['organization', name, 'plan'], () => { + return fetchPlan(name, isUserOrganization); + }); + + return { + error, + loading: isLoading || isPlaceholderData, + plan, + }; +} diff --git a/src/hooks/UseUpdateOrganization.ts b/src/hooks/UseUpdateOrganization.ts new file mode 100644 index 00000000..6c6acaba --- /dev/null +++ b/src/hooks/UseUpdateOrganization.ts @@ -0,0 +1,36 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query'; +import {updateOrg, UpdateOrgRequest} from 'src/resources/OrganizationResource'; +export function useUpdateOrganization({onSuccess, onError}) { + const queryClient = useQueryClient(); + + const updateOrganizationMutator = useMutation( + async ({ + name, + updateOrgRequest, + }: { + name: string; + updateOrgRequest: UpdateOrgRequest; + }) => { + return updateOrg(name, updateOrgRequest); + }, + { + onSuccess: () => { + onSuccess(); + queryClient.invalidateQueries(['user']); + queryClient.invalidateQueries(['organization']); + }, + onError: (err) => { + onError(err); + }, + }, + ); + + return { + updateOrganization: async ( + name: string, + updateOrgRequest: UpdateOrgRequest, + ) => updateOrganizationMutator.mutate({name, updateOrgRequest}), + loading: updateOrganizationMutator.isLoading, + error: updateOrganizationMutator.error, + }; +} diff --git a/src/hooks/UseUpdateUser.ts b/src/hooks/UseUpdateUser.ts new file mode 100644 index 00000000..6235625f --- /dev/null +++ b/src/hooks/UseUpdateUser.ts @@ -0,0 +1,35 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query'; +import {UpdateUserRequest, updateUser} from 'src/resources/UserResource'; + +export function useUpdateUser({onSuccess, onError}) { + const queryClient = useQueryClient(); + + const updateUserMutator = useMutation( + async ({ + name, + updateUserRequest, + }: { + name: string; + updateUserRequest: UpdateUserRequest; + }) => { + return updateUser(name, updateUserRequest); + }, + { + onSuccess: () => { + onSuccess(); + queryClient.invalidateQueries(['user']); + queryClient.invalidateQueries(['organization']); + }, + onError: (err) => { + onError(err); + }, + }, + ); + + return { + updateUser: async (name: string, updateUserRequest: UpdateUserRequest) => + updateUserMutator.mutate({name, updateUserRequest}), + loading: updateUserMutator.isLoading, + error: updateUserMutator.error, + }; +} diff --git a/src/resources/OrganizationResource.ts b/src/resources/OrganizationResource.ts index e5a64b7c..0ec60522 100644 --- a/src/resources/OrganizationResource.ts +++ b/src/resources/OrganizationResource.ts @@ -10,6 +10,8 @@ export interface IAvatar { } export interface IOrganization { + invoice_email?: boolean; + invoice_email_address?: string; name: string; avatar?: IAvatar; can_create_repo?: boolean; @@ -17,6 +19,8 @@ export interface IOrganization { is_org_admin?: boolean; preferred_namespace?: boolean; teams?: string[]; + email?: string; + tag_expiration_s?: number; } export async function fetchOrg(orgname: string, signal: AbortSignal) { @@ -108,3 +112,20 @@ export async function createOrg(name: string, email?: string) { assertHttpCode(response.status, 201); return response.data; } + +export interface UpdateOrgRequest { + invoice_email?: boolean; + invoice_email_address?: string; + tag_expiration_s?: string; + email?: string; +} + +export async function updateOrg( + name: string, + updateOrgRequest: UpdateOrgRequest, +) { + const updateOrgUrl = `/api/v1/organization/${name}`; + const response = await axios.put(updateOrgUrl, updateOrgRequest); + assertHttpCode(response.status, 200); + return response.data; +} diff --git a/src/resources/PlanResource.ts b/src/resources/PlanResource.ts new file mode 100644 index 00000000..2586e46f --- /dev/null +++ b/src/resources/PlanResource.ts @@ -0,0 +1,32 @@ +import {AxiosResponse} from 'axios'; +import axios from 'src/libs/axios'; +import {assertHttpCode} from './ErrorHandling'; + +export interface IPlan { + hasSubscription: boolean; + isExistingCustomer: boolean; + plan: string; + usedPrivateRepos: number; +} + +// FIXME we have to mock this for now + +export async function fetchPlan(name: string, isUserOrganization: boolean) { + // let fetchPlanUrl: string; + // if (isUserOrganization) { + // fetchPlanUrl = `/api/v1/user/${name}/plan`; + // } else { + // fetchPlanUrl = `/api/v1/organization/${name}/plan`; + // } + + // // TODO: Add return type + // const response: AxiosResponse = await axios.get(fetchPlanUrl); + // assertHttpCode(response.status, 200); + + return { + hasSubscription: true, + isExistingCustomer: true, + plan: 'free', + usedPrivateRepos: 10, + } as IPlan; +} diff --git a/src/resources/UserResource.ts b/src/resources/UserResource.ts index af080c19..89fbdbdb 100644 --- a/src/resources/UserResource.ts +++ b/src/resources/UserResource.ts @@ -79,3 +79,45 @@ export async function fetchEntities(org: string, search: string) { assertHttpCode(response.status, 200); return response.data?.results; } + +export interface UpdateUserRequest { + invoice_email?: boolean; + family_name?: string; + location?: string; + company?: string; + password?: string; + invoice_email_address?: string; + tag_expiration_s?: string; + email?: string; +} + +export async function updateUser( + name: string, + updateUserRequest: UpdateUserRequest, +) { + const updateUserUrl = `/api/v1/user/`; + const response = await axios.put(updateUserUrl, updateUserRequest); + assertHttpCode(response.status, 200); + return response.data; +} + +export async function createClientKey(password: string): Promise { + const updateUserUrl = `/api/v1/user/clientkey`; + const response = await axios.post(updateUserUrl, {password}); + assertHttpCode(response.status, 200); + return response.data.key; +} + +export interface ConvertUserRequest { + plan?: string; + adminUser: string; + adminPassword: string; +} + +export async function convert( + convertUserRequest: ConvertUserRequest, +): Promise { + const updateUserUrl = `/api/v1/user/convert`; + const response = await axios.post(updateUserUrl, convertUserRequest); + assertHttpCode(response.status, 200); +} diff --git a/src/routes/OrganizationsList/Organization/Tabs/Settings/BillingInformation.tsx b/src/routes/OrganizationsList/Organization/Tabs/Settings/BillingInformation.tsx new file mode 100644 index 00000000..f664ad10 --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/Settings/BillingInformation.tsx @@ -0,0 +1,364 @@ +import {useEffect, useState} from 'react'; +import { + Flex, + FlexItem, + Form, + TextInput, + ActionGroup, + Button, + Title, + Checkbox, + FormAlert, + Alert, + Radio, + FormGroup, + AlertActionLink, + HelperText, +} from '@patternfly/react-core'; +import {useLocation} from 'react-router-dom'; +import {useCurrentUser} from 'src/hooks/UseCurrentUser'; +import {useOrganization} from 'src/hooks/UseOrganization'; +import {useUpdateOrganization} from 'src/hooks/UseUpdateOrganization'; + +import {usePlan} from 'src/hooks/UsePlan'; +import {ExclamationCircleIcon} from '@patternfly/react-icons'; +import {AxiosError} from 'axios'; +import {useUpdateUser} from 'src/hooks/UseUpdateUser'; +import {UserConvertConflictsModal} from 'src/components/modals/UserConvertConflictsModal'; +import {useConvertAccount} from 'src/hooks/UseConvertAccount'; + +export const BillingInformation = () => { + const location = useLocation(); + const organizationName = location.pathname.split('/')[2]; + const {plan} = usePlan(organizationName); + const {user} = useCurrentUser(); + + const [touched, setTouched] = useState(false); + const [invoiceEmail, setInvoiceEmail] = useState(false); + const [invoiceEmailAddress, setInvoiceEmailAddress] = useState(''); + const [convertConflictModalOpen, setConvertConflictModalOpen] = + useState(false); + + const [adminUser, setAdminUser] = useState(''); + const [adminPassword, setAdminPassword] = useState(''); + + type validate = 'success' | 'warning' | 'error' | 'default'; + const [validated, setValidated] = useState('success'); + + const {organization, isUserOrganization, loading} = + useOrganization(organizationName); + + const [accountType, setAccountType] = useState( + isUserOrganization ? 'individual' : 'organization', + ); + useEffect(() => { + setAccountType(isUserOrganization ? 'individual' : 'organization'); + }, [loading]); + + const { + updateOrganization, + loading: organizationUpdateLoading, + error: organizationUpdateError, + } = useUpdateOrganization({ + onSuccess: () => { + setTouched(false); + }, + onError: (err) => { + console.log(err); + }, + }); + + const { + updateUser, + loading: userUpdateLoading, + error: userUpdateError, + } = useUpdateUser({ + onSuccess: () => { + setTouched(false); + }, + onError: (err) => { + console.log(err); + }, + }); + + const { + convert, + loading: convertAccountLoading, + error: convertAccountError, + } = useConvertAccount({ + onSuccess: () => { + setTouched(false); + }, + onError: (err) => { + console.log(err); + }, + }); + + const error = + userUpdateError || organizationUpdateError || convertAccountError; + + const updateLoading = userUpdateLoading || organizationUpdateLoading; + useEffect(() => { + resetFields(); + }, [loading]); + + const resetFields = () => { + if (!loading && organization) { + setInvoiceEmail(organization.invoice_email || false); + setInvoiceEmailAddress(organization.invoice_email_address || ''); + if (organization.invoice_email_address) { + setValidated('success'); + } else { + setValidated('default'); + } + } else if (isUserOrganization) { + setInvoiceEmail(user.invoice_email || false); + setInvoiceEmailAddress(user.invoice_email_address || ''); + if (user.invoice_email_address) { + setValidated('success'); + } else { + setValidated('default'); + } + } + setTouched(false); + }; + + return ( +
+ {error && ( + + + + )} + + + + {plan?.plan.toUpperCase()} organization's plan + + + {`100 of 125 private repositories used`} + {`20 of unlimited public repositories used`} + + + + + + + + { + setTouched(true); + setInvoiceEmail(!invoiceEmail); + }} + /> + + } + helperText="Invoices will be sent to this e-mail address." + > + { + setTouched(true); + setInvoiceEmailAddress(val); + if (/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(val)) { + setValidated('success'); + } else { + setValidated('error'); + } + }} + /> + + + + {isUserOrganization && ( + <> + Account Type + setAccountType('individual')} + description="Single account with multiple repositories" + /> + setAccountType('organization')} + isChecked={accountType == 'organization'} + description="Multiple users and teams that share access and billing under a single namespace" + /> + {user.organizations.length > 0 && accountType == 'organization' && ( + + setConvertConflictModalOpen(true)} + > + View details + + + } + > +

+ This account cannot be converted into an organization, as it is + already a member of one or many organizations. +

+
+ )} + {!user.organizations.length && accountType == 'organization' && ( + <> + + setConvertConflictModalOpen(true)} + > + View details + + + } + > +

+ Fill out the form below to convert your current user account + into an organization. Your existing repositories will be + maintained under the namespace. All direct permissions + delegated to quayusername will be deleted. +

+
+ + Admin User + + The username and password for the account that will become an + administrator of the organization. Note that this account must + be a seperate registered account from the account that you are + trying to convert, and must already exist + + + { + setAdminUser(val); + }} + value={adminUser} + /> + + + { + setAdminPassword(val); + }} + /> + + + + + )} + + )} + + + + + + + + setConvertConflictModalOpen(false)} + mapOfColNamesToTableData={{}} + /> + + ); +}; diff --git a/src/routes/OrganizationsList/Organization/Tabs/Settings/CLIConfiguration.tsx b/src/routes/OrganizationsList/Organization/Tabs/Settings/CLIConfiguration.tsx new file mode 100644 index 00000000..fc276aa2 --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/Settings/CLIConfiguration.tsx @@ -0,0 +1,33 @@ +import {Grid, GridItem, Text, Title} from '@patternfly/react-core'; +import {Button} from '@patternfly/react-core'; +import {GenerateEncryptedPassword} from 'src/components/modals/GenerateEncryptedPasswordModal'; +import {useState} from 'react'; + +export const CliConfiguration = () => { + const [open, toggleOpen] = useState(false); + return ( + + + Docker CLI Password + + + + The Docker CLI stores passwords entered on the command line in + plaintext. It is therefore highly recommended to generate an encrypted + version of your password for use with docker login. + + + + + + toggleOpen(false)} + /> + + ); +}; diff --git a/src/routes/OrganizationsList/Organization/Tabs/Settings/GeneralSettings.tsx b/src/routes/OrganizationsList/Organization/Tabs/Settings/GeneralSettings.tsx new file mode 100644 index 00000000..cac228fa --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/Settings/GeneralSettings.tsx @@ -0,0 +1,280 @@ +import {useEffect, useState} from 'react'; +import { + Flex, + FormGroup, + Form, + TextInput, + FormSelect, + FormSelectOption, + ActionGroup, + Button, + FormAlert, + Alert, + Grid, + GridItem, +} from '@patternfly/react-core'; +import {useLocation} from 'react-router-dom'; +import {useCurrentUser} from 'src/hooks/UseCurrentUser'; +import {useOrganization} from 'src/hooks/UseOrganization'; +import {useUpdateOrganization} from 'src/hooks/UseUpdateOrganization'; +import {useQuayConfig} from 'src/hooks/UseQuayConfig'; +import moment from 'moment'; +import {ExclamationCircleIcon} from '@patternfly/react-icons'; +import {AxiosError} from 'axios'; +import {useUpdateUser} from 'src/hooks/UseUpdateUser'; + +export const GeneralSettings = () => { + const location = useLocation(); + const organizationName = location.pathname.split('/')[2]; + + const {user} = useCurrentUser(); + const {config} = useQuayConfig(); + + const tagExpirationOptions = config.TAG_EXPIRATION_OPTIONS.map((option) => { + const number = option.substring(0, option.length - 1); + const suffix = option.substring(option.length - 1); + return moment.duration(number, suffix).asSeconds(); + }); + + type validate = 'success' | 'warning' | 'error' | 'default'; + const [validated, setValidated] = useState('success'); + + const {organization, isUserOrganization, loading} = + useOrganization(organizationName); + + const { + updateOrganization, + loading: organizationUpdateLoading, + error: organizationUpdateError, + } = useUpdateOrganization({ + onSuccess: () => { + setTouched(false); + }, + onError: (err) => { + console.log(err); + }, + }); + + const { + updateUser, + loading: userUpdateLoading, + error: userUpdateError, + } = useUpdateUser({ + onSuccess: () => { + setTouched(false); + }, + onError: (err) => { + console.log(err); + }, + }); + + const error = userUpdateError || organizationUpdateError; + + const updateLoading = userUpdateLoading || organizationUpdateLoading; + + // Time Machine + const timeMachineOptions = tagExpirationOptions; + const [timeMachineFormValue, setTimeMachineFormValue] = useState( + timeMachineOptions[0], + ); + + const [touched, setTouched] = useState(false); + + // Email + const [emailFormValue, setEmailFormValue] = useState(''); + const [fullName, setFullName] = useState(''); + const [userLocation, setUserLocation] = useState(''); + const [company, setCompany] = useState(''); + + useEffect(() => { + resetFields(); + }, [loading, isUserOrganization]); + + const resetFields = () => { + if (!loading && organization) { + setEmailFormValue(organization.email || ''); + setTimeMachineFormValue(organization.tag_expiration_s || 0); + } else if (isUserOrganization) { + setEmailFormValue(user.email || ''); + setTimeMachineFormValue(user.tag_expiration_s || 0); + setFullName(user.family_name || ''); + setCompany(user.company || ''); + setUserLocation(user.location || ''); + } + setTouched(false); + }; + + return ( +
+ {error && touched && ( + + + + )} + + + {!isUserOrganization && ( + + + + + + )} + + + } + > + { + setTouched(true); + setEmailFormValue(val); + if (/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(val)) { + setValidated('success'); + } else { + setValidated('error'); + } + }} + /> + + + + {isUserOrganization && ( + <> + + + { + setTouched(true); + setFullName(val); + }} + /> + + + + + { + setTouched(true); + setUserLocation(val); + }} + /> + + + + + { + setTouched(true); + setCompany(val); + }} + /> + + + + )} + + + + { + setTouched(true); + setTimeMachineFormValue(parseInt(val)); + }} + > + {timeMachineOptions.map((option, index) => ( + + ))} + + + + + + + + + + + +
+ ); +}; diff --git a/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx b/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx index 2e5cbb78..71d2d071 100644 --- a/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx +++ b/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx @@ -1,114 +1,16 @@ -import {useEffect, useState} from 'react'; -import { - Tabs, - Tab, - TabTitleText, - Flex, - FlexItem, - FormGroup, - Form, - TextInput, - FormSelect, - FormSelectOption, - ActionGroup, - Button, -} from '@patternfly/react-core'; +import {useState} from 'react'; +import {Tabs, Tab, TabTitleText, Flex, FlexItem} from '@patternfly/react-core'; +import {GeneralSettings} from './GeneralSettings'; +import {BillingInformation} from './BillingInformation'; import {useLocation} from 'react-router-dom'; -import {useCurrentUser} from 'src/hooks/UseCurrentUser'; import {useOrganization} from 'src/hooks/UseOrganization'; - -const GeneralSettings = () => { - const location = useLocation(); - const organizationName = location.pathname.split('/')[2]; - - const {user} = useCurrentUser(); - const {organization, isUserOrganization, loading} = - useOrganization(organizationName); - - // Time Machine - const timeMachineOptions = ['1 week', '1 month', '1 year', 'Never']; - const [timeMachineFormValue, setTimeMachineFormValue] = useState( - timeMachineOptions[0], - ); - - // Email - const [emailFormValue, setEmailFormValue] = useState(''); - useEffect(() => { - if (!loading && organization) { - setEmailFormValue((organization as any).email); - } else if (isUserOrganization) { - setEmailFormValue(user.email); - } - }, [loading, isUserOrganization]); - - return ( -
- - - - - - setEmailFormValue(val)} - /> - - - - setTimeMachineFormValue(val)} - > - {timeMachineOptions.map((option, index) => ( - - ))} - - - - - - - - - -
- ); -}; - -const BillingInformation = () => { - return

Hello

; -}; +import {CliConfiguration} from './CLIConfiguration'; export default function Settings() { const [activeTabIndex, setActiveTabIndex] = useState(0); + const location = useLocation(); + const organizationName = location.pathname.split('/')[2]; + const {isUserOrganization} = useOrganization(organizationName); const handleTabClick = (event, tabIndex) => { setActiveTabIndex(tabIndex); @@ -127,6 +29,14 @@ export default function Settings() { }, ]; + if (isUserOrganization) { + tabs.push({ + name: 'CLI configuration', + id: 'cliconfig', + content: , + }); + } + return ( @@ -141,14 +51,16 @@ export default function Settings() { {tab.name}} + title={{tab.name}} /> ))} {tabs.at(activeTabIndex).content} From a08ecbbd40e6f5ab2422086b5960fb6b851b9788 Mon Sep 17 00:00:00 2001 From: Jonathan King Date: Tue, 13 Dec 2022 10:43:15 -0800 Subject: [PATCH 15/16] fix superusers returning undefined username --- src/hooks/UseOrganizations.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/hooks/UseOrganizations.ts b/src/hooks/UseOrganizations.ts index f9fbcde9..785a3700 100644 --- a/src/hooks/UseOrganizations.ts +++ b/src/hooks/UseOrganizations.ts @@ -24,7 +24,7 @@ export function useOrganizations() { // Get super user orgs const {data: superUserOrganizations} = useQuery( - ['organization', 'superuser'], + ['organization', 'superuser', 'organizations'], fetchOrgsAsSuperUser, { enabled: isSuperUser, @@ -33,7 +33,7 @@ export function useOrganizations() { // Get super user users const {data: superUserUsers} = useQuery( - ['organization', 'superuser'], + ['organization', 'superuser', 'users'], fetchUsersAsSuperUser, { enabled: isSuperUser, @@ -50,7 +50,9 @@ export function useOrganizations() { // Get user names let usernames: string[]; if (isSuperUser) { - usernames = (superUserUsers || []).map((user) => user.username); + usernames = (superUserUsers || []) + .map((user) => user.username) + .filter((x) => x); } else { usernames = [user.username]; } From de1635f04235ab3bee8392bb0819f2b4df20b9f9 Mon Sep 17 00:00:00 2001 From: Jonathan King Date: Wed, 14 Dec 2022 20:19:21 -0800 Subject: [PATCH 16/16] fix keys --- src/hooks/UseOrganizations.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/UseOrganizations.ts b/src/hooks/UseOrganizations.ts index f9fbcde9..b40e5e6f 100644 --- a/src/hooks/UseOrganizations.ts +++ b/src/hooks/UseOrganizations.ts @@ -24,7 +24,7 @@ export function useOrganizations() { // Get super user orgs const {data: superUserOrganizations} = useQuery( - ['organization', 'superuser'], + ['organization', 'superuser', 'superuserorgs'], fetchOrgsAsSuperUser, { enabled: isSuperUser, @@ -33,7 +33,7 @@ export function useOrganizations() { // Get super user users const {data: superUserUsers} = useQuery( - ['organization', 'superuser'], + ['organization', 'superuser', 'superuserorgs'], fetchUsersAsSuperUser, { enabled: isSuperUser,