Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
76fd09b
feat(pr-review): add authorized paginated PR inbox procedure
iscekic Aug 18, 2026
ad194cb
perf(pr-review): parallelize overview and checks retrieval
iscekic Aug 19, 2026
c10f307
perf(pr-review): cache context expansion and viewed-state reads
iscekic Aug 19, 2026
2e2ca27
feat(pr-review): add server-backed inbox and honest recents
iscekic Aug 19, 2026
4e8ad2b
feat(pr-review): submit fresh comments independently of stale items
iscekic Aug 19, 2026
47d70a6
perf(pr-review): virtualize the file navigator and load on demand
iscekic Aug 19, 2026
cb898e4
perf(pr-review): virtualize the organization members list
iscekic Aug 19, 2026
1c9006d
test(pr-review): fix lint in viewed-files late-read tests
iscekic Aug 19, 2026
dddb570
docs(pr-review): correct submit-input mapper contract comment
iscekic Aug 19, 2026
1036897
refactor(pr-review): simplify inbox view and navigator filter
iscekic Aug 19, 2026
c48dcae
chore(format): wrap deferred type annotation in viewed-files test
iscekic Aug 19, 2026
236c291
fix(pr-review): return authoritative viewed map on late read
iscekic Aug 19, 2026
f30037c
fix(pr-review): keep navigator row callbacks from going stale
iscekic Aug 19, 2026
fbc967d
Merge remote-tracking branch 'origin/main' into audit-w5a-pr-review-ae21
iscekic Aug 19, 2026
002fd0e
fix(pr-review): keep empty approve from wiping a hydrating queue
iscekic Aug 19, 2026
ffbcc61
fix(mobile): name return types and cast notification hrefs for typecheck
iscekic Aug 19, 2026
214bbe4
fix(mobile): drop unused discussion type and return notification path…
iscekic Aug 19, 2026
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
100 changes: 100 additions & 0 deletions apps/mobile/src/components/organization/members-list-items.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';

import { type ActiveOrgMember, type InvitedOrgMember } from '@/lib/hooks/use-organization-queries';

import { buildMembersListItems } from './members-list-items';

function activeMember(id: string): ActiveOrgMember {
return {
id,
name: `Member ${id}`,
email: `${id}@example.com`,
role: 'member',
status: 'active',
inviteDate: null,
dailyUsageLimitUsd: null,
currentDailyUsageUsd: null,
};
}

function invitedMember(id: string): InvitedOrgMember {
return {
email: `${id}@example.com`,
role: 'member',
inviteDate: null,
inviteToken: `token-${id}`,
inviteId: `invite-${id}`,
status: 'invited',
inviteUrl: `https://example.com/invite/${id}`,
emailStatus: null,
dailyUsageLimitUsd: null,
currentDailyUsageUsd: null,
};
}

describe('buildMembersListItems', () => {
it('returns [] when both lists are empty', () => {
expect(buildMembersListItems({ activeMembers: [], invitedMembers: [] })).toEqual([]);
});

it('builds members only with a Members section and a last flag', () => {
const items = buildMembersListItems({
activeMembers: [activeMember('a'), activeMember('b')],
invitedMembers: [],
});
expect(items).toEqual([
{ kind: 'section', title: 'Members' },
{ kind: 'member', member: activeMember('a'), last: false },
{ kind: 'member', member: activeMember('b'), last: true },
]);
});

it('builds members plus invites with both sections and correct last flags', () => {
const items = buildMembersListItems({
activeMembers: [activeMember('a')],
invitedMembers: [invitedMember('i1'), invitedMember('i2')],
});
expect(items).toEqual([
{ kind: 'section', title: 'Members' },
{ kind: 'member', member: activeMember('a'), last: true },
{ kind: 'section', title: 'Pending invitations' },
{ kind: 'invite', invite: invitedMember('i1'), last: false },
{ kind: 'invite', invite: invitedMember('i2'), last: true },
]);
});

it('omits the Pending invitations header when there are no invites', () => {
const items = buildMembersListItems({
activeMembers: [activeMember('a')],
invitedMembers: [],
});
expect(
items.some(item => item.kind === 'section' && item.title === 'Pending invitations')
).toBe(false);
expect(items.some(item => item.kind === 'invite')).toBe(false);
});

it('starts with members-empty when there are no active members but invites exist', () => {
const items = buildMembersListItems({
activeMembers: [],
invitedMembers: [invitedMember('i1')],
});
expect(items[0]).toEqual({ kind: 'members-empty' });
expect(items).toEqual([
{ kind: 'members-empty' },
{ kind: 'section', title: 'Pending invitations' },
{ kind: 'invite', invite: invitedMember('i1'), last: true },
]);
});

it('flags the final row of each group as last', () => {
const items = buildMembersListItems({
activeMembers: [activeMember('a'), activeMember('b')],
invitedMembers: [invitedMember('i1')],
});
const members = items.filter(item => item.kind === 'member');
const invites = items.filter(item => item.kind === 'invite');
expect(members.map(item => item.last)).toEqual([false, true]);
expect(invites.map(item => item.last)).toEqual([true]);
});
});
55 changes: 55 additions & 0 deletions apps/mobile/src/components/organization/members-list-items.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Pure builder for the organization Members screen's FlashList items.
//
// One flat array drives a single FlashList. The composition rules are exact so
// the empty state can never go missing:
//
// - both lists empty -> `[]` (the list renders `ListEmptyComponent`)
// - active members present -> `section: 'Members'` + one `member` row each
// - no active, invites -> `members-empty` + the invites section
// - invites present -> `section: 'Pending invitations'` + one
// `invite` row each
//
// `last` marks the final row of each group so the existing hairline rule is
// preserved.

import { type ActiveOrgMember, type InvitedOrgMember } from '@/lib/hooks/use-organization-queries';

export type MembersListItem =
| { kind: 'section'; title: string }
| { kind: 'members-empty' }
| { kind: 'member'; member: ActiveOrgMember; last: boolean }
| { kind: 'invite'; invite: InvitedOrgMember; last: boolean };

export function buildMembersListItems(args: {
activeMembers: ActiveOrgMember[];
invitedMembers: InvitedOrgMember[];
}): MembersListItem[] {
const { activeMembers, invitedMembers } = args;
const items: MembersListItem[] = [];

if (activeMembers.length > 0) {
items.push({ kind: 'section', title: 'Members' });
for (const [index, member] of activeMembers.entries()) {
items.push({
kind: 'member',
member,
last: index === activeMembers.length - 1,
});
}
} else if (invitedMembers.length > 0) {
items.push({ kind: 'members-empty' });
}

if (invitedMembers.length > 0) {
items.push({ kind: 'section', title: 'Pending invitations' });
for (const [index, invite] of invitedMembers.entries()) {
items.push({
kind: 'invite',
invite,
last: index === invitedMembers.length - 1,
});
}
}

return items;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom); same pattern as src/test/render-with-providers.tsx. */

// Screen-level empty-state precedence regression: when the member query errors
// with no data, both member arrays are empty, so the list's empty component
// must render the QueryError — not "No members yet". The item builder and the
// error selector are unit-tested separately; this proves the loading → error →
// empty precedence in the screen JSX itself.

import { type ComponentType, createElement, type ReactElement } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { renderWithProviders } from '@/test/render-with-providers';

import { OrganizationMembersScreen } from './members-screen';

const withMembersQuery = vi.hoisted(() => ({
data: undefined as unknown,
isLoading: false,
isFetching: false,
isError: false,
error: null as unknown,
refetch: vi.fn(),
}));

vi.mock('@/lib/hooks/use-organization-queries', () => ({
isMoneyRole: () => true,
useOrgBoundary: () => ({
organizationId: 'org-1',
role: 'owner',
org: { organizationId: 'org-1', role: 'owner' },
isResolving: false,
}),
useOrgWithMembers: () => withMembersQuery,
}));

vi.mock('@shopify/flash-list', () => ({
FlashList: (props: {
data?: unknown[];
ListEmptyComponent?: ComponentType | ReactElement | null;
}) => {
const data = props.data ?? [];
if (data.length === 0) {
const Empty = props.ListEmptyComponent;
if (typeof Empty === 'function') {
return createElement(Empty);
}
return Empty ?? null;
}
return null;
},
}));

vi.mock('expo-router', () => ({
useRouter: () => ({ push: vi.fn() }),
}));

vi.mock('@/components/ui/icons', () => ({
UserPlus: 'UserPlus',
Users: 'Users',
}));

vi.mock('@/components/empty-state', () => ({
EmptyState: ({ title }: { title: string }) => `EMPTY_STATE:${title}`,
}));

vi.mock('@/components/organization/invited-member-row', () => ({
InvitedMemberRow: () => null,
}));

vi.mock('@/components/organization/member-row', () => ({
MemberRow: () => null,
}));

vi.mock('@/components/organization/organization-boundary', () => ({
OrganizationBoundary: () => null,
}));

vi.mock('@/components/query-error', () => ({
QueryError: () => 'QUERY_ERROR',
}));

vi.mock('@/components/screen-header', () => ({
ScreenHeader: () => null,
}));

vi.mock('@/components/ui/button', () => ({
Button: 'Button',
}));

vi.mock('@/components/ui/skeleton', () => ({
Skeleton: 'Skeleton',
}));

vi.mock('@/components/ui/text', () => ({
Text: 'Text',
}));

vi.mock('@/components/tab-screen', () => ({
useTabBarBottomPadding: () => 0,
}));

vi.mock('@/lib/hooks/use-current-user-id', () => ({
useCurrentUserId: () => ({ userId: 'user-1' }),
}));

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

vi.mock('@/lib/utils', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
firstNonEmpty: (...args: (string | null | undefined)[]) =>
args.find(value => value != null && value !== '') ?? '',
parseTimestamp: (value: string) => new Date(value),
}));

vi.mock('react-native', () => ({
Pressable: 'Pressable',
View: 'View',
}));

function collectText(node: unknown): string[] {
if (node == null) {
return [];
}
if (typeof node === 'string') {
return [node];
}
if (Array.isArray(node)) {
return node.flatMap(item => collectText(item));
}
if (typeof node === 'object' && 'children' in node) {
return collectText((node as { children?: unknown }).children);
}
return [];
}

async function renderScreen(): Promise<string[]> {
const { renderer } = await renderWithProviders(createElement(OrganizationMembersScreen));
return collectText(renderer.toJSON());
}

beforeEach(() => {
withMembersQuery.data = undefined;
withMembersQuery.isLoading = false;
withMembersQuery.isFetching = false;
withMembersQuery.isError = false;
withMembersQuery.error = null;
withMembersQuery.refetch.mockClear();
});

describe('OrganizationMembersScreen empty-state precedence', () => {
it('renders QueryError, not "No members yet", when an error leaves both member arrays empty', async () => {
withMembersQuery.isError = true;
withMembersQuery.error = { data: { code: 'INTERNAL_SERVER_ERROR' } };

const texts = await renderScreen();

expect(texts).toContain('QUERY_ERROR');
expect(texts).not.toContain('No members yet');
});

it('renders "No members yet" when there is no error and both member arrays are empty', async () => {
const texts = await renderScreen();

expect(texts).not.toContain('QUERY_ERROR');
expect(texts).toContain('EMPTY_STATE:No members yet');
});
});
Loading