From e68e0c9dfc0ad27e010b3faf3d7615ceb91667bc Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 28 Oct 2026 17:54:02 -0600 Subject: [PATCH 1/4] fix(mosaic): reset the open profile page when it leaves the navigation When the active organization changes and the open page is no longer listed, the profile now stores the page it shows instead of keeping the old one, so switching back no longer reopens it. Applies to OrganizationProfile and the combined UserProfile. --- .changeset/thin-trains-lose.md | 2 + .../organization-profile.feature.test.tsx | 24 ++++++- .../organization-profile.tsx | 22 ++++++- .../__tests__/user-profile.feature.test.tsx | 50 +++++++++++++- .../user-profile/user-profile.layout.ts | 14 ++++ .../features/user-profile/user-profile.tsx | 65 +++++++++++++++---- .../user-profile/user-profile.view.tsx | 15 ++--- 7 files changed, 164 insertions(+), 28 deletions(-) create mode 100644 .changeset/thin-trains-lose.md diff --git a/.changeset/thin-trains-lose.md b/.changeset/thin-trains-lose.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/thin-trains-lose.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx index afc43fae780..f52f2cadd02 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor, within } from '@testing-library/react'; +import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeAll, describe, expect, it } from 'vitest'; import { page } from 'vitest/browser'; @@ -177,4 +177,26 @@ describe('OrganizationProfile', () => { await waitFor(() => expect(fapi.memberships).toHaveLength(0)); await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations')); }); + + it('keeps the shown page after the open one leaves the navigation', async () => { + const globex = fapiOrganization({ id: 'org_2', name: 'Globex', slug: 'globex' }); + const memberships = [ + fapiMembership(acme, { permissions: ['org:sys_memberships:read'] }), + fapiMembership(globex, { permissions: [] }), + ]; + const user = fapiUser({ id: 'user_1', organization_memberships: memberships }); + serveFapi({ + environment: fapiEnvironment(), + client: fapiClient([fapiSession({ id: 'sess_1', user, last_active_organization_id: acme.id })]), + memberships, + }); + const { clerk } = await renderWithClerk(); + + await act(() => clerk.setActive({ organization: globex.id })); + await waitFor(() => expect(tabNames()).toEqual(['General'])); + await act(() => clerk.setActive({ organization: acme.id })); + await waitFor(() => expect(tabNames()).toEqual(['General', 'Members'])); + + expect(profileNav().getByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); + }); }); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.tsx index a3b5349aaae..442767906f8 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile.tsx @@ -4,11 +4,12 @@ import type { ReactNode } from 'react'; import { useState } from 'react'; import type { APIKeysPageProps } from '../api-keys/api-keys-table'; +import { getAvailableOrganizationProfilePages, resolveOrganizationProfilePages } from './organization-profile.layout'; import { useOrganizationProfileModel } from './organization-profile.model'; import type { OrganizationProfileOptions } from './organization-profile.provider'; import { OrganizationProfileProvider } from './organization-profile.provider'; import type { OrganizationProfilePageId, OrganizationProfilePages } from './organization-profile.types'; -import type { OrganizationProfileViewProps } from './organization-profile.view'; +import type { OrganizationProfilePageListOptions, OrganizationProfileViewProps } from './organization-profile.view'; import { OrganizationProfileView } from './organization-profile.view'; import { OrganizationProfileApiKeysPanel } from './organization-profile-api-keys-panel'; import { OrganizationProfileGeneralPanel } from './organization-profile-general-panel'; @@ -98,6 +99,13 @@ export function OrganizationProfile({ return null; } + const pages = organizationProfilePanels(model.pages, apiKeysProps); + const pageIds = organizationProfilePageIds({ pages, customPages, pageOrder }); + const firstPage = pageIds[0]; + if (firstPage !== undefined && !pageIds.includes(activePage)) { + setActivePage(firstPage); + } + return ( ); @@ -127,3 +135,13 @@ export function organizationProfilePanels( available.includes('apiKeys') && !hideAPIKeys ? : undefined, }; } + +export function organizationProfilePageIds({ + pages, + customPages, + pageOrder, +}: OrganizationProfilePageListOptions): string[] { + return resolveOrganizationProfilePages(getAvailableOrganizationProfilePages(pages), customPages, pageOrder).map( + entry => entry.id, + ); +} diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx index d73f1de4307..cf80f1acbd9 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor, within } from '@testing-library/react'; +import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeAll, describe, expect, it } from 'vitest'; import { page } from 'vitest/browser'; @@ -281,4 +281,52 @@ describe('UserProfile with an active organization', () => { ); expect(tabsDescribedBy('Organization')).toEqual(['Audit log', 'General', 'Members']); }); + + it('keeps the shown page after the open organization page leaves the navigation', async () => { + const globex = fapiOrganization({ id: 'org_2', name: 'Globex' }); + const memberships = [ + fapiMembership(acme, { permissions: ['org:sys_memberships:read'] }), + fapiMembership(globex, { permissions: [] }), + ]; + const user = fapiUser({ id: 'user_1', organization_memberships: memberships }); + serveFapi({ + environment: fapiEnvironment(), + client: fapiClient([fapiSession({ id: 'sess_1', user, last_active_organization_id: acme.id })]), + memberships, + }); + const { clerk } = await renderWithClerk(); + + await act(() => clerk.setActive({ organization: globex.id })); + await waitFor(() => expect(tabsDescribedBy('Organization')).toEqual(['General'])); + await act(() => clerk.setActive({ organization: acme.id })); + await waitFor(() => expect(tabsDescribedBy('Organization')).toEqual(['General', 'Members'])); + + expect(screen.getByRole('tab', { name: 'Account', description: 'Account' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + + it('stays on the organization page when the next organization lists it too', async () => { + const globex = fapiOrganization({ id: 'org_2', name: 'Globex' }); + const memberships = [ + fapiMembership(acme, { permissions: ['org:sys_memberships:read'] }), + fapiMembership(globex, { permissions: ['org:sys_memberships:read'] }), + ]; + const user = fapiUser({ id: 'user_1', organization_memberships: memberships }); + serveFapi({ + environment: fapiEnvironment(), + client: fapiClient([fapiSession({ id: 'sess_1', user, last_active_organization_id: acme.id })]), + memberships, + }); + const { clerk } = await renderWithClerk(); + + await act(() => clerk.setActive({ organization: globex.id })); + await waitFor(() => expect(clerk.organization?.id).toBe(globex.id)); + + expect(screen.getByRole('tab', { name: 'Members', description: 'Organization' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile.layout.ts b/packages/mosaic/src/features/user-profile/user-profile.layout.ts index 2410672bf9e..ca3265c66cd 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.layout.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.layout.ts @@ -1,3 +1,5 @@ +import { logger } from '@clerk/shared/logger'; + import type { IconComponent } from '../../icons/glyph'; import { Code } from '../../icons/glyphs/code'; import { CreditCard } from '../../icons/glyphs/credit-card'; @@ -55,3 +57,15 @@ export function resolveUserProfilePages( ]; return applyOrder(order, entries, entry => entry.id); } + +export function withoutOrganizationPaths(customPages: readonly CustomProfilePage[] = []): CustomProfilePage[] { + return customPages.filter(page => { + if (!page.path.startsWith(ORGANIZATION_PAGE_PREFIX)) { + return true; + } + logger.warnOnce( + `[Clerk] UserProfile custom page "${page.path}" uses the reserved "${ORGANIZATION_PAGE_PREFIX}" prefix and was skipped.`, + ); + return false; + }); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile.tsx b/packages/mosaic/src/features/user-profile/user-profile.tsx index 1591a2ace70..92e0f384b65 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.tsx @@ -5,11 +5,17 @@ import { useState } from 'react'; import type { APIKeysPageProps } from '../api-keys/api-keys-table'; import type { OrganizationProfileProps } from '../organization-profile/organization-profile'; -import { organizationProfilePanels } from '../organization-profile/organization-profile'; +import { organizationProfilePageIds, organizationProfilePanels } from '../organization-profile/organization-profile'; import { useOrganizationProfileModel } from '../organization-profile/organization-profile.model'; import { OrganizationProfileProvider } from '../organization-profile/organization-profile.provider'; import { useUserProfilePages } from '../switcher/switcher.pages'; import type { SwitcherMode } from '../switcher/switcher.types'; +import { + getAvailableUserProfilePages, + ORGANIZATION_PAGE_PREFIX, + resolveUserProfilePages, + withoutOrganizationPaths, +} from './user-profile.layout'; import type { UserProfileOptions } from './user-profile.provider'; import { UserProfileProvider } from './user-profile.provider'; import type { UserProfileViewProps } from './user-profile.view'; @@ -130,9 +136,6 @@ export function UserProfile({ }: UserProfileProps) { const model = useUserProfileUserModel(); const builtInPages = useUserProfilePages(); - const [activePage, setActivePage] = useState( - defaultPage ?? pageOrder?.[0] ?? 'account', - ); if (model.status === 'loading') { return fallback ?? null; @@ -141,10 +144,9 @@ export function UserProfile({ return null; } - const viewProps: UserProfileViewProps = { + const viewProps: AccountViewProps = { elevation: 'flush', - activePage, - onPageChange: setActivePage, + initialPage: defaultPage ?? pageOrder?.[0] ?? 'account', customPages, pageOrder, pages: { @@ -161,7 +163,7 @@ export function UserProfile({ mode='mounted' > {mode === 'user' ? ( - + ) : ( & { + initialPage: UserProfileViewProps['activePage']; +}; + +function AccountUserProfileView({ initialPage, ...viewProps }: AccountViewProps) { + const [activePage, setActivePage] = useState(initialPage); + + return ( + + ); +} + function CombinedUserProfileView({ + initialPage, organizationProfileProps: { customPages, pageOrder, apiKeysProps, afterLeaveOrganizationUrl } = {}, ...viewProps -}: UserProfileViewProps & Pick) { +}: AccountViewProps & Pick) { const model = useOrganizationProfileModel(); + const [activePage, setActivePage] = useState(initialPage); + const organization = + model.status === 'ready' + ? { pages: organizationProfilePanels(model.pages, apiKeysProps), customPages, pageOrder } + : undefined; + + if (model.status !== 'loading' && activePage.startsWith(ORGANIZATION_PAGE_PREFIX)) { + const organizationPageIds = organization + ? organizationProfilePageIds(organization).map(id => `${ORGANIZATION_PAGE_PREFIX}${id}`) + : []; + if (!organizationPageIds.includes(activePage)) { + setActivePage( + resolveUserProfilePages( + getAvailableUserProfilePages(viewProps.pages), + withoutOrganizationPaths(viewProps.customPages), + viewProps.pageOrder, + )[0]?.id ?? 'account', + ); + } + } return ( ); diff --git a/packages/mosaic/src/features/user-profile/user-profile.view.tsx b/packages/mosaic/src/features/user-profile/user-profile.view.tsx index 27994a99272..bda54dbce02 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.view.tsx @@ -13,6 +13,7 @@ import { ORGANIZATION_PAGE_PREFIX, resolveUserProfilePages, USER_PROFILE_PAGE_ICONS, + withoutOrganizationPaths, } from './user-profile.layout'; import type { CustomProfilePage, UserProfilePageId, UserProfilePages } from './user-profile.types'; @@ -52,16 +53,10 @@ export const UserProfileView = React.forwardRef { - if (!page.path.startsWith(ORGANIZATION_PAGE_PREFIX)) { - return true; - } - logger.warnOnce( - `[Clerk] UserProfile custom page "${page.path}" uses the reserved "${ORGANIZATION_PAGE_PREFIX}" prefix and was skipped.`, - ); - return false; - }); - const accountPages = userProfilePageList({ pages, customPages: accountCustomPages, pageOrder }, m.pages); + const accountPages = userProfilePageList( + { pages, customPages: withoutOrganizationPaths(customPages), pageOrder }, + m.pages, + ); const groups: ProfilePageGroup[] = organization ? [ { label: m.accountGroup, pages: accountPages }, From 31761ba8e240c01c8a9c4cd19a0afafda922a292 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 28 Oct 2026 18:06:53 -0600 Subject: [PATCH 2/4] docs(mosaic): note that organizationProfileProps.pageOrder takes unprefixed ids --- packages/mosaic/src/features/user-profile/user-profile.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile.tsx b/packages/mosaic/src/features/user-profile/user-profile.tsx index 92e0f384b65..284d6cacb44 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.tsx @@ -60,7 +60,8 @@ export interface UserProfileProps mode?: UserProfileMode; /** * Options for the organization's pages in `combined` mode: your own pages and their order, the API - * keys page, and where leaving the organization lands. + * keys page, and where leaving the organization lands. `pageOrder` names the organization's pages + * without the `organization/` prefix, as in `['members', 'general']`. */ organizationProfileProps?: Pick< OrganizationProfileProps, From 2f988b8efb62512d936b2faea0ef7d20dd70d4d1 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 28 Oct 2026 18:22:32 -0600 Subject: [PATCH 3/4] refactor(mosaic): move profile navigation state into controllers --- .../organization-profile.controller.ts | 30 +++++++++++ .../organization-profile.layout.ts | 11 ++++ .../organization-profile.tsx | 32 +++--------- .../user-profile/user-profile.controller.ts | 45 ++++++++++++++++ .../features/user-profile/user-profile.tsx | 52 +++++++------------ 5 files changed, 110 insertions(+), 60 deletions(-) create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile.controller.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile.controller.ts diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.controller.ts b/packages/mosaic/src/features/organization-profile/organization-profile.controller.ts new file mode 100644 index 00000000000..4f1542df339 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile.controller.ts @@ -0,0 +1,30 @@ +import { useState } from 'react'; + +import { organizationProfilePageIds } from './organization-profile.layout'; +import type { OrganizationProfilePageListOptions, OrganizationProfileViewProps } from './organization-profile.view'; + +type OrganizationProfileControllerOptions = Pick & { + pages?: OrganizationProfilePageListOptions['pages']; + defaultPage?: OrganizationProfileViewProps['activePage']; +}; + +export function useOrganizationProfileController({ + pages, + customPages, + pageOrder, + defaultPage, +}: OrganizationProfileControllerOptions) { + const [activePage, setActivePage] = useState( + defaultPage ?? pageOrder?.[0] ?? 'general', + ); + + if (pages) { + const pageIds = organizationProfilePageIds({ pages, customPages, pageOrder }); + const firstPage = pageIds[0]; + if (firstPage !== undefined && !pageIds.includes(activePage)) { + setActivePage(firstPage); + } + } + + return { activePage, onPageChange: setActivePage }; +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts b/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts index e2d14f244bd..53a103ef079 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts @@ -11,6 +11,7 @@ import type { OrganizationProfilePageId, OrganizationProfilePages, } from './organization-profile.types'; +import type { OrganizationProfilePageListOptions } from './organization-profile.view'; export const ORGANIZATION_PROFILE_PAGE_IDS: readonly OrganizationProfilePageId[] = [ 'general', @@ -56,3 +57,13 @@ export function resolveOrganizationProfilePages( ]; return applyOrder(order, entries, entry => entry.id); } + +export function organizationProfilePageIds({ + pages, + customPages, + pageOrder, +}: OrganizationProfilePageListOptions): string[] { + return resolveOrganizationProfilePages(getAvailableOrganizationProfilePages(pages), customPages, pageOrder).map( + entry => entry.id, + ); +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.tsx index 442767906f8..cdffb75ce62 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile.tsx @@ -1,15 +1,14 @@ 'use client'; import type { ReactNode } from 'react'; -import { useState } from 'react'; import type { APIKeysPageProps } from '../api-keys/api-keys-table'; -import { getAvailableOrganizationProfilePages, resolveOrganizationProfilePages } from './organization-profile.layout'; +import { useOrganizationProfileController } from './organization-profile.controller'; import { useOrganizationProfileModel } from './organization-profile.model'; import type { OrganizationProfileOptions } from './organization-profile.provider'; import { OrganizationProfileProvider } from './organization-profile.provider'; import type { OrganizationProfilePageId, OrganizationProfilePages } from './organization-profile.types'; -import type { OrganizationProfilePageListOptions, OrganizationProfileViewProps } from './organization-profile.view'; +import type { OrganizationProfileViewProps } from './organization-profile.view'; import { OrganizationProfileView } from './organization-profile.view'; import { OrganizationProfileApiKeysPanel } from './organization-profile-api-keys-panel'; import { OrganizationProfileGeneralPanel } from './organization-profile-general-panel'; @@ -88,30 +87,21 @@ export function OrganizationProfile({ apiKeysProps, }: OrganizationProfileProps) { const model = useOrganizationProfileModel(); - const [activePage, setActivePage] = useState( - defaultPage ?? pageOrder?.[0] ?? 'general', - ); + const pages = model.status === 'ready' ? organizationProfilePanels(model.pages, apiKeysProps) : undefined; + const navigation = useOrganizationProfileController({ pages, customPages, pageOrder, defaultPage }); if (model.status === 'loading') { return fallback ?? null; } - if (model.status === 'hidden') { + if (!pages) { return null; } - const pages = organizationProfilePanels(model.pages, apiKeysProps); - const pageIds = organizationProfilePageIds({ pages, customPages, pageOrder }); - const firstPage = pageIds[0]; - if (firstPage !== undefined && !pageIds.includes(activePage)) { - setActivePage(firstPage); - } - return ( : undefined, }; } - -export function organizationProfilePageIds({ - pages, - customPages, - pageOrder, -}: OrganizationProfilePageListOptions): string[] { - return resolveOrganizationProfilePages(getAvailableOrganizationProfilePages(pages), customPages, pageOrder).map( - entry => entry.id, - ); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile.controller.ts b/packages/mosaic/src/features/user-profile/user-profile.controller.ts new file mode 100644 index 00000000000..39f839a2119 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile.controller.ts @@ -0,0 +1,45 @@ +import { useState } from 'react'; + +import { organizationProfilePageIds } from '../organization-profile/organization-profile.layout'; +import { + getAvailableUserProfilePages, + ORGANIZATION_PAGE_PREFIX, + resolveUserProfilePages, + withoutOrganizationPaths, +} from './user-profile.layout'; +import type { UserProfileViewProps } from './user-profile.view'; + +type UserProfileControllerOptions = Pick & { + initialPage: UserProfileViewProps['activePage']; + organization?: + | { status: 'loading' | 'hidden' } + | { status: 'ready'; pageList: NonNullable }; +}; + +export function useUserProfileController({ + initialPage, + pages, + customPages, + pageOrder, + organization, +}: UserProfileControllerOptions) { + const [activePage, setActivePage] = useState(initialPage); + + if (organization && organization.status !== 'loading' && activePage.startsWith(ORGANIZATION_PAGE_PREFIX)) { + const organizationPageIds = + organization.status === 'ready' + ? organizationProfilePageIds(organization.pageList).map(id => `${ORGANIZATION_PAGE_PREFIX}${id}`) + : []; + if (!organizationPageIds.includes(activePage)) { + setActivePage( + resolveUserProfilePages( + getAvailableUserProfilePages(pages), + withoutOrganizationPaths(customPages), + pageOrder, + )[0]?.id ?? 'account', + ); + } + } + + return { activePage, onPageChange: setActivePage }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile.tsx b/packages/mosaic/src/features/user-profile/user-profile.tsx index 284d6cacb44..f77d428ef11 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.tsx @@ -1,21 +1,15 @@ 'use client'; import type { ReactNode } from 'react'; -import { useState } from 'react'; import type { APIKeysPageProps } from '../api-keys/api-keys-table'; import type { OrganizationProfileProps } from '../organization-profile/organization-profile'; -import { organizationProfilePageIds, organizationProfilePanels } from '../organization-profile/organization-profile'; +import { organizationProfilePanels } from '../organization-profile/organization-profile'; import { useOrganizationProfileModel } from '../organization-profile/organization-profile.model'; import { OrganizationProfileProvider } from '../organization-profile/organization-profile.provider'; import { useUserProfilePages } from '../switcher/switcher.pages'; import type { SwitcherMode } from '../switcher/switcher.types'; -import { - getAvailableUserProfilePages, - ORGANIZATION_PAGE_PREFIX, - resolveUserProfilePages, - withoutOrganizationPaths, -} from './user-profile.layout'; +import { useUserProfileController } from './user-profile.controller'; import type { UserProfileOptions } from './user-profile.provider'; import { UserProfileProvider } from './user-profile.provider'; import type { UserProfileViewProps } from './user-profile.view'; @@ -175,18 +169,17 @@ export function UserProfile({ ); } -type AccountViewProps = Omit & { +type AccountViewProps = Omit & { initialPage: UserProfileViewProps['activePage']; }; function AccountUserProfileView({ initialPage, ...viewProps }: AccountViewProps) { - const [activePage, setActivePage] = useState(initialPage); + const navigation = useUserProfileController({ initialPage, ...viewProps }); return ( ); } @@ -197,34 +190,25 @@ function CombinedUserProfileView({ ...viewProps }: AccountViewProps & Pick) { const model = useOrganizationProfileModel(); - const [activePage, setActivePage] = useState(initialPage); - const organization = + const organizationState = model.status === 'ready' - ? { pages: organizationProfilePanels(model.pages, apiKeysProps), customPages, pageOrder } - : undefined; - - if (model.status !== 'loading' && activePage.startsWith(ORGANIZATION_PAGE_PREFIX)) { - const organizationPageIds = organization - ? organizationProfilePageIds(organization).map(id => `${ORGANIZATION_PAGE_PREFIX}${id}`) - : []; - if (!organizationPageIds.includes(activePage)) { - setActivePage( - resolveUserProfilePages( - getAvailableUserProfilePages(viewProps.pages), - withoutOrganizationPaths(viewProps.customPages), - viewProps.pageOrder, - )[0]?.id ?? 'account', - ); - } - } + ? { + status: 'ready' as const, + pageList: { pages: organizationProfilePanels(model.pages, apiKeysProps), customPages, pageOrder }, + } + : { status: model.status }; + const navigation = useUserProfileController({ + initialPage, + ...viewProps, + organization: organizationState, + }); return ( ); From 7731e2f2bd41cc15e87504907372e8a5e45e8c16 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 29 Oct 2026 14:28:55 -0400 Subject: [PATCH 4/4] refactor(mosaic): resolve the open profile page with one shared controller (#10530) --- .changeset/shy-pages-share.md | 2 + .../organization-profile.controller.ts | 30 ---------- .../organization-profile.layout.ts | 14 +++-- .../organization-profile.tsx | 8 ++- .../organization-profile.types.ts | 13 +++++ .../organization-profile.view.tsx | 56 ++++++------------- .../profile-pages/profile-pages.controller.ts | 14 +++++ .../user-profile/user-profile.controller.ts | 45 --------------- .../user-profile/user-profile.layout.ts | 27 ++++++++- .../features/user-profile/user-profile.tsx | 23 ++++---- .../user-profile/user-profile.view.tsx | 21 +++---- 11 files changed, 103 insertions(+), 150 deletions(-) create mode 100644 .changeset/shy-pages-share.md delete mode 100644 packages/mosaic/src/features/organization-profile/organization-profile.controller.ts create mode 100644 packages/mosaic/src/features/profile-pages/profile-pages.controller.ts delete mode 100644 packages/mosaic/src/features/user-profile/user-profile.controller.ts diff --git a/.changeset/shy-pages-share.md b/.changeset/shy-pages-share.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/shy-pages-share.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.controller.ts b/packages/mosaic/src/features/organization-profile/organization-profile.controller.ts deleted file mode 100644 index 4f1542df339..00000000000 --- a/packages/mosaic/src/features/organization-profile/organization-profile.controller.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useState } from 'react'; - -import { organizationProfilePageIds } from './organization-profile.layout'; -import type { OrganizationProfilePageListOptions, OrganizationProfileViewProps } from './organization-profile.view'; - -type OrganizationProfileControllerOptions = Pick & { - pages?: OrganizationProfilePageListOptions['pages']; - defaultPage?: OrganizationProfileViewProps['activePage']; -}; - -export function useOrganizationProfileController({ - pages, - customPages, - pageOrder, - defaultPage, -}: OrganizationProfileControllerOptions) { - const [activePage, setActivePage] = useState( - defaultPage ?? pageOrder?.[0] ?? 'general', - ); - - if (pages) { - const pageIds = organizationProfilePageIds({ pages, customPages, pageOrder }); - const firstPage = pageIds[0]; - if (firstPage !== undefined && !pageIds.includes(activePage)) { - setActivePage(firstPage); - } - } - - return { activePage, onPageChange: setActivePage }; -} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts b/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts index 53a103ef079..176d7bd842c 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts @@ -9,9 +9,9 @@ import { withUniquePaths } from '../profile-pages/profile-pages.layout'; import type { CustomOrganizationProfilePage, OrganizationProfilePageId, + OrganizationProfilePageListOptions, OrganizationProfilePages, } from './organization-profile.types'; -import type { OrganizationProfilePageListOptions } from './organization-profile.view'; export const ORGANIZATION_PROFILE_PAGE_IDS: readonly OrganizationProfilePageId[] = [ 'general', @@ -58,12 +58,14 @@ export function resolveOrganizationProfilePages( return applyOrder(order, entries, entry => entry.id); } -export function organizationProfilePageIds({ +export function organizationProfileNavEntries({ pages, customPages, pageOrder, -}: OrganizationProfilePageListOptions): string[] { - return resolveOrganizationProfilePages(getAvailableOrganizationProfilePages(pages), customPages, pageOrder).map( - entry => entry.id, - ); +}: OrganizationProfilePageListOptions): OrganizationProfileNavEntry[] { + return resolveOrganizationProfilePages(getAvailableOrganizationProfilePages(pages), customPages, pageOrder); +} + +export function organizationProfilePageIds(options: OrganizationProfilePageListOptions): string[] { + return organizationProfileNavEntries(options).map(entry => entry.id); } diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.tsx index cdffb75ce62..9d736de54df 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile.tsx @@ -3,7 +3,8 @@ import type { ReactNode } from 'react'; import type { APIKeysPageProps } from '../api-keys/api-keys-table'; -import { useOrganizationProfileController } from './organization-profile.controller'; +import { useProfilePagesController } from '../profile-pages/profile-pages.controller'; +import { organizationProfilePageIds } from './organization-profile.layout'; import { useOrganizationProfileModel } from './organization-profile.model'; import type { OrganizationProfileOptions } from './organization-profile.provider'; import { OrganizationProfileProvider } from './organization-profile.provider'; @@ -88,7 +89,10 @@ export function OrganizationProfile({ }: OrganizationProfileProps) { const model = useOrganizationProfileModel(); const pages = model.status === 'ready' ? organizationProfilePanels(model.pages, apiKeysProps) : undefined; - const navigation = useOrganizationProfileController({ pages, customPages, pageOrder, defaultPage }); + const navigation = useProfilePagesController( + defaultPage ?? pageOrder?.[0] ?? 'general', + pages && organizationProfilePageIds({ pages, customPages, pageOrder }), + ); if (model.status === 'loading') { return fallback ?? null; diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.types.ts b/packages/mosaic/src/features/organization-profile/organization-profile.types.ts index d632bbd9f7e..8c421e9a5bf 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.types.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile.types.ts @@ -17,3 +17,16 @@ export interface CustomOrganizationProfilePage { icon?: ReactNode; content: ReactNode; } + +/** What the organization's navigation is built from. */ +export interface OrganizationProfilePageListOptions { + /** The built-in pages. One left undefined is left out of the navigation. */ + pages: OrganizationProfilePages; + /** Pages of the consumer's own, added to the navigation after the built-ins. */ + customPages?: readonly CustomOrganizationProfilePage[]; + /** + * The order the navigation runs in, by id: a built-in page's id, or a custom page's `path`. Ids + * left out keep their default place behind the ones named. + */ + pageOrder?: readonly (OrganizationProfilePageId | (string & {}))[]; +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx index 9f55147fe40..7db4186450b 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx @@ -5,29 +5,8 @@ import { Icon } from '../../components/icon'; import { useMessages } from '../../localization'; import type { ProfilePage, ProfilePagesViewProps } from '../profile-pages/profile-pages.view'; import { ProfilePagesView } from '../profile-pages/profile-pages.view'; -import { - getAvailableOrganizationProfilePages, - ORGANIZATION_PROFILE_PAGE_ICONS, - resolveOrganizationProfilePages, -} from './organization-profile.layout'; -import type { - CustomOrganizationProfilePage, - OrganizationProfilePageId, - OrganizationProfilePages, -} from './organization-profile.types'; - -/** What the organization's navigation is built from. */ -export interface OrganizationProfilePageListOptions { - /** The built-in pages. One left undefined is left out of the navigation. */ - pages: OrganizationProfilePages; - /** Pages of the consumer's own, added to the navigation after the built-ins. */ - customPages?: readonly CustomOrganizationProfilePage[]; - /** - * The order the navigation runs in, by id: a built-in page's id, or a custom page's `path`. Ids - * left out keep their default place behind the ones named. - */ - pageOrder?: readonly (OrganizationProfilePageId | (string & {}))[]; -} +import { ORGANIZATION_PROFILE_PAGE_ICONS, organizationProfileNavEntries } from './organization-profile.layout'; +import type { OrganizationProfilePageId, OrganizationProfilePageListOptions } from './organization-profile.types'; export interface OrganizationProfileViewProps extends @@ -59,23 +38,22 @@ export const OrganizationProfileView = React.forwardRef, ): ProfilePage[] { - return resolveOrganizationProfilePages(getAvailableOrganizationProfilePages(pages), customPages, pageOrder).map( - entry => - entry.custom - ? { id: entry.id, label: entry.custom.label, icon: entry.custom.icon, content: entry.custom.content } - : { - id: entry.id, - label: labels[entry.id], - icon: ( - - ), - content: pages[entry.id], - }, + return organizationProfileNavEntries(options).map(entry => + entry.custom + ? { id: entry.id, label: entry.custom.label, icon: entry.custom.icon, content: entry.custom.content } + : { + id: entry.id, + label: labels[entry.id], + icon: ( + + ), + content: options.pages[entry.id], + }, ); } diff --git a/packages/mosaic/src/features/profile-pages/profile-pages.controller.ts b/packages/mosaic/src/features/profile-pages/profile-pages.controller.ts new file mode 100644 index 00000000000..bfd0d37f898 --- /dev/null +++ b/packages/mosaic/src/features/profile-pages/profile-pages.controller.ts @@ -0,0 +1,14 @@ +import { useState } from 'react'; + +export function useProfilePagesController(initialPage: string, pageIds: readonly string[] | undefined) { + const [activePage, setActivePage] = useState(initialPage); + + if (pageIds) { + const [firstPage] = pageIds; + if (firstPage !== undefined && !pageIds.includes(activePage)) { + setActivePage(firstPage); + } + } + + return { activePage, onPageChange: setActivePage }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile.controller.ts b/packages/mosaic/src/features/user-profile/user-profile.controller.ts deleted file mode 100644 index 39f839a2119..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile.controller.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useState } from 'react'; - -import { organizationProfilePageIds } from '../organization-profile/organization-profile.layout'; -import { - getAvailableUserProfilePages, - ORGANIZATION_PAGE_PREFIX, - resolveUserProfilePages, - withoutOrganizationPaths, -} from './user-profile.layout'; -import type { UserProfileViewProps } from './user-profile.view'; - -type UserProfileControllerOptions = Pick & { - initialPage: UserProfileViewProps['activePage']; - organization?: - | { status: 'loading' | 'hidden' } - | { status: 'ready'; pageList: NonNullable }; -}; - -export function useUserProfileController({ - initialPage, - pages, - customPages, - pageOrder, - organization, -}: UserProfileControllerOptions) { - const [activePage, setActivePage] = useState(initialPage); - - if (organization && organization.status !== 'loading' && activePage.startsWith(ORGANIZATION_PAGE_PREFIX)) { - const organizationPageIds = - organization.status === 'ready' - ? organizationProfilePageIds(organization.pageList).map(id => `${ORGANIZATION_PAGE_PREFIX}${id}`) - : []; - if (!organizationPageIds.includes(activePage)) { - setActivePage( - resolveUserProfilePages( - getAvailableUserProfilePages(pages), - withoutOrganizationPaths(customPages), - pageOrder, - )[0]?.id ?? 'account', - ); - } - } - - return { activePage, onPageChange: setActivePage }; -} diff --git a/packages/mosaic/src/features/user-profile/user-profile.layout.ts b/packages/mosaic/src/features/user-profile/user-profile.layout.ts index ca3265c66cd..e3df4d774f2 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.layout.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.layout.ts @@ -6,6 +6,8 @@ import { CreditCard } from '../../icons/glyphs/credit-card'; import { ShieldCheck } from '../../icons/glyphs/shield-check'; import { UserCircle } from '../../icons/glyphs/user-circle'; import { applyOrder } from '../../utils/apply-order'; +import { organizationProfilePageIds } from '../organization-profile/organization-profile.layout'; +import type { OrganizationProfilePageListOptions } from '../organization-profile/organization-profile.types'; import { withUniquePaths } from '../profile-pages/profile-pages.layout'; import type { CustomProfilePage, UserProfilePageId, UserProfilePages } from './user-profile.types'; @@ -58,7 +60,30 @@ export function resolveUserProfilePages( return applyOrder(order, entries, entry => entry.id); } -export function withoutOrganizationPaths(customPages: readonly CustomProfilePage[] = []): CustomProfilePage[] { +type UserProfilePageListOptions = { + pages: UserProfilePages; + customPages?: readonly CustomProfilePage[]; + pageOrder?: readonly string[]; + organization?: OrganizationProfilePageListOptions; +}; + +export function userProfileNavEntries({ + pages, + customPages, + pageOrder, +}: UserProfilePageListOptions): UserProfileNavEntry[] { + return resolveUserProfilePages(getAvailableUserProfilePages(pages), withoutOrganizationPaths(customPages), pageOrder); +} + +export function userProfilePageIds(options: UserProfilePageListOptions): string[] { + const accountPageIds = userProfileNavEntries(options).map(entry => entry.id); + const organizationPageIds = options.organization + ? organizationProfilePageIds(options.organization).map(id => `${ORGANIZATION_PAGE_PREFIX}${id}`) + : []; + return [...accountPageIds, ...organizationPageIds]; +} + +function withoutOrganizationPaths(customPages: readonly CustomProfilePage[] = []): CustomProfilePage[] { return customPages.filter(page => { if (!page.path.startsWith(ORGANIZATION_PAGE_PREFIX)) { return true; diff --git a/packages/mosaic/src/features/user-profile/user-profile.tsx b/packages/mosaic/src/features/user-profile/user-profile.tsx index f77d428ef11..99e07acb914 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.tsx @@ -7,9 +7,10 @@ import type { OrganizationProfileProps } from '../organization-profile/organizat import { organizationProfilePanels } from '../organization-profile/organization-profile'; import { useOrganizationProfileModel } from '../organization-profile/organization-profile.model'; import { OrganizationProfileProvider } from '../organization-profile/organization-profile.provider'; +import { useProfilePagesController } from '../profile-pages/profile-pages.controller'; import { useUserProfilePages } from '../switcher/switcher.pages'; import type { SwitcherMode } from '../switcher/switcher.types'; -import { useUserProfileController } from './user-profile.controller'; +import { userProfilePageIds } from './user-profile.layout'; import type { UserProfileOptions } from './user-profile.provider'; import { UserProfileProvider } from './user-profile.provider'; import type { UserProfileViewProps } from './user-profile.view'; @@ -174,7 +175,7 @@ type AccountViewProps = Omit) { const model = useOrganizationProfileModel(); - const organizationState = + const organization = model.status === 'ready' - ? { - status: 'ready' as const, - pageList: { pages: organizationProfilePanels(model.pages, apiKeysProps), customPages, pageOrder }, - } - : { status: model.status }; - const navigation = useUserProfileController({ + ? { pages: organizationProfilePanels(model.pages, apiKeysProps), customPages, pageOrder } + : undefined; + const navigation = useProfilePagesController( initialPage, - ...viewProps, - organization: organizationState, - }); + model.status === 'loading' ? undefined : userProfilePageIds({ ...viewProps, organization }), + ); return ( ); diff --git a/packages/mosaic/src/features/user-profile/user-profile.view.tsx b/packages/mosaic/src/features/user-profile/user-profile.view.tsx index bda54dbce02..19845659935 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.view.tsx @@ -3,18 +3,14 @@ import React from 'react'; import { Icon } from '../../components/icon'; import { useMessages } from '../../localization'; -import type { OrganizationProfilePageId } from '../organization-profile/organization-profile.types'; -import type { OrganizationProfilePageListOptions } from '../organization-profile/organization-profile.view'; +import type { + OrganizationProfilePageId, + OrganizationProfilePageListOptions, +} from '../organization-profile/organization-profile.types'; import { organizationProfilePageList } from '../organization-profile/organization-profile.view'; import type { ProfilePage, ProfilePageGroup, ProfilePagesViewProps } from '../profile-pages/profile-pages.view'; import { ProfilePagesView } from '../profile-pages/profile-pages.view'; -import { - getAvailableUserProfilePages, - ORGANIZATION_PAGE_PREFIX, - resolveUserProfilePages, - USER_PROFILE_PAGE_ICONS, - withoutOrganizationPaths, -} from './user-profile.layout'; +import { ORGANIZATION_PAGE_PREFIX, USER_PROFILE_PAGE_ICONS, userProfileNavEntries } from './user-profile.layout'; import type { CustomProfilePage, UserProfilePageId, UserProfilePages } from './user-profile.types'; export interface UserProfileViewProps extends Omit< @@ -53,10 +49,7 @@ export const UserProfileView = React.forwardRef, labels: Record, ): ProfilePage[] { - return resolveUserProfilePages(getAvailableUserProfilePages(pages), customPages, pageOrder).map(entry => + return userProfileNavEntries({ pages, customPages, pageOrder }).map(entry => entry.custom ? { id: entry.id, label: entry.custom.label, icon: entry.custom.icon, content: entry.custom.content } : {