Skip to content
260 changes: 97 additions & 163 deletions packages/web/app/src/components/layouts/organization.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { FunctionComponentElement, ReactElement, ReactNode } from 'react';
import { FunctionComponentElement } from 'react';
import { BlocksIcon, BoxIcon, FoldVerticalIcon } from 'lucide-react';
import { useForm, UseFormReturn } from 'react-hook-form';
import { useMutation, useQuery } from 'urql';
import { useMutation } from 'urql';
import { z } from 'zod';
import { NotFoundContent } from '@/components/common/not-found-content';
import { PrimaryNavigation } from '@/components/navigation/primary-navigation';
import { SecondaryNavigation } from '@/components/navigation/secondary-navigation';
import { Button } from '@/components/ui/button';
import {
Dialog,
Expand All @@ -23,24 +25,21 @@ import {
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Spinner } from '@/components/ui/spinner';
import { useToast } from '@/components/ui/use-toast';
import { UserMenu } from '@/components/ui/user-menu';
import { graphql, useFragment } from '@/gql';
import { AuthProviderType, ProjectType } from '@/gql/graphql';
import { ProjectType } from '@/gql/graphql';
import { getIsStripeEnabled } from '@/lib/billing/stripe-public-key';
import { useToggle } from '@/lib/hooks';
import { useLastVisitedOrganizationWriter } from '@/lib/last-visited-org';
import { cn } from '@/lib/utils';
import { organizationLayoutRoute } from '@/router';
import { zodResolver } from '@hookform/resolvers/zod';
import { Slot } from '@radix-ui/react-slot';
import { Link, useRouter } from '@tanstack/react-router';
import { Outlet, useMatches, useRouter } from '@tanstack/react-router';
import { ProPlanBilling } from '../organization/billing/ProPlanBillingWarm';
import { RateLimitWarn } from '../organization/billing/RateLimitWarn';
import { HiveLink } from '../ui/hive-link';
import { PlusIcon } from '../ui/icon';
import { QueryError } from '../ui/query-error';
import { Tabs, TabsList, TabsTrigger } from '../ui/tabs';
import { OrganizationSelector } from './organization-selectors';

export enum Page {
Overview = 'overview',
Expand All @@ -50,181 +49,114 @@ export enum Page {
Subscription = 'subscription',
}

const OrganizationLayout_OrganizationFragment = graphql(`
fragment OrganizationLayout_OrganizationFragment on Organization {
id
slug
viewerCanCreateProject
viewerCanManageSupportTickets
viewerCanDescribeBilling
viewerCanSeeMembers
...ProPlanBilling_OrganizationFragment
...RateLimitWarn_OrganizationFragment
}
`);

const OrganizationLayoutQuery = graphql(`
query OrganizationLayoutQuery($organizationSlug: String!) {
export const OrganizationLayoutDataFragment = graphql(`
fragment OrganizationLayoutDataFragment on Query {
me {
id
provider
...UserMenu_MeFragment
...PrimaryNavigation_MeFragment
}
organizationBySlug(organizationSlug: $organizationSlug) {
id
}
organizations {
...OrganizationSelector_OrganizationConnectionFragment
...UserMenu_OrganizationConnectionFragment
...PrimaryNavigation_OrganizationConnectionFragment
nodes {
...OrganizationLayout_OrganizationFragment
id
slug
viewerCanCreateProject
viewerCanManageSupportTickets
viewerCanDescribeBilling
viewerCanSeeMembers
...ProPlanBilling_OrganizationFragment
...RateLimitWarn_OrganizationFragment
}
}
}
`);

export function OrganizationLayout({
children,
page,
className,
...props
}: {
page?: Page;
className?: string;
organizationSlug: string;
children: ReactNode;
}): ReactElement | null {
export function OrganizationLayout() {
const [isModalOpen, toggleModalOpen] = useToggle();
const [query] = useQuery({
query: OrganizationLayoutQuery,
variables: {
organizationSlug: props.organizationSlug,
},
requestPolicy: 'cache-first',
});

const organizationExists = query.data?.organizationBySlug;
const { organizationSlug } = organizationLayoutRoute.useParams();

const matches = useMatches();

const organizations = useFragment(
OrganizationLayout_OrganizationFragment,
query.data?.organizations.nodes,
const matchesWithData = matches.filter(m => m.status !== 'pending');
const activeChildMatch = matchesWithData[matchesWithData.length - 1];
const layoutFragmentRef = activeChildMatch?.loaderData || null;

const layoutData = useFragment(OrganizationLayoutDataFragment, layoutFragmentRef);

const currentOrganization = layoutData?.organizations.nodes.find(
org => org.slug === organizationSlug,
);
const currentOrganization = organizations?.find(org => org.slug === props.organizationSlug);

useLastVisitedOrganizationWriter(currentOrganization?.slug);

if (query.error) {
return <QueryError error={query.error} organizationSlug={props.organizationSlug} />;
}

// Only show the null state state if the query has finished fetching and data is not stale
// This prevents showing null state when switching between orgs with cached data
const shouldShowNoOrg = !query.fetching && !query.stale && !organizationExists;
// If we have layoutData, we've loaded
const shouldShowNoOrg = layoutData && !layoutData.organizationBySlug;

return (
<>
<header>
<div className="container flex h-[--header-height] items-center justify-between">
<div className="flex flex-row items-center gap-4">
<HiveLink className="size-8" />
<OrganizationSelector
isOIDCUser={query.data?.me.provider === AuthProviderType.Oidc}
currentOrganizationSlug={props.organizationSlug}
organizations={query.data?.organizations ?? null}
/>
</div>
<div>
<UserMenu
me={query.data?.me ?? null}
currentOrganizationSlug={props.organizationSlug}
organizations={query.data?.organizations ?? null}
/>
</div>
</div>
</header>
<div className="relative h-[--tabs-navbar-height] border-b border-gray-800">
<div className="container flex items-center justify-between">
{currentOrganization ? (
<Tabs value={page} className="min-w-[600px]">
<TabsList variant="menu">
<TabsTrigger variant="menu" value={Page.Overview} asChild>
<Link
to="/$organizationSlug"
params={{ organizationSlug: currentOrganization.slug }}
>
Overview
</Link>
</TabsTrigger>
{currentOrganization.viewerCanSeeMembers && (
<TabsTrigger variant="menu" value={Page.Members} asChild>
<Link
to="/$organizationSlug/view/members"
params={{ organizationSlug: currentOrganization.slug }}
search={{ page: 'list' }}
>
Members
</Link>
</TabsTrigger>
)}
<TabsTrigger variant="menu" value={Page.Settings} asChild>
<Link
to="/$organizationSlug/view/settings"
params={{ organizationSlug: currentOrganization.slug }}
>
Settings
</Link>
</TabsTrigger>
{currentOrganization.viewerCanManageSupportTickets && (
<TabsTrigger variant="menu" value={Page.Support} asChild>
<Link
to="/$organizationSlug/view/support"
params={{ organizationSlug: currentOrganization.slug }}
>
Support
</Link>
</TabsTrigger>
)}
{getIsStripeEnabled() && currentOrganization.viewerCanDescribeBilling && (
<TabsTrigger variant="menu" value={Page.Subscription} asChild>
<Link
to="/$organizationSlug/view/subscription"
params={{ organizationSlug: currentOrganization.slug }}
>
Subscription
</Link>
</TabsTrigger>
)}
</TabsList>
</Tabs>
) : (
<div className="flex flex-row gap-x-8 border-b-2 border-b-transparent px-4 py-3">
<div className="h-5 w-12 animate-pulse rounded-full bg-gray-800" />
<div className="h-5 w-12 animate-pulse rounded-full bg-gray-800" />
<div className="h-5 w-12 animate-pulse rounded-full bg-gray-800" />
</div>
)}
{currentOrganization?.viewerCanCreateProject ? (
<>
<Button
onClick={toggleModalOpen}
variant="link"
className="text-orange-500"
data-cy="new-project-button"
>
<PlusIcon size={16} className="mr-2" />
New project
</Button>
<CreateProjectModal
organizationSlug={props.organizationSlug}
isOpen={isModalOpen}
toggleModalOpen={toggleModalOpen}
// reset the form every time it is closed
key={String(isModalOpen)}
/>
</>
) : null}
</div>
</div>
<PrimaryNavigation
me={layoutData?.me ?? null}
organizations={layoutData?.organizations ?? null}
/>
<SecondaryNavigation
displayCondition={!!currentOrganization}
actions={[
{
displayCondition: currentOrganization?.viewerCanCreateProject,
actionItem: (
<>
<Button
onClick={toggleModalOpen}
variant="link"
className="text-orange-500"
data-cy="new-project-button"
>
<PlusIcon size={16} className="mr-2" />
New project
</Button>
<CreateProjectModal
organizationSlug={organizationSlug!}
isOpen={isModalOpen}
toggleModalOpen={toggleModalOpen}
// reset the form every time it is closed
key={String(isModalOpen)}
/>
</>
),
},
]}
items={[
{
activeOptions: { exact: true, includeSearch: false },
title: 'Overview',
to: '/$organizationSlug',
},
{
displayCondition: currentOrganization?.viewerCanSeeMembers,
title: 'Members',
to: '/$organizationSlug/view/members',
},
{
title: 'Settings',
to: '/$organizationSlug/view/settings',
},
{
displayCondition: currentOrganization?.viewerCanManageSupportTickets,
title: 'Support',
to: '/$organizationSlug/view/support',
},
{
displayCondition: getIsStripeEnabled() && currentOrganization?.viewerCanDescribeBilling,
title: 'Subscription',
to: '/$organizationSlug/view/subscription',
},
]}
params={{ organizationSlug: currentOrganization?.slug }}
/>

<div className="container min-h-[var(--content-height)] pb-7">
{currentOrganization ? (
<>
Expand All @@ -239,8 +171,10 @@ export function OrganizationLayout({
subheading="Use the empty dropdown in the header to select an organization to which you have access."
includeBackButton={false}
/>
) : !layoutData ? (
<Spinner className="m-auto mt-6" />
) : (
<div className={className}>{children}</div>
<Outlet />
)}
</div>
</>
Expand Down
Loading
Loading