Skip to content

Commit 2420778

Browse files
feat(mobile, app-shell): open the mobile shell's native connection switcher from the header organization button (#29565)
domain:ui#5 · zhuangjianguo · session_01QsR2jQjvWkZ7is6GXbC6DB <!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbNevw71SCDMBexHiueLgSVt)_ Fixes #29458 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:手机 App(原生壳)里点顶栏的组织按钮,现在打开 App 自带的「切换组织」面板,而不是网页下拉菜单;在 App 里只属于一个组织的用户也能看到这个按钮(只读的组织名标记随之让位,顶栏只显示一次组织);用户在 App 里移除某个连接时,Console 会及时应答,不再让 App 干等 10 秒。 - **为什么改**:原生面板能同时列出已保存的组织和公司服务器,网页菜单看不到这些;此前它只能从离线页打开。移除连接的应答是推送令牌注销的接口位,等设备注册(#29042)落地后在这里填上注销动作。 - **风险与回滚**:只在 App 声明了该能力时生效,普通浏览器和旧版 App 行为不变;回滚即还原本 PR,无数据与服务端改动。 - **先看**:`ui/packages/app-shell/src/layout/WorkspaceSwitcher.tsx`(按钮分支,位于单组织判断之前)、`ui/packages/app-shell/src/layout/CurrentOrganizationIndicator.tsx`(App 内让位)、`ui/packages/mobile/src/useNativeHost.ts`(connectionRemoved 应答)。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/layout/__tests__/WorkspaceSwitcher.nativeConnectionSwitcher.test.tsx packages/mobile/src/__tests__/nativeHost.test.tsx`,应全部通过(7 + 64)。 - **需要你做的**:无,席位落地。 ## What changes The bridge contract is the mobile repository's `docs/HYBRID-SHELL.md` (objectstack-ai/mobile#173, merged at `678fbe9`). This session cannot read that repository, so the contract below is taken from the card's quoted text: > **`window.ObjectStackNative.showConnectionSwitcher()`**, feature-detected with `has('showConnectionSwitcher')`. It opens the native 切换组织 sheet: the saved organizations and company servers, plus an add action. > **`objectstack-native:connectionRemoved`**, a window event the shell sends to a loaded Console when the user removes its connection. The Console should: 1. call `event.waitUntil(promise)`; 2. unregister this device's push token with its own session, by the existing device-registry unregister path; 3. resolve. […] If the Console does not answer within 10 s, the shell records the token as left registered. **The header organization button is `WorkspaceSwitcher`** (`data-testid="workspace-switcher"`), which `AppHeader` draws at every width (the badge alone below `sm`). `CurrentOrganizationIndicator` draws no control by design, and `OrgBadge` is only the mark, so neither is the button. - `WorkspaceSwitcher`: when `nativeHostCan(useNativeHost(), 'showConnectionSwitcher')`, the trigger is a plain button that calls `nativeShowConnectionSwitcher()` and no dropdown is mounted. In the shell, with the capability, the button renders whenever there is an active organization, at any membership count: the native branch sits after the no-active-organization guard and before the single-membership guard. `orgList` counts this server's memberships only, while the sheet lists the shell's saved connections across company servers, so a user with one organization on each of two servers has two connections. Without the capability (plain browser, older shell) every rule is unchanged, the single-membership guard included. A `failed` answer from the shell is logged at `warn`. - `CurrentOrganizationIndicator` renders nothing when the shell announces the capability, so a walled deployment at one membership draws the organization once, on the native button. The name shows at the same widths the indicator showed it (badge always, name from `sm` up, the same `hidden truncate sm:inline` span); a test pins that equality. - **Why the button renders at one membership.** The seat chose this (option B) over option A in the first report ("leave as is (card scope)"), on review of this PR: the card's beneficiary is "a phone user with several organizations or company servers, who switches with one tap from the header", and single-organization users are the majority of them. - `useNativeHostRouting` (already mounted by the Console's `NativeShellRouting`) listens for `objectstack-native:connectionRemoved` while the bridge is present and calls `event.waitUntil()` with the promise its new optional `onConnectionRemoved` returns. When the option is omitted, as the Console does today, the promise is already resolved. The latest handler is read through a ref, so the listener is installed once per bridge. - `@object-ui/app-shell` now depends on `@object-ui/mobile` (which depends only on `@object-ui/types`; no cycle). Vitest and the Console's Vite config already alias `@object-ui/mobile` to `src`. **Which unregister case holds: no registration exists, so there is nothing to unregister.** No Console path registers a device: the client SDK's device methods were removed (its `notifications` namespace comment says the `/notifications/devices` routes were never built), the server routes are #29041 (`pm:blocked`), and the registration card is #29042 (`pm:blocked`). So the Console has no token and no device id. The handler answers `waitUntil` with a resolved promise, and `onConnectionRemoved` is the seam where #29042 adds the unregister call. No registration is invented here. **Widening (`@object-ui/mobile`, all new exports):** - `'showConnectionSwitcher'` joins `NativeHostMethod`, and `showConnectionSwitcher?(): Promise<null>` joins `ObjectStackNative` as an optional method, the way `scan?` is: an older shell does not carry it. - `nativeShowConnectionSwitcher()`: the typed wrapper, same `unavailable | ok | failed` answer as the others. - `nativeHostCan(host, method)`: new export. It is the wrappers' own feature-detect (the former private `callable`), so a control can pick its native or web path before it renders. - `NATIVE_CONNECTION_REMOVED_EVENT` and the `NativeConnectionRemovedEvent` type. - `UseNativeHostRoutingOptions.onConnectionRemoved`. No server change, no metadata key, no new i18n key. ## Tests - New `WorkspaceSwitcher.nativeConnectionSwitcher.test.tsx` (7 cases, real Radix dropdown and a real click). - Two memberships: a fake host with the capability makes the button call `showConnectionSwitcher` once, with no `menu` role, no organization row and nothing switched. Two controls, with no host and with a host without the capability: the web menu opens with its rows and `showConnectionSwitcher` is never called. - One membership on a walled (`isolated`) deployment, `WorkspaceSwitcher` and `CurrentOrganizationIndicator` rendered side by side as `AppHeader` draws them. With the capability, the native button renders and opens the sheet, the indicator is absent, and the organization name is drawn once. The native button's name span carries the same classes as the indicator's. Two controls, with no host and with a host without the capability: no switcher and the read-only indicator, as today. - `nativeHost.test.tsx` extended: `showConnectionSwitcher` joins the unavailable / missing-method / ok wrapper rows; `nativeHostCan` cases; a `connectionRemoved` block. With no unregister step, `waitUntil` gets a promise that resolves at once. With one, the shell is held until the step settles and the step runs once. The latest handler runs without a second listener. An event without `waitUntil` is left alone. Outside shell mode no listener is installed. The listener is removed on unmount. ## Acceptance notes - **No unsaved-edits prompt before the native sheet opens.** Opening the sheet leaves nothing; the switch itself happens in the shell, which the page does not see. ## Verification Final head `c962ab9e`. Its last commit changes three code files against `dec7cd0b`: `WorkspaceSwitcher.tsx`, `CurrentOrganizationIndicator.tsx` and the new test file. It also updates the changeset text. All CI check-runs on `c962ab9e` concluded `success` or `skipped`: Test UI (1/16 … 16/16), Type Check UI, Lint UI, Build & E2E UI, Live E2E UI, Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard and Check Changeset. Before that, main was merged three times. Two of those merges brought in #29475 and the shell code-scanner card, which had edited `nativeHost.ts` / `useNativeHost.ts`; their conflicts were import and union lines only. `showConnectionSwitcher` is optional on `ObjectStackNative`, matching `scan?`. <details><summary>Tests, ablation and type-check</summary> On `c962ab9e`: - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/mobile/` + `WorkspaceSwitcher.nativeConnectionSwitcher`, `WorkspaceSwitcher`, `WorkspaceSwitcher.noActiveOrg-12698`, `WorkspaceSwitcher.organizationNoun-12851`, `CurrentOrganizationIndicator`, `CurrentOrganizationIndicator.noActiveOrg-12698`: Test Files 15 passed, Tests 168 passed. - Ablations (`ablation-replace.mjs`, each restored to the HEAD blob with `git diff HEAD` empty; vitest aliases `@object-ui/*` to `src`, so no rebuild is involved): - `if (nativeSwitcher) {` to `if (nativeSwitcher && false) {` in `WorkspaceSwitcher.tsx`: 3 failed, 4 passed (every capability case red, every control green). - `if (nativeSwitcher) {` to `if (nativeSwitcher && orgList.length > 1) {` (the old placement after the single-membership guard): 2 failed, 5 passed (the one-membership capability cases). - `if (nativeSwitcher) return null;` to `if (nativeSwitcher && false) return null;` in `CurrentOrganizationIndicator.tsx`: 2 failed, 5 passed. - `turbo run type-check --filter=@object-ui/app-shell`: 65/65 tasks. `eslint` on the three changed files: exit 0. On `33a962e6` (the bridge half, unchanged since): the mobile + native-shell console suites 18 files / 181 tests; the `connectionRemoved` ablation (the `waitUntil` guard forced to return) red 3 cases and restored; `turbo run type-check` for mobile + app-shell 66/66; `pnpm check:new-line-citations` 0 new. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands` at `c962ab9e` derived the same 54 families as on `33a962e6`. On `c962ab9e` the 53 non-vitest families were run with their exit codes recorded before any pipe, plus `pnpm check:ci-filter-parity` at the seat's request: - `node scripts/check-adr-0087-registration.mjs --base origin/main`: exit 0 - `node scripts/check-adr-0087-registration.mjs --self-test`: exit 0 - `node scripts/check-changeset-no-major.mjs --base origin/main`: exit 0 - `node scripts/check-closing-keyword-parity.mjs`: exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test`: exit 0 - `node scripts/check-comment-mask-corpus.mjs`: exit 0 - `node scripts/check-dts-emitted.mjs --self-test`: exit 0 - `node scripts/check-empty-changeset.mjs --base origin/main`: exit 0 - `node scripts/check-empty-changeset.mjs --self-test`: exit 0 - `node scripts/check-osv-exemptions.mjs`: exit 0 - `node scripts/check-osv-exemptions.mjs --self-test`: exit 0 - `node scripts/check-sdui-manifest.mjs`: exit 0 - `node scripts/check-sdui-manifest.mjs --self-test`: exit 0 - `node scripts/ci/assert-turbo-remote-cache.mjs --self-test`: exit 0 - `node scripts/ci/plan-test-core-shards.mjs --self-test`: exit 0 - `node scripts/ci/prune-turbo-cache.mjs --self-test`: exit 0 - `node scripts/osv-base-relative.mjs --self-test`: exit 0 - `node scripts/pm/release-rehearsal-clone.mjs --self-test`: exit 0 - `node scripts/release-pending-publish.mjs --self-test`: exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys`: exit 0 - `pnpm --filter @objectstack/spec run check:llms-txt`: exit 0 - `pnpm check:changeset-fixed`: exit 0 - `pnpm check:changeset-gate-self-tests`: exit 0 - `pnpm check:closed-ceremony-text`: exit 0 - `pnpm check:cross-package-test-inputs`: exit 0 - `pnpm check:dispatcher-error-vocabulary`: exit 0 - `pnpm check:driver-memory-census`: exit 0 - `pnpm check:dts-closure`: exit 0 - `pnpm check:dual-build-cjs-loads`: exit 3 - `pnpm check:engine-double-contract`: exit 0 - `pnpm check:gitlink-declared`: exit 0 - `pnpm check:lean-entry-closure`: exit 0 - `pnpm check:manifest-repository-directory`: exit 0 - `pnpm check:merge-driver`: exit 0 - `pnpm check:nul-bytes`: exit 0 - `pnpm check:override-consistency`: exit 0 - `pnpm check:pm-changeset-deadline-census`: exit 0 - `pnpm check:pnpm-flag-spellings`: exit 0 - `pnpm check:published-files`: exit 0 - `pnpm check:query-options-erasure`: exit 0 - `pnpm check:refd-timer-probe`: exit 0 - `pnpm check:skill-identifier-liveness`: exit 0 - `pnpm check:sourcemap-no-sources-content`: exit 0 - `pnpm check:test-source-alias`: exit 0 - `pnpm check:tier-file-adoption`: exit 0 - `pnpm check:turbo-task-graph`: exit 0 - `pnpm check:type-check-coverage`: exit 0 - `pnpm check:type-check-debt`: exit 0 - `pnpm check:undeclared-dep-imports`: exit 0 - `pnpm check:vendor-export-contract-resolve`: exit 0 - `pnpm check:watch-hint-literal`: exit 0 - `pnpm check:where-matcher`: exit 0 - `pnpm check:workspace-manifest-cycles`: exit 0 - `pnpm check:ci-filter-parity`: exit 0 `check:dual-build-cjs-loads` exited 3 on `c962ab9e` (PREREQUISITE NOT MET: the re-created worktree had no built dists). It is NOT MEASURED locally there. It measured exit 0 on `33a962e6` (115 require entry points across 70 packages), and CI's Lint & Repo Gates on `c962ab9e` is green. `pnpm --dir ui exec vitest run scripts/` was not re-run locally on `c962ab9e`; the CI runs listed above for that head are green. It ran green locally on `33a962e6` (169 files) and on `dec7cd0b` (169 files, plus `check-doc-json-fences.test.ts` 42/42 after a stale-build rebuild). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jTVYu9vDSx28X2zi7h)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 50e35a2 commit 2420778

11 files changed

Lines changed: 496 additions & 25 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/mobile': patch
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
feat(mobile, app-shell): inside the ObjectStack mobile shell, the header's organization button opens the shell's native connection switcher, and the Console answers the shell's `connectionRemoved` event
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
The mobile shell's bridge (`window.ObjectStackNative`) gained `showConnectionSwitcher()` and the `objectstack-native:connectionRemoved` window event. The Console now uses both:
12+
13+
- **Organization button.** When the shell announces `has('showConnectionSwitcher')`, `WorkspaceSwitcher`'s button calls `showConnectionSwitcher()`, which opens the shell's own sheet of saved organizations and company servers. The web organization menu does not open. In the shell the button renders whenever there is an active organization, at one membership too, since the sheet lists the shell's connections across company servers; `CurrentOrganizationIndicator` then renders nothing, so the organization is drawn once. In a plain browser, or in a shell that does not announce the capability, both render as before.
14+
- **Removed connection.** `useNativeHostRouting` listens for `objectstack-native:connectionRemoved` while the bridge is present and calls `event.waitUntil()` with the promise its new optional `onConnectionRemoved` returns. Omitted, the promise is already resolved, so the shell is released at once. No Console path registers a device push token yet, so there is nothing to unregister today. The option is where that unregister step goes once registration exists.
15+
16+
`@object-ui/mobile` adds `showConnectionSwitcher` to `NativeHostMethod` and, as an optional method (an older shell does not carry it), to `ObjectStackNative`, and exports `nativeShowConnectionSwitcher()`, `nativeHostCan(host, method)` (the wrappers' own feature-detect), `NATIVE_CONNECTION_REMOVED_EVENT` and the `NativeConnectionRemovedEvent` type. `@object-ui/app-shell` now depends on `@object-ui/mobile`.

‎pnpm-lock.yaml‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎ui/packages/app-shell/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
"@object-ui/fields": "workspace:*",
8484
"@object-ui/i18n": "workspace:*",
8585
"@object-ui/layout": "workspace:*",
86+
"@object-ui/mobile": "workspace:*",
8687
"@object-ui/permissions": "workspace:*",
8788
"@object-ui/plugin-editor": "workspace:*",
8889
"@object-ui/providers": "workspace:*",

‎ui/packages/app-shell/src/layout/CurrentOrganizationIndicator.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,20 +33,29 @@
3333
* `@objectstack/spec/security`, which measured +237 KB on the eager chunk. That
3434
* file's docblock carries the measurement and the reasoning.
3535
*
36+
* Inside the mobile shell, when it announces `showConnectionSwitcher`, this
37+
* renders nothing: `WorkspaceSwitcher` then draws the same badge and name, at
38+
* the same widths, as the button that opens the shell's native switcher.
39+
*
3640
* An absent or unrecognized posture (older server, config fetch still in
3741
* flight or failed) renders nothing, matching how every other posture-keyed
3842
* affordance in this repo fails toward today's rendering.
3943
*/
4044

4145
import { useAuth } from '@object-ui/auth';
4246
import { useObjectTranslation } from '@object-ui/i18n';
47+
import { nativeHostCan, useNativeHost } from '@object-ui/mobile';
4348
import { OrgBadge } from './OrgBadge.js';
4449
import { postureHasOrgWall, useTenancyPosture } from '../hooks/useTenancyPosture.js';
4550

4651
export function CurrentOrganizationIndicator() {
4752
const { t } = useObjectTranslation();
4853
const { organizations, activeOrganization } = useAuth();
4954
const posture = useTenancyPosture();
55+
const nativeSwitcher = nativeHostCan(useNativeHost(), 'showConnectionSwitcher');
56+
57+
// The shell's switcher button carries the organization instead.
58+
if (nativeSwitcher) return null;
5059

5160
// No organization wall in force ⇒ the top bar stays organization-silent,
5261
// exactly as it is today. Also covers the not-yet-resolved config.

‎ui/packages/app-shell/src/layout/WorkspaceSwitcher.tsx‎

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,25 @@
44
* Header-left organization (workspace) switcher — the standard place users
55
* expect "which org am I in / switch org" to live (Linear/Vercel/GitHub style).
66
*
7-
* - Single-org users (the vast majority): render NOTHING. With one org there is
8-
* nothing to switch to, so a dropdown here would control nothing. The
9-
* switcher is a multi-org affordance. Where a tenancy wall IS in force the
7+
* - Single-org users (the vast majority): render NOTHING on the web. With one
8+
* org there is nothing to switch to, so a dropdown here would control
9+
* nothing. The web switcher is a multi-org affordance (the mobile shell's
10+
* native switcher, below, is not). Where a tenancy wall IS in force the
1011
* org name still matters at one membership — `CurrentOrganizationIndicator`
1112
* renders it read-only there (objectui#5287), without a control.
1213
* - Multi-org users: the active org name + a dropdown to switch orgs inline
1314
* (full-page reload so the active-org context refreshes app-wide, mirroring
1415
* OrganizationsPage), plus shortcuts to manage members / create a workspace.
1516
* - No org context at all: renders nothing.
1617
*
18+
* Inside the ObjectStack mobile shell (`@object-ui/mobile`), when the shell
19+
* announces `showConnectionSwitcher`, the button opens the shell's native
20+
* switcher instead of the dropdown, and it renders whenever there is an active
21+
* organization, at any membership count: the sheet lists the connections the
22+
* shell holds across company servers, which this server's membership list
23+
* cannot count. In a plain browser, or a shell without the capability, the
24+
* rules above apply unchanged.
25+
*
1726
* Under `group` tenancy posture (ADR-0105) the switcher's meaning changes:
1827
* the active organization is only the WRITE target — reads span every
1928
* organization the member belongs to — so the dropdown labels it as the
@@ -25,6 +34,7 @@ import { useNavigate } from 'react-router-dom';
2534
import { useAuth } from '@object-ui/auth';
2635
import type { AuthOrganization } from '@object-ui/auth';
2736
import { useObjectTranslation } from '@object-ui/i18n';
37+
import { nativeHostCan, nativeShowConnectionSwitcher, useNativeHost } from '@object-ui/mobile';
2838
import {
2939
DropdownMenu,
3040
DropdownMenuTrigger,
@@ -45,6 +55,7 @@ export function WorkspaceSwitcher() {
4555
const { organizations, activeOrganization, switchOrganization, getAuthConfig } = useAuth();
4656
const [multiOrgDisabled, setMultiOrgDisabled] = useState(false);
4757
const isGroupPosture = useTenancyPosture() === 'group';
58+
const nativeSwitcher = nativeHostCan(useNativeHost(), 'showConnectionSwitcher');
4859

4960
useEffect(() => {
5061
let cancelled = false;
@@ -71,6 +82,38 @@ export function WorkspaceSwitcher() {
7182
// rather than an empty switcher or a guessed name.
7283
if (!current) return null;
7384

85+
const triggerClassName =
86+
'ml-2 inline-flex max-w-[14rem] items-center gap-1.5 rounded-md px-1.5 py-1 text-sm font-medium text-foreground/80 transition-colors hover:bg-accent hover:text-foreground';
87+
const triggerContent = (
88+
<>
89+
<OrgBadge name={current.name} />
90+
<span className="hidden truncate sm:inline">{current.name}</span>
91+
<ChevronsUpDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
92+
</>
93+
);
94+
95+
// The shell's sheet replaces the web menu; switching there is the shell's
96+
// own flow, so nothing is sent from here.
97+
if (nativeSwitcher) {
98+
return (
99+
<button
100+
type="button"
101+
className={triggerClassName}
102+
data-testid="workspace-switcher"
103+
data-native-switcher=""
104+
onClick={() => {
105+
void nativeShowConnectionSwitcher().then((result) => {
106+
if (result.status === 'failed') {
107+
console.warn('[WorkspaceSwitcher] native connection switcher failed', result.code, result.message);
108+
}
109+
});
110+
}}
111+
>
112+
{triggerContent}
113+
</button>
114+
);
115+
}
116+
74117
// Single-org: render nothing. With only one org there's nothing to switch to,
75118
// so a dropdown here would be a control that controls nothing. The switcher
76119
// appears only once a second org exists. On a walled deployment the org NAME
@@ -102,13 +145,8 @@ export function WorkspaceSwitcher() {
102145

103146
return (
104147
<DropdownMenu>
105-
<DropdownMenuTrigger
106-
className="ml-2 inline-flex max-w-[14rem] items-center gap-1.5 rounded-md px-1.5 py-1 text-sm font-medium text-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
107-
data-testid="workspace-switcher"
108-
>
109-
<OrgBadge name={current.name} />
110-
<span className="hidden truncate sm:inline">{current.name}</span>
111-
<ChevronsUpDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
148+
<DropdownMenuTrigger className={triggerClassName} data-testid="workspace-switcher">
149+
{triggerContent}
112150
</DropdownMenuTrigger>
113151
<DropdownMenuContent align="start" className="w-60">
114152
<DropdownMenuLabel className="text-xs text-muted-foreground">
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* WorkspaceSwitcher inside the ObjectStack mobile shell.
5+
*
6+
* When the shell's bridge (`window.ObjectStackNative`, read through
7+
* `@object-ui/mobile`) announces `showConnectionSwitcher`, the header's
8+
* organization button opens the shell's native switcher and the web menu does
9+
* not open. With no bridge, or a bridge without that capability, the web menu
10+
* opens exactly as before. The dropdown is the real one, opened by a real
11+
* click, so "the web menu opens" is observed, not assumed.
12+
*
13+
* In the shell the button renders at ONE membership too: the sheet lists the
14+
* shell's connections across servers, which this server's membership count
15+
* cannot see. On a walled deployment `CurrentOrganizationIndicator` then steps
16+
* aside, so the header carries the organization once, with the name shown at
17+
* the same widths the indicator showed it.
18+
*/
19+
20+
import '@testing-library/jest-dom/vitest';
21+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22+
import { render, screen, cleanup, waitFor } from '@testing-library/react';
23+
import userEvent from '@testing-library/user-event';
24+
25+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
26+
...(await importOriginal<Record<string, unknown>>()),
27+
useObjectTranslation: () => ({
28+
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
29+
}),
30+
}));
31+
32+
vi.mock('react-router-dom', async (importOriginal) => ({
33+
...(await importOriginal<Record<string, unknown>>()),
34+
useNavigate: () => vi.fn(),
35+
}));
36+
37+
let authState: Record<string, unknown>;
38+
vi.mock('@object-ui/auth', async (importOriginal) => ({
39+
...(await importOriginal<Record<string, unknown>>()),
40+
useAuth: () => authState,
41+
}));
42+
43+
import { NATIVE_HOST_GLOBAL } from '@object-ui/mobile';
44+
import { WorkspaceSwitcher } from '../WorkspaceSwitcher';
45+
import { CurrentOrganizationIndicator } from '../CurrentOrganizationIndicator';
46+
import { useTenancyPosture } from '../../hooks/useTenancyPosture';
47+
48+
const ORGS = [
49+
{ id: 'org_a', name: 'Plant A', slug: 'plant-a' },
50+
{ id: 'org_b', name: 'Plant B', slug: 'plant-b' },
51+
];
52+
53+
const BASE_METHODS = ['getPushToken', 'haptic', 'openExternal', 'navigate', 'resetServer'];
54+
55+
function installBridge(capabilities: string[]) {
56+
const showConnectionSwitcher = vi.fn(async () => null);
57+
(window as unknown as Record<string, unknown>)[NATIVE_HOST_GLOBAL] = {
58+
version: 1,
59+
platform: 'ios',
60+
appVersion: '2.1.0',
61+
capabilities,
62+
safeArea: { top: 47, bottom: 34, left: 0, right: 0 },
63+
has: (capability: string) => capabilities.includes(capability),
64+
getPushToken: vi.fn(async () => null),
65+
haptic: vi.fn(async () => null),
66+
openExternal: vi.fn(async () => null),
67+
navigate: vi.fn(async () => null),
68+
resetServer: vi.fn(async () => null),
69+
showConnectionSwitcher,
70+
ready: vi.fn(),
71+
routeChanged: vi.fn(),
72+
};
73+
return showConnectionSwitcher;
74+
}
75+
76+
beforeEach(() => {
77+
delete (window as unknown as Record<string, unknown>)[NATIVE_HOST_GLOBAL];
78+
authState = {
79+
organizations: ORGS,
80+
activeOrganization: ORGS[0],
81+
switchOrganization: vi.fn(),
82+
getAuthConfig: vi.fn().mockResolvedValue({ features: { multiOrgEnabled: true } }),
83+
};
84+
});
85+
86+
afterEach(() => {
87+
cleanup();
88+
delete (window as unknown as Record<string, unknown>)[NATIVE_HOST_GLOBAL];
89+
});
90+
91+
describe('WorkspaceSwitcher in the mobile shell', () => {
92+
it('with showConnectionSwitcher announced, the button opens the native switcher and no web menu', async () => {
93+
const showConnectionSwitcher = installBridge([...BASE_METHODS, 'showConnectionSwitcher']);
94+
render(<WorkspaceSwitcher />);
95+
96+
const button = screen.getByTestId('workspace-switcher');
97+
expect(button).toHaveAttribute('data-native-switcher');
98+
expect(button).not.toHaveAttribute('aria-haspopup');
99+
100+
await userEvent.click(button);
101+
102+
expect(showConnectionSwitcher).toHaveBeenCalledTimes(1);
103+
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
104+
expect(screen.queryByText('Plant B')).not.toBeInTheDocument();
105+
expect(authState.switchOrganization).not.toHaveBeenCalled();
106+
});
107+
108+
it('control: with no bridge, the button opens the web organization menu', async () => {
109+
render(<WorkspaceSwitcher />);
110+
111+
const button = screen.getByTestId('workspace-switcher');
112+
expect(button).not.toHaveAttribute('data-native-switcher');
113+
114+
await userEvent.click(button);
115+
116+
expect(await screen.findByRole('menu')).toBeInTheDocument();
117+
expect(screen.getByText('Switch organization')).toBeInTheDocument();
118+
expect(screen.getByText('Plant B')).toBeInTheDocument();
119+
});
120+
121+
it('control: a shell without the capability keeps the web menu and never calls the native switcher', async () => {
122+
const showConnectionSwitcher = installBridge(BASE_METHODS);
123+
render(<WorkspaceSwitcher />);
124+
125+
const button = screen.getByTestId('workspace-switcher');
126+
expect(button).not.toHaveAttribute('data-native-switcher');
127+
128+
await userEvent.click(button);
129+
130+
expect(await screen.findByRole('menu')).toBeInTheDocument();
131+
expect(screen.getByText('Plant B')).toBeInTheDocument();
132+
expect(showConnectionSwitcher).not.toHaveBeenCalled();
133+
});
134+
});
135+
136+
describe('WorkspaceSwitcher in the mobile shell at one membership (walled deployment)', () => {
137+
/** AppHeader's pair, in its order, plus the resolved posture to wait on. */
138+
function PostureProbe() {
139+
return <span data-testid="posture">{String(useTenancyPosture() ?? 'unresolved')}</span>;
140+
}
141+
function mountHeaderPair() {
142+
return render(
143+
<>
144+
<WorkspaceSwitcher />
145+
<CurrentOrganizationIndicator />
146+
<PostureProbe />
147+
</>,
148+
);
149+
}
150+
151+
beforeEach(() => {
152+
authState = {
153+
organizations: [ORGS[0]],
154+
activeOrganization: ORGS[0],
155+
switchOrganization: vi.fn(),
156+
getAuthConfig: vi.fn().mockResolvedValue({ features: { multiOrgEnabled: true, tenancyPosture: 'isolated' } }),
157+
};
158+
});
159+
160+
/** The name span of whichever surface carries the organization. */
161+
function nameSpan(container: HTMLElement): HTMLElement {
162+
const spans = Array.from(container.querySelectorAll('span')).filter((el) => el.textContent === ORGS[0].name);
163+
expect(spans).toHaveLength(1);
164+
return spans[0] as HTMLElement;
165+
}
166+
167+
it('with the capability, the native button renders, opens the sheet, and the read-only indicator steps aside', async () => {
168+
const showConnectionSwitcher = installBridge([...BASE_METHODS, 'showConnectionSwitcher']);
169+
const { container } = mountHeaderPair();
170+
await waitFor(() => expect(screen.getByTestId('posture')).toHaveTextContent('isolated'));
171+
172+
const button = screen.getByTestId('workspace-switcher');
173+
expect(button).toHaveAttribute('data-native-switcher');
174+
expect(screen.queryByTestId('current-organization-indicator')).not.toBeInTheDocument();
175+
// The organization is drawn once.
176+
expect(nameSpan(container).closest('[data-testid="workspace-switcher"]')).toBe(button);
177+
178+
await userEvent.click(button);
179+
expect(showConnectionSwitcher).toHaveBeenCalledTimes(1);
180+
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
181+
});
182+
183+
it('the native button shows the name at the same widths the indicator did', async () => {
184+
const plain = mountHeaderPair();
185+
await waitFor(() => expect(screen.getByTestId('current-organization-indicator')).toBeInTheDocument());
186+
const indicatorNameClass = nameSpan(plain.container).className;
187+
plain.unmount();
188+
189+
installBridge([...BASE_METHODS, 'showConnectionSwitcher']);
190+
const shell = mountHeaderPair();
191+
await waitFor(() => expect(screen.getByTestId('posture')).toHaveTextContent('isolated'));
192+
expect(nameSpan(shell.container).className).toBe(indicatorNameClass);
193+
});
194+
195+
it('control: with no bridge, one membership draws no switcher and the read-only indicator, as today', async () => {
196+
mountHeaderPair();
197+
await waitFor(() => expect(screen.getByTestId('current-organization-indicator')).toBeInTheDocument());
198+
expect(screen.queryByTestId('workspace-switcher')).not.toBeInTheDocument();
199+
});
200+
201+
it('control: a shell without the capability keeps today\'s single-membership rendering', async () => {
202+
const showConnectionSwitcher = installBridge(BASE_METHODS);
203+
mountHeaderPair();
204+
await waitFor(() => expect(screen.getByTestId('current-organization-indicator')).toBeInTheDocument());
205+
expect(screen.queryByTestId('workspace-switcher')).not.toBeInTheDocument();
206+
expect(showConnectionSwitcher).not.toHaveBeenCalled();
207+
});
208+
});

0 commit comments

Comments
 (0)