feat(ui): add UserButton controller - #9185
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository YAML (base), Repository UI (inherited) Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
8786841 to
ba22f92
Compare
ba22f92 to
c9bdf23
Compare
c9bdf23 to
93aa6f2
Compare
93aa6f2 to
4643795
Compare
4643795 to
4467f3d
Compare
4467f3d to
fbd5c2f
Compare
API Changes Report
Summary
No API Changes DetectedAll packages have stable APIs with no detected changes. Report generated by Break Check Last ran on |
47901d4 to
6a9f56e
Compare
6a9f56e to
e44cc6d
Compare
Every other action — switching account, signing out of one, joining a suggested or invited workspace — now resolves back into an open popover so the result is visible where it happened. The swingset prototypes fake the round trip they make against Clerk, so the spinner and stood-down rows are demonstrable without a running app.
The trigger carried the avatar alone. It now names what is active beside it — the organization and its plan wherever one heads the trigger, the account otherwise — behind `showLabel`, which defaults on. Badge's `neutral` color was unreadable in both schemes: its fill is a 900 and its text token is a text color, not an on-fill one. It now rides the same black/white scrim the button's neutral fill does.
`showLabel` becomes `renderTriggerLabel`, and the plan badge gets its own `renderPlanBadge`. The badge is part of the label, so it needs both.
…ccount The trigger and the popup's header now always name the same workspace. `combined` carries both switchers, so `modePriority` picks which one it leads with: the active organization by default, the account with `modePriority="user"`. Both are still listed either way.
…g state `useOrganization()` resolves before the organization list does, so the controller describes the active organization from the resource itself rather than leaving the view to find it in a list that has not arrived. `organizationsLoading` covers that window, and revoked or expired invitations are dropped since accepting is all an invitation row offers. Accepting an invitation joins the organization, so it now revalidates the membership list alongside the invitation one.
…ists load The membership count is 0 until the first page lands, so the surface had no way to tell an account with no organizations from one whose list had yet to arrive, and opened a workspace section under both. The user resource carries its own memberships, so the question is settled before any request goes out; the fetched count still counts, in case the resource is behind the server.
Managing an account and managing an organization both navigated to Clerk's built-in profile URLs. Both now open the corresponding modal instead, which is what `<UserButton>` and `<OrganizationSwitcher>` each already do, so returning from one puts you back where you were rather than on another page. Apps that would rather route take `userProfileUrl` and `organizationProfileUrl`, in the same url-plus-mode shape as the existing components: a URL is the whole opt-in to navigation, and `modal` forbids one, so the pair cannot contradict itself. The two profiles resolve apart, so routing one leaves the other a modal. Inviting members follows wherever managing the organization goes. It is the other way into administering the same organization, so splitting them would send one to the app's own page and the other to Clerk's.
The profile modals opened into `document.body`, so an app that mounts the button inside its own dialog or popover got the modal rendered behind it. Both now open into the portal root from `usePortalRoot`, matching what the pre-Mosaic `<UserButton>` and `<OrganizationSwitcher>` pass as `getContainer`.
Switching into an organization was a one-way door: nothing on the surface cleared the active one. The controller now offers the account's own workspace as a selectable row, and withholds it where there are no organizations to leave.
Selecting the personal workspace had nowhere to land, since a redirect resolved against an organization has nothing to resolve. `afterSelectPersonalUrl` gives it its own, taking the same path template or builder function as `afterSelectOrganizationUrl` and resolving against the user.
…s required
An instance with force organization selection enabled has no personal workspace to
return to: clerk-js refuses `setActive({ organization: null })` outright there, so the
row stood in the list and did nothing when clicked. It is now withheld, the way the
existing OrganizationSwitcher hides personal under the same setting.
`hidePersonal` leaves the account's own workspace out of the list, for an app whose organizations are the whole product. It joins the instance setting rather than overriding it: where organization selection is forced there is no personal workspace either way, and passing `false` cannot opt back into one.
The row was offered unconditionally, so a user whose instance restricts organization creation, or who is at their creation limit, landed on a page that turns them away. Legacy gates the same action on `user.createOrganizationEnabled`.
Create organization always navigated to Clerk's hosted page. It now resolves the same way the two profile surfaces do: a modal by default, or navigation when `createOrganizationUrl` routes it. The shared helper is renamed `openOrNavigate`, since it no longer serves only profiles.
Invite has no page of its own, so it opens Clerk.openInviteMembers() rather than following organizationProfileUrl into the app's own page.
e44cc6d to
b9b02e1
Compare
Summary
Stacked on #9184 (the presentational view). Adds the controller layer that connects
UserButtonto live Clerk data, plus its tests (TDD).useUserButtonController()(user-button.controller.tsx) — a plain hook (no machine) returning a'loading' | 'hidden' | 'ready'discriminated union. Whenreadyit carriesUserButtonData & UserButtonCallbacks1:1 with the contract inuser-button.view.tsx.UserButton(user-button.tsx) — the connected container. Owns popover open state and close-on-success (Option A: wraps each one-shot callback with.finally(close); manage/create navigations do not auto-close).UserButton→UserButtonView, reserving the bareUserButtonfor the connected container. Updated the swingset story + mdx.Data mapping (Clerk → contract)
activeSessionfromuseUser()+useSession(), name fallback first+last > username > email.activeOrganizationIdfromuseOrganization()(null= personal).memberships/suggestions/invitationsfromuseOrganizationList(organizationListParams)(reused from the legacy switcher).hasOrganizationsfromuserMemberships.count(never array length).additionalSessionsfromclerk.client.signedInSessions, excluding the active user's session.membershipRequestCountonly on the active-org row and only withorg:sys_memberships:manage.planLabel/upgradeabledeferred (undefined).Actions (navigation mode)
setActivefor select/switch,signOutfor sign-out,router.navigate(clerk.build*Url())for manage/create,.accept()+revalidate()for invitations/suggestions.Tests
12 controller tests (
__tests__/user-button.controller.test.tsx) using the mosaicvi.mock('@clerk/shared/react')harness (copied fromorganization-profile-delete-section.controller.test.tsx). Written failing first, then implemented to green.Notes / deviations from the handoff
Two points where the handoff wasn't directly implementable; resolved as follows:
onSelectOrganizationnavigation. The handoff referenceddisplayConfig.afterSelectOrganizationUrl, but that field only exists as a component prop, not on the environment. Per decision, navigation goes throughsetActive({ organization, redirectUrl })(matching the legacy switcher), usingdisplayConfig.afterCreateOrganizationUrlas the redirect. Fully type-safe, no cast.loading → <UserButtonView status="loading" />, but the view requiresactiveSession(which is only present whenready) and the view was out of scope to restructure. The container rendersnullwhile loading/hidden and the view only whenready. The controller still exposesstatus: 'loading'for future use.Checklist
turbo test --filter @clerk/ui(12 new + full suite: 2666 passing)type-checkcleaneslintclean (0 errors)