From e7ec9fecc71b5056d91da0f3c32172c3ddb5fa5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Sun, 29 Dec 2024 19:55:38 +0100 Subject: [PATCH] feat: improve overall ux --- apps/frontend/src/containers/Apollo.tsx | 36 +--- .../src/containers/GithubRepositoryList.tsx | 130 ++++++------ .../src/containers/GitlabProjectList.tsx | 85 ++++---- .../frontend/src/containers/PaymentBanner.tsx | 6 +- .../containers/Project/ConnectRepository.tsx | 8 +- .../Contributors/AddProjectContributor.tsx | 4 +- .../Contributors/ProjectContributorsList.tsx | 4 +- .../Contributors/ProjectTeamMembersList.tsx | 4 +- .../src/containers/Project/Transfer.tsx | 6 +- apps/frontend/src/containers/ProjectList.tsx | 72 +++++-- .../containers/Team/GitHubSSO/Configure.tsx | 7 +- apps/frontend/src/containers/Team/Members.tsx | 6 +- apps/frontend/src/containers/Team/NewForm.tsx | 6 +- .../src/containers/Team/SubscribeDialog.tsx | 4 +- apps/frontend/src/pages/Account/Analytics.tsx | 54 ++--- .../frontend/src/pages/Account/NewProject.tsx | 95 +++++---- apps/frontend/src/pages/Account/Projects.tsx | 48 +++-- apps/frontend/src/pages/Account/Settings.tsx | 199 ++++++++++-------- .../src/pages/Build/BuildDiffState.tsx | 7 +- apps/frontend/src/pages/Build/BuildPage.tsx | 8 +- apps/frontend/src/pages/Invite.tsx | 4 +- apps/frontend/src/pages/NewTeam.tsx | 33 +-- apps/frontend/src/pages/NotFound.tsx | 33 +-- apps/frontend/src/pages/Project/Builds.tsx | 97 +++++---- apps/frontend/src/pages/Project/Settings.tsx | 110 ++++++---- apps/frontend/src/ui/Charts.tsx | 2 +- apps/frontend/src/ui/Container.tsx | 14 +- apps/frontend/src/ui/Layout.tsx | 106 ++++++++++ apps/frontend/src/ui/Menu.tsx | 2 +- apps/frontend/src/ui/PageLoader.tsx | 8 +- apps/frontend/src/ui/Radio.tsx | 2 +- apps/frontend/src/ui/Typography.tsx | 26 --- apps/frontend/tailwind.config.js | 2 +- 33 files changed, 694 insertions(+), 534 deletions(-) create mode 100644 apps/frontend/src/ui/Layout.tsx delete mode 100644 apps/frontend/src/ui/Typography.tsx diff --git a/apps/frontend/src/containers/Apollo.tsx b/apps/frontend/src/containers/Apollo.tsx index a24d581f4..a51b1ffd1 100644 --- a/apps/frontend/src/containers/Apollo.tsx +++ b/apps/frontend/src/containers/Apollo.tsx @@ -10,7 +10,7 @@ import { QueryHookOptions, QueryResult, TypedDocumentNode, - useQuery as useApolloQuery, + useQuery, } from "@apollo/client"; import { onError } from "@apollo/client/link/error"; import { RetryLink } from "@apollo/client/link/retry"; @@ -78,44 +78,16 @@ export const ApolloInitializer = (props: { children: React.ReactNode }) => { ); }; -export function useQuery< +export function useSafeQuery< TData = any, TVariables extends OperationVariables = OperationVariables, >( query: DocumentNode | TypedDocumentNode, options?: QueryHookOptions, -): QueryResult { - const { loading, error, data, ...others } = useApolloQuery(query, options); +): Omit, "error"> { + const { loading, error, data, ...others } = useQuery(query, options); if (error) { throw error; } return { loading, data, ...others }; } - -export function Query< - TData = any, - TVariables extends OperationVariables = OperationVariables, ->({ - fallback = null, - children, - query, - ...options -}: { - children: ( - data: NonNullable["data"]>, - ) => React.ReactElement | null; - fallback?: React.ReactElement | null; - query: DocumentNode | TypedDocumentNode; - variables?: TVariables; - skip?: boolean; -}): React.ReactElement | null { - const { data, previousData } = useQuery(query, options); - - const dataOrPreviousData = data || previousData; - - if (!dataOrPreviousData) { - return fallback; - } - - return children(dataOrPreviousData); -} diff --git a/apps/frontend/src/containers/GithubRepositoryList.tsx b/apps/frontend/src/containers/GithubRepositoryList.tsx index 93c883095..eb1d23703 100644 --- a/apps/frontend/src/containers/GithubRepositoryList.tsx +++ b/apps/frontend/src/containers/GithubRepositoryList.tsx @@ -15,7 +15,7 @@ import { } from "@/ui/Pagination"; import { Time } from "@/ui/Time"; -import { Query } from "./Apollo"; +import { useSafeQuery } from "./Apollo"; import { getGitHubAppInstallURL } from "./GitHub"; const InstallationQuery = graphql(` @@ -145,72 +145,70 @@ export function GithubRepositoryList(props: { const reposPerPage = 100; const [page, setPage] = useState(1); - return ( - } - query={InstallationQuery} - variables={{ - installationId: props.installationId, - page, - reposPerPage, - fromAuthUser: props.app === "main", - }} - > - {({ ghApiInstallationRepositories }) => { - const pageCount = Math.ceil( - ghApiInstallationRepositories.pageInfo.totalCount / reposPerPage, - ); + const result = useSafeQuery(InstallationQuery, { + variables: { + installationId: props.installationId, + page, + reposPerPage, + fromAuthUser: props.app === "main", + }, + }); + + const data = result.data || result.previousData; - return ( - <> - - {ghApiInstallationRepositories.edges.map((repo) => ( - -
- {repo.name} •{" "} -
- -
- ))} - {page === pageCount && ( - -
- Repository not in the list?{" "} - - Manage repositories - -
-
- )} -
+ if (!data) { + return ; + } + + const { ghApiInstallationRepositories } = data; - {pageCount > 1 && ( - - )} - - ); - }} -
+ const pageCount = Math.ceil( + ghApiInstallationRepositories.pageInfo.totalCount / reposPerPage, + ); + + return ( + <> + + {ghApiInstallationRepositories.edges.map((repo) => ( + +
+ {repo.name} •
+ +
+ ))} + {page === pageCount && ( + +
+ Repository not in the list?{" "} + + Manage repositories + +
+
+ )} +
+ + {pageCount > 1 && ( + + )} + ); } diff --git a/apps/frontend/src/containers/GitlabProjectList.tsx b/apps/frontend/src/containers/GitlabProjectList.tsx index 8bd4278b1..159790e38 100644 --- a/apps/frontend/src/containers/GitlabProjectList.tsx +++ b/apps/frontend/src/containers/GitlabProjectList.tsx @@ -4,7 +4,7 @@ import { List, ListRow } from "@/ui/List"; import { Loader } from "@/ui/Loader"; import { Time } from "@/ui/Time"; -import { Query } from "./Apollo"; +import { useSafeQuery } from "./Apollo"; const ProjectsQuery = graphql(` query GitlabProjectList_glApiProjects( @@ -49,49 +49,46 @@ export type GitlabProjectListProps = { ); export function GitlabProjectList(props: GitlabProjectListProps) { + const result = useSafeQuery(ProjectsQuery, { + variables: { + accountId: props.accountId, + userId: props.userId, + groupId: props.groupId, + allProjects: props.allProjects, + search: props.search, + page: 1, + }, + }); + + const data = result.data || result.previousData; + + if (!data) { + return ; + } + + const { glApiProjects } = data; + + if (glApiProjects.edges.length === 0) { + return
No projects in this namespace
; + } return ( - } - query={ProjectsQuery} - variables={{ - accountId: props.accountId, - userId: props.userId, - groupId: props.groupId, - allProjects: props.allProjects, - search: props.search, - page: 1, - }} - > - {({ glApiProjects }) => { - if (glApiProjects.edges.length === 0) { - return ( -
No projects in this namespace
- ); - } - return ( - - {glApiProjects.edges.map((project) => ( - -
- {project.name} •{" "} -
- -
- ))} -
- ); - }} -
+ + {glApiProjects.edges.map((project) => ( + +
+ {project.name} •{" "} +
+ +
+ ))} +
); } diff --git a/apps/frontend/src/containers/PaymentBanner.tsx b/apps/frontend/src/containers/PaymentBanner.tsx index cad806375..3db67e596 100644 --- a/apps/frontend/src/containers/PaymentBanner.tsx +++ b/apps/frontend/src/containers/PaymentBanner.tsx @@ -1,7 +1,7 @@ import { memo } from "react"; +import { useSuspenseQuery } from "@apollo/client"; import { invariant } from "@argos/util/invariant"; -import { useQuery } from "@/containers/Apollo"; import { TeamSubscribeDialog } from "@/containers/Team/SubscribeDialog"; import { FragmentType, graphql, useFragment } from "@/gql"; import { AccountPermission, AccountSubscriptionStatus } from "@/gql/graphql"; @@ -88,12 +88,12 @@ function BannerTemplate(props: { export const PaymentBanner = memo( (props: { account: FragmentType }) => { const account = useFragment(PaymentBannerFragment, props.account); - const { data: { me } = {} } = useQuery(PaymentBannerQuery); + const { data } = useSuspenseQuery(PaymentBannerQuery); const { subscription, subscriptionStatus, permissions, stripeCustomerId } = account; - if (!me) { + if (!data.me) { return null; } diff --git a/apps/frontend/src/containers/Project/ConnectRepository.tsx b/apps/frontend/src/containers/Project/ConnectRepository.tsx index 6b2905d86..cc9750e74 100644 --- a/apps/frontend/src/containers/Project/ConnectRepository.tsx +++ b/apps/frontend/src/containers/Project/ConnectRepository.tsx @@ -1,5 +1,4 @@ import { useCallback, useState } from "react"; -import { useQuery } from "@apollo/client"; import { invariant } from "@apollo/client/utilities/globals"; import { assertNever } from "@argos/util/assertNever"; import { MarkGithubIcon } from "@primer/octicons-react"; @@ -23,6 +22,7 @@ import { Link } from "@/ui/Link"; import { PageLoader } from "@/ui/PageLoader"; import { TextInput } from "@/ui/TextInput"; +import { useSafeQuery } from "../Apollo"; import { getMainGitHubAppInstallURL, GitHubLoginButton } from "../GitHub"; import { GitLabLogo } from "../GitLab"; import { @@ -287,16 +287,12 @@ export function ConnectRepository(props: ConnectRepositoryProps) { } }, []); - const result = useQuery(ConnectRepositoryQuery, { + const result = useSafeQuery(ConnectRepositoryQuery, { variables: { accountSlug: props.accountSlug, }, }); - if (result.error) { - throw result.error; - } - if (!result.data) { return ( diff --git a/apps/frontend/src/containers/Project/Contributors/AddProjectContributor.tsx b/apps/frontend/src/containers/Project/Contributors/AddProjectContributor.tsx index 3b74b728a..55ebf0bb4 100644 --- a/apps/frontend/src/containers/Project/Contributors/AddProjectContributor.tsx +++ b/apps/frontend/src/containers/Project/Contributors/AddProjectContributor.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { invariant } from "@argos/util/invariant"; import { useDebounce } from "use-debounce"; -import { useQuery } from "@/containers/Apollo"; +import { useSafeQuery } from "@/containers/Apollo"; import { UserListRow } from "@/containers/UserList"; import { FragmentType, graphql, useFragment } from "@/gql"; import { Button } from "@/ui/Button"; @@ -94,7 +94,7 @@ function TeamContributorsList(props: { const [search, setSearch] = useState(""); const [debouncedSearch] = useDebounce(search, 300); const searchInputRef = useRef(null); - const result = useQuery(TeamContributorsQuery, { + const result = useSafeQuery(TeamContributorsQuery, { variables: { projectId: props.projectId, teamAccountId: props.teamAccountId, diff --git a/apps/frontend/src/containers/Project/Contributors/ProjectContributorsList.tsx b/apps/frontend/src/containers/Project/Contributors/ProjectContributorsList.tsx index 42829c7ab..16bd56f73 100644 --- a/apps/frontend/src/containers/Project/Contributors/ProjectContributorsList.tsx +++ b/apps/frontend/src/containers/Project/Contributors/ProjectContributorsList.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { invariant } from "@argos/util/invariant"; -import { useQuery } from "@/containers/Apollo"; +import { useSafeQuery } from "@/containers/Apollo"; import { useAssertAuthTokenPayload } from "@/containers/Auth"; import { ProjectContributorLevelLabel } from "@/containers/ProjectContributor"; import { RemoveMenu, UserListRow } from "@/containers/UserList"; @@ -60,7 +60,7 @@ export function ProjectContributorsList(props: { } }, }; - const result = useQuery(ProjectContributorsQuery, { + const result = useSafeQuery(ProjectContributorsQuery, { variables: { projectId: props.projectId, after: 0, diff --git a/apps/frontend/src/containers/Project/Contributors/ProjectTeamMembersList.tsx b/apps/frontend/src/containers/Project/Contributors/ProjectTeamMembersList.tsx index 1374e51d0..3dc1da1a7 100644 --- a/apps/frontend/src/containers/Project/Contributors/ProjectTeamMembersList.tsx +++ b/apps/frontend/src/containers/Project/Contributors/ProjectTeamMembersList.tsx @@ -1,6 +1,6 @@ import { invariant } from "@argos/util/invariant"; -import { useQuery } from "@/containers/Apollo"; +import { useSafeQuery } from "@/containers/Apollo"; import { ProjectContributorLevelLabel } from "@/containers/ProjectContributor"; import { TeamMemberLabel, UserListRow } from "@/containers/UserList"; import { graphql } from "@/gql"; @@ -41,7 +41,7 @@ export function ProjectTeamMembersList(props: { projectId: string; teamAccountId: string; }) { - const result = useQuery(TeamMembersQuery, { + const result = useSafeQuery(TeamMembersQuery, { variables: { teamAccountId: props.teamAccountId, after: 0, diff --git a/apps/frontend/src/containers/Project/Transfer.tsx b/apps/frontend/src/containers/Project/Transfer.tsx index ff1cb432c..efc68bdf8 100644 --- a/apps/frontend/src/containers/Project/Transfer.tsx +++ b/apps/frontend/src/containers/Project/Transfer.tsx @@ -30,7 +30,7 @@ import { Modal } from "@/ui/Modal"; import { AccountAvatar } from "../AccountAvatar"; import { AccountSelector } from "../AccountSelector"; -import { useQuery } from "../Apollo"; +import { useSafeQuery } from "../Apollo"; type TransferDialogButtonProps = { project: FragmentType; @@ -57,7 +57,7 @@ const MeQuery = graphql(` `); const SelectAccountStep = (props: SelectAccountStepProps) => { - const { data } = useQuery(MeQuery); + const { data } = useSafeQuery(MeQuery); return ( <> @@ -183,7 +183,7 @@ type ReviewInputs = { }; const ReviewStep = (props: ReviewStepProps) => { - const { data } = useQuery(ReviewQuery, { + const { data } = useSafeQuery(ReviewQuery, { variables: { projectId: props.projectId, actualAccountId: props.actualAccountId, diff --git a/apps/frontend/src/containers/ProjectList.tsx b/apps/frontend/src/containers/ProjectList.tsx index 5372654f9..9c11cf58c 100644 --- a/apps/frontend/src/containers/ProjectList.tsx +++ b/apps/frontend/src/containers/ProjectList.tsx @@ -1,8 +1,18 @@ -import { PlusCircleIcon } from "lucide-react"; +import { FolderIcon, PlusCircleIcon } from "lucide-react"; +import { Heading, Text } from "react-aria-components"; import { AccountAvatar } from "@/containers/AccountAvatar"; import { DocumentType, FragmentType, graphql, useFragment } from "@/gql"; import { ButtonIcon, LinkButton, LinkButtonProps } from "@/ui/Button"; +import { + EmptyState, + EmptyStateActions, + EmptyStateIcon, + PageContainer, + PageHeader, + PageHeaderActions, + PageHeaderContent, +} from "@/ui/Layout"; import { HeadlessLink } from "@/ui/Link"; import { Time } from "@/ui/Time"; @@ -94,35 +104,59 @@ export function ProjectList(props: { const projects = useFragment(ProjectFragment, props.projects); if (projects.length === 0) { + if (props.canCreateProject) { + return ( + + + + + Create your first project + + Start by creating your first Argos project. + + + + + + ); + } + return ( -
-
- There's no projects yet. -
+ + + + + No projects + You haven't created any project yet. {props.canCreateProject && ( - <> -
- Start by creating a new project. -
+ - + )} -
+ ); } return ( -
- {props.canCreateProject && ( -
- -
- )} -
+ + + + Projects + + View all the projects associated with this account. + + + {props.canCreateProject && ( + + + + )} + +
{projects.map((project) => ( ))}
-
+ ); } diff --git a/apps/frontend/src/containers/Team/GitHubSSO/Configure.tsx b/apps/frontend/src/containers/Team/GitHubSSO/Configure.tsx index b399dfaa8..828bff815 100644 --- a/apps/frontend/src/containers/Team/GitHubSSO/Configure.tsx +++ b/apps/frontend/src/containers/Team/GitHubSSO/Configure.tsx @@ -9,7 +9,7 @@ import { } from "react-hook-form"; import { GITHUB_SSO_PRICING } from "@/constants"; -import { useQuery } from "@/containers/Apollo"; +import { useSafeQuery } from "@/containers/Apollo"; import { GithubInstallationsSelect } from "@/containers/GithubInstallationsSelect"; import { graphql } from "@/gql"; import { Button } from "@/ui/Button"; @@ -64,12 +64,9 @@ const query = graphql(` `); function GitHubInstallationsSelectControl(props: { teamAccountId: string }) { - const { data, error } = useQuery(query, { + const { data } = useSafeQuery(query, { variables: { teamAccountId: props.teamAccountId }, }); - if (error) { - throw error; - } const installations = (() => { if (!data) { return []; diff --git a/apps/frontend/src/containers/Team/Members.tsx b/apps/frontend/src/containers/Team/Members.tsx index 5bbe44688..08dca9191 100644 --- a/apps/frontend/src/containers/Team/Members.tsx +++ b/apps/frontend/src/containers/Team/Members.tsx @@ -4,7 +4,7 @@ import { invariant } from "@argos/util/invariant"; import { MarkGithubIcon } from "@primer/octicons-react"; import { useNavigate } from "react-router-dom"; -import { useQuery } from "@/containers/Apollo"; +import { useSafeQuery } from "@/containers/Apollo"; import { DocumentType, FragmentType, graphql, useFragment } from "@/gql"; import { AccountPermission, TeamUserLevel } from "@/gql/graphql"; import { Button } from "@/ui/Button"; @@ -405,7 +405,7 @@ function TeamMembersList(props: { hasFineGrainedAccessControl: boolean; }) { const authPayload = useAssertAuthTokenPayload(); - const { data, fetchMore } = useQuery(TeamMembersQuery, { + const { data, fetchMore } = useSafeQuery(TeamMembersQuery, { variables: { id: props.teamId, after: 0, @@ -533,7 +533,7 @@ function TeamGithubMembersList(props: { props.githubAccount, ); const authPayload = useAssertAuthTokenPayload(); - const { data, fetchMore } = useQuery(TeamGithubMembersQuery, { + const { data, fetchMore } = useSafeQuery(TeamGithubMembersQuery, { variables: { id: props.teamId, after: 0, diff --git a/apps/frontend/src/containers/Team/NewForm.tsx b/apps/frontend/src/containers/Team/NewForm.tsx index 4b38177d2..e47304f5e 100644 --- a/apps/frontend/src/containers/Team/NewForm.tsx +++ b/apps/frontend/src/containers/Team/NewForm.tsx @@ -1,4 +1,4 @@ -import { useApolloClient, useQuery } from "@apollo/client"; +import { useApolloClient } from "@apollo/client"; import { invariant } from "@argos/util/invariant"; import { clsx } from "clsx"; import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; @@ -10,6 +10,8 @@ import { FormSubmit } from "@/ui/FormSubmit"; import { FormTextInput } from "@/ui/FormTextInput"; import { useEventCallback } from "@/ui/useEventCallback"; +import { useSafeQuery } from "../Apollo"; + const CreateTeamMutation = graphql(` mutation NewTeam_createTeam($name: String!) { createTeam(input: { name: $name }) { @@ -60,7 +62,7 @@ export const TeamNewForm = (props: { }) => { const createTeamAndRedirect = useCreateTeamAndRedirect(); - const { data } = useQuery(MeQuery); + const { data } = useSafeQuery(MeQuery); const form = useForm({ defaultValues: { name: props.defaultTeamName ?? "", diff --git a/apps/frontend/src/containers/Team/SubscribeDialog.tsx b/apps/frontend/src/containers/Team/SubscribeDialog.tsx index e26cc4b59..2fae98035 100644 --- a/apps/frontend/src/containers/Team/SubscribeDialog.tsx +++ b/apps/frontend/src/containers/Team/SubscribeDialog.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { AccountSelector } from "@/containers/AccountSelector"; -import { useQuery } from "@/containers/Apollo"; +import { useSafeQuery } from "@/containers/Apollo"; import { graphql } from "@/gql"; import { AccountSubscriptionStatus } from "@/gql/graphql"; import { Button, ButtonProps } from "@/ui/Button"; @@ -43,7 +43,7 @@ export function TeamSubscribeDialog({ children?: React.ReactNode; variant?: ButtonProps["variant"]; }) { - const { data } = useQuery(MeQuery); + const { data } = useSafeQuery(MeQuery); const [accountId, setAccountId] = useState(initialAccountId); const hasSubscribedToTrial = Boolean(data?.me?.hasSubscribedToTrial); const teams = data?.me ? data.me.teams : null; diff --git a/apps/frontend/src/pages/Account/Analytics.tsx b/apps/frontend/src/pages/Account/Analytics.tsx index aea2cd002..519306d43 100644 --- a/apps/frontend/src/pages/Account/Analytics.tsx +++ b/apps/frontend/src/pages/Account/Analytics.tsx @@ -1,10 +1,11 @@ import { Suspense, useCallback, useEffect, useMemo } from "react"; -import { useQuery } from "@apollo/client"; +import { useSuspenseQuery } from "@apollo/client"; import { invariant } from "@argos/util/invariant"; import NumberFlow from "@number-flow/react"; import clsx from "clsx"; import { FileDownIcon } from "lucide-react"; import moment from "moment"; +import { Heading, Text } from "react-aria-components"; import { Helmet } from "react-helmet"; import { Navigate, useParams, useSearchParams } from "react-router-dom"; import { @@ -29,14 +30,19 @@ import { ChartTooltipContent, getChartColorFromIndex, } from "@/ui/Charts"; -import { Container } from "@/ui/Container"; import { IconButton } from "@/ui/IconButton"; +import { + Page, + PageContainer, + PageHeader, + PageHeaderActions, + PageHeaderContent, +} from "@/ui/Layout"; import { ListBox, ListBoxItem, ListBoxItemLabel } from "@/ui/ListBox"; -import { Loader } from "@/ui/Loader"; +import { PageLoader } from "@/ui/PageLoader"; import { Popover } from "@/ui/Popover"; import { Select, SelectButton } from "@/ui/Select"; import { Tooltip } from "@/ui/Tooltip"; -import { Heading } from "@/ui/Typography"; const AccountQuery = graphql(` query AccountUsage_account( @@ -110,28 +116,28 @@ export function Component() { }, [setPeriod, period]); return ( -
- -
-
- - Analytics - -

+ + + {accountSlug} • Analytics + + + + + Analytics + Track builds and screenshots to monitor your visual testing activity at a glance. -

-
- -
- - {accountSlug} • Analytics - - }> + + + + + + + }> -
-
+ + ); } @@ -139,7 +145,7 @@ function Charts(props: { accountSlug: string; period: Period }) { const { accountSlug, period } = props; const { from, to, groupBy } = Periods[period]; - const { data } = useQuery(AccountQuery, { + const { data } = useSuspenseQuery(AccountQuery, { variables: { slug: accountSlug, from: from.toISOString(), @@ -147,7 +153,7 @@ function Charts(props: { accountSlug: string; period: Period }) { }, }); - const metrics = data?.account?.metrics; + const metrics = data.account?.metrics; const screenshotByBuildSeries: Metric | null = useMemo(() => { if (!metrics) { diff --git a/apps/frontend/src/pages/Account/NewProject.tsx b/apps/frontend/src/pages/Account/NewProject.tsx index 089d36916..38769bdeb 100644 --- a/apps/frontend/src/pages/Account/NewProject.tsx +++ b/apps/frontend/src/pages/Account/NewProject.tsx @@ -1,12 +1,17 @@ import { useMutation } from "@apollo/client"; +import { Heading, Text } from "react-aria-components"; import { Helmet } from "react-helmet"; import { useNavigate, useParams } from "react-router-dom"; import { ConnectRepository } from "@/containers/Project/ConnectRepository"; import { graphql } from "@/gql"; -import { Container } from "@/ui/Container"; import { getGraphQLErrorMessage } from "@/ui/Form"; -import { Heading, Headline } from "@/ui/Typography"; +import { + Page, + PageContainer, + PageHeader, + PageHeaderContent, +} from "@/ui/Layout"; const ImportGithubProjectMutation = graphql(` mutation NewProject_importGithubProject( @@ -96,50 +101,52 @@ export function Component() { } return ( - <> + New Project -
- - Create a new Project - - To add visual testing a new Project, import an existing Git - Repository. - -
- { - importGithubProject({ - variables: { - repo: repo.name, - owner: repo.owner_login, - accountSlug: accountSlug, - app, - }, - }).catch((error) => { - // TODO: Show error in UI - alert(getGraphQLErrorMessage(error)); - }); - }} - onSelectProject={(glProject) => { - importGitLabProject({ - variables: { - gitlabProjectId: glProject.id, - accountSlug: accountSlug, - }, - }).catch((error) => { - // TODO: Show error in UI - alert(getGraphQLErrorMessage(error)); - }); - }} - /> -
-
-
- + + + + Create a new Project + + To add visual testing a new project, import an existing Git + repository. + + + +
+ { + importGithubProject({ + variables: { + repo: repo.name, + owner: repo.owner_login, + accountSlug: accountSlug, + app, + }, + }).catch((error) => { + // TODO: Show error in UI + alert(getGraphQLErrorMessage(error)); + }); + }} + onSelectProject={(glProject) => { + importGitLabProject({ + variables: { + gitlabProjectId: glProject.id, + accountSlug: accountSlug, + }, + }).catch((error) => { + // TODO: Show error in UI + alert(getGraphQLErrorMessage(error)); + }); + }} + /> +
+
+
); } diff --git a/apps/frontend/src/pages/Account/Projects.tsx b/apps/frontend/src/pages/Account/Projects.tsx index afcff50c6..2d2188306 100644 --- a/apps/frontend/src/pages/Account/Projects.tsx +++ b/apps/frontend/src/pages/Account/Projects.tsx @@ -1,3 +1,4 @@ +import { Suspense } from "react"; import { useSuspenseQuery } from "@apollo/client"; import { invariant } from "@argos/util/invariant"; import { Helmet } from "react-helmet"; @@ -7,7 +8,8 @@ import { CheckoutStatusDialog } from "@/containers/CheckoutStatusDialog"; import { ProjectList } from "@/containers/ProjectList"; import { graphql } from "@/gql"; import { AccountPermission } from "@/gql/graphql"; -import { Container } from "@/ui/Container"; +import { Page } from "@/ui/Layout"; +import { PageLoader } from "@/ui/PageLoader"; import { NotFound } from "../NotFound"; @@ -31,29 +33,35 @@ export function Component() { const { accountSlug } = useParams(); invariant(accountSlug); + return ( + + + {accountSlug} • Projects + + }> + + + + + ); +} + +function Projects(props: { accountSlug: string }) { const { data } = useSuspenseQuery(AccountQuery, { - variables: { slug: accountSlug }, + variables: { slug: props.accountSlug }, fetchPolicy: "cache-and-network", }); + if (!data.account) { + return ; + } + return ( -
- - - {accountSlug} • Projects - - {data.account ? ( - - ) : ( - - )} - - -
+ ); } diff --git a/apps/frontend/src/pages/Account/Settings.tsx b/apps/frontend/src/pages/Account/Settings.tsx index 842505df3..37892e247 100644 --- a/apps/frontend/src/pages/Account/Settings.tsx +++ b/apps/frontend/src/pages/Account/Settings.tsx @@ -1,10 +1,12 @@ +import { Suspense } from "react"; +import { useSuspenseQuery } from "@apollo/client"; +import { Heading, Text } from "react-aria-components"; import { Helmet } from "react-helmet"; import { useParams } from "react-router-dom"; import { AccountChangeName } from "@/containers/Account/ChangeName"; import { AccountChangeSlug } from "@/containers/Account/ChangeSlug"; import { AccountGitLab } from "@/containers/Account/GitLab"; -import { Query } from "@/containers/Apollo"; import { useAuthTokenPayload } from "@/containers/Auth"; import { SettingsLayout } from "@/containers/Layout"; import { PlanCard } from "@/containers/PlanCard"; @@ -18,9 +20,13 @@ import { UserAuth } from "@/containers/User/Auth"; import { graphql } from "@/gql"; import { AccountPermission } from "@/gql/graphql"; import { NotFound } from "@/pages/NotFound"; -import { Container } from "@/ui/Container"; +import { + Page, + PageContainer, + PageHeader, + PageHeaderContent, +} from "@/ui/Layout"; import { PageLoader } from "@/ui/PageLoader"; -import { Heading } from "@/ui/Typography"; import { useAccountContext } from "."; @@ -54,7 +60,6 @@ const AccountQuery = graphql(` /** @route */ export function Component() { const { accountSlug } = useParams(); - const { permissions } = useAccountContext(); const authPayload = useAuthTokenPayload(); const userSlug = authPayload?.account.slug; @@ -62,96 +67,108 @@ export function Component() { return ; } - const hasAdminPermission = permissions.includes(AccountPermission.Admin); + const title = + userSlug === accountSlug ? "Personal Settings" : "Team Settings"; return ( -
- - - {accountSlug} • Settings - - - {userSlug === accountSlug ? "Personal" : "Team"} Settings - - } - query={AccountQuery} - variables={{ slug: accountSlug }} + + + {`${accountSlug} • ${title}`} + + + + + {title} + + Manage your {userSlug === accountSlug ? "personal" : "team"}{" "} + settings and preferences. + + + + + + + } > - {({ account }) => { - if (!account) { - return ; - } - const isTeam = account.__typename === "Team"; - const isUser = account.__typename === "User"; - const fineGrainedAccessControlIncluded = Boolean( - isTeam && account.plan?.fineGrainedAccessControlIncluded, - ); + + + + + ); +} - return ( - - {hasAdminPermission && - (() => { - switch (account.__typename) { - case "User": - return ( - <> - - - - ); - case "Team": - return ( - <> - - - - ); - } - return null; - })()} - {isUser && hasAdminPermission && } - {hasAdminPermission && } - {isTeam && } - {isTeam && hasAdminPermission && ( - - )} - {isTeam && - hasAdminPermission && - fineGrainedAccessControlIncluded && ( - - )} - {isTeam && } - {isTeam && hasAdminPermission && ( - - )} - {hasAdminPermission && } - {isTeam && hasAdminPermission && } - - ); - }} - - -
+function PageContent(props: { accountSlug: string }) { + const { permissions } = useAccountContext(); + const { + data: { account }, + } = useSuspenseQuery(AccountQuery, { + variables: { slug: props.accountSlug }, + }); + + if (!account) { + return ; + } + + const hasAdminPermission = permissions.includes(AccountPermission.Admin); + const isTeam = account.__typename === "Team"; + const isUser = account.__typename === "User"; + const fineGrainedAccessControlIncluded = Boolean( + isTeam && account.plan?.fineGrainedAccessControlIncluded, + ); + + return ( + + {hasAdminPermission && + (() => { + switch (account.__typename) { + case "User": + return ( + <> + + + + ); + case "Team": + return ( + <> + + + + ); + } + return null; + })()} + {isUser && hasAdminPermission && } + {hasAdminPermission && } + {isTeam && } + {isTeam && hasAdminPermission && } + {isTeam && hasAdminPermission && fineGrainedAccessControlIncluded && ( + + )} + {isTeam && } + {isTeam && hasAdminPermission && } + {hasAdminPermission && } + {isTeam && hasAdminPermission && } + ); } diff --git a/apps/frontend/src/pages/Build/BuildDiffState.tsx b/apps/frontend/src/pages/Build/BuildDiffState.tsx index fce09b180..3b5566900 100644 --- a/apps/frontend/src/pages/Build/BuildDiffState.tsx +++ b/apps/frontend/src/pages/Build/BuildDiffState.tsx @@ -10,12 +10,12 @@ import { useRef, useState, } from "react"; -import { useQuery } from "@apollo/client"; import { invariant } from "@argos/util/invariant"; import { ResultOf } from "@graphql-typed-document-node/core"; import { MatchData, Searcher } from "fast-fuzzy"; import { useNavigate } from "react-router-dom"; +import { useSafeQuery } from "@/containers/Apollo"; import { DocumentType, FragmentType, graphql, useFragment } from "@/gql"; import { ScreenshotDiffStatus } from "@/gql/graphql"; import { useEventCallback } from "@/ui/useEventCallback"; @@ -335,7 +335,7 @@ function useDataState({ projectName: string; buildNumber: number; }) { - const { data, loading, error, fetchMore } = useQuery(ProjectQuery, { + const { data, loading, fetchMore } = useSafeQuery(ProjectQuery, { variables: { accountSlug, projectName, @@ -344,9 +344,6 @@ function useDataState({ first: 20, }, }); - if (error) { - throw error; - } useEffect(() => { if ( !loading && diff --git a/apps/frontend/src/pages/Build/BuildPage.tsx b/apps/frontend/src/pages/Build/BuildPage.tsx index b24fddc9d..bb00af8dd 100644 --- a/apps/frontend/src/pages/Build/BuildPage.tsx +++ b/apps/frontend/src/pages/Build/BuildPage.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; -import { useQuery } from "@apollo/client"; +import { useSafeQuery } from "@/containers/Apollo"; import { PaymentBanner } from "@/containers/PaymentBanner"; import { graphql } from "@/gql"; @@ -46,7 +46,7 @@ const ProjectQuery = graphql(` `); export const BuildPage = ({ params }: { params: BuildParams }) => { - const { data, error, refetch } = useQuery(ProjectQuery, { + const { data, refetch } = useSafeQuery(ProjectQuery, { variables: { accountSlug: params.accountSlug, projectName: params.projectName, @@ -73,10 +73,6 @@ export const BuildPage = ({ params }: { params: BuildParams }) => { return undefined; }, [buildStatusProgress, refetch]); - if (error) { - throw error; - } - if (data && !data.project?.build) { return ; } diff --git a/apps/frontend/src/pages/Invite.tsx b/apps/frontend/src/pages/Invite.tsx index b10f30161..52bd7f9b1 100644 --- a/apps/frontend/src/pages/Invite.tsx +++ b/apps/frontend/src/pages/Invite.tsx @@ -4,7 +4,7 @@ import { Helmet } from "react-helmet"; import { useNavigate, useParams } from "react-router-dom"; import { AccountAvatar } from "@/containers/AccountAvatar"; -import { useQuery } from "@/containers/Apollo"; +import { useSafeQuery } from "@/containers/Apollo"; import { useIsLoggedIn } from "@/containers/Auth"; import { LoginButtons } from "@/containers/LoginButtons"; import { graphql } from "@/gql"; @@ -75,7 +75,7 @@ export function Component() { const params = useParams(); const token = params.inviteToken; invariant(token, "no invite token"); - const { data } = useQuery(InvitationQuery, { + const { data } = useSafeQuery(InvitationQuery, { variables: { token, }, diff --git a/apps/frontend/src/pages/NewTeam.tsx b/apps/frontend/src/pages/NewTeam.tsx index 46f3a2dc9..1731c821b 100644 --- a/apps/frontend/src/pages/NewTeam.tsx +++ b/apps/frontend/src/pages/NewTeam.tsx @@ -1,4 +1,5 @@ import { useEffect } from "react"; +import { Heading, Text } from "react-aria-components"; import { Helmet } from "react-helmet"; import { useSearchParams } from "react-router-dom"; @@ -7,9 +8,13 @@ import { TeamNewForm, useCreateTeamAndRedirect, } from "@/containers/Team/NewForm"; -import { Container } from "@/ui/Container"; +import { + Page, + PageContainer, + PageHeader, + PageHeaderContent, +} from "@/ui/Layout"; import { PageLoader } from "@/ui/PageLoader"; -import { Heading, Headline } from "@/ui/Typography"; const AutoCreateTeam = ({ name }: { name: string }) => { const createTeamAndRedirect = useCreateTeamAndRedirect(); @@ -34,26 +39,30 @@ export function Component() { } return ( - <> -
+ New Team +
{() => { return ( - - Create a Team - - A team alllows you to collaborate on one or several projects. - -
+ + + + Create a Team + + Create a team to collaborate on one or several projects. + + + +
- +
); }} - + ); } diff --git a/apps/frontend/src/pages/NotFound.tsx b/apps/frontend/src/pages/NotFound.tsx index 9ed759f39..41d7de948 100644 --- a/apps/frontend/src/pages/NotFound.tsx +++ b/apps/frontend/src/pages/NotFound.tsx @@ -1,22 +1,31 @@ +import { CircleXIcon } from "lucide-react"; +import { Heading, Text } from "react-aria-components"; import { Helmet } from "react-helmet"; -import { Alert, AlertActions, AlertText, AlertTitle } from "@/ui/Alert"; import { LinkButton } from "@/ui/Button"; -import { Container } from "@/ui/Container"; +import { + EmptyState, + EmptyStateActions, + EmptyStateIcon, + Page, +} from "@/ui/Layout"; -export const NotFound = () => { +export function NotFound() { return ( - + Page not found - - Page not found - There is nothing to see here. - + + + + + Page not found + There is nothing to see here. + Back to home - - - + + + ); -}; +} diff --git a/apps/frontend/src/pages/Project/Builds.tsx b/apps/frontend/src/pages/Project/Builds.tsx index 660b04c17..e132ad833 100644 --- a/apps/frontend/src/pages/Project/Builds.tsx +++ b/apps/frontend/src/pages/Project/Builds.tsx @@ -1,19 +1,29 @@ import { memo, useCallback, useEffect, useRef } from "react"; -import { useQuery } from "@apollo/client"; import { GitBranchIcon, GitCommitIcon } from "@primer/octicons-react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { clsx } from "clsx"; +import { BoxesIcon } from "lucide-react"; +import { Heading, Text } from "react-aria-components"; import { Helmet } from "react-helmet"; import { useParams, useResolvedPath } from "react-router-dom"; +import { useSafeQuery } from "@/containers/Apollo"; import { BuildModeIndicator } from "@/containers/BuildModeIndicator"; import { BuildStatusChip } from "@/containers/BuildStatusChip"; import { PullRequestButton } from "@/containers/PullRequestButton"; import { DocumentType, graphql } from "@/gql"; import { ProjectPermission } from "@/gql/graphql"; -import { Alert, AlertActions, AlertText, AlertTitle } from "@/ui/Alert"; import { LinkButton } from "@/ui/Button"; -import { Container } from "@/ui/Container"; +import { + EmptyState, + EmptyStateActions, + EmptyStateIcon, + Page, + PageContainer, + PageHeader, + PageHeaderActions, + PageHeaderContent, +} from "@/ui/Layout"; import { List, ListRowLink, ListRowLoader } from "@/ui/List"; import { PageLoader } from "@/ui/PageLoader"; import { Time } from "@/ui/Time"; @@ -288,18 +298,14 @@ const PageContent = (props: { accountSlug: string; projectName: string }) => { const { permissions } = useProjectContext(); const hasReviewerPermission = permissions.includes(ProjectPermission.Review); const [buildName, setBuildName] = useBuildNameFilter(); - const projectResult = useQuery(ProjectQuery, { + const projectResult = useSafeQuery(ProjectQuery, { variables: { accountSlug: props.accountSlug, projectName: props.projectName, }, }); - if (projectResult.error) { - throw projectResult.error; - } - - const buildsResult = useQuery(ProjectBuildsQuery, { + const buildsResult = useSafeQuery(ProjectBuildsQuery, { variables: { accountSlug: props.accountSlug, projectName: props.projectName, @@ -313,10 +319,6 @@ const PageContent = (props: { accountSlug: string; projectName: string }) => { const buildResultRef = useRef(buildsResult); buildResultRef.current = buildsResult; - if (buildsResult.error) { - throw buildsResult.error; - } - const fetchNextPage = useCallback(() => { const displayCount = buildResultRef.current.data?.project?.builds.edges.length; @@ -347,11 +349,7 @@ const PageContent = (props: { accountSlug: string; projectName: string }) => { !(projectResult.data || projectResult.previousData) || !(buildsResult.data || buildsResult.previousData) ) { - return ( - - - - ); + return ; } const project = @@ -361,44 +359,55 @@ const PageContent = (props: { accountSlug: string; projectName: string }) => { buildsResult.previousData?.project?.builds; if (!project || !builds) { - return ( - - - - ); + return ; } if (builds.pageInfo.totalCount === 0) { if (hasReviewerPermission) { return ( - + - + ); } else { return ( - - - No build - There is no build yet on this project. - + + + + + + No build + + There is no build yet on this project. + + Back to home - - - + + + ); } } return ( - - {project.buildNames.length > 1 && ( - - )} + + + + Builds + + View all the builds associated with this project. + + + {project.buildNames.length > 1 && ( + + + + )} +
{ fetching={buildsResult.loading} />
-
+ ); }; @@ -420,13 +429,13 @@ export function Component() { } return ( -
+ {accountSlug}/{projectName} • Builds -
+ ); } diff --git a/apps/frontend/src/pages/Project/Settings.tsx b/apps/frontend/src/pages/Project/Settings.tsx index 0d1117b14..6331960f8 100644 --- a/apps/frontend/src/pages/Project/Settings.tsx +++ b/apps/frontend/src/pages/Project/Settings.tsx @@ -1,7 +1,9 @@ +import { Suspense } from "react"; +import { useSuspenseQuery } from "@apollo/client"; +import { Heading, Text } from "react-aria-components"; import { Helmet } from "react-helmet"; import { useParams } from "react-router-dom"; -import { Query } from "@/containers/Apollo"; import { SettingsLayout } from "@/containers/Layout"; import { ProjectBadge } from "@/containers/Project/Badge"; import { ProjectBranches } from "@/containers/Project/Branches"; @@ -16,9 +18,13 @@ import { ProjectVisibility } from "@/containers/Project/Visibility"; import { graphql } from "@/gql"; import { ProjectPermission } from "@/gql/graphql"; import { NotFound } from "@/pages/NotFound"; -import { Container } from "@/ui/Container"; +import { + Page, + PageContainer, + PageHeader, + PageHeaderContent, +} from "@/ui/Layout"; import { PageLoader } from "@/ui/PageLoader"; -import { Heading } from "@/ui/Typography"; import { useProjectContext } from "."; @@ -67,53 +73,73 @@ export function Component() { return ; } - const hasAdminPermission = permissions.includes(ProjectPermission.Admin); - const hasReviewPermission = permissions.includes(ProjectPermission.Review); - return ( - + {accountSlug}/{projectName} • Settings - Project Settings - } - query={ProjectQuery} - variables={{ - accountSlug, - projectName, - }} - > - {({ project, account }) => { - if (!project || !account) { - return ; + + + + Project Settings + + Configure the settings for this project. + + + + + + } + > + + + + + ); +} - const isTeam = account.__typename === "Team"; - const fineGrainedAccessControlIncluded = Boolean( - isTeam && account.plan?.fineGrainedAccessControlIncluded, - ); +function PageContent(props: { accountSlug: string; projectName: string }) { + const { permissions } = useProjectContext(); + const { + data: { account, project }, + } = useSuspenseQuery(ProjectQuery, { + variables: { + accountSlug: props.accountSlug, + projectName: props.projectName, + }, + }); - return ( - - {hasAdminPermission && } - {hasReviewPermission && } - {hasAdminPermission && } - {hasAdminPermission && } - {hasAdminPermission && } - - {hasAdminPermission && } - {fineGrainedAccessControlIncluded && ( - - )} - {hasAdminPermission && } - {hasAdminPermission && } - - ); - }} - - + if (!project || !account) { + return ; + } + + const hasAdminPermission = permissions.includes(ProjectPermission.Admin); + const hasReviewPermission = permissions.includes(ProjectPermission.Review); + + const isTeam = account.__typename === "Team"; + const fineGrainedAccessControlIncluded = Boolean( + isTeam && account.plan?.fineGrainedAccessControlIncluded, + ); + + return ( + + {hasAdminPermission && } + {hasReviewPermission && } + {hasAdminPermission && } + {hasAdminPermission && } + {hasAdminPermission && } + + {hasAdminPermission && } + {fineGrainedAccessControlIncluded && ( + + )} + {hasAdminPermission && } + {hasAdminPermission && } + ); } diff --git a/apps/frontend/src/ui/Charts.tsx b/apps/frontend/src/ui/Charts.tsx index 2196b9e21..d48d990f5 100644 --- a/apps/frontend/src/ui/Charts.tsx +++ b/apps/frontend/src/ui/Charts.tsx @@ -184,7 +184,7 @@ function ChartTooltipContent({
diff --git a/apps/frontend/src/ui/Container.tsx b/apps/frontend/src/ui/Container.tsx index c391f62b5..83194accd 100644 --- a/apps/frontend/src/ui/Container.tsx +++ b/apps/frontend/src/ui/Container.tsx @@ -1,11 +1,11 @@ -import { HTMLProps } from "react"; +import { ComponentPropsWithRef } from "react"; import { clsx } from "clsx"; -export const Container = ({ - className, - ...props -}: HTMLProps) => { +export function Container(props: ComponentPropsWithRef<"div">) { return ( -
+
); -}; +} diff --git a/apps/frontend/src/ui/Layout.tsx b/apps/frontend/src/ui/Layout.tsx new file mode 100644 index 000000000..e225b672b --- /dev/null +++ b/apps/frontend/src/ui/Layout.tsx @@ -0,0 +1,106 @@ +import { ComponentPropsWithRef } from "react"; +import clsx from "clsx"; +import { HeadingContext, Provider, TextContext } from "react-aria-components"; + +import { Container } from "./Container"; + +export function PageHeader(props: ComponentPropsWithRef<"div">) { + return ( + +
+ + ); +} + +export function PageHeaderContent(props: ComponentPropsWithRef<"div">) { + return
; +} + +export function PageHeaderActions(props: ComponentPropsWithRef<"div">) { + return
; +} + +export function PageContainer(props: ComponentPropsWithRef<"div">) { + return ( + + ); +} + +export function EmptyState(props: ComponentPropsWithRef<"div">) { + return ( + + + + ); +} + +export function EmptyStateIcon(props: ComponentPropsWithRef<"div">) { + return ( +
+
+
+
+
+
+ {props.children} +
+
+ ); +} + +export function EmptyStateActions(props: ComponentPropsWithRef<"div">) { + return ( +
+ ); +} + +export function Page(props: ComponentPropsWithRef<"div">) { + return ( +
+ ); +} diff --git a/apps/frontend/src/ui/Menu.tsx b/apps/frontend/src/ui/Menu.tsx index 27362bb7b..75d7bd4a4 100644 --- a/apps/frontend/src/ui/Menu.tsx +++ b/apps/frontend/src/ui/Menu.tsx @@ -136,7 +136,7 @@ export function UpDownMenuButton({ return (