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 ? ( - <> - - - - ) : null} -
-
+ + + + + + ), + }, + ]} + 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 ( + + ); +} 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 ( +
+
+
+ + {organizationSlug && ( + + )} +
+ {organizationSlug && ( +
+ +
+ )} +
+
+ ); +}; 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 ( - - ); - })} - - -
- {resolvedPage.key === 'general' ? ( - - ) : null} - {resolvedPage.key === 'policy' ? ( - - ) : null} - {resolvedPage.key === 'access-tokens' ? ( - - ) : null} - {resolvedPage.key === 'personal-access-tokens' ? ( - - ) : null} -
-
-
-
+ + + {subPages.map(subPage => { + return ( + + ); + })} + + +
+ {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 ? ( -
- -
- ) : null} -
+
+
- {currentOrganization && billingPlans ? ( - - ) : null} + Manage subscription + Manage your current plan and invoices.
+ {currentOrganization ? ( +
+ +
+ ) : 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. +
- {organization.viewerCanModifyBilling && ( -
- -
- )} -
-
- - 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. -
-
-
-
- - -
- - - + <> +
+
+
+ Projects + A list of available project in your organization. +
+
+
+
+ +
+ + +
- {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([