Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

// The mock harness is imported before any product module so its `vi.mock`
// calls register before the modules under test are loaded.
import {
authState,
findConfigureRows,
flushInteractions,
interactionState,
keys,
mountProfile,
nodeCount,
nodeCountWithChildren,
organizationsQueryFn,
organizationState,
providersQueryFn,
routerPush,
setOrganizationIdFn,
} from '@/components/profile-screen.test-helpers';
import { i18n } from '@/i18n';
import { act } from '@/test/renderer';
import { createTestQueryClient, waitFor } from '@/test/render-with-providers';

describe('ProfileScreen Organization section', () => {
beforeEach(() => {
vi.useRealTimers();
providersQueryFn.mockReset();
organizationsQueryFn.mockReset();
routerPush.mockReset();
authState.token = 'token-1';
interactionState.storedCallback = undefined;
interactionState.cancel.mockReset();
organizationState.organizationId = 'org-1';
organizationState.isLoaded = true;
setOrganizationIdFn.mockReset();
});

it('keeps organization management reachable from Profile in Personal context', async () => {
organizationState.organizationId = null;
providersQueryFn.mockResolvedValue({ providers: [] });
organizationsQueryFn.mockResolvedValue([
{ organizationId: 'org-1', organizationName: 'Kilo', role: 'admin' },
{ organizationId: 'org-2', organizationName: 'Acme', role: 'member' },
]);

const { renderer, unmount } = await mountProfile();
const root = renderer.root;

flushInteractions();
const chooserTitle = i18n.t('organization.boundary.selectOrganization');
await waitFor(() => findConfigureRows(root, chooserTitle).length === 1);

// Personal is the credit-account context, and the Organization section is
// still on Profile with a control that reaches the memberships.
expect(nodeCountWithChildren(root, 'Text', i18n.t('common.organization'))).toBe(1);
const row = findConfigureRows(root, chooserTitle)[0];
expect(row?.props.hue).toBe('lime');
expect(nodeCount(root, 'BottomSheet')).toBe(0);

act(() => {
(row?.props as { onPress?: () => void } | undefined)?.onPress?.();
});

// The row opens the app's account picker: Personal first, then both
// memberships, with the current account checked.
const radios = root.findAll(
node => typeof node.type === 'string' && node.props.accessibilityRole === 'radio'
);
expect(radios.map(radio => radio.props.accessibilityLabel)).toEqual([
i18n.t('common.personal'),
'Kilo',
'Acme',
]);
expect(radios[0]?.props.accessibilityState).toMatchObject({ checked: true });

act(() => {
(radios[2]?.props as { onPress?: () => void } | undefined)?.onPress?.();
});

expect(setOrganizationIdFn).toHaveBeenCalledWith('org-2');
// Choosing stays on Profile; the section renders the chosen organization's
// row once the context publishes instead of navigating away.
expect(routerPush).not.toHaveBeenCalled();

unmount();
});

it('leaves the Organization section hidden in Personal context without memberships', async () => {
organizationState.organizationId = null;
providersQueryFn.mockResolvedValue({ providers: [] });
organizationsQueryFn.mockResolvedValue([]);

const { renderer, unmount } = await mountProfile();

flushInteractions();
await waitFor(
() => providersQueryFn.mock.calls.length > 0 && organizationsQueryFn.mock.calls.length > 0
);
await waitFor(() => nodeCount(renderer.root, 'Skeleton') === 0);

// Accounts without an organization keep the hub's own boundary; Profile
// gains no control for them.
expect(nodeCountWithChildren(renderer.root, 'Text', i18n.t('common.organization'))).toBe(0);
expect(
findConfigureRows(renderer.root, i18n.t('organization.boundary.selectOrganization'))
).toHaveLength(0);

unmount();
});

it('waits for the organization restore before offering the picker', async () => {
organizationState.organizationId = null;
organizationState.isLoaded = false;

const queryClient = createTestQueryClient();
queryClient.setQueryData(keys.providers, { providers: [] });
queryClient.setQueryData(keys.organizations, [
{ organizationId: 'org-1', organizationName: 'Kilo', role: 'admin' },
{ organizationId: 'org-2', organizationName: 'Acme', role: 'member' },
]);

const { renderer, unmount } = await mountProfile(queryClient);

// Cached memberships are already enough to render, but the stored selection
// has not been restored yet, so the chooser must not paint and then flip to
// the active organization's row.
expect(nodeCountWithChildren(renderer.root, 'Text', i18n.t('common.organization'))).toBe(0);
expect(
findConfigureRows(renderer.root, i18n.t('organization.boundary.selectOrganization'))
).toHaveLength(0);

unmount();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import {
nodeCount,
nodeCountWithChildren,
organizationsQueryFn,
organizationState,
providersQueryFn,
routerPush,
setOrganizationIdFn,
signOutFn,
} from '@/components/profile-screen.test-helpers';
import { i18n } from '@/i18n';
Expand All @@ -41,6 +43,9 @@ describe('ProfileScreen deferred queries', () => {
interactionState.cancel.mockReset();
getProfileAgentScopeMock.mockReset();
getProfileAgentScopeMock.mockReturnValue('personal');
organizationState.organizationId = 'org-1';
organizationState.isLoaded = true;
setOrganizationIdFn.mockReset();
});

it('defers both queries until interactions settle, showing the skeleton first', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ vi.mock('@/components/ui/icons', () => ({
}));

vi.mock('@/components/profile-action-tile', () => ({ ActionTile: 'ActionTile' }));
vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' }));
vi.mock('@/components/profile-credits-card', () => ({ CreditsCard: 'CreditsCard' }));
vi.mock('@/components/language-picker-sheet', () => ({
LanguagePickerSheet: 'LanguagePickerSheet',
Expand Down
25 changes: 23 additions & 2 deletions apps/mobile/src/components/profile-screen.test-helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ const captureInteraction = vi.hoisted(() => (cb: () => void) => {
});
const getProfileAgentScopeMock = vi.hoisted(() => vi.fn());
const openExternalUrlFn = vi.hoisted(() => vi.fn());
const organizationState = vi.hoisted(() => ({
organizationId: 'org-1' as string | null,
isLoaded: true,
}));
const setOrganizationIdFn = vi.hoisted(() => vi.fn());
const connectivityState = vi.hoisted(() => ({
value: 'online' as 'online' | 'offline' | 'unknown',
}));
Expand All @@ -39,20 +44,29 @@ export {
getProfileAgentScopeMock,
interactionState,
keys,
organizationState,
organizationsQueryFn,
providersQueryFn,
routerPush,
setOrganizationIdFn,
signOutFn,
};

vi.mock('react-native', () => ({
Alert: { alert: vi.fn() },
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
View: 'View',
InteractionManager: {
runAfterInteractions: vi.fn(captureInteraction),
},
}));

vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ bottom: 0 }),
}));

vi.mock('react-native-reanimated', () => ({
default: { View: 'Animated.View' },
FadeIn: { duration: vi.fn() },
Expand Down Expand Up @@ -89,7 +103,11 @@ vi.mock('@/lib/auth/auth-context', () => ({
}));

vi.mock('@/lib/organization-context', () => ({
useOrganization: () => ({ organizationId: 'org-1', isLoaded: true }),
useOrganization: () => ({
organizationId: organizationState.organizationId,
isLoaded: organizationState.isLoaded,
setOrganizationId: setOrganizationIdFn,
}),
}));

vi.mock('@/components/use-delete-account', () => ({
Expand All @@ -115,7 +133,7 @@ vi.mock('@/lib/hooks/use-offline-banner-state', () => ({
}));

vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({ mutedForeground: '#000000' }),
useThemeColors: () => ({ mutedForeground: '#000000', primary: '#000000', card: '#ffffff' }),
}));

vi.mock('@/lib/hooks/use-language-preference', () => ({
Expand Down Expand Up @@ -156,6 +174,8 @@ vi.mock('@/components/ui/dialog', () => ({
vi.mock('@/components/ui/icons', () => ({
BookOpenCheck: 'BookOpenCheck',
Building2: 'Building2',
Check: 'Check',
ChevronDown: 'ChevronDown',
GitMerge: 'GitMerge',
GitPullRequest: 'GitPullRequest',
Globe: 'Globe',
Expand All @@ -171,6 +191,7 @@ vi.mock('@/components/ui/icons', () => ({
}));

vi.mock('@/components/profile-action-tile', () => ({ ActionTile: 'ActionTile' }));
vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' }));
vi.mock('@/components/profile-credits-card', () => ({ CreditsCard: 'CreditsCard' }));
vi.mock('@/components/language-picker-sheet', () => ({
LanguagePickerSheet: 'LanguagePickerSheet',
Expand Down
59 changes: 42 additions & 17 deletions apps/mobile/src/components/profile-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { type LayoutChangeEvent, type ScrollView, View } from 'react-native';
import Animated, { FadeOut } from 'react-native-reanimated';

import { useConfirmDialog } from '@/components/ui/dialog';
import { useContextPicker } from '@/components/context-control';
import { ActionTile } from '@/components/profile-action-tile';
import { CreditsCard } from '@/components/profile-credits-card';
import { QueryError } from '@/components/query-error';
Expand Down Expand Up @@ -127,9 +128,17 @@ export function ProfileScreen() {
const agentScope = organizationContextLoaded
? getProfileAgentScope(organizationId, orgs, organizationsFetching || !afterInteractions)
: undefined;
const { openPicker, picker } = useContextPicker(orgs);
const selectedOrg = orgs?.find(org => org.organizationId === organizationId);
const orgRole = selectedOrg?.role;
const orgName = selectedOrg?.organizationName;
// The Organization section stays on Profile in every credit-account context:
// a Personal context still offers the account picker when the account has
// memberships, so organization management is not gated behind the credits
// account control. The restore decides the context first, so the chooser never
// paints for a frame before the active organization's row replaces it.
const showOrganizationSection =
organizationId != null || (organizationContextLoaded && (orgs?.length ?? 0) > 0);

const { userId } = useCurrentUserId({ enabled: isAuthenticated });
// The prompt's surface is platform-specific (`feedback-prompt-platform.ts`);
Expand All @@ -142,6 +151,36 @@ export function ProfileScreen() {
// so instead of presenting itself as a server fault.
const committedConnectivity = useCommittedConnectivityStatus();

// Personal context has no selected organization to label, so its row opens
// the account picker; otherwise the row names the destination for the active
// organization's role.
const organizationRow =
organizationId == null ? (
<ConfigureRow
icon={Building2}
title={t('organization.boundary.selectOrganization')}
hue="lime"
className="rounded-lg bg-secondary px-3"
last
onPress={openPicker}
/>
) : (
<ConfigureRow
icon={Building2}
title={
orgRole === 'member' ? t('profile.viewOrganization') : t('profile.manageOrganization')
}
subtitle={orgName}
hue="lime"
className="rounded-lg bg-secondary px-3"
disabled={!orgRole}
last
onPress={() => {
router.push('/(app)/(tabs)/(3_profile)/organization' as Href);
}}
/>
);

const requestSignOut = () => {
confirm({
title: t('profile.signOutTitle'),
Expand Down Expand Up @@ -291,7 +330,7 @@ export function ProfileScreen() {
</View>

{/* Organization */}
{organizationId != null && (
{showOrganizationSection && (
<View className="mt-6 gap-3">
<Text variant="small" className="uppercase tracking-wide text-muted-foreground">
{t('common.organization')}
Expand All @@ -306,23 +345,9 @@ export function ProfileScreen() {
isRetrying={organizationsFetching}
/>
) : (
<ConfigureRow
icon={Building2}
title={
orgRole === 'member'
? t('profile.viewOrganization')
: t('profile.manageOrganization')
}
subtitle={orgName}
hue="lime"
className="rounded-lg bg-secondary px-3"
disabled={!orgRole}
last
onPress={() => {
router.push('/(app)/(tabs)/(3_profile)/organization' as Href);
}}
/>
organizationRow
)}
{picker}
</View>
)}

Expand Down
Loading