diff --git a/packages/web/app/src/components/layouts/organization.tsx b/packages/web/app/src/components/layouts/organization.tsx
index 593fc50a80c..f1de48426f1 100644
--- a/packages/web/app/src/components/layouts/organization.tsx
+++ b/packages/web/app/src/components/layouts/organization.tsx
@@ -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,
@@ -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',
@@ -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 ;
- }
-
- // 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 (
<>
-
-
-
- {currentOrganization ? (
-
-
-
-
- Overview
-
-
- {currentOrganization.viewerCanSeeMembers && (
-
-
- Members
-
-
- )}
-
-
- Settings
-
-
- {currentOrganization.viewerCanManageSupportTickets && (
-
-
- Support
-
-
- )}
- {getIsStripeEnabled() && currentOrganization.viewerCanDescribeBilling && (
-
-
- Subscription
-
-
- )}
-
-
- ) : (
-
- )}
- {currentOrganization?.viewerCanCreateProject ? (
- <>
-
-
- New project
-
-
- >
- ) : null}
-
-
+
+
+
+
+ New project
+
+
+ >
+ ),
+ },
+ ]}
+ 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 }}
+ />
+
{currentOrganization ? (
<>
@@ -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 ? (
+
) : (
-
{children}
+
)}
>
diff --git a/packages/web/app/src/components/navigation/entity-selector.tsx b/packages/web/app/src/components/navigation/entity-selector.tsx
new file mode 100644
index 00000000000..a3527701527
--- /dev/null
+++ b/packages/web/app/src/components/navigation/entity-selector.tsx
@@ -0,0 +1,77 @@
+import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
+import { cn } from '@/lib/utils';
+import { Link } from '@tanstack/react-router';
+
+export function LoadingSkeleton({ className }: { className?: string }) {
+ return
;
+}
+
+export function BreadcrumbSeparator() {
+ return /
;
+}
+
+interface EntitySelectorProps {
+ // Current state
+ currentSlug: string;
+ currentItem: T | null | undefined;
+
+ // Available items
+ items: T[];
+
+ // Behavior
+ onNavigate: (slug: string) => void;
+
+ // Rendering mode
+ mode: 'link' | 'select';
+
+ // Link-specific props
+ linkTo?: string;
+ linkParams?: Record;
+
+ // Testing
+ dataCyPrefix: string;
+}
+
+export function EntitySelector({
+ currentSlug,
+ currentItem,
+ items,
+ onNavigate,
+ mode,
+ linkTo,
+ linkParams,
+ dataCyPrefix,
+}: EntitySelectorProps) {
+ if (mode === 'link') {
+ return (
+
+ {currentSlug}
+
+ );
+ }
+
+ return (
+
+
+
+ {currentItem?.slug}
+
+
+
+ {items.map(item => (
+
+ {item.slug}
+
+ ))}
+
+
+ );
+}
diff --git a/packages/web/app/src/components/navigation/hierarchical-selector.tsx b/packages/web/app/src/components/navigation/hierarchical-selector.tsx
new file mode 100644
index 00000000000..cbc630d43d2
--- /dev/null
+++ b/packages/web/app/src/components/navigation/hierarchical-selector.tsx
@@ -0,0 +1,153 @@
+import { FragmentType, graphql, useFragment } from '@/gql';
+import { useRouter } from '@tanstack/react-router';
+import { BreadcrumbSeparator, EntitySelector, LoadingSkeleton } from './entity-selector';
+
+const HierarchicalSelector_OrganizationConnectionFragment = graphql(`
+ fragment HierarchicalSelector_OrganizationConnectionFragment on OrganizationConnection {
+ nodes {
+ id
+ slug
+ projects {
+ edges {
+ node {
+ id
+ slug
+ targets {
+ edges {
+ node {
+ id
+ slug
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+`);
+
+interface HierarchicalSelectorProps {
+ // Required
+ currentOrganizationSlug: string;
+ organizations: FragmentType | null;
+
+ // Optional - component detects and renders based on presence
+ currentProjectSlug?: string;
+ currentTargetSlug?: string;
+
+ // Special case
+ isOIDCUser?: boolean;
+}
+
+export function HierarchicalSelector(props: HierarchicalSelectorProps) {
+ const router = useRouter();
+ const organizations = useFragment(
+ HierarchicalSelector_OrganizationConnectionFragment,
+ props.organizations,
+ )?.nodes;
+
+ const currentOrg = organizations?.find(n => n.slug === props.currentOrganizationSlug);
+ const projectEdges = currentOrg?.projects?.edges;
+ const currentProject = projectEdges?.find(e => e.node.slug === props.currentProjectSlug)?.node;
+ const targetEdges = currentProject?.targets?.edges;
+ const currentTarget = targetEdges?.find(e => e.node.slug === props.currentTargetSlug)?.node;
+
+ // Determine which level we're on to know what should be a select vs link
+ const hasProject = !!props.currentProjectSlug;
+ const hasTarget = !!props.currentTargetSlug;
+
+ return (
+ <>
+ {/* Level 1: Organization */}
+ {!organizations ? (
+
+ ) : hasProject || hasTarget || props.isOIDCUser ? (
+ // Show as link when on project/target route, or when OIDC user
+ {}}
+ dataCyPrefix="organization"
+ />
+ ) : (
+ // Show as select only when on organization route and not OIDC
+ {
+ void router.navigate({ to: '/$organizationSlug', params: { organizationSlug: slug } });
+ }}
+ dataCyPrefix="organization"
+ />
+ )}
+
+ {/* Level 2: Project (only if projectSlug provided) */}
+ {props.currentProjectSlug && (
+ <>
+
+ {!currentOrg ? (
+
+ ) : projectEdges?.length && currentProject ? (
+ e.node)}
+ onNavigate={slug => {
+ void router.navigate({
+ to: '/$organizationSlug/$projectSlug',
+ params: {
+ organizationSlug: props.currentOrganizationSlug,
+ projectSlug: slug,
+ },
+ });
+ }}
+ dataCyPrefix="project"
+ />
+ ) : (
+
+ )}
+ >
+ )}
+
+ {/* Level 3: Target (only if targetSlug provided) */}
+ {props.currentTargetSlug && (
+ <>
+
+ {!currentProject ? (
+
+ ) : targetEdges?.length && currentTarget ? (
+ e.node)}
+ onNavigate={slug => {
+ void router.navigate({ params: { targetSlug: slug } });
+ }}
+ dataCyPrefix="target"
+ />
+ ) : (
+
+ )}
+ >
+ )}
+ >
+ );
+}
diff --git a/packages/web/app/src/components/navigation/primary-navigation.tsx b/packages/web/app/src/components/navigation/primary-navigation.tsx
new file mode 100644
index 00000000000..96978223fef
--- /dev/null
+++ b/packages/web/app/src/components/navigation/primary-navigation.tsx
@@ -0,0 +1,64 @@
+import { HierarchicalSelector } from '@/components/navigation/hierarchical-selector';
+import { HiveLink } from '@/components/ui/hive-link';
+import { UserMenu } from '@/components/ui/user-menu';
+import { FragmentType, graphql, useFragment } from '@/gql';
+import { AuthProviderType } from '@/gql/graphql';
+import { useParams } from '@tanstack/react-router';
+
+export const PrimaryNavigation_MeFragment = graphql(`
+ fragment PrimaryNavigation_MeFragment on User {
+ id
+ provider
+ ...UserMenu_MeFragment
+ }
+`);
+
+export const PrimaryNavigation_OrganizationConnectionFragment = graphql(`
+ fragment PrimaryNavigation_OrganizationConnectionFragment on OrganizationConnection {
+ ...HierarchicalSelector_OrganizationConnectionFragment
+ ...UserMenu_OrganizationConnectionFragment
+ }
+`);
+
+interface PrimaryNavigationProps {
+ me: FragmentType | null;
+ organizations: FragmentType | null;
+}
+
+export const PrimaryNavigation = (props: PrimaryNavigationProps) => {
+ const { organizationSlug, projectSlug, targetSlug } = useParams({ strict: false });
+
+ const me = useFragment(PrimaryNavigation_MeFragment, props.me);
+ const organizations = useFragment(
+ PrimaryNavigation_OrganizationConnectionFragment,
+ props.organizations,
+ );
+
+ return (
+
+ );
+};
diff --git a/packages/web/app/src/components/navigation/secondary-navigation-item.tsx b/packages/web/app/src/components/navigation/secondary-navigation-item.tsx
new file mode 100644
index 00000000000..02532bb568c
--- /dev/null
+++ b/packages/web/app/src/components/navigation/secondary-navigation-item.tsx
@@ -0,0 +1,41 @@
+import { Link, type LinkProps } from '@tanstack/react-router';
+
+export type SecondaryNavigationItemProps = {
+ activeOptions?: LinkProps['activeOptions'];
+ /**
+ * Optional: display this SecondaryNavigationItem only if this provided condition returns true
+ */
+ displayCondition?: boolean;
+ params: LinkProps['params'];
+ search?: LinkProps['search'];
+ /**
+ * The text to show for this link
+ */
+ title: string;
+ to: LinkProps['to'];
+};
+
+export const SecondaryNavigationItem = ({
+ activeOptions,
+ displayCondition,
+ params,
+ search,
+ title,
+ to,
+}: SecondaryNavigationItemProps) => {
+ if (displayCondition !== undefined && !displayCondition) {
+ return null;
+ }
+
+ return (
+
+ {title}
+
+ );
+};
diff --git a/packages/web/app/src/components/navigation/secondary-navigation.tsx b/packages/web/app/src/components/navigation/secondary-navigation.tsx
new file mode 100644
index 00000000000..0ef36dac1d1
--- /dev/null
+++ b/packages/web/app/src/components/navigation/secondary-navigation.tsx
@@ -0,0 +1,56 @@
+import { Fragment, ReactElement } from 'react';
+import {
+ SecondaryNavigationItem,
+ SecondaryNavigationItemProps,
+} from '@/components/navigation/secondary-navigation-item';
+
+type SecondaryNavigationProps = {
+ /**
+ * Optional: An array of actions to right-align (buttons, links, etc)
+ */
+ actions?: Array<{
+ displayCondition?: boolean;
+ actionItem: ReactElement;
+ }>;
+ /**
+ * Show the skeleton unless this condition is true
+ */
+ displayCondition: boolean;
+ items: Omit[];
+ /**
+ * Link params that are shared for all items
+ */
+ params: SecondaryNavigationItemProps['params'];
+};
+
+export const SecondaryNavigation = ({
+ actions,
+ displayCondition,
+ items,
+ params,
+}: SecondaryNavigationProps) => {
+ return (
+
+
+ {!displayCondition ? (
+
+ ) : (
+ <>
+
+ {items.map(item => (
+
+ ))}
+
+ {actions
+ ?.filter(action => action.displayCondition !== false)
+ .map((action, index) =>
{action.actionItem} )}
+ >
+ )}
+
+
+ );
+};
diff --git a/packages/web/app/src/components/organization/members/list.tsx b/packages/web/app/src/components/organization/members/list.tsx
index 828a9e57d94..09d8a07db51 100644
--- a/packages/web/app/src/components/organization/members/list.tsx
+++ b/packages/web/app/src/components/organization/members/list.tsx
@@ -312,10 +312,6 @@ const OrganizationMembers_OrganizationFragment = graphql(`
export function OrganizationMembers(props: {
organization: FragmentType;
refetchMembers: UseQueryExecute;
- /**
- * The setter for the reactive "after" variable required by urql
- */
- setAfter: (after: string | null) => void;
}) {
// Pagination state
const [cursorHistory, setCursorHistory] = useState>([null]);
@@ -327,20 +323,21 @@ export function OrganizationMembers(props: {
const members = organization.members?.edges?.map(edge => edge.node);
const pageInfo = organization.members?.pageInfo;
+ const [searchValue, setSearchValue] = useSearchParamsFilter('search', '');
+ const [_afterValue, setAfterValue] = useSearchParamsFilter('after', '');
+
// Reset pagination when search changes
useEffect(() => {
setCursorHistory([null]);
setCurrentPage(0);
- props.setAfter(null);
+ setAfterValue('');
}, [search.search]);
useEffect(() => {
// Update the cursor in parent, which will trigger query refetch
- props.setAfter(cursorHistory[currentPage]);
+ setAfterValue(cursorHistory[currentPage] || '');
}, [currentPage]);
- const [searchValue, setSearchValue] = useSearchParamsFilter('search', '');
-
const handleSearchChange = useDebouncedCallback((e: React.ChangeEvent) => {
setSearchValue(e.target.value);
}, 300);
diff --git a/packages/web/app/src/components/v2/modals/transfer-organization-ownership.tsx b/packages/web/app/src/components/v2/modals/transfer-organization-ownership.tsx
index 1d417312c74..c08291e6a7a 100644
--- a/packages/web/app/src/components/v2/modals/transfer-organization-ownership.tsx
+++ b/packages/web/app/src/components/v2/modals/transfer-organization-ownership.tsx
@@ -97,6 +97,8 @@ export const TransferOrganizationOwnershipModal = ({
organizationSlug: organization.slug,
},
},
+ // don't fire this query until the modal opens
+ pause: !isOpen,
});
const [searchPhrase, setSearchPhrase] = useState('');
diff --git a/packages/web/app/src/pages/organization-members.tsx b/packages/web/app/src/pages/organization-members.tsx
index 6c165136239..ee0181787b5 100644
--- a/packages/web/app/src/pages/organization-members.tsx
+++ b/packages/web/app/src/pages/organization-members.tsx
@@ -1,17 +1,17 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useMemo } from 'react';
import { useQuery, UseQueryExecute } from 'urql';
-import { OrganizationLayout, Page } from '@/components/layouts/organization';
+import z from 'zod';
import { OrganizationInvitations } from '@/components/organization/members/invitations';
import { OrganizationMembers } from '@/components/organization/members/list';
import { OrganizationMemberRoles } from '@/components/organization/members/roles';
import { Button } from '@/components/ui/button';
import { Meta } from '@/components/ui/meta';
import { NavLayout, PageLayout, PageLayoutContent } from '@/components/ui/page-content-layout';
-import { QueryError } from '@/components/ui/query-error';
import { FragmentType, graphql, useFragment } from '@/gql';
import { useRedirect } from '@/lib/access/common';
import { cn } from '@/lib/utils';
-import { organizationMembersRoute } from '../router';
+import { useNavigate } from '@tanstack/react-router';
+import { organizationMembersRoute, OrganizationMembersRouteSearch } from '../router';
const OrganizationMembersPage_OrganizationFragment = graphql(`
fragment OrganizationMembersPage_OrganizationFragment on Organization {
@@ -39,20 +39,24 @@ const subPages = [
},
] as const;
-type SubPage = (typeof subPages)[number]['key'];
-
function PageContent(props: {
- page: SubPage;
- onPageChange(page: SubPage): void;
organization: FragmentType;
refetchQuery: UseQueryExecute;
- setAfter: (after: string | null) => void;
}) {
const organization = useFragment(
OrganizationMembersPage_OrganizationFragment,
props.organization,
);
+ const { page } = organizationMembersRoute.useSearch();
+ const navigate = useNavigate({ from: organizationMembersRoute.fullPath });
+ const onPageChange = useCallback(
+ (newPage: z.infer['page']) => {
+ void navigate({ search: { page: newPage, search: undefined } });
+ },
+ [navigate],
+ );
+
const filteredSubPages = useMemo(() => {
return subPages.filter(page => {
if (!organization.viewerCanManageInvitations && page.key === 'invitations') {
@@ -78,12 +82,12 @@ function PageContent(props: {
key={subPage.key}
variant="ghost"
className={cn(
- props.page === subPage.key
+ page === subPage.key
? 'bg-muted hover:bg-muted'
: 'hover:bg-transparent hover:underline',
'justify-start',
)}
- onClick={() => props.onPageChange(subPage.key)}
+ onClick={() => onPageChange(subPage.key)}
>
{subPage.title}
@@ -91,17 +95,13 @@ function PageContent(props: {
})}
- {props.page === 'list' ? (
-
+ {page === 'list' ? (
+
) : null}
- {props.page === 'roles' && organization.viewerCanManageRoles ? (
+ {page === 'roles' && organization.viewerCanManageRoles ? (
) : null}
- {props.page === 'invitations' && organization.viewerCanManageInvitations ? (
+ {page === 'invitations' && organization.viewerCanManageInvitations ? (
(null);
+function OrganizationMembersPageContent() {
+ const data = organizationMembersRoute.useLoaderData();
- // Reset cursor when search changes
- useEffect(() => {
- setAfter(null);
- }, [search.search]);
+ const { organizationSlug } = organizationMembersRoute.useParams();
+ const { after, search: searchTerm } = organizationMembersRoute.useSearch();
- const [query, refetch] = useQuery({
- query: OrganizationMembersPageQuery,
+ const [_query, refetch] = useQuery({
+ query: OrganizationMembersPageWithLayoutQuery,
variables: {
- organizationSlug: props.organizationSlug,
- searchTerm: search.search || undefined,
+ organizationSlug,
+ searchTerm,
first: 20,
after,
},
+ pause: true,
});
useRedirect({
- canAccess: query.data?.organization?.viewerCanSeeMembers === true,
+ canAccess: data?.organization?.viewerCanSeeMembers === true,
redirectTo: router => {
void router.navigate({
to: '/$organizationSlug',
params: {
- organizationSlug: props.organizationSlug,
+ organizationSlug,
},
});
},
- entity: query.data?.organization,
+ entity: data?.organization,
});
const refetchQuery = useCallback(() => {
refetch({ requestPolicy: 'network-only' });
}, [refetch]);
- if (query.data?.organization?.viewerCanSeeMembers === false) {
+ if (data?.organization?.viewerCanSeeMembers === false) {
return null;
}
- if (query.error) {
- return ;
- }
-
return (
-
- {query.data?.organization ? (
-
+ <>
+ {data?.organization ? (
+
) : null}
-
+ >
);
}
-export function OrganizationMembersPage(props: {
- organizationSlug: string;
- page: SubPage;
- onPageChange(page: SubPage): void;
-}) {
+export function OrganizationMembersPage() {
return (
<>
-
+
>
);
}
diff --git a/packages/web/app/src/pages/organization-settings.tsx b/packages/web/app/src/pages/organization-settings.tsx
index 61988374ccd..c9e6dc97ffb 100644
--- a/packages/web/app/src/pages/organization-settings.tsx
+++ b/packages/web/app/src/pages/organization-settings.tsx
@@ -1,9 +1,8 @@
import { useCallback, useMemo } from 'react';
import { ArrowRightIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
-import { useMutation, useQuery } from 'urql';
+import { useMutation } from 'urql';
import { z } from 'zod';
-import { OrganizationLayout, Page } from '@/components/layouts/organization';
import { AccessTokensSubPage } from '@/components/organization/settings/access-tokens/access-tokens-sub-page';
import { OIDCIntegrationSection } from '@/components/organization/settings/oidc-integration-section';
import { PersonalAccessTokensSubPage } from '@/components/organization/settings/personal-access-tokens/personal-access-tokens-sub-page';
@@ -31,7 +30,6 @@ import {
SubPageLayout,
SubPageLayoutHeader,
} from '@/components/ui/page-content-layout';
-import { QueryError } from '@/components/ui/query-error';
import { ResourceDetails } from '@/components/ui/resource-details';
import { useToast } from '@/components/ui/use-toast';
import { TransferOrganizationOwnershipModal } from '@/components/v2/modals';
@@ -40,6 +38,7 @@ import { FragmentType, graphql, useFragment } from '@/gql';
import { useRedirect } from '@/lib/access/common';
import { useToggle } from '@/lib/hooks';
import { cn } from '@/lib/utils';
+import { organizationSettingsRoute } from '@/router';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from '@tanstack/react-router';
@@ -613,9 +612,13 @@ function OrganizationPolicySettings(props: {
);
}
-const OrganizationSettingsPageQuery = graphql(`
- query OrganizationSettingsPageQuery($organizationSlug: String!) {
+export const OrganizationSettingsPageWithLayoutQuery = graphql(`
+ query OrganizationSettingsPageWithLayout($organizationSlug: String!) {
+ ...OrganizationLayoutDataFragment
+
organization: organizationBySlug(organizationSlug: $organizationSlug) {
+ id
+ slug
...SettingsPageRenderer_OrganizationFragment
...OrganizationPolicySettings_OrganizationFragment
viewerCanAccessSettings
@@ -633,19 +636,15 @@ export const OrganizationSettingsPageEnum = z.enum([
]);
export type OrganizationSettingsSubPage = z.TypeOf;
-function SettingsPageContent(props: {
- organizationSlug: string;
- page?: OrganizationSettingsSubPage;
-}) {
+function SettingsPageContent() {
+ const data = organizationSettingsRoute.useLoaderData();
+
+ const { organizationSlug } = organizationSettingsRoute.useParams();
+ const { page: currentPage } = organizationSettingsRoute.useSearch();
+
const router = useRouter();
- const [query] = useQuery({
- query: OrganizationSettingsPageQuery,
- variables: {
- organizationSlug: props.organizationSlug,
- },
- });
- const currentOrganization = query.data?.organization;
+ const currentOrganization = data?.organization;
const subPages = useMemo(() => {
const pages: Array<{
@@ -682,7 +681,9 @@ function SettingsPageContent(props: {
return pages;
}, [currentOrganization]);
- const resolvedPage = props.page ? subPages.find(page => page.key === props.page) : subPages.at(0);
+ const resolvedPage = currentPage
+ ? subPages.find(page => page.key === currentPage)
+ : subPages.at(0);
useRedirect({
canAccess: resolvedPage !== undefined,
@@ -690,86 +691,73 @@ function SettingsPageContent(props: {
void router.navigate({
to: '/$organizationSlug',
params: {
- organizationSlug: props.organizationSlug,
+ organizationSlug,
},
});
},
entity: currentOrganization,
});
- if (query.error) {
- return ;
- }
-
if (!resolvedPage || !currentOrganization) {
return null;
}
return (
-
-
-
- {subPages.map(subPage => {
- return (
- {
- void router.navigate({
- search: {
- page: subPage.key,
- },
- });
- }}
- className={cn(
- resolvedPage.key === subPage.key
- ? 'bg-muted hover:bg-muted'
- : 'hover:bg-transparent hover:underline',
- 'w-full justify-start text-left',
- )}
- >
- {subPage.title}
-
- );
- })}
-
-
-
- {resolvedPage.key === 'general' ? (
-
- ) : null}
- {resolvedPage.key === 'policy' ? (
-
- ) : null}
- {resolvedPage.key === 'access-tokens' ? (
-
- ) : null}
- {resolvedPage.key === 'personal-access-tokens' ? (
-
- ) : null}
-
-
-
-
+
+
+ {subPages.map(subPage => {
+ return (
+ {
+ void router.navigate({
+ search: {
+ page: subPage.key,
+ },
+ });
+ }}
+ className={cn(
+ resolvedPage.key === subPage.key
+ ? 'bg-muted hover:bg-muted'
+ : 'hover:bg-transparent hover:underline',
+ 'w-full justify-start text-left',
+ )}
+ >
+ {subPage.title}
+
+ );
+ })}
+
+
+
+ {resolvedPage.key === 'general' ? (
+
+ ) : null}
+ {resolvedPage.key === 'policy' ? (
+
+ ) : null}
+ {resolvedPage.key === 'access-tokens' ? (
+
+ ) : null}
+ {resolvedPage.key === 'personal-access-tokens' ? (
+
+ ) : null}
+
+
+
);
}
-export function OrganizationSettingsPage(props: {
- organizationSlug: string;
- page?: OrganizationSettingsSubPage;
-}) {
+export function OrganizationSettingsPage() {
return (
<>
-
+
>
);
}
diff --git a/packages/web/app/src/pages/organization-subscription-manage.tsx b/packages/web/app/src/pages/organization-subscription-manage.tsx
index 286cdc42563..2b12cb814cb 100644
--- a/packages/web/app/src/pages/organization-subscription-manage.tsx
+++ b/packages/web/app/src/pages/organization-subscription-manage.tsx
@@ -1,6 +1,5 @@
import { ReactElement, useCallback, useEffect, useRef, useState } from 'react';
import { useMutation, useQuery } from 'urql';
-import { OrganizationLayout, Page } from '@/components/layouts/organization';
import {
BillingPaymentMethodForm,
ManagePaymentMethod,
@@ -441,37 +440,31 @@ function ManageSubscriptionPageContent(props: { organizationSlug: string }) {
}
return (
-
-
-
-
-
Manage subscription
- Manage your current plan and invoices.
-
- {currentOrganization ? (
-
-
-
- Subscription usage
-
-
-
- ) : null}
-
+
+
- {currentOrganization && billingPlans ? (
-
- ) : null}
+
Manage subscription
+ Manage your current plan and invoices.
+ {currentOrganization ? (
+
+
+
+ Subscription usage
+
+
+
+ ) : null}
-
+
+ {currentOrganization && billingPlans ? (
+
+ ) : null}
+
+
);
}
diff --git a/packages/web/app/src/pages/organization-subscription.tsx b/packages/web/app/src/pages/organization-subscription.tsx
index ff46bc019bc..fddbbe59230 100644
--- a/packages/web/app/src/pages/organization-subscription.tsx
+++ b/packages/web/app/src/pages/organization-subscription.tsx
@@ -3,7 +3,6 @@ import { endOfMonth, startOfDay, startOfMonth } from 'date-fns';
import ReactECharts from 'echarts-for-react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { useQuery } from 'urql';
-import { OrganizationLayout, Page } from '@/components/layouts/organization';
import { BillingView } from '@/components/organization/billing/Billing';
import { CurrencyFormatter } from '@/components/organization/billing/helpers';
import { InvoicesList } from '@/components/organization/billing/InvoicesList';
@@ -111,146 +110,140 @@ function SubscriptionPageContent(props: { organizationSlug: string }) {
const end = endOfMonth(today);
return (
-
-
-
+
+
+
+
Your subscription
+ Explore your current plan and usage.
+
+ {organization.viewerCanModifyBilling && (
-
Your subscription
- Explore your current plan and usage.
+
+
+ Manage subscription
+
+
- {organization.viewerCanModifyBilling && (
-
-
-
- Manage subscription
-
-
-
- )}
-
-
-
- Your current plan
-
-
- {organization.billingConfiguration?.upcomingInvoice && (
-
- Next Invoice
-
- {CurrencyFormatter.format(
- organization.billingConfiguration.upcomingInvoice.amount,
- )}
-
-
- {DateFormatter.format(
- new Date(organization.billingConfiguration.upcomingInvoice.date),
- )}
-
-
- )}
-
-
-
+ )}
+
+
+
+ Your current plan
+
+
+ {organization.billingConfiguration?.upcomingInvoice && (
+
+ Next Invoice
+
+ {CurrencyFormatter.format(
+ organization.billingConfiguration.upcomingInvoice.amount,
+ )}
+
+
+ {DateFormatter.format(
+ new Date(organization.billingConfiguration.upcomingInvoice.date),
+ )}
+
+
+ )}
+
+
+
+
+ Current Usage
+
+ {DateFormatter.format(start)} — {DateFormatter.format(end)}
+
+
+
+
+
+ {monthlyUsagePoints.length ? (
- Current Usage
-
- {DateFormatter.format(start)} — {DateFormatter.format(end)}
-
+ Historical Usage
-
-
-
- {monthlyUsagePoints.length ? (
-
- Historical Usage
-
-
- {size => (
- formatNumber(value),
- formatter(params: any[]) {
- const param = params[0];
- const value = param.data[1];
+
+ {size => (
+ formatNumber(value),
+ formatter(params: any[]) {
+ const param = params[0];
+ const value = param.data[1];
- return `${numberFormatter.format(value)} `;
- },
+ return `${numberFormatter.format(value)} `;
},
- xAxis: [
- {
- type: 'time',
- splitNumber: 12,
+ },
+ xAxis: [
+ {
+ type: 'time',
+ splitNumber: 12,
+ },
+ ],
+ yAxis: [
+ {
+ type: 'value',
+ boundaryGap: false,
+ min: 0,
+ axisLabel: {
+ formatter: (value: number) => formatNumber(value),
},
- ],
- yAxis: [
- {
- type: 'value',
- boundaryGap: false,
- min: 0,
- axisLabel: {
- formatter: (value: number) => formatNumber(value),
- },
- splitLine: {
- lineStyle: {
- color: '#595959',
- type: 'dashed',
- },
+ splitLine: {
+ lineStyle: {
+ color: '#595959',
+ type: 'dashed',
},
},
- ],
- series: [
- {
- type: 'bar',
- name: 'Events',
- showSymbol: false,
- boundaryGap: false,
- color: '#595959',
- areaStyle: {},
- emphasis: {
- focus: 'series',
- },
- data: monthlyUsagePoints,
+ },
+ ],
+ series: [
+ {
+ type: 'bar',
+ name: 'Events',
+ showSymbol: false,
+ boundaryGap: false,
+ color: '#595959',
+ areaStyle: {},
+ emphasis: {
+ focus: 'series',
},
- ],
- }}
- />
- )}
-
-
-
- ) : null}
- {organization.billingConfiguration?.invoices?.length ? (
-
- Invoices
-
-
-
-
- ) : null}
-
+ data: monthlyUsagePoints,
+ },
+ ],
+ }}
+ />
+ )}
+
+
+
+ ) : null}
+ {organization.billingConfiguration?.invoices?.length ? (
+
+ Invoices
+
+
+
+
+ ) : null}
-
+
);
}
diff --git a/packages/web/app/src/pages/organization-support-ticket.tsx b/packages/web/app/src/pages/organization-support-ticket.tsx
index 06c9d02d20b..d264ad3e71a 100644
--- a/packages/web/app/src/pages/organization-support-ticket.tsx
+++ b/packages/web/app/src/pages/organization-support-ticket.tsx
@@ -3,7 +3,6 @@ import { ChevronRightIcon, UserIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useMutation, useQuery } from 'urql';
import { z } from 'zod';
-import { OrganizationLayout, Page } from '@/components/layouts/organization';
import { priorityDescription, statusDescription } from '@/components/organization/support';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
@@ -303,11 +302,7 @@ function SupportTicketPageContent(props: { ticketId: string; organizationSlug: s
const ticket = currentOrganization?.supportTicket;
return (
-
+ <>
{currentOrganization ? (
ticket ? (
@@ -320,7 +315,7 @@ function SupportTicketPageContent(props: { ticketId: string; organizationSlug: s
)
) : null}
-
+ >
);
}
diff --git a/packages/web/app/src/pages/organization-support.tsx b/packages/web/app/src/pages/organization-support.tsx
index 61e717aa58b..8304cd8a409 100644
--- a/packages/web/app/src/pages/organization-support.tsx
+++ b/packages/web/app/src/pages/organization-support.tsx
@@ -3,7 +3,6 @@ import { PencilIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useMutation, useQuery } from 'urql';
import { z } from 'zod';
-import { OrganizationLayout, Page } from '@/components/layouts/organization';
import { Priority, priorityDescription, Status } from '@/components/organization/support';
import { Button } from '@/components/ui/button';
import {
@@ -402,17 +401,10 @@ function SupportPageContent(props: { organizationSlug: string }) {
const currentOrganization = query.data?.organization;
- return (
-
- {currentOrganization ? (
-
- ) : null}
-
- );
+ if (currentOrganization) {
+ return ;
+ }
+ return null;
}
export function OrganizationSupportPage(props: { organizationSlug: string }) {
diff --git a/packages/web/app/src/pages/organization.tsx b/packages/web/app/src/pages/organization.tsx
index bf93e2c00de..97b942f4c9a 100644
--- a/packages/web/app/src/pages/organization.tsx
+++ b/packages/web/app/src/pages/organization.tsx
@@ -1,19 +1,14 @@
-import { ChangeEvent, ReactElement, useCallback, useMemo, useRef } from 'react';
-import { endOfDay, formatISO, startOfDay } from 'date-fns';
+import { ChangeEvent, ReactElement, useCallback, useMemo } from 'react';
import * as echarts from 'echarts';
import ReactECharts from 'echarts-for-react';
import { Globe, History, MoveDownIcon, MoveUpIcon, SearchIcon } from 'lucide-react';
import AutoSizer from 'react-virtualized-auto-sizer';
-import { useQuery } from 'urql';
-import { z } from 'zod';
-import { OrganizationLayout, Page } from '@/components/layouts/organization';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { EmptyList } from '@/components/ui/empty-list';
import { Input } from '@/components/ui/input';
import { Meta } from '@/components/ui/meta';
import { Subtitle, Title } from '@/components/ui/page';
-import { QueryError } from '@/components/ui/query-error';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@@ -22,17 +17,9 @@ import { ProjectType } from '@/gql/graphql';
import { subDays } from '@/lib/date-time';
import { useFormattedNumber } from '@/lib/hooks';
import { pluralize } from '@/lib/utils';
-import { UTCDate } from '@date-fns/utc';
+import { organizationIndexRoute } from '@/router';
import { Link, useRouter } from '@tanstack/react-router';
-export const OrganizationIndexRouteSearch = z.object({
- search: z.string().optional(),
- sortBy: z.enum(['requests', 'versions', 'name']).optional(),
- sortOrder: z.enum(['asc', 'desc']).optional(),
-});
-
-type RouteSearchProps = z.infer;
-
const ProjectCard_ProjectFragment = graphql(`
fragment ProjectCard_ProjectFragment on Project {
id
@@ -223,12 +210,14 @@ const ProjectCard = (props: {
);
};
-const OrganizationProjectsPageQuery = graphql(`
- query OrganizationProjectsPageQuery(
+export const OrganizationPageWithLayoutQuery = graphql(`
+ query OrganizationPageWithLayoutQuery(
$organizationSlug: String!
$chartResolution: Int!
$period: DateRangeInput!
) {
+ ...OrganizationLayoutDataFragment
+
organization: organizationBySlug(organizationSlug: $organizationSlug) {
id
slug
@@ -251,50 +240,28 @@ const OrganizationProjectsPageQuery = graphql(`
}
`);
-function OrganizationPageContent(
- props: {
- organizationSlug: string;
- } & RouteSearchProps,
-) {
+function OrganizationPageContent() {
+ const data = organizationIndexRoute.useLoaderData();
+
+ const { search, sortBy, sortOrder: searchSortOrder } = organizationIndexRoute.useSearch();
+
const days = 14;
- const period = useRef<{
- from: string;
- to: string;
- }>();
// Sort by requests by default
- const sortKey = props.sortBy ?? 'requests';
+ const sortKey = sortBy ?? 'requests';
const sortOrder =
- props.sortOrder === 'asc'
+ searchSortOrder === 'asc'
? -1
: // if the sort order is not set, sort by name in ascending order by default
- !props.sortOrder && props.sortBy === 'name'
+ !searchSortOrder && sortBy === 'name'
? -1
: // if the sort order is not set, sort in descending order by default
1;
- if (!period.current) {
- const now = new UTCDate();
- const from = formatISO(startOfDay(subDays(now, days)));
- const to = formatISO(endOfDay(now));
-
- period.current = { from, to };
- }
-
const router = useRouter();
- const [query] = useQuery({
- query: OrganizationProjectsPageQuery,
- variables: {
- organizationSlug: props.organizationSlug,
- chartResolution: days, // 14 days = 14 data points
- period: period.current,
- },
- requestPolicy: 'cache-and-network',
- });
-
- const currentOrganization = query.data?.organization;
+ const currentOrganization = data.organization;
const projectsConnection = currentOrganization?.projects;
const highestNumberOfRequests = useMemo(() => {
@@ -318,7 +285,7 @@ function OrganizationPageContent(
return [];
}
- const searchPhrase = props.search;
+ const searchPhrase = search;
const newProjects = searchPhrase
? projectsConnection.edges.filter(edge =>
edge.node.slug.toLowerCase().includes(searchPhrase.toLowerCase()),
@@ -346,7 +313,7 @@ function OrganizationPageContent(
// falls back to sort by name in ascending order
return a.slug.localeCompare(b.slug);
});
- }, [projectsConnection, props.search, sortKey, sortOrder]);
+ }, [projectsConnection, search, sortKey, sortOrder]);
const onSearchChange = useCallback(
(event: ChangeEvent) => {
@@ -382,136 +349,117 @@ function OrganizationPageContent(
search(params) {
return {
...params,
- sortOrder: props.sortOrder === 'asc' ? 'desc' : 'asc',
+ sortOrder: searchSortOrder === 'asc' ? 'desc' : 'asc',
};
},
});
- }, [router, props.sortOrder]);
-
- if (query.error) {
- return ;
- }
+ }, [router, searchSortOrder]);
return (
-
- <>
-
-
-
-
Projects
- A list of available project in your organization.
-
-
-
-
-
-
-
-
-
-
- {props.sortBy === 'versions'
- ? 'Schema Versions'
- : props.sortBy === 'name'
- ? 'Name'
- : 'Requests'}
-
-
-
- Requests
-
- GraphQL requests made in the last {days} days.
-
-
-
- Schema Versions
-
- Schemas published in last {days} days.
-
-
-
- Name
- Sort by project name.
-
-
-
-
- {props.sortOrder === 'asc' ? (
-
- ) : (
-
- )}
-
+ <>
+
+
+
+
Projects
+ A list of available project in your organization.
+
+
+
+
+
+
+
+
+
+ {sortBy === 'versions'
+ ? 'Schema Versions'
+ : sortBy === 'name'
+ ? 'Name'
+ : 'Requests'}
+
+
+
+ Requests
+
+ GraphQL requests made in the last {days} days.
+
+
+
+ Schema Versions
+
+ Schemas published in last {days} days.
+
+
+
+ Name
+ Sort by project name.
+
+
+
+
+ {searchSortOrder === 'asc' ? (
+
+ ) : (
+
+ )}
+
- {currentOrganization && projectsConnection ? (
- projectsConnection.edges.length === 0 ? (
-
- ) : (
-
- {projects.map(project => (
-
- ))}
-
- )
+
+ {currentOrganization && projectsConnection ? (
+ projectsConnection.edges.length === 0 ? (
+
) : (
- {Array.from({ length: 4 }).map((_, index) => (
+ {projects.map(project => (
))}
- )}
-
- >
-
+ )
+ ) : (
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+ )}
+
+ >
);
}
-export function OrganizationPage(
- props: {
- organizationSlug: string;
- } & RouteSearchProps,
-) {
+export function OrganizationPage() {
return (
<>
-
+
>
);
}
diff --git a/packages/web/app/src/router.tsx b/packages/web/app/src/router.tsx
index 849bf6ae58c..fea65c7f6d4 100644
--- a/packages/web/app/src/router.tsx
+++ b/packages/web/app/src/router.tsx
@@ -1,10 +1,10 @@
-import { lazy, useCallback, useEffect, useMemo } from 'react';
+import { lazy, useEffect, useMemo } from 'react';
import { parse as jsUrlParse, stringify as jsUrlStringify } from 'jsurl2';
import { HelmetProvider } from 'react-helmet-async';
import { ToastContainer } from 'react-toastify';
import SuperTokens, { SuperTokensWrapper } from 'supertokens-auth-react';
import Session from 'supertokens-auth-react/recipe/session';
-import { Provider as UrqlProvider } from 'urql';
+import { CombinedError, Provider as UrqlProvider, type TypedDocumentNode } from 'urql';
import { z } from 'zod';
import { LoadingAPIIndicator } from '@/components/common/LoadingAPI';
import { Toaster } from '@/components/ui/toaster';
@@ -21,11 +21,14 @@ import {
Outlet,
parseSearchWith,
stringifySearchWith,
- useNavigate,
} from '@tanstack/react-router';
import { ErrorComponent } from './components/error';
import { NotFound } from './components/not-found';
import 'react-toastify/dist/ReactToastify.css';
+import { endOfDay, formatISO, startOfDay, subDays } from 'date-fns';
+import { OrganizationLayout } from '@/components/layouts/organization';
+import { QueryError } from '@/components/ui/query-error';
+import { UTCDate } from '@date-fns/utc';
import { zodValidator } from '@tanstack/zod-adapter';
import { authenticated } from './components/authenticated-container';
import { AuthPage } from './pages/auth';
@@ -41,13 +44,17 @@ import { IndexPage } from './pages/index';
import { LogoutPage } from './pages/logout';
import { ManagePage } from './pages/manage';
import { NativeCompositionDiff } from './pages/native-composition-diff';
-import { OrganizationIndexRouteSearch, OrganizationPage } from './pages/organization';
+import { OrganizationPage, OrganizationPageWithLayoutQuery } from './pages/organization';
import { JoinOrganizationPage } from './pages/organization-join';
-import { OrganizationMembersPage } from './pages/organization-members';
+import {
+ OrganizationMembersPage,
+ OrganizationMembersPageWithLayoutQuery,
+} from './pages/organization-members';
import { NewOrgPage } from './pages/organization-new';
import {
OrganizationSettingsPage,
OrganizationSettingsPageEnum,
+ OrganizationSettingsPageWithLayoutQuery,
} from './pages/organization-settings';
import { OrganizationSubscriptionPage } from './pages/organization-subscription';
import { OrganizationSubscriptionManagePage } from './pages/organization-subscription-manage';
@@ -95,6 +102,20 @@ if (env.sentry) {
const queryClient = new QueryClient();
+async function loadGraphQLData
>(
+ query: TypedDocumentNode,
+ variables: TVariables,
+): Promise {
+ const result = await urqlClient
+ .query(query, variables, {
+ requestPolicy: 'cache-first',
+ })
+ .toPromise();
+
+ if (result.error) throw result.error;
+ return result.data!;
+}
+
const LazyTanStackRouterDevtools = lazy(() =>
import('@tanstack/router-devtools').then(({ TanStackRouterDevtools }) => ({
default: TanStackRouterDevtools,
@@ -343,28 +364,46 @@ const organizationRoute = createRoute({
errorComponent: ErrorComponent,
});
-const organizationIndexRoute = createRoute({
+export const organizationLayoutRoute = createRoute({
getParentRoute: () => organizationRoute,
+ id: '_organizationLayout',
+ component: OrganizationLayout,
+});
+
+export const OrganizationIndexRouteSearch = z.object({
+ search: z.string().optional(),
+ sortBy: z.enum(['requests', 'versions', 'name']).optional(),
+ sortOrder: z.enum(['asc', 'desc']).optional(),
+});
+
+export const organizationIndexRoute = createRoute({
+ getParentRoute: () => organizationLayoutRoute,
path: '/',
validateSearch: OrganizationIndexRouteSearch.parse,
- component: function OrganizationRoute() {
- const { organizationSlug } = organizationRoute.useParams();
- const { search, sortBy, sortOrder } = organizationIndexRoute.useSearch();
- return (
-
- );
+
+ loader: async ({ params }) => {
+ const now = new UTCDate();
+ const from = formatISO(startOfDay(subDays(now, 14)));
+ const to = formatISO(endOfDay(now));
+
+ return await loadGraphQLData(OrganizationPageWithLayoutQuery, {
+ organizationSlug: params.organizationSlug,
+ chartResolution: 14,
+ period: { from, to },
+ });
},
+
+ component: OrganizationPage,
notFoundComponent: NotFound,
- errorComponent: ErrorComponent,
+ errorComponent: ({ error }) => {
+ const urqlError = error as CombinedError;
+ const { organizationSlug } = organizationIndexRoute.useParams();
+ return ;
+ },
});
const organizationSupportRoute = createRoute({
- getParentRoute: () => organizationRoute,
+ getParentRoute: () => organizationLayoutRoute,
path: 'view/support',
component: function OrganizationSupportRoute() {
const { organizationSlug } = organizationSupportRoute.useParams();
@@ -373,7 +412,7 @@ const organizationSupportRoute = createRoute({
});
const organizationSupportTicketRoute = createRoute({
- getParentRoute: () => organizationRoute,
+ getParentRoute: () => organizationLayoutRoute,
path: 'view/support/ticket/$ticketId',
component: function OrganizationSupportTicketRoute() {
const { organizationSlug, ticketId } = organizationSupportTicketRoute.useParams();
@@ -384,7 +423,7 @@ const organizationSupportTicketRoute = createRoute({
});
const organizationSubscriptionRoute = createRoute({
- getParentRoute: () => organizationRoute,
+ getParentRoute: () => organizationLayoutRoute,
path: 'view/subscription',
component: function OrganizationSubscriptionRoute() {
const { organizationSlug } = organizationSubscriptionRoute.useParams();
@@ -393,7 +432,7 @@ const organizationSubscriptionRoute = createRoute({
});
const organizationSubscriptionManageLegacyRoute = createRoute({
- getParentRoute: () => organizationRoute,
+ getParentRoute: () => organizationLayoutRoute,
path: 'view/subscription/manage',
component: function OrganizationSubscriptionManageLegacyRoute() {
const { organizationSlug } = organizationSubscriptionManageLegacyRoute.useParams();
@@ -404,7 +443,7 @@ const organizationSubscriptionManageLegacyRoute = createRoute({
});
const organizationSubscriptionManageRoute = createRoute({
- getParentRoute: () => organizationRoute,
+ getParentRoute: () => organizationLayoutRoute,
path: 'view/manage-subscription',
component: function OrganizationSubscriptionManageRoute() {
const { organizationSlug } = organizationSubscriptionManageRoute.useParams();
@@ -416,48 +455,58 @@ const OrganizationSettingRouteSearch = z.object({
page: OrganizationSettingsPageEnum.default('general').optional(),
});
-const organizationSettingsRoute = createRoute({
- getParentRoute: () => organizationRoute,
+export const organizationSettingsRoute = createRoute({
+ getParentRoute: () => organizationLayoutRoute,
validateSearch(search) {
return OrganizationSettingRouteSearch.parse(search);
},
path: 'view/settings',
- component: function OrganizationSettingsRoute() {
+
+ loader: async ({ params }) => {
+ return await loadGraphQLData(OrganizationSettingsPageWithLayoutQuery, {
+ organizationSlug: params.organizationSlug,
+ });
+ },
+
+ component: OrganizationSettingsPage,
+
+ errorComponent: ({ error }) => {
+ const urqlError = error as CombinedError;
const { organizationSlug } = organizationSettingsRoute.useParams();
- const { page } = organizationSettingsRoute.useSearch();
- return ;
+ return ;
},
});
-const OrganizationMembersRouteSearch = z.object({
+export const OrganizationMembersRouteSearch = z.object({
page: z.enum(['list', 'roles', 'invitations']).catch('list').default('list'),
search: z.string().optional(),
+ after: z.string().optional(),
});
export const organizationMembersRoute = createRoute({
- getParentRoute: () => organizationRoute,
+ getParentRoute: () => organizationLayoutRoute,
path: 'view/members',
validateSearch(search) {
return OrganizationMembersRouteSearch.parse(search);
},
- component: function OrganizationMembersRoute() {
- const { organizationSlug } = organizationMembersRoute.useParams();
- const { page } = organizationMembersRoute.useSearch();
- const navigate = useNavigate({ from: organizationMembersRoute.fullPath });
- const onPageChange = useCallback(
- (newPage: z.infer['page']) => {
- void navigate({ search: { page: newPage, search: undefined } });
- },
- [navigate],
- );
- return (
-
- );
+ loaderDeps: ({ search: { after, page, search } }) => ({ after, page, search }),
+
+ loader: async ({ params, deps }) => {
+ return await loadGraphQLData(OrganizationMembersPageWithLayoutQuery, {
+ organizationSlug: params.organizationSlug,
+ searchTerm: deps.search,
+ first: 20,
+ after: deps.after,
+ });
+ },
+
+ component: OrganizationMembersPage,
+
+ errorComponent: ({ error }) => {
+ const urqlError = error as CombinedError;
+ const { organizationSlug } = organizationMembersRoute.useParams();
+ return ;
},
});
@@ -918,16 +967,18 @@ const routeTree = root.addChildren([
manageRoute,
logoutRoute,
organizationRoute.addChildren([
- organizationIndexRoute,
- joinOrganizationRoute,
- transferOrganizationRoute,
- organizationSupportRoute,
- organizationSupportTicketRoute,
- organizationSubscriptionRoute,
- organizationSubscriptionManageRoute,
- organizationSubscriptionManageLegacyRoute,
- organizationMembersRoute,
- organizationSettingsRoute,
+ organizationLayoutRoute.addChildren([
+ organizationIndexRoute,
+ joinOrganizationRoute,
+ transferOrganizationRoute,
+ organizationSupportRoute,
+ organizationSupportTicketRoute,
+ organizationSubscriptionRoute,
+ organizationSubscriptionManageRoute,
+ organizationSubscriptionManageLegacyRoute,
+ organizationMembersRoute,
+ organizationSettingsRoute,
+ ]),
]),
projectRoute.addChildren([projectIndexRoute, projectSettingsRoute, projectAlertsRoute]),
targetRoute.addChildren([