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/.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.layout.ts b/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts index e2d14f244bd..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,6 +9,7 @@ import { withUniquePaths } from '../profile-pages/profile-pages.layout'; import type { CustomOrganizationProfilePage, OrganizationProfilePageId, + OrganizationProfilePageListOptions, OrganizationProfilePages, } from './organization-profile.types'; @@ -56,3 +57,15 @@ export function resolveOrganizationProfilePages( ]; return applyOrder(order, entries, entry => entry.id); } + +export function organizationProfileNavEntries({ + pages, + customPages, + pageOrder, +}: 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 a3b5349aaae..9d736de54df 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile.tsx @@ -1,9 +1,10 @@ 'use client'; import type { ReactNode } from 'react'; -import { useState } from 'react'; import type { APIKeysPageProps } from '../api-keys/api-keys-table'; +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'; @@ -87,14 +88,16 @@ export function OrganizationProfile({ apiKeysProps, }: OrganizationProfileProps) { const model = useOrganizationProfileModel(); - const [activePage, setActivePage] = useState( + const pages = model.status === 'ready' ? organizationProfilePanels(model.pages, apiKeysProps) : undefined; + const navigation = useProfilePagesController( defaultPage ?? pageOrder?.[0] ?? 'general', + pages && organizationProfilePageIds({ pages, customPages, pageOrder }), ); if (model.status === 'loading') { return fallback ?? null; } - if (model.status === 'hidden') { + if (!pages) { return null; } @@ -102,11 +105,10 @@ export function OrganizationProfile({ ); 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/__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..e3df4d774f2 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.layout.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.layout.ts @@ -1,9 +1,13 @@ +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'; 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'; @@ -55,3 +59,38 @@ export function resolveUserProfilePages( ]; return applyOrder(order, entries, entry => entry.id); } + +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; + } + 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..99e07acb914 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.tsx @@ -1,15 +1,16 @@ '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 { 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 { 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'; @@ -54,7 +55,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, @@ -130,9 +132,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 +140,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 +159,7 @@ export function UserProfile({ mode='mounted' > {mode === 'user' ? ( - + ) : ( & { + initialPage: UserProfileViewProps['activePage']; +}; + +function AccountUserProfileView({ initialPage, ...viewProps }: AccountViewProps) { + const navigation = useProfilePagesController(initialPage, userProfilePageIds(viewProps)); + + return ( + + ); +} + function CombinedUserProfileView({ + initialPage, organizationProfileProps: { customPages, pageOrder, apiKeysProps, afterLeaveOrganizationUrl } = {}, ...viewProps -}: UserProfileViewProps & Pick) { +}: AccountViewProps & Pick) { const model = useOrganizationProfileModel(); + const organization = + model.status === 'ready' + ? { pages: organizationProfilePanels(model.pages, apiKeysProps), customPages, pageOrder } + : undefined; + const navigation = useProfilePagesController( + initialPage, + 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 27994a99272..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,17 +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, -} 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< @@ -52,16 +49,7 @@ 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, pageOrder }, m.pages); const groups: ProfilePageGroup[] = organization ? [ { label: m.accountGroup, pages: accountPages }, @@ -93,7 +81,7 @@ function userProfilePageList( { pages, customPages, pageOrder }: Pick, 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 } : {