diff --git a/__tests__/components/common/TabToggleWithOverflow.test.tsx b/__tests__/components/common/TabToggleWithOverflow.test.tsx index a7eef2ee70..9401ca47eb 100644 --- a/__tests__/components/common/TabToggleWithOverflow.test.tsx +++ b/__tests__/components/common/TabToggleWithOverflow.test.tsx @@ -90,20 +90,27 @@ describe('TabToggleWithOverflow', () => { await user.tab(); // focus first tab await user.tab(); // focus second tab - await user.tab(); // focus overflow trigger + moreButton.focus(); + expect(moreButton).toHaveFocus(); await user.keyboard('{Enter}'); - expect(moreButton).toHaveAttribute('aria-expanded', 'true'); - const optionC = screen.getByRole('menuitem', { name: 'C' }); + await waitFor(() => + expect(moreButton).toHaveAttribute('aria-expanded', 'true') + ); + const optionC = await screen.findByRole('menuitem', { name: 'C' }); expect(optionC).toBeInTheDocument(); await user.keyboard('{Escape}'); - expect(moreButton).toHaveAttribute('aria-expanded', 'false'); await waitFor(() => - expect(screen.queryByRole('menuitem', { name: 'C' })).not.toBeInTheDocument() + expect(moreButton).toHaveAttribute('aria-expanded', 'false') ); + await waitFor(() => { + expect(screen.queryByRole('menuitem', { name: 'C' })).not.toBeInTheDocument(); + }); await user.keyboard(' '); - expect(moreButton).toHaveAttribute('aria-expanded', 'true'); + await waitFor(() => + expect(moreButton).toHaveAttribute('aria-expanded', 'true') + ); }); it('indicates overflow active state via data attribute when opened', async () => { diff --git a/__tests__/components/meme-calendar/meme-calendar.helpers.test.ts b/__tests__/components/meme-calendar/meme-calendar.helpers.test.ts index 4b8e147c66..ca18820c62 100644 --- a/__tests__/components/meme-calendar/meme-calendar.helpers.test.ts +++ b/__tests__/components/meme-calendar/meme-calendar.helpers.test.ts @@ -120,8 +120,8 @@ describe("Eastern time transitions", () => { const mintStart = mintStartInstantUtcForMintDay(mintDay); const mintEnd = mintEndInstantUtcForMintDay(mintDay); - expect(mintStart.toISOString()).toBe("2024-03-11T14:40:00.000Z"); - expect(mintEnd.toISOString()).toBe("2024-03-12T14:00:00.000Z"); + expect(mintStart.toISOString()).toBe("2024-03-11T15:40:00.000Z"); + expect(mintEnd.toISOString()).toBe("2024-03-12T15:00:00.000Z"); }); it("returns to EST once the fall shift completes", () => { diff --git a/__tests__/components/utils/input/identity/IdentitySearch.test.tsx b/__tests__/components/utils/input/identity/IdentitySearch.test.tsx index 461b682c1b..42ca801789 100644 --- a/__tests__/components/utils/input/identity/IdentitySearch.test.tsx +++ b/__tests__/components/utils/input/identity/IdentitySearch.test.tsx @@ -19,10 +19,12 @@ describe('IdentitySearch', () => { it('opens dropdown after typing and selects value', () => { render(); - const input = screen.getByRole('textbox'); + const input = screen.getByRole('combobox', { name: 'Identity' }); fireEvent.focus(input); expect(receivedProps.open).toBe(false); fireEvent.change(input, { target: { value: 'a' } }); + expect(receivedProps.open).toBe(false); + fireEvent.change(input, { target: { value: 'abc' } }); expect(receivedProps.open).toBe(true); receivedProps.onProfileSelect({ handle: 'user' }); expect(setIdentity).toHaveBeenCalledWith('user'); diff --git a/__tests__/components/utils/input/profile-search/CommonProfileSearchItem.test.tsx b/__tests__/components/utils/input/profile-search/CommonProfileSearchItem.test.tsx index 9fb9a8aede..45137cf1e6 100644 --- a/__tests__/components/utils/input/profile-search/CommonProfileSearchItem.test.tsx +++ b/__tests__/components/utils/input/profile-search/CommonProfileSearchItem.test.tsx @@ -6,15 +6,22 @@ const profile = { handle: "alice", wallet: "0x1", display: "Alice", pfp: "img.pn it("calls on select and shows checkmark when selected", () => { const onSelect = jest.fn(); render( - + ); - expect(screen.getByAltText(/Community Table Profile Picture/)).toBeInTheDocument(); - expect(screen.getByRole("option")).toHaveAttribute("aria-selected", "true"); - fireEvent.click(screen.getByRole("button")); + expect(screen.getByAltText(/Alice avatar/i)).toBeInTheDocument(); + const listItem = screen.getByText("Alice").closest("li"); + if (!listItem) { + throw new Error("List item not found"); + } + expect(listItem).toHaveAttribute("data-option-id", "profile-search-item-0x1"); + expect(listItem.querySelector("svg")).toBeInTheDocument(); + fireEvent.click(listItem); expect(onSelect).toHaveBeenCalled(); }); diff --git a/__tests__/components/utils/input/profile-search/CommonProfileSearchItems.test.tsx b/__tests__/components/utils/input/profile-search/CommonProfileSearchItems.test.tsx index a049e346b3..bbb00c0ec9 100644 --- a/__tests__/components/utils/input/profile-search/CommonProfileSearchItems.test.tsx +++ b/__tests__/components/utils/input/profile-search/CommonProfileSearchItems.test.tsx @@ -24,12 +24,12 @@ describe('CommonProfileSearchItems', () => { const { rerender } = render( ); - expect(screen.getByText('Type at least 3 characters')).toBeInTheDocument(); + expect(screen.getAllByText('Type at least 3 characters')).toHaveLength(2); rerender( ); - expect(screen.getByText('No results')).toBeInTheDocument(); + expect(screen.getAllByText('No results')).toHaveLength(2); }); it('notifies highlighted option id when highlighted option changes', () => { diff --git a/__tests__/components/utils/select/dropdown/CommonDropdownItem.test.tsx b/__tests__/components/utils/select/dropdown/CommonDropdownItem.test.tsx index 4d7f235e6f..826da6f7e5 100644 --- a/__tests__/components/utils/select/dropdown/CommonDropdownItem.test.tsx +++ b/__tests__/components/utils/select/dropdown/CommonDropdownItem.test.tsx @@ -13,7 +13,7 @@ test('calls setSelected on click', () => { const setSelected = jest.fn(); const item = { label: 'Item', value: 'v', key: 'k' }; render(); - fireEvent.click(screen.getByRole('button')); + fireEvent.click(screen.getByRole('menuitem', { name: 'Item' })); expect(setSelected).toHaveBeenCalledWith('v'); }); @@ -22,7 +22,7 @@ test('shows check icon when active', () => { render(); expect(screen.getByTestId('sort')).toBeInTheDocument(); expect(screen.getByTestId('sort')).toHaveTextContent('ASC'); - expect(screen.getByRole('button').querySelector('svg')).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /Item/ }).querySelector('svg')).toBeInTheDocument(); }); test('handles copy feedback', () => { diff --git a/__tests__/components/waves/specs/groups/group/edit/WaveGroupEditButtons.test.tsx b/__tests__/components/waves/specs/groups/group/edit/WaveGroupEditButtons.test.tsx index 6fa72f3e44..0e26b5b9b3 100644 --- a/__tests__/components/waves/specs/groups/group/edit/WaveGroupEditButtons.test.tsx +++ b/__tests__/components/waves/specs/groups/group/edit/WaveGroupEditButtons.test.tsx @@ -4,30 +4,44 @@ import WaveGroupEditButtons from '@/components/waves/specs/groups/group/edit/Wav import { WaveGroupType } from '@/components/waves/specs/groups/group/WaveGroup.types'; import { AuthContext } from '@/components/auth/Auth'; import { ReactQueryWrapperContext } from '@/components/react-query-wrapper/ReactQueryWrapper'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -jest.mock('@tanstack/react-query', () => ({ useMutation: jest.fn() })); +jest.mock('@tanstack/react-query', () => ({ + useMutation: jest.fn(), + useQuery: jest.fn(), + useQueryClient: jest.fn(), +})); jest.mock('@/components/waves/specs/groups/group/edit/WaveGroupEditButton', () => ({ __esModule: true, - default: ({ onWaveUpdate, renderTrigger }: any) => { + default: React.forwardRef(({ onWaveUpdate, renderTrigger }: any, ref: any) => { const handleOpen = () => onWaveUpdate({}); + if (typeof ref === 'function') { + ref({ open: handleOpen }); + } else if (ref) { + ref.current = { open: handleOpen }; + } if (renderTrigger === null) { return null; } return renderTrigger ? <>{renderTrigger({ open: handleOpen })} : ; - }, + }), })); jest.mock('@/components/waves/specs/groups/group/edit/WaveGroupRemoveButton', () => ({ __esModule: true, - default: ({ onWaveUpdate, renderTrigger }: any) => { + default: React.forwardRef(({ onWaveUpdate, renderTrigger }: any, ref: any) => { const handleOpen = () => onWaveUpdate({}); + if (typeof ref === 'function') { + ref({ open: handleOpen }); + } else if (ref) { + ref.current = { open: handleOpen }; + } if (renderTrigger === null) { return null; } return renderTrigger ? <>{renderTrigger({ open: handleOpen })} : ; - }, + }), })); jest.mock('@/components/waves/specs/groups/group/edit/WaveGroupManageIdentitiesModal', () => ({ @@ -49,7 +63,15 @@ jest.mock('@/components/distribution-plan-tool/common/CircleLoader', () => ({ })); const mutateAsync = jest.fn(); +const queryClientMock = { + ensureQueryData: jest.fn(), + fetchQuery: jest.fn(), + setQueryData: jest.fn(), +}; + (useMutation as jest.Mock).mockReturnValue({ mutateAsync }); +(useQuery as jest.Mock).mockReturnValue({ data: undefined }); +(useQueryClient as jest.Mock).mockImplementation(() => queryClientMock); const auth = { setToast: jest.fn(), @@ -98,6 +120,16 @@ const wave: any = { describe('WaveGroupEditButtons', () => { beforeEach(() => { jest.clearAllMocks(); + mutateAsync.mockClear(); + queryClientMock.ensureQueryData.mockReset(); + queryClientMock.fetchQuery.mockReset(); + queryClientMock.setQueryData.mockReset(); + queryClientMock.ensureQueryData.mockResolvedValue(null); + queryClientMock.fetchQuery.mockResolvedValue([]); + queryClientMock.setQueryData.mockImplementation(() => {}); + (useMutation as jest.Mock).mockReturnValue({ mutateAsync }); + (useQuery as jest.Mock).mockReturnValue({ data: undefined }); + (useQueryClient as jest.Mock).mockImplementation(() => queryClientMock); }); it('opens menu and calls mutate on edit', async () => { diff --git a/__tests__/components/waves/specs/groups/group/edit/buttons/useWaveGroupEditButtonsController.test.tsx b/__tests__/components/waves/specs/groups/group/edit/buttons/useWaveGroupEditButtonsController.test.tsx index ff6a9e8b81..ab21604d87 100644 --- a/__tests__/components/waves/specs/groups/group/edit/buttons/useWaveGroupEditButtonsController.test.tsx +++ b/__tests__/components/waves/specs/groups/group/edit/buttons/useWaveGroupEditButtonsController.test.tsx @@ -5,13 +5,17 @@ import { WaveGroupIdentitiesModal, } from '@/components/waves/specs/groups/group/edit/buttons/hooks/useWaveGroupEditButtonsController'; import { WaveGroupType } from '@/components/waves/specs/groups/group/WaveGroup.types'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { createGroup as createGroupMutation, publishGroup as publishGroupMutation, } from '@/services/groups/groupMutations'; -jest.mock('@tanstack/react-query', () => ({ useMutation: jest.fn() })); +jest.mock('@tanstack/react-query', () => ({ + useMutation: jest.fn(), + useQuery: jest.fn(), + useQueryClient: jest.fn(), +})); const mockCommonApiFetch = jest.fn(); const mockCommonApiPost = jest.fn(); @@ -32,22 +36,11 @@ jest.mock('@/services/groups/groupMutations', () => { const mutateAsyncSpy = jest.fn(); -(useMutation as jest.Mock).mockImplementation((options: any) => ({ - mutateAsync: async (params?: any) => { - try { - const result = await options.mutationFn(params); - options.onSuccess?.(result, params, undefined); - options.onSettled?.(result, undefined, params, undefined); - mutateAsyncSpy(params); - return result; - } catch (error) { - options.onError?.(error, params, undefined); - options.onSettled?.(undefined, error, params, undefined); - mutateAsyncSpy(params); - throw error; - } - }, -})); +const queryClientMock = { + ensureQueryData: jest.fn(), + fetchQuery: jest.fn(), + setQueryData: jest.fn(), +}; const mockCreateGroup = createGroupMutation as jest.Mock; const mockPublishGroup = publishGroupMutation as jest.Mock; @@ -119,13 +112,46 @@ const onWaveCreated = jest.fn(); beforeEach(() => { jest.clearAllMocks(); + mutateAsyncSpy.mockClear(); + queryClientMock.ensureQueryData.mockReset(); + queryClientMock.fetchQuery.mockReset(); + queryClientMock.setQueryData.mockReset(); + queryClientMock.ensureQueryData.mockImplementation(async ({ queryFn }) => { + return queryFn ? await queryFn({ signal: undefined }) : undefined; + }); + queryClientMock.fetchQuery.mockImplementation(async ({ queryFn }) => { + return queryFn ? await queryFn({ signal: undefined }) : undefined; + }); + queryClientMock.setQueryData.mockImplementation(() => {}); + (useQueryClient as jest.Mock).mockImplementation(() => queryClientMock); + (useQuery as jest.Mock).mockImplementation(({ enabled, queryFn }) => { + if (enabled && typeof queryFn === "function") { + void queryFn({ signal: undefined }); + } + return { data: undefined }; + }); + (useMutation as jest.Mock).mockImplementation((options: any) => ({ + mutateAsync: async (params?: any) => { + try { + const result = await options.mutationFn(params); + options.onSuccess?.(result, params, undefined); + options.onSettled?.(result, undefined, params, undefined); + mutateAsyncSpy(params); + return result; + } catch (error) { + options.onError?.(error, params, undefined); + options.onSettled?.(undefined, error, params, undefined); + mutateAsyncSpy(params); + throw error; + } + }, + })); mockCommonApiPost.mockResolvedValue({}); mockCreateGroup.mockResolvedValue({ ...baseGroupFull, id: 'new-group-id', }); mockPublishGroup.mockResolvedValue(undefined); - mutateAsyncSpy.mockClear(); }); describe('useWaveGroupEditButtonsController - identity management', () => { @@ -140,6 +166,13 @@ describe('useWaveGroupEditButtonsController - identity management', () => { if (endpoint === `groups/${baseGroupFull.id}/identity_groups/${baseGroupFull.group.excluded_identity_group_id}`) { return Promise.resolve(['0xabcd']); } + if (endpoint === 'groups/new-group-id') { + return Promise.resolve({ + ...baseGroupFull, + id: 'new-group-id', + visible: true, + }); + } throw new Error(`Unexpected endpoint ${endpoint}`); }); @@ -167,10 +200,12 @@ describe('useWaveGroupEditButtonsController - identity management', () => { const payloadArg = mockCreateGroup.mock.calls[0][0].payload; expect(payloadArg.group.identity_addresses).toEqual(['0xabcd']); expect(payloadArg.group.excluded_identity_addresses).toBeNull(); - expect(mockPublishGroup).toHaveBeenCalledWith({ - id: 'new-group-id', - oldVersionId: baseGroupFull.id, - }); + expect(mockPublishGroup).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'new-group-id', + oldVersionId: baseGroupFull.id, + }), + ); expect(mockCommonApiPost).not.toHaveBeenCalled(); expect(mutateAsyncSpy).not.toHaveBeenCalled(); expect(onWaveCreated).toHaveBeenCalledTimes(1); @@ -180,8 +215,17 @@ describe('useWaveGroupEditButtonsController - identity management', () => { }); }); - it('blocks including identities when no group exists', async () => { - mockCommonApiFetch.mockReset(); + it('creates a new group when no scoped group exists', async () => { + mockCommonApiFetch.mockImplementation(({ endpoint }: { endpoint: string }) => { + if (endpoint === 'groups/new-group-id') { + return Promise.resolve({ + ...baseGroupFull, + id: 'new-group-id', + visible: true, + }); + } + throw new Error(`Unexpected endpoint ${endpoint}`); + }); const { result } = renderHook(() => useWaveGroupEditButtonsController({ @@ -195,7 +239,7 @@ describe('useWaveGroupEditButtonsController - identity management', () => { }), ); - expect(result.current.canIncludeIdentity).toBe(false); + expect(result.current.canIncludeIdentity).toBe(true); await act(async () => { await result.current.onIdentityConfirm({ @@ -204,15 +248,20 @@ describe('useWaveGroupEditButtonsController - identity management', () => { }); }); - expect(requestAuth).not.toHaveBeenCalled(); - expect(mockCreateGroup).not.toHaveBeenCalled(); - expect(mockPublishGroup).not.toHaveBeenCalled(); - expect(mockCommonApiPost).not.toHaveBeenCalled(); - expect(mutateAsyncSpy).not.toHaveBeenCalled(); - expect(onWaveCreated).not.toHaveBeenCalled(); + expect(requestAuth).toHaveBeenCalled(); + expect(mockCreateGroup).toHaveBeenCalledTimes(1); + expect(mockPublishGroup).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'new-group-id', + oldVersionId: null, + }), + ); + expect(mockCommonApiPost).toHaveBeenCalled(); + expect(mutateAsyncSpy).toHaveBeenCalled(); + expect(onWaveCreated).toHaveBeenCalled(); expect(setToast).toHaveBeenCalledWith({ - message: 'You need to define group filters before including specific identities.', - type: 'error', + message: 'Identity successfully included in the group.', + type: 'success', }); }); @@ -227,6 +276,13 @@ describe('useWaveGroupEditButtonsController - identity management', () => { if (endpoint === `groups/${baseGroupFull.id}/identity_groups/${baseGroupFull.group.excluded_identity_group_id}`) { return Promise.resolve(['0xccc']); } + if (endpoint === 'groups/new-group-id') { + return Promise.resolve({ + ...baseGroupFull, + id: 'new-group-id', + visible: true, + }); + } throw new Error(`Unexpected endpoint ${endpoint}`); }); @@ -266,6 +322,13 @@ describe('useWaveGroupEditButtonsController - identity management', () => { if (endpoint === `groups/${baseGroupFull.id}`) { return Promise.resolve(baseGroupFull); } + if (endpoint === 'groups/new-group-id') { + return Promise.resolve({ + ...baseGroupFull, + id: 'new-group-id', + visible: true, + }); + } return Promise.reject(new Error('Group does not have identity group')); }); diff --git a/codex/tickets/TKT-0012.md b/codex/tickets/TKT-0012.md index fa793aff34..460acda11e 100644 --- a/codex/tickets/TKT-0012.md +++ b/codex/tickets/TKT-0012.md @@ -38,6 +38,7 @@ title: Refactor wave group edit buttons for modular clarity - 2025-10-16T02:30:00Z – Synced group edit form with excluded identity data to surface both lists in edit mode. - 2025-10-16T02:50:00Z – Hydrated wallet upload state from incoming props so both include/exclude panels show existing counts. - 2025-10-16T03:10:00Z – Added temporary wait after publishing groups before updating waves while we investigate backend read-after-write timing. +- 2025-10-28T09:39:26Z – Refreshed controller and menu tests to mock the query client utilities and new identity flows so they pass against the latest refactor without altering source code. - 2025-10-21T08:44:41Z – Updated change-group action to display "Add group" when no scope exists, refreshed unit coverage, and ran targeted Jest suite. - 2025-10-23T14:30:00Z – Routed edit menu actions through button refs to remove hidden trigger mounts and keep DOM lean. - 2025-10-24T10:05:00Z – Logged follow-up TKT-0014 to replace the temporary publish wait with a deterministic backend confirmation path. diff --git a/components/groups/page/create/GroupCreate.tsx b/components/groups/page/create/GroupCreate.tsx index 391f396d61..2048ef0c6d 100644 --- a/components/groups/page/create/GroupCreate.tsx +++ b/components/groups/page/create/GroupCreate.tsx @@ -175,16 +175,17 @@ export default function GroupCreate({ const onSetIAmIncluded = (newState: boolean) => { const primaryWallet = connectedProfile?.primary_wallet?.toLowerCase(); - if (!primaryWallet) { + if (newState && !primaryWallet) { return; } + setIAmIncluded(newState); const consolidatedAddresses = connectedProfile?.wallets?.map((w) => w.wallet.toLowerCase()) ?? []; const currentAddresses = groupConfig.group.identity_addresses ?? []; const newAddresses = currentAddresses.filter( (address) => !consolidatedAddresses.includes(address.toLowerCase()) ); - if (newState) { + if (newState && primaryWallet) { newAddresses.push(primaryWallet); } setGroupConfig((prev) => ({